# 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** (`/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 - `/fence.jsonc` if present - else `/fence.json` if present - else *proposed* `/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 .tmp, fsync, rename. 8. Backup: copy original to .bak. before rename. Keep last 3 backups, delete older. ``` **B) File missing at project root.** Confirm with user: ``` pi-fence: no fence.jsonc found in . Create one with this grant? [y] yes, create /fence.jsonc [n] no, just allow for the session ``` If yes, scaffold: ```jsonc { "$schema": "https://raw.githubusercontent.com/Use-Tusk/fence/main/docs/schema/fence.schema.json", "extends": "", "filesystem": { "allowRead": [""] } } ``` `` 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 will be inherited via `extends: "@base"`. Create /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 `/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 to /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 `/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: }`. 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 }`. - On the **N**th 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: ```jsonc { "ts": "2026-05-10T14:30:01.123Z", "session": "", "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 - /** # 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 ` 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. ## 12. Footer status state machine | 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 ` — 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., -1, p, {isArrayInsertion: true})` preserves comments/formatting; ajv 2020-12 validates the post-edit JSON; mtime-recheck before write; `.bak.` 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.