[READ-ONLY] Mirror of https://github.com/andrioid/pi-fence. Fence wrapper for Pi coding agent. Wraps bash calls and intercepts reads/writes according to fence config
0

Configure Feed

Select the types of activity you want to include in your feed.

pi-fence / README.md
24 kB

pi-fence#

A Pi extension that enforces one shared fence.jsonc policy across two enforcement layers:

  1. Bash subprocesses, wrapped via the fence binary (Seatbelt on macOS, bubblewrap + Landlock on Linux). Covers the LLM bash tool and user ! / !! commands.
  2. Pi's own file tools (read, write, edit, grep, find, ls), gated by an in-process tool_call interceptor 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:

  1. The built-in bash tool is replaced by a fenced version that prefixes every command with exec 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.

  2. Pi's read, write, edit, grep, find, ls tools each fire a tool_call event that pi-fence intercepts. The path is realpath'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."
    }
    
  3. 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's fence.jsonc. Picking session records the grant in session storage so it survives fork/resume.

  4. Every block decision is appended to ~/.pi/agent/pi-fence.log for forensics. Daily rotation, 7-day retention.

  5. 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 scaffold or /fence init. See Project scaffolding.

  6. The footer status indicator reflects the active state:

    • 🔒 fenced (bash + fs) — both layers active
    • 🔒 fenced (bash) — no policy — fence available, no fence.jsonc (FS layer is dormant; bash uses fence defaults)
    • ⚠ unfenced (--no-fence) — bypassed via the per-session flag
    • ⚠ unfenced (fence missing)fence binary not on PATH; symmetric fail-open with a warning notification

Requirements#

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:

  1. <project>/fence.jsonc (project-local)
  2. ~/.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 codegit 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 strictDenyRead for 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-run fence config show and refresh the cached policy. Use after editing fence.jsonc mid-session.
  • /fence scaffold — write pi-fence's starter fence.jsonc to the project root. Refuses to clobber an existing config; use this if you previously declined the session_start prompt or want to upgrade a minimal extends: "code" stub.
  • /fence init — proxy to fence config init in cwd. Writes the minimal extends: "code" stub fence ships by default. Prefer /fence scaffold for projects you actually want pi-fence to manage.
  • /fence log [N] — tail the last N audit 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 via pi.appendEntry so it survives fork/resume. Cleared on /new. Logged as grant-session.
  • persist: session grant plus writes the path into the project-root fence.jsonc (see below). Requires a second confirmation. Logged as grant-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)
  • allowRead matching ~/.ssh/id_rsa, ~/.aws/credentials, /etc/passwd, ~/.bash_history, /etc/shadow
  • allowWrite matching /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 code update you care about.
  • If you want fence's code template as fence ships it today, run /fence init instead of /fence scaffold and 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 realpath for 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-fence flag.
  • Pass 1 (shipped): Pi-tool-layer enforcement for read/write/edit/grep/find/ls, structured block messages, /fence command surface, mandatory denies, footgun lint.
  • Pass 2 (shipped): interactive grants (once / session / persist / deny), persist-to-fence.jsonc flow, audit log with daily rotation.
  • Pass 3 (shipped): project scaffolding with vetted starter template, /fence scaffold slash command, mise/toolchain CDNs in the network allowlist, project-scoped denyWrite patterns.
  • Future (no pass scheduled): rolling-window rate limiter, --allow-unfenced headless guard, fs.watch-based auto-reload, Linux append-only audit log via chattr +a, drift detection vs. upstream code template.

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.