[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 / PLAN.md
39 kB

pi-fence — design and build plan#

Status snapshot:

  • Pass 0 (shipped): bash + user_bash wrapping via the fence binary, startup detection, footer status, --no-fence flag.
  • Pass 1 (shipped): FS-policy layer driven by fence.jsonc, structured block messages to the LLM, /fence / /fence reload / /fence init commands, footgun lint, footer state machine, mandatory denyWrite floor (PLAN §11) on the Pi-tool layer.
  • Pass 2 (shipped): interactive grant prompt with default-deny + realpath display + 500ms anti-fatfinger + persist 2nd-confirm; session grants via pi.appendEntry (restored on fork/resume); persist-to-disk via jsonc-parser modify + ajv validate + atomic rename + 3-deep .bak rotation; audit log at ~/.pi/agent/pi-fence.log with daily rotation + 7-day retention; per-turn rate limiter with batch confirm at N=3; /fence log [N] slash command.
  • Pass 3 (shipped): project scaffolding. On session_start in a git project with no fence.jsonc, prompt to write a vendored starter template (policy/starter-template.ts) tuned for agent workflows: mise infrastructure + nodejs.org + dl.google.com + hashicorp + rust toolchain CDNs in the network allowlist, ~/Library/Caches/** in allowWrite, denyWrite patterns scoped to ./**/* instead of **/*. The template is self-contained (no extends) because fence's slice-merge is append-only — a child config cannot remove inherited rules, so we author the policy from scratch to narrow the over-broad **/*.pem deny that breaks mise install go|node|.... New /fence scaffold slash command forces the prompt; existing /fence init keeps proxying to fence config init. Idempotent: the prompt fires once per session and is silenced as soon as a project-scoped config exists in the resolved chain. See PLAN §"Starter template" for the snapshot rationale and drift discussion.

This document is the contract. If anything below is unclear or feels wrong when you pick this up, push back before writing code.


1. One-line description#

pi-fence is a single Pi extension that enforces one shared fence.jsonc policy across two surfaces:

  1. Bash subprocesses — wrapped via the fence binary (kernel-level sandbox: Seatbelt on macOS, bubblewrap + Landlock on Linux).
  2. Pi's own file tools (read, write, edit, grep, find, ls) — gated by an in-process tool_call interceptor that reads the same fence config.

The two layers cannot disagree because they consume the same resolved config via fence config show.

2. Scope#

In scope:

  • Bash + user_bash → fence binary
  • Built-in file tools (read, write, edit, grep, find, ls)
  • fence.jsonc resolution + caching
  • Structured block messages to the LLM
  • Interactive grant prompts (TUI only)
  • Session-scoped grants via pi.appendEntry
  • Persist-to-disk grants (project-root file only, schema-validated)
  • Audit log with daily rotation
  • /fence, /fence log, /fence reload, /fence init commands
  • One-shot lint warnings for footgun configs

Out of scope (document loudly in README):

  • MCP server tools (neither bash subprocess nor built-in tool — invisible to both layers)
  • Pi-internal reads (session storage, AGENTS.md, image attachments, extension files) — these are not tool_call events
  • Pi's own network calls (LLM API requests, etc.)
  • A proactive request_permission tool — revisit in v2 if the deny-then-grant loop is too friction-heavy
  • Wrapping non-bash extensions or remote-tool plugins

3. Locked design decisions#

Question Decision
Reads default Mirror fence: default-allow unless defaultDenyRead: true. Recommended starter is extends: "code" (per fence's own agents.md).
Layer alignment under defaultDenyRead Pi-tool layer stays strict (no implicit system-path allowlist). Users who want both layers symmetric add strictDenyRead: true. We do not auto-mirror fence's implicit defaults — there's no documented API for them today (only -d debug-output parsing of Seatbelt/Landlock profiles, which isn't a stable contract). See PLAN §Layer alignment.
Glob library picomatch strict mode
Fence binary missing Symmetric fail-open: no enforcement on either side, one warning
First run, no fence.jsonc Notify, run unfenced, suggest /fence init
Headless mode (!ctx.hasUI) No prompts; deny is final; every block hits the audit log
Block UX for the LLM Structured payload (rule, path, suggestion) — see §6
Interactive grants Yes, with [once] [session] [persist] [deny]
Session grant storage pi.appendEntry (survives fork/resume; cleared on /new)
Persist target Project-root file only (<repo-root>/fence.jsonc) — see §7
Persist format jsonc-parser for editing, ajv to validate against vendored schema
Audit log ~/.pi/agent/pi-fence.log, daily rotation, 7-day retention
Lint warnings Warn-only, never block — cwd === $HOME, broad globs, etc.
MCP support Out of scope, document the gap
Proactive permission tool Skip v1
Glob case sensitivity Auto-detected from cwd's filesystem; nocase: true on macOS APFS by default
Brace expansion in globs Disabled (nobrace: true) — fence's matcher doesn't support it; lint warns on braces in patterns
Mandatory denies Non-overridable denyWrite for ~/.pi/**, ~/.agents/skills/**, ~/.config/fence/**, audit log — see §11
Grant prompt default [deny] (not [once]); shows realpath; [persist] requires a second confirmation; 500ms anti-fatfinger delay

4. Architecture#

pi-fence/
├── index.ts              ← entry; wires layers, owns footer status, registers /fence
├── bash.ts               ← bash + user_bash wrappers (mostly unchanged from pass 0)
├── policy/
│   ├── resolve.ts        ← `fence config show` + cache + chain parsing
│   ├── match.ts          ← picomatch wrapper + realpath-deepest-existing-ancestor
│   ├── decide.ts         ← block/allow logic, returns structured decision
│   └── lint.ts           ← one-shot footgun warnings
├── grants/
│   ├── prompt.ts         ← ctx.ui.confirm dialog (TUI only)
│   ├── store.ts          ← session-scoped grants via pi.appendEntry
│   └── persist.ts        ← project-root edit-or-scaffold + ajv validation
├── audit.ts              ← append + rotate
├── schema/
│   └── fence.schema.json ← vendored from upstream
├── package.json
├── tsconfig.json
├── PLAN.md               (this file)
└── README.md

Data flow on a tool_call#

LLM calls read({ path })
  ↓
tool_call event handler
  ↓
policy/match.ts: resolve → realpath → check against cached policy
  ↓
policy/decide.ts: returns
    { allow: true } | { block: true, rule, path, suggestion }
  ↓
on block:
  - audit.ts: append log entry
  - if TUI present:
      grants/prompt.ts: ask user [once / session / persist / deny]
      on grant: register in grants/store.ts (and grants/persist.ts if persist)
      on grant: re-emit allow, tool proceeds
  - else:
      return structured error to LLM

5. Pass 1 (core) — implementation order#

Build in this order so each step is independently verifiable.

  1. policy/resolve.ts

    • execFile("fence", ["config", "show"], { cwd })
    • Parse stdout JSON (resolved config)
    • Parse stderr lines for the resolution chain (file paths fence loaded)
    • Detect case-insensitive filesystem: fs.statSync(cwd.toLowerCase()) and .toUpperCase() and compare inodes; if they match, we're on a case-insensitive FS (typical macOS APFS, Windows NTFS). Cache the boolean alongside the resolved policy.
    • Apply mandatory denies (§11) by augmenting the resolved policy's filesystem.denyWrite array. These are non-overridable — even if the user explicitly adds the same path to allowWrite, our deny stays.
    • Return { resolved: FenceConfig, chain: string[], available: boolean, caseInsensitive: boolean }
    • Cache per-session in module state; refresh in session_start and on /fence reload
    • Handle: fence missing (return { available: false }), invalid JSON, timeout
  2. policy/match.ts

    • canonicalize(rawPath, cwd)path.resolve then realpath the deepest existing ancestor (same logic we used in the deleted andridk-sandbox)
    • matchesAny(absPath, patterns[], { caseInsensitive }) — picomatch with { dot: true, nocase: caseInsensitive, strictSlashes: true, nobrace: true }. We disable brace expansion because fence's Go matcher doesn't support it; allowing it here would create silent disagreement between our Pi-tool layer and the bash layer.
    • Document picomatch ↔ Go filepath.Match divergences in README. Known: character classes, escape sequences, the literal-{/} interpretation.
  3. policy/decide.ts

    • Pure function: (toolName, absPath, policy) → Decision
    • Read tools (read, grep, find, ls):
      • If defaultDenyRead && !matchesAny(allowRead): block
      • If matchesAny(denyRead): block (overrides allowRead)
      • Else: allow
    • Write tools (write, edit):
      • If !matchesAny(allowWrite): block (writes are default-deny)
      • If matchesAny(denyWrite): block
      • Else: allow
    • Decision shape: { allow: true } | { block: true, rule: string, path: string, suggestion: string }
  4. tool_call wiring in index.ts

    • Hook tool_call for the six file tools
    • Resolve path → decide → on block, return { block: true, reason } with a serialized structured payload as the reason
    • Fence missing or no config: skip enforcement, log nothing
  5. /fence and /fence reload

    • /fence: pretty-print resolved policy with per-field "applies to: bash / fs / both" annotations
    • /fence reload: re-call resolve, refresh status footer
  6. policy/lint.ts

    • Run once at session_start after resolve
    • Warn if cwd === $HOME
    • Warn if any pattern in allowRead/allowWrite matches canary paths (/etc/passwd, ~/.ssh/id_rsa, ~/.aws/credentials)
    • Warn via ctx.ui.notify once per session

Verification gate for pass 1: with a fence.jsonc containing defaultDenyRead: true, allowRead: ["."], read("/etc/passwd") is blocked with a structured error, read("./README.md") works, and bash("cat /etc/passwd") is also blocked (by the existing fence binary).

6. Structured block message format#

The reason string we return from tool_call's { block: true, reason } should be valid JSON the LLM can parse, but readable as text if it doesn't. Format:

pi-fence: blocked
{
  "tool": "read",
  "path": "/Users/andri/.ssh/id_rsa",
  "matched_rule": "filesystem.defaultDenyRead",
  "matched_pattern": null,
  "policy_source": "/Users/andri/repo/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."
}

Rationale:

  • Leading human prefix so terse error rendering still makes sense
  • JSON body so the agent can self-correct or surface a clean ask to the user
  • matched_pattern filled when a deny rule matched (e.g. denyRead: ["~/.ssh/**"]); null when the implicit default-deny fired
  • policy_source points at the file the user should edit

7. Persist target — project root only#

Locating project root#

  1. git rev-parse --show-toplevel from cwd. If success → that's root.
  2. Else: cwd itself.

We do not walk upward looking for an existing fence.jsonc. Project root is a stable, predictable target.

Edit target#

  • <root>/fence.jsonc if present
  • else <root>/fence.json if present
  • else proposed <root>/fence.jsonc (does not yet exist)

Persist flow — three branches#

A) File exists at project root.

1. Read current contents (record mtime).
2. Parse via jsonc-parser → CST (preserves comments, formatting).
3. modify({ filesystem.allowRead | filesystem.allowWrite }, append op).
4. Apply edits, get new text.
5. JSON.parse the new text and validate against vendored schema (ajv).
6. mtime check: if file changed on disk since step 1, abort with a clean
   error. Tell user to retry.
7. Write to <file>.tmp, fsync, rename.
8. Backup: copy original to <file>.bak.<unix-ts> before rename. Keep last
   3 backups, delete older.

B) File missing at project root.

Confirm with user:

pi-fence: no fence.jsonc found in <project-root>.
Create one with this grant?
  [y] yes, create <root>/fence.jsonc
  [n] no, just allow for the session

If yes, scaffold:

{
  "$schema": "https://raw.githubusercontent.com/Use-Tusk/fence/main/docs/schema/fence.schema.json",
  "extends": "<inherit>",
  "filesystem": {
    "allowRead": ["<the-path>"]
  }
}

<inherit> selection:

  • "@base" if any non-project fence config exists in the resolved chain (e.g., ~/.config/fence/fence.jsonc). Layers project file on top of user's global config — fence's own documented pattern.
  • "code" if no global config exists. Sensible default for coding agents.

Validate the scaffolded JSON against the schema, then atomic-write.

C) File only exists outside project root (e.g., only at ~/.config/fence/).

Treated as case (B). We do not edit the global file. The scaffolded project-root file uses extends: "@base" to inherit the global. Notify user:

pi-fence: persist requires a project-local fence.jsonc.
Your existing config at <path> will be inherited via `extends: "@base"`.
Create <root>/fence.jsonc?

Why these rules#

  • Never touch ~/.config/fence/. Per-user global affects every project; pi-fence shouldn't silently mutate it.
  • Never edit nested fence files in subdirectories. Power-user feature; out of pi-fence's lane.
  • git-aware project root. Doesn't shift when you cd src/ mid-session.

What "user-owned" was, and why it dissolved#

Earlier draft included a path classifier to skip fence's built-in templates (code, code-strict, etc.) and fence install-dir paths. With project-root-only, we don't need it: the only path we ever write is <root>/fence.jsonc, which is by definition user territory.

Remaining edge cases the persist code still handles:

  • File exists but unwritable (EACCES) → clean error
  • File is a symlink → realpath; refuse if target is in fence install dir or /etc (paranoia, cheap)
  • File is malformed JSONC → don't try to edit; tell user to fix manually
  • File is valid JSONC but fails schema before our edit → still attempt the edit; abort only if our edit produces invalid output. We're not the linter for existing config.

8. Grants — semantics and storage#

Grant flow on block (TUI present)#

┌─ pi-fence: read denied ──────────────────────────────────────┐
│ Path:   /Users/andri/other-repo/package.json                 │
│ Tool:   read                                                 │
│ Rule:   filesystem.defaultDenyRead (no allowRead match)      │
│                                                              │
│  [a] allow once   [s] session   [p] persist   [d] deny       │
└──────────────────────────────────────────────────────────────┘

Mandatory safety properties of the prompt:

  • Default-focused button is [d] deny. A user who reflexively hits Enter denies the access. Intentional friction.
  • Resolved (realpath) path is shown first, requested path shown as secondary. Defends against an agent that does ln -s ~/.ssh ./innocent then read("./innocent/id_rsa") — the prompt makes the actual target visible.
  • 500 ms anti-fatfinger delay before any keypress is accepted. Prevents a chain of denials from being grant-bombed by a single keypress.
  • [p] persist requires a second confirmation step. After picking persist, show a separate confirm: Persist <realpath> to <root>/fence.jsonc? [y/N]. The persist write happens only on explicit y. Never combine "grant for this call" with "write to disk" in one keystroke.

Action semantics:

  • [a] allow once — this single tool call only; no record kept
  • [s] session — store grant via pi.appendEntry("pi-fence-grant", {...}); applies for the rest of the session; survives fork/resume; cleared on /new
  • [p] persist — session grant plus edit-or-scaffold <root>/fence.jsonc (see §7), only after the second confirmation
  • [d] deny — return the structured error to the LLM; no grant

Grant matching#

A grant is keyed by { tool: "read"|"write", path: <realpath> }. Grants match exactly (no globs); each unique blocked path gets its own grant. Rationale: keeps the grant list short, predictable, easy to revoke.

Grant lookup order on each tool_call#

  1. Check session grants (in-memory, sourced from pi.appendEntry history)
  2. If no match, run policy/decide.ts

Persisted grants live in fence.jsonc and flow back through the fence config show resolver, so they don't need separate runtime tracking.

Rate-limiting prompts#

To avoid prompt-fatigue exploitation by an aggressive agent:

  • Track { deniedPathsThisTurn: Set<string> }.
  • On the Nth distinct denial in a single agent turn (start with N=3), collapse the prompt to a single batch confirm: "Agent has been denied 3+ paths this turn. Allow individually, allow all once, or deny all?"
  • Reset the counter at turn_end.

Headless mode#

ctx.hasUI === false → no prompts. Every block returns the structured error directly to the LLM and appends to the audit log. Operators configure policy via fence.jsonc upfront. Matches fence binary's own ethos.

9. Audit log#

  • Path: ~/.pi/agent/pi-fence.log
  • Format: one JSON object per line (newline-delimited)
  • Schema:
    {
      "ts": "2026-05-10T14:30:01.123Z",
      "session": "<session-id>",
      "decision": "block" | "grant-once" | "grant-session" | "grant-persist",
      "tool": "read",
      "path": "/Users/andri/.ssh/id_rsa",
      "rule": "filesystem.defaultDenyRead",
      "pattern": null
    }
    
  • Rotation: daily. On the first append of a UTC day, if pi-fence.log exists, rename to pi-fence.log.YYYY-MM-DD. Delete files older than 7 days.
  • /fence log [N]: tail last N lines (default 20), pretty-printed.
  • Bash-side denials are not in this log (fence has its own monitor mode, -m). Document this in the README.

10. Footgun lint (one-shot at session_start)#

Probe the resolved policy with canary paths:

Canary Warn if allowed
/etc/passwd allowRead matches
~/.ssh/id_rsa allowRead matches
~/.aws/credentials allowRead matches
~/.bash_history allowRead matches
/etc/hosts allowWrite matches
~/.bashrc, ~/.zshrc allowWrite matches

Plus:

  • Warn if cwd === os.homedir() ("workspace is your home directory; fence policies will be very permissive — consider running pi from a project subdirectory")
  • Warn on ** at top level of allowRead/allowWrite (matches everything)
  • Warn on brace expansion in any pattern (e.g., ~/.{ssh,aws,gnupg}/**). picomatch supports braces, fence's Go matcher doesn't. Without this warning the user thinks they've denied three paths but only the Pi-tool layer is actually denying them; bash sees no rule. Suggest expanding to separate entries.
  • Warn if any pattern contains a literal ** segment without slashes around it (often a typo for *).

Each warning fires once per session via ctx.ui.notify at "warning" level. Never blocks.

11. Mandatory denies (non-overridable)#

Some paths are dangerous to write regardless of user policy because writing to them gives the agent persistent influence over future Pi sessions — effectively, persistence + arbitrary code execution. The resolver augments every loaded policy with these denies, and they cannot be overridden by the user's allowWrite rules.

filesystem.denyWrite (mandatory, applied last after user rules):
  - ~/.pi/agent/extensions/**       # extension code is loaded as TS at startup
  - ~/.pi/agent/skills/**           # skills become part of the system prompt
  - ~/.pi/agent/prompts/**          # prompt templates same effect
  - ~/.agents/skills/**             # alternate skills location
  - ~/.config/fence/**              # editing global fence config silently broadens policy
  - ~/.pi/agent/pi-fence.log*       # audit log integrity
  - <ext-install-dir>/**            # any path inside the running pi-fence install

Rationale by entry:

  • Pi extensions / skills / prompts. Pi auto-loads any TS file under ~/.pi/agent/extensions/ at session start, and any markdown / JSON under skills/ and prompts/ is folded into future system prompts. An agent that can write here owns every future Pi session via prompt-injection or arbitrary code execution, regardless of fence.jsonc. This is the highest-severity escalation path in the whole system.
  • Global fence config. Writing to ~/.config/fence/fence.jsonc silently broadens policy for every project on the machine. The agent could grant itself global allowRead/allowWrite without the user ever seeing a prompt.
  • Audit log. If the agent can rewrite ~/.pi/agent/pi-fence.log, post-incident forensics is gone. Append-only on disk is a different mechanism (chattr +a / extended attributes) and out of scope; this denyWrite is the practical floor.
  • pi-fence's own install dir. Belt-and-braces protection against an agent rewriting its own enforcement code mid-session.

Applies on both layers:

  • Pi-tool layer: policy/decide.ts checks the mandatory deny list before any user-configured rules. A match returns block with rule = pi-fence.mandatoryDenyWrite. This is the layer we control fully.
  • Bash layer: these paths are appended to fence's denyWrite via the resolved config we hand off to the binary at startup time. Caveat: we can only ensure this for the bash side if we can mutate the resolved config that fence enforces. If fence config show is read-only and fence doesn't accept a runtime augmentation, bash-side mandatory denies require either:
    • emitting a project-root scaffold with these denies pre-populated, or
    • shelling out as fence -- bash -c <cmd> with --deny-write flags if/when fence supports them (currently it does not — needs upstream feature request or our own pre-flight wrapper).

For v1, document the bash-side limitation clearly and ensure the Pi-tool layer always enforces. The skills/extensions deny is the most critical and that one we control fully because it's a Pi-tool-layer concern (Pi loads those via Pi's own Node, not via bash).

What about ~/.pi/agent/sessions/?#

Not in the mandatory deny set. Session storage is sensitive (conversation history) but writing there doesn't grant code execution or prompt-injection over future sessions — each session is its own container. Users who want this protected can add it to their own fence.jsonc denyWrite.

Bash layer FS layer Footer text Color
active active 🔒 fenced (bash + fs) accent
active inactive (no fs rules in config) 🔒 fenced (bash) accent
inactive (no-fence flag) inactive ⚠ unfenced (--no-fence) warning
inactive (binary missing) inactive (forced by missing binary) ⚠ unfenced (fence missing) error
active inactive (no fence.jsonc) 🔒 fenced (bash) — no policy accent

13. /fence command surface#

  • /fence — show resolved policy. Each top-level field annotated with "applies to: bash | fs | both | none." Highlights the source file path for each rule when traceable.
  • /fence log [N] — tail audit log
  • /fence reload — re-resolve config without /reload-ing extensions
  • /fence init — proxy to fence config init in cwd

Skipped for v1 (revisit if useful):

  • /fence allow <path> — proactive grant
  • /fence diff — what was enforced vs what config says

14. Threat model — be honest in the README#

What pi-fence stops:

  • Accidental over-reach by the LLM via routine tool calls
  • Casual probing via bash (caught by fence binary at the kernel level)
  • Symlink-escape tricks for both reads and writes (we realpath)
  • Path-traversal via .. (resolve normalizes)
  • Stale config drift between the bash and FS layers (impossible — same source)

What pi-fence does NOT stop:

  • A determined attacker with kernel-level escapes (use Firecracker / gVisor if you need that)
  • Content-based exfiltration to allowed destinations (fence is allowlist by domain, not content)
  • TOCTOU between our realpath and the actual fs operation (requires a hostile process racing pi — outside threat model)
  • MCP-server file access (out of scope)
  • Pi's own internal file/network access (not tool calls)
  • Smart agents that work around denials by, e.g., writing a script in . and calling bash, where bash then does something fence allows. The bash side catches genuine system-file reads, but agents can be creative.

The honest pitch: stops accidental and casual over-reach across both the agent's tools and any subprocess it spawns, with a single config and no drift. Not a containment boundary against malicious code.

15. Dependencies#

Runtime:

  • picomatch (~10KB)
  • jsonc-parser (~30KB)
  • ajv + ajv-formats (~150KB combined)

Vendored:

  • fence.schema.json from https://raw.githubusercontent.com/Use-Tusk/fence/main/docs/schema/fence.schema.json — pinned to the version we test against. Bump deliberately.

Dev:

  • existing: @mariozechner/pi-coding-agent, @types/node, typescript

External binaries (not npm-managed):

  • fence on PATH — required for both layers; symmetric fail-open if missing
  • git — used to find project root; fall back to cwd if absent

15a. Layer alignment#

Fence's bash layer treats defaultDenyRead: true (without strictDenyRead) as "deny reads outside allowRead plus a built-in allowlist of system paths". The implicit allowlist (probed via fence -d -c true on macOS 0.1.54):

  • OS-fixed: /, /Applications, /Library, /System, /bin, /dev, /etc, /lib, /lib64, /nix, /opt, /opt/homebrew, /private/etc, /private/tmp, /private/var/db, /private/var/run, /proc, /run, /sbin, /snap, /sys, /tmp, /usr, /usr/local
  • Dynamic per-host (only when present): ~/.bun/bin, ~/.cargo/bin, ~/.deno/bin, ~/.fnm, ~/.go, ~/.local/bin, ~/.local/pipx, ~/.n, ~/.nvm, ~/.pyenv, ~/.rbenv, ~/.rustup, ~/.rvm, ~/.volta, ~/bin, ~/go/bin
  • Plus realpath'd cwd

The Pi-tool layer in policy/decide.ts does not honor any of these. Result: under defaultDenyRead: true alone, read("/etc/passwd") is blocked but bash("cat /etc/passwd") succeeds.

Decision#

Stay strict by-the-book on the Pi-tool layer. Document the gap; recommend strictDenyRead: true for users who explicitly opt into defaultDenyRead. Fence's own agents.md recommends extends: "code" (no defaultDenyRead) for coding agents — under that template both layers default-allow reads and there is no asymmetry to engineer around.

Rejected alternatives#

  • Hardcode fence's system-path list in pi-fence — rejected because it drifts from fence on upgrades and silently loosens the Pi-tool layer in a way that user mental models won't track. Buys little since the recommended baseline (extends: "code") doesn't have the problem.
  • Parse fence -d -c true at session_start — rejected because the debug-output format is undocumented and platform-specific (Seatbelt S-expressions on macOS, bwrap+Landlock setup on Linux). Brittle, fence-version-coupled.
  • Tighten Pi-tool to fence's stricter shape automatically — already what we do; the question was whether to loosen Pi-tool to match fence's default-deny + system-paths behavior, and the answer was no.

Long-term path#

File an upstream feature request for fence config show --effective (or similar) that returns the implicit-defaults-merged rule set as JSON. Once that ships, pi-fence can consume it and offer a mirrorFenceDefaults mode without parsing debug output.

16. Things to decide while writing (no need to ask first)#

  • Exact subprocess library for execFile — node's built-in child_process with promisified spawn, no need for execa
  • How to render the structured block message in the TUI (probably reuse Pi's existing tool-result error styling)
  • Whether to debounce/coalesce identical denials within ~100ms (probably yes — agents sometimes retry instantly)
  • Exact naming/casing of the pi-fence-grant custom entry type
  • Whether the prompt component lives in grants/prompt.ts or is registered as a custom UI element via ctx.ui.custom() (probably the former for v1; latter is more flexible but heavier)
  • Backup file naming format (fence.jsonc.bak.1715357400 vs ISO-8601 — pick whichever sorts cleanly in ls)

17. Testing notes#

Manual smoke tests (no formal test suite for v1; revisit if this gets extracted to a package):

  1. Happy path: fence.jsonc with defaultDenyRead: true, allowRead: ["."], allowWrite: ["."]`. Verify:
    • read("./README.md") allowed
    • read("/etc/passwd") blocked, structured error
    • write("/tmp/foo") blocked
    • bash("cat /etc/passwd") blocked (fence binary)
  2. Symlink escape: ln -s ~/.ssh /tmp/x, then read("/tmp/x/id_rsa") — must resolve to ~/.ssh/id_rsa and block
  3. Grant flow: trigger a deny, pick [s] session — verify same path succeeds on retry. Pick [p] persist — verify fence.jsonc updates and validates.
  4. Missing fence.jsonc: trigger persist; verify scaffold prompt. Accept; verify created file is schema-valid.
  5. Headless: pi -p "read /etc/passwd" — must deny without prompt and log to audit.
  6. Fence missing: rename fence binary; verify warning, both layers no-op, status footer reads ⚠ unfenced (fence missing).
  7. Lint: start pi from $HOME — verify warning. Set allowRead: ["**"] — verify warning.
  8. Rotation: seed a pi-fence.log with yesterday's date, trigger an append, verify rotation to pi-fence.log.YYYY-MM-DD and 7-day prune.

18. Pickup checklist#

When resuming work on this:

  1. Re-read this file end to end.
  2. Confirm fence is still on PATH and pass-0 still works (pi --version, /sandbox should not exist; pi-fence should set the footer status).
  3. Run npm outdated in extensions/pi-fence/ and decide whether to bump deps.
  4. Diff the upstream fence.schema.json against the vendored copy. If changed, decide whether to bump.
  5. Start with §5 step 1 (policy/resolve.ts). Don't skip ahead — each step has a verification gate.
  6. Update this file as decisions land or change. Treat the "Locked design decisions" table as authoritative; if a decision needs to change, edit the table and note it in the changelog at the bottom.
  7. Verify fence binary version is within tested range. Add a compatibleFenceVersions field to the extension if you bump.

Changelog#

  • 2026-05-10 (initial): plan written; pass 0 shipped (bash + user_bash wrapping with status footer). Pass 1 not started.

  • 2026-05-10 (security review): added §11 (mandatory denies), case-insensitive FS auto-detection, nobrace: true for picomatch, brace-pattern lint warning, grant-prompt safety properties (default-deny, realpath display, persist confirmation, fatfinger delay). Renumbered §12–§18. See conversation thread for the security gap analysis that motivated each.

  • 2026-05-10 (README): wrote README.md covering pass-0 usage, configuration pointer to fence.jsonc, and a 12-item Trade-offs section documenting the accept-and-document gaps (delayed payloads, cache staleness, adversarial LLM, headless silence, rolling rate limit, Unicode, TOCTOU, audit log integrity, version drift, fence binary trust, MCP scope, Pi-internal reads). Items planned for pass 1/2 mitigation are tagged in-line with their pass. README is the user-facing surface; this PLAN remains the build contract.

  • 2026-05-10 (pass 1): shipped FS-policy layer.

    • policy/resolve.ts shells out to fence config show, parses stdout JSON + stderr chain, detects case-insensitive FS via inode probe, appends mandatory denies (§11) to denyWrite. Vendored schema/fence.schema.json from upstream main.
    • policy/match.ts realpath's the deepest existing ancestor for canonicalization (symlink-escape defense) and realpath's the literal prefix of patterns so /tmp matches realpath'd /private/tmp/... on macOS. picomatch options: dot, nocase (auto), strictSlashes, nobrace.
    • policy/decide.ts pure function over (tool, path, policy); write-default-deny, deny-overrides-allow, mandatory denies tagged with pi-fence.mandatoryDenyWrite rule and a non-overridable suggestion in the structured block payload (§6).
    • policy/lint.ts warns on cwd === $HOME, secret canaries matched by allowRead/allowWrite, top-level ** patterns, brace expansion (silent layer disagreement: picomatch yes, fence's Go matcher no), and bare ** segments without slash neighbors.
    • index.ts wires the tool_call event to gate read/write/edit/ grep/find/ls, registers /fence, /fence reload, /fence init, and renders the §12 footer state machine.
    • Verification gate (§5) green: read(".") allowed, read("/etc/ passwd") blocked, read(symlink-to-~/.ssh) blocked via realpath, write("/tmp/...") allowed via prefix-realpath, write("~/.pi/ agent/extensions/x.ts") blocked as mandatory.
    • Bug fixed during build: initial realpathOrSelf used require("node:fs") which throws ReferenceError in ESM and silently fell through to the catch — this caused policy.cwd to skip realpath and break every cwd-relative pattern. Replaced with a top-level realpathSync import.
    • Not yet implemented (deferred to pass 2): grants prompt + store
      • persist, audit log + /fence log, jsonc-parser/ajv editing of project-root fence.jsonc. Pass-1 blocks return the structured error directly with a suggestion to use /fence or edit the file by hand.
  • 2026-05-10 (pass 2): shipped grants + persistence + audit log.

    • audit.ts: append-only NDJSON at ~/.pi/agent/pi-fence.log. Daily rotation triggered by mtime delta on first append-of-day; rotated files named pi-fence.log.YYYY-MM-DD, pruned past 7 days. All failures swallowed (audit must never block the agent).
    • grants/store.ts: in-memory GrantStore keyed by ${class}::${realpath} where class ∈ {read, write}. hydrate() validates each record via isGrant; corrupted session entries are dropped silently.
    • grants/prompt.ts: ctx.ui.select with deny first (default-focus), realpath shown above requested path, [p] opens a follow-up ctx.ui.confirm before any disk write. 500ms anti-fatfinger implemented via ctx.ui.onTerminalInput with consume: true — real key drain, not just a sleep. Headless surfaces → immediate deny. Batch prompt for rate-limit trips returns {individual, allow-all-once, deny-all}.
    • grants/persist.ts: locates project root via git rev-parse --show-toplevel (fallback cwd). For existing files: jsonc-parser modify(filesystem.<allow*>, -1, p, {isArrayInsertion: true}) preserves comments/formatting; ajv 2020-12 validates the post-edit JSON; mtime-recheck before write; .bak.<unix-ts> snapshot, atomic write via tmp+rename, prune to last 3 backups. For missing files: scaffolds extends: "@base" if a non-project config sits in the chain, else extends: "code". Refuses to write ~/.config/fence/**, /etc/**, or paths inside pi-fence's own install dir (defense in depth on top of findProjectRoot).
    • index.ts tool_call flow: session grant lookup → decide() → if block: audit, mandatory-or-headless short-circuit, otherwise consult per-turn rate limiter and prompt. Persist failure falls back to a session grant with a notify(error). After successful persist we refreshPolicy() so the bash layer also sees the new rule on its next call.
    • Per-turn state (deniedThisTurn, turnMode, batchPromptInFlight) is reset on turn_end. batchPromptInFlight is the only async guard against two concurrent denials both opening the batch confirm.
    • /fence now lists session grants alongside the resolved policy. /fence log [N] tails the audit file, default N=20, capped at 500 to keep the notify view tractable.
    • Smoke verified: scaffold creates a schema-valid file with the correct extends; edit-existing appends to the right list while preserving prior structure; refuse-global rejects writes to ~/.config/fence/; audit rotation triggers on backdated mtime; grant store correctly classifies grep/find/ls as read-class.
    • Same ESM/require trap as pass 1 caught early in persist.ts (fsyncSync, realpathSync) and audit.ts (readFileSync) — all replaced with proper top-level imports.
    • Deferred / out of scope (still): MCP-server file access, Pi-internal reads, content-based exfil, TOCTOU between canonicalize and the actual fs op, append-only audit log on disk (chattr +a / immutable bits). All documented in README §Trade-offs.
  • 2026-05-10 (layer alignment, post-pass-2): investigated whether pi-fence should auto-mirror fence's implicit "default readable system paths" allowlist that fires under defaultDenyRead: true alone. Probed it via fence -d -c true (Seatbelt profile) and catalogued the OS-fixed + dynamic paths in PLAN §15a. Decision: keep Pi-tool layer strict by-the-book; document strictDenyRead for users who want symmetric behavior; file upstream for a documented API. README updated to recommend extends: "code" (matching fence's own docs/agents.md) as the starter rather than defaultDenyRead-based configs, which moves the asymmetry out of the common path entirely.

Last updated: 2026-05-10.