pi-fence#
A Pi extension that enforces one shared fence.jsonc policy
across two enforcement layers:
- Bash subprocesses, wrapped via the
fencebinary (Seatbelt on macOS, bubblewrap + Landlock on Linux). Covers the LLMbashtool and user!/!!commands. - Pi's own file tools (
read,write,edit,grep,find,ls), gated by an in-processtool_callinterceptor that consumes the same resolved policy.
Both layers read from fence config show, so they cannot disagree.
Status#
| Layer | Status | Notes |
|---|---|---|
| Bash + user_bash | shipped | Pass 0 |
| Pi file tools (read/write/edit/grep/find/ls) | shipped | Pass 1 — PLAN.md §5 |
| Interactive grants + persist + audit log | shipped | Pass 2 — PLAN.md §8 |
What it does#
When pi-fence is loaded:
-
The built-in
bashtool is replaced by a fenced version that prefixes every command withexec fence -c <cmd>. The agent sees identical stdout/stderr/exit-code behavior; the kernel-level sandbox enforces filesystem and network policy on the spawned process tree. User shell commands (!,!!) go through the same wrapping. -
Pi's
read,write,edit,grep,find,lstools each fire atool_callevent that pi-fence intercepts. The path isrealpath'd (defeating symlink escape) and matched against the resolved policy. A block returns a structured JSON payload to the LLM:pi-fence: blocked { "tool": "read", "path": "/private/etc/passwd", "matched_rule": "filesystem.defaultDenyRead", "matched_pattern": null, "policy_source": "/Users/me/proj/fence.jsonc", "suggestion": "If this access is legitimate, ask the user to grant it. They can use /fence or add the path to filesystem.allowRead in fence.jsonc." } -
On a block, if a TUI is available, you're prompted with four options:
[d] deny(default),[a] allow once,[s] session,[p] persist. Picking persist edits (or scaffolds) the project'sfence.jsonc. Picking session records the grant in session storage so it survives fork/resume. -
Every block decision is appended to
~/.pi/agent/pi-fence.logfor forensics. Daily rotation, 7-day retention. -
On first run in a git project that has no
fence.jsonc, pi-fence prompts to scaffold one tuned for agent workflows (mise, npm, language-toolchain installs). Decline once and pi-fence stays out of the way until you ask via/fence scaffoldor/fence init. See Project scaffolding. -
The footer status indicator reflects the active state:
🔒 fenced (bash + fs)— both layers active🔒 fenced (bash) — no policy— fence available, nofence.jsonc(FS layer is dormant; bash uses fence defaults)⚠ unfenced (--no-fence)— bypassed via the per-session flag⚠ unfenced (fence missing)—fencebinary not on PATH; symmetric fail-open with a warning notification
Requirements#
- Pi ≥ 0.72
fenceon PATH (install instructions)- macOS or Linux
Install#
The directory is auto-discovered by Pi when placed at
~/.pi/agent/extensions/pi-fence/.
cd ~/.pi/agent/extensions/pi-fence
npm install # installs dev deps for type-checking only
No runtime npm dependencies; the extension only needs the fence binary
on PATH.
Usage#
Just start Pi as usual. The footer status will show 🔒 fenced if all
prerequisites are met.
To bypass for a single session:
pi --no-fence
Configuration#
All policy lives in fence.jsonc (or fence.json), the same file the
fence binary itself consumes. Search order:
<project>/fence.jsonc(project-local)~/.config/fence/fence.jsonc(user-global)
If neither exists, fence runs with default-deny network and default-deny writes — see fence's configuration reference.
Project scaffolding (default behavior)#
The first time pi-fence runs in a git project that has no fence.jsonc,
it prompts:
pi-fence: scaffold fence.jsonc?
This project has no fence policy. Pi-fence ships a starter tuned for
agent workflows: mise / npm / language-toolchain installs work, project
secrets (.env, *.pem, *.key) stay protected, common destructive
commands (git push, npm publish, sudo, ...) are denied. You can edit
the file freely afterwards; pi-fence won't overwrite it.
Create /path/to/repo/fence.jsonc? [Y/n]
Accept and you get a self-contained fence.jsonc with a vetted policy
(see policy/starter-template.ts for
the authoritative source). The file is yours to commit, edit, or
delete. Pi-fence treats its presence as authoritative and will not
re-prompt or overwrite it.
Decline and pi-fence stays out of the way for the rest of the session.
You can re-run the prompt later via /fence scaffold, or get a
minimal extends: "code" stub via /fence init.
Why not just extends: "code"? The code template ships a
denyWrite: ["**/*.pem", "**/*.key", ...] that's globally scoped, so
it false-positives on legitimate package-manager extractions like Go's
crypto/tls/testdata/*.pem and breaks mise install go@…,
mise install node@…, native-module npm installs, etc. Fence's slice
fields are append-only on extends, so a child config can't narrow
those patterns. Pi-fence's starter sidesteps the problem by authoring
the whole policy directly, with denyWrite scoped to the project tree
and the agent-relevant CDNs (mise infrastructure, nodejs.org,
dl.google.com, hashicorp, rust toolchains) in the network allowlist.
For the full diff vs. the upstream code template, see the comment
block in policy/starter-template.ts.
What the starter contains#
Network: every domain in fence's code template (LLM providers,
GitHub, npmjs, pypi, crates.io, formulae.brew.sh, ...) plus the
language-toolchain CDNs *.jdx.dev, nodejs.org, dl.google.com,
static.rust-lang.org, releases.hashicorp.com. Same deniedDomains
(metadata services, statsig, sentry).
Filesystem: same denyRead (credentials, ssh keys, cloud creds).
Same allowWrite plus ~/Library/Caches/** (where mise's cache lives
on macOS). denyWrite patterns scoped to ./**/* instead of **/* —
the agent still can't write .env/*.pem/*.key into your project,
but tarball test fixtures extracted into caches no longer
false-positive.
Command: identical deny list to code — git push, npm publish,
sudo, destructive gh subcommands, etc.
Drift from upstream code#
The starter is a pinned snapshot of fence's code template plus the
adjustments above. When fence ships a new version of code (a new
allowed domain, a new writable cache dir), pi-fence's snapshot does
not auto-update. Periodic resync recipe:
diff <(jq -S '.network, .filesystem, .command' \
< fence.jsonc 2>/dev/null) \
<(fence config show --template code | jq -S '.network, .filesystem, .command')
The template is versioned via STARTER_TEMPLATE_VERSION in
policy/starter-template.ts so you
can tell at a glance which generation a project's fence.jsonc was
scaffolded from.
Bypass / opt-out#
- One session:
pi --no-fence. - This project: decline the scaffold prompt; pi-fence stays out of
the way until you accept (or run
/fence scaffold//fence init). - Don't want pi-fence loaded at all: remove
~/.pi/agent/extensions/pi-fence.
Manual configuration#
If you'd rather hand-roll your fence.jsonc, the standard fence form works fine and pi-fence will respect it:
{
"$schema": "https://raw.githubusercontent.com/Use-Tusk/fence/main/docs/schema/fence.schema.json",
"extends": "code",
"network": {
"allowedDomains": ["private-registry.company.com"]
},
"filesystem": {
"denyRead": ["./secrets/**"]
}
}
If you go this route on macOS and use mise, expect the issues
described above (mise install of source toolchains fails on the
broad pem/key deny). Either scope your own denyWrite to ./**/*
patterns and add ~/Library/Caches/** to allowWrite, or accept
that toolchain installs need to happen outside pi-fence (pi --no-fence
or a separate terminal).
Going stricter (optional): defaultDenyRead#
If you want reads to be default-deny instead of default-allow, fence
ships the code-strict template (extends: "code-strict") which sets
defaultDenyRead: true plus a curated allowRead covering the
project and common tool config dirs.
Caveat with defaultDenyRead alone: fence's bash layer treats
defaultDenyRead: true as "deny reads outside allowRead plus a
built-in allowlist of system paths" (/etc, /usr, /System,
language-version-manager dirs, etc.). So cat /etc/passwd still
succeeds at the bash layer.
The Pi-tool layer (the read tool) does not honor that built-in
allowlist — it blocks read("/etc/passwd") directly. Result: the
layers appear to disagree under bare defaultDenyRead.
To make both layers reject system-path reads, also add
strictDenyRead: true:
{
"extends": "code-strict",
"filesystem": {
"strictDenyRead": true // tighten bash layer to match Pi-tool layer
}
}
Most users do not need this. Stick with extends: "code" unless you
have a specific reason to deny reads.
Why pi-fence doesn't auto-mirror fence's implicit defaults: there's no documented API for them today (only debug-output parsing). We chose to keep the Pi-tool layer strict by-the-book and let users opt into
strictDenyReadfor symmetry. See PLAN.md §Layer alignment for the trade-off discussion.
Slash commands#
/fence— show the resolved policy, including the chain (project config + any extends), case-sensitivity detection, and the current session grants./fence reload— re-runfence config showand refresh the cached policy. Use after editingfence.jsoncmid-session./fence scaffold— write pi-fence's starterfence.jsoncto the project root. Refuses to clobber an existing config; use this if you previously declined the session_start prompt or want to upgrade a minimalextends: "code"stub./fence init— proxy tofence config initin cwd. Writes the minimalextends: "code"stub fence ships by default. Prefer/fence scaffoldfor projects you actually want pi-fence to manage./fence log [N]— tail the lastNaudit log entries (default 20, cap 500).
Grants#
When the FS layer blocks a tool call and a TUI is available, you'll see:
pi-fence: read denied
path: /Users/me/other-repo/package.json
requested: ./other-repo/package.json (only shown when realpath differs)
rule: filesystem.defaultDenyRead
[d] deny ← default-focused
[a] allow once
[s] allow for this session
[p] persist to fence.jsonc
The four options:
- deny: structured error returned to the LLM, no record kept.
- allow once: this single tool call only. Logged to audit as
grant-once. - session: matches future calls in this session for the same
(read|write, realpath)pair. Persisted viapi.appendEntryso it survives fork/resume. Cleared on/new. Logged asgrant-session. - persist: session grant plus writes the path into the
project-root
fence.jsonc(see below). Requires a second confirmation. Logged asgrant-persist.
A 500ms anti-fatfinger delay drains buffered keystrokes before the prompt accepts input — a chain of denials can't be grant-bombed by a lingering Enter.
Persist target rules#
Persist always writes to <project-root>/fence.jsonc, where project
root = git rev-parse --show-toplevel, falling back to cwd. We never
edit ~/.config/fence/fence.jsonc (the global) and we never walk
upward looking for an existing file. If no project file exists, we
scaffold one with extends: "@base" (when a global config is in the
resolution chain) or extends: "code" otherwise.
Edits go through jsonc-parser so comments and formatting are
preserved; the result is validated against a vendored copy of
fence's JSON schema. Atomic write via tmp + rename;
last 3 backups kept as fence.jsonc.bak.<unix-ts>.
Mandatory denies (non-overridable)#
A short list of paths is denied for writes regardless of your
fence.jsonc. They cannot be granted via any prompt or persist:
~/.pi/agent/extensions/**— Pi auto-loads TS extensions at startup~/.pi/agent/skills/**,~/.agents/skills/**— skills become part of the system prompt~/.pi/agent/prompts/**— prompt templates same effect~/.config/fence/**— editing global fence config silently broadens policy machine-wide~/.pi/agent/pi-fence.log*— audit log integrity<pi-fence install dir>/**— belt-and-braces against self-modification
The rationale is that a write to any of these gives the agent
persistent influence over future Pi sessions — effectively, prompt
injection or arbitrary code execution that survives even after this
session ends. See PLAN.md §11.
This enforcement is full-strength on the Pi-tool layer (which we control). The bash layer inherits the same denies via the resolved config fence reads.
Headless mode#
When ctx.hasUI === false (RPC and --print modes), no prompts are
shown. Every block returns the structured error directly to the LLM
and the audit log records it. Set policy upfront via fence.jsonc for
headless agents.
Audit log#
Location: ~/.pi/agent/pi-fence.log. One JSON object per line:
{
"ts": "2026-05-10T14:30:01.123Z",
"session": "<session-id>",
"decision": "block" | "grant-once" | "grant-session" | "grant-persist",
"tool": "read" | "write",
"path": "/Users/me/.ssh/id_rsa",
"rule": "filesystem.defaultDenyRead",
"pattern": null
}
Daily rotation: on the first append of a UTC day, an existing
pi-fence.log is renamed to pi-fence.log.YYYY-MM-DD. Files older
than 7 days are pruned. Use /fence log [N] to tail.
Note: only Pi-tool-layer decisions are logged here. Bash-side denials
live in fence's own monitoring (fence -m).
Footgun lint#
At session start, pi-fence runs a one-shot lint of your resolved policy and emits warnings (never blocks) for:
- cwd ===
$HOME(every relative pattern becomes very permissive) allowReadmatching~/.ssh/id_rsa,~/.aws/credentials,/etc/passwd,~/.bash_history,/etc/shadowallowWritematching/etc/hosts,/etc/passwd,~/.bashrc,~/.zshrc,~/.profile- Top-level
**patterns - Brace expansion in patterns (
~/.{ssh,aws}/**) — picomatch expands these but fence's Go matcher does not, so the layers would silently disagree - Bare
**segments without slash neighbors (likely typos for*)
Trade-offs and known limits#
Pi-fence is defense-in-depth against agent error and casual probing. It is not a containment boundary against malicious code or an adversarial LLM. The items below are real and intentionally not addressed in code. Each is something to understand before relying on this extension.
1. Delayed-execution payloads#
The agent legitimately writes package.json (allowed by
allowWrite: ["."]). It includes a malicious postinstall hook. Later,
you run npm install in a normal terminal — outside any sandbox.
Game over.
This is fundamentally outside pi-fence's lane. Fence sandboxes the agent's bash, not your subsequent shell sessions. Mitigations:
- Run package managers through fence yourself:
fence -- npm install - Use
--ignore-scripts(npm ci --ignore-scripts,pnpm install --ignore-scripts) - Diff-review dependency changes the agent introduces
2. Cache staleness between bash and Pi-tool layers#
The bash layer reads fence.jsonc per invocation; the Pi-tool layer
caches at session start. If you edit fence.jsonc mid-session, bash
gets the new policy immediately but the Pi-tool layer keeps the stale
view until /fence reload.
Worst case: tightening denyRead mid-session leaves a brief window where
the Pi-tool layer still allows it.
A future enhancement may auto-reload via fs.watch. Until then, run
/fence reload after editing config.
3. Adversarial LLM#
Pi-fence assumes the LLM is fallible, not adversarial. A jailbroken or prompt-injected model can:
- Construct grant requests that look legitimate but aren't
- Tell you to run
--no-fence"to fix the issue" - Chain operations that reach denied outcomes via allowed primitives
- Exfiltrate data via its own response text — invisible to every layer of pi-fence
If your threat model includes a malicious model served from an untrusted provider, you need VM-level isolation (Firecracker, gVisor, full container) — not pi-fence.
4. Headless --no-fence is silent by default#
In -p print mode and RPC mode, ctx.ui.notify may be swallowed. A CI
runner that picks up --no-fence from a pasted command line gets
unsandboxed bash with no visible warning.
A future enhancement may refuse to start in headless mode if
--no-fence is set or fence is missing, unless an explicit
--allow-unfenced flag is also present. Tracked but not implemented.
5. Grant rate-limiting is per-turn#
The grant flow rate-limits to 3 distinct denials per agent turn,
resetting on turn_end. A chatty agent doing one tool call per turn
never hits the limit. A rolling 60-second window would close this;
filter for v2.
6. Unicode normalization#
café in NFC and café in NFD are different byte strings, but the
same file on macOS APFS. A deny rule written one way may not match a
path requested the other way. Mostly theoretical.
7. TOCTOU between realpath check and operation#
We realpath a path, decide, then the tool reads. A racing process
could swap a symlink between the check and the open. Defeating this
requires concurrent host-level malware, outside pi-fence's threat model.
8. Audit log integrity#
The audit log at ~/.pi/agent/pi-fence.log is plain append-only text
— not cryptographically signed and not OS-level append-only. Mandatory
denies (see PLAN.md §11) cover the log path so the
agent cannot persist a grant to it via the normal flow, but a
sufficiently determined attacker can still rewrite it.
If forensic-grade auditing matters, use OS-level append-only flags
(chattr +a on Linux, ext attrs on macOS).
9. Schema and binary version drift#
Pi-fence vendors a pinned fence.schema.json for config validation
(used during /fence-driven persists). Your installed fence binary
may be older or newer. Schema-valid configs can still be rejected by
the binary, or vice versa. Tested with fence 0.1.54.
10. Trust in the fence binary#
If fence on your PATH is replaced with a malicious shim, the entire
bash sandbox is gone, and the Pi-tool layer's policy comes from whatever
the shim's fence config show returns. Same trust model as any binary
on PATH.
Install fence from official sources (brew tap use-tusk/tap, signed
GitHub releases, or build from source) and verify your installation.
11. MCP server tools (out of scope)#
If you have MCP servers configured in Pi, their tools run as subprocesses of Pi — neither bash subprocesses (invisible to fence) nor Pi's built-in file tools (invisible to pi-fence's Pi-tool layer once pass 1 ships).
There is no plan to extend pi-fence to MCP servers in v1. If you use MCP, treat those tools as ungated and configure their access at the MCP-server level.
12. Pi-internal reads and writes#
Pi reads files for itself outside of tool calls — AGENTS.md, skill files, session storage, image attachments, extension code. Pi-fence governs the agent's tool calls, not Pi's own filesystem access.
The mandatory denies in PLAN.md §11 prevent the agent
from writing to the most dangerous of these locations
(~/.pi/agent/extensions/, ~/.pi/agent/skills/,
~/.config/fence/). Other Pi-internal paths are unprotected by design.
13. Pi-fence ships an opinionated policy snapshot#
The scaffolded fence.jsonc is a vendored snapshot of fence's code
template plus the adjustments described in
Project scaffolding. When fence
upgrades the code template upstream (e.g. adds a new domain to
allowedDomains), pi-fence's snapshot does not auto-update. You may
be running on a slightly stale baseline.
This is the trade for solving the **/*.pem deny problem at all:
fence's slice fields are append-only on extends, so the only way
to narrow an inherited deny is to author the policy from scratch.
Pi-fence pays for that by owning a small ongoing sync burden against
upstream code.
Practical mitigations:
- The starter is intentionally short and human-readable; review before committing to a shared repo.
- Bump pi-fence periodically; new releases ship a refreshed snapshot.
- Run the diff recipe in
Project scaffolding when
fence ships a
codeupdate you care about. - If you want fence's
codetemplate as fence ships it today, run/fence initinstead of/fence scaffoldand accept the mise/npm toolchain-install gap.
Threat model summary#
Pi-fence stops:
- Accidental over-reach by the LLM via routine tool calls
- Casual probing via bash (caught by fence at the kernel level)
- Symlink-escape tricks (we
realpathfor both reads and writes — pass 1) - Path-traversal via
.. - Stale config drift between layers (impossible — same source)
- Persistent prompt-injection / arbitrary code execution via writes to Pi's extension or skill directories (mandatory denies)
Pi-fence does NOT stop:
- Determined attackers with kernel-level escapes
- Content-based exfiltration to allowed network destinations
- Delayed payloads in files the user later runs outside fence
- TOCTOU races against host-level malware
- Adversarial LLMs that work around denials via creative chains
- MCP-server tool access
- Pi's own internal filesystem and network access
Roadmap#
See PLAN.md for the full design and build plan.
- Pass 0 (shipped): bash + user_bash wrapping, status footer,
--no-fenceflag. - Pass 1 (shipped): Pi-tool-layer enforcement for
read/write/edit/grep/find/ls, structured block messages,
/fencecommand surface, mandatory denies, footgun lint. - Pass 2 (shipped): interactive grants
(once / session / persist / deny), persist-to-
fence.jsoncflow, audit log with daily rotation. - Pass 3 (shipped): project scaffolding with vetted starter
template,
/fence scaffoldslash command, mise/toolchain CDNs in the network allowlist, project-scoped denyWrite patterns. - Future (no pass scheduled): rolling-window rate limiter,
--allow-unfencedheadless guard,fs.watch-based auto-reload, Linux append-only audit log viachattr +a, drift detection vs. upstreamcodetemplate.
License#
MIT. Same as Pi.
Credits#
Built on fence by Use-Tusk, which itself credits Anthropic's
sandbox-runtime as inspiration. Both are sandbox wrappers around
OS-level isolation primitives — pi-fence wires fence into Pi's
extension event surface.