pi-fence — design and build plan#
Status snapshot:
- Pass 0 (shipped): bash + user_bash wrapping via the
fencebinary, startup detection, footer status,--no-fenceflag. - Pass 1 (shipped): FS-policy layer driven by
fence.jsonc, structured block messages to the LLM,/fence//fence reload//fence initcommands, 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.logwith 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_startin a git project with nofence.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/**inallowWrite, denyWrite patterns scoped to./**/*instead of**/*. The template is self-contained (noextends) 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**/*.pemdeny that breaksmise install go|node|.... New/fence scaffoldslash command forces the prompt; existing/fence initkeeps proxying tofence 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:
- Bash subprocesses — wrapped via the
fencebinary (kernel-level sandbox: Seatbelt on macOS, bubblewrap + Landlock on Linux). - Pi's own file tools (
read,write,edit,grep,find,ls) — gated by an in-processtool_callinterceptor 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.jsoncresolution + 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 initcommands- 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_callevents - Pi's own network calls (LLM API requests, etc.)
- A proactive
request_permissiontool — 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.
-
policy/resolve.tsexecFile("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.denyWritearray. These are non-overridable — even if the user explicitly adds the same path toallowWrite, our deny stays. - Return
{ resolved: FenceConfig, chain: string[], available: boolean, caseInsensitive: boolean } - Cache per-session in module state; refresh in
session_startand on/fence reload - Handle: fence missing (return
{ available: false }), invalid JSON, timeout
-
policy/match.tscanonicalize(rawPath, cwd)—path.resolvethen 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.
-
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
- If
- Write tools (
write,edit):- If
!matchesAny(allowWrite): block (writes are default-deny) - If
matchesAny(denyWrite): block - Else: allow
- If
- Decision shape:
{ allow: true } | { block: true, rule: string, path: string, suggestion: string }
- Pure function:
-
tool_callwiring inindex.ts- Hook
tool_callfor 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
- Hook
-
/fenceand/fence reload/fence: pretty-print resolved policy with per-field "applies to: bash / fs / both" annotations/fence reload: re-call resolve, refresh status footer
-
policy/lint.ts- Run once at session_start after resolve
- Warn if cwd ===
$HOME - Warn if any pattern in
allowRead/allowWritematches canary paths (/etc/passwd,~/.ssh/id_rsa,~/.aws/credentials) - Warn via
ctx.ui.notifyonce 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_patternfilled when a deny rule matched (e.g.denyRead: ["~/.ssh/**"]); null when the implicit default-deny firedpolicy_sourcepoints at the file the user should edit
7. Persist target — project root only#
Locating project root#
git rev-parse --show-toplevelfrom cwd. If success → that's root.- 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.jsoncif present- else
<root>/fence.jsonif 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 ./innocentthenread("./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] persistrequires 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 explicity. 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 viapi.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#
- Check session grants (in-memory, sourced from
pi.appendEntryhistory) - 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.logexists, rename topi-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 ofallowRead/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 underskills/andprompts/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 offence.jsonc. This is the highest-severity escalation path in the whole system. - Global fence config. Writing to
~/.config/fence/fence.jsoncsilently 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.tschecks 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
denyWritevia 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. Iffence config showis 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-writeflags 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 tofence config initin 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 callingbash, 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.jsonfromhttps://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):
fenceon PATH — required for both layers; symmetric fail-open if missinggit— 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 trueat 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-inchild_processwith promisified spawn, no need forexeca - 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-grantcustom entry type - Whether the prompt component lives in
grants/prompt.tsor is registered as a custom UI element viactx.ui.custom()(probably the former for v1; latter is more flexible but heavier) - Backup file naming format (
fence.jsonc.bak.1715357400vs ISO-8601 — pick whichever sorts cleanly inls)
17. Testing notes#
Manual smoke tests (no formal test suite for v1; revisit if this gets extracted to a package):
- Happy path:
fence.jsoncwithdefaultDenyRead: true, allowRead: ["."], allowWrite: ["."]`. Verify:read("./README.md")allowedread("/etc/passwd")blocked, structured errorwrite("/tmp/foo")blockedbash("cat /etc/passwd")blocked (fence binary)
- Symlink escape:
ln -s ~/.ssh /tmp/x, thenread("/tmp/x/id_rsa")— must resolve to~/.ssh/id_rsaand block - Grant flow: trigger a deny, pick
[s]session — verify same path succeeds on retry. Pick[p]persist — verifyfence.jsoncupdates and validates. - Missing fence.jsonc: trigger persist; verify scaffold prompt. Accept; verify created file is schema-valid.
- Headless:
pi -p "read /etc/passwd"— must deny without prompt and log to audit. - Fence missing: rename
fencebinary; verify warning, both layers no-op, status footer reads⚠ unfenced (fence missing). - Lint: start pi from
$HOME— verify warning. SetallowRead: ["**"]— verify warning. - Rotation: seed a
pi-fence.logwith yesterday's date, trigger an append, verify rotation topi-fence.log.YYYY-MM-DDand 7-day prune.
18. Pickup checklist#
When resuming work on this:
- Re-read this file end to end.
- Confirm
fenceis still on PATH and pass-0 still works (pi --version,/sandboxshould not exist;pi-fenceshould set the footer status). - Run
npm outdatedinextensions/pi-fence/and decide whether to bump deps. - Diff the upstream
fence.schema.jsonagainst the vendored copy. If changed, decide whether to bump. - Start with §5 step 1 (
policy/resolve.ts). Don't skip ahead — each step has a verification gate. - 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.
- Verify fence binary version is within tested range. Add a
compatibleFenceVersionsfield 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: truefor 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.mdcovering pass-0 usage, configuration pointer tofence.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.tsshells out tofence config show, parses stdout JSON + stderr chain, detects case-insensitive FS via inode probe, appends mandatory denies (§11) todenyWrite. Vendoredschema/fence.schema.jsonfrom upstream main.policy/match.tsrealpath's the deepest existing ancestor for canonicalization (symlink-escape defense) and realpath's the literal prefix of patterns so/tmpmatches realpath'd/private/tmp/...on macOS. picomatch options:dot, nocase (auto), strictSlashes, nobrace.policy/decide.tspure function over (tool, path, policy); write-default-deny, deny-overrides-allow, mandatory denies tagged withpi-fence.mandatoryDenyWriterule and a non-overridable suggestion in the structured block payload (§6).policy/lint.tswarns on cwd === $HOME, secret canaries matched byallowRead/allowWrite, top-level**patterns, brace expansion (silent layer disagreement: picomatch yes, fence's Go matcher no), and bare**segments without slash neighbors.index.tswires thetool_callevent to gateread/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
realpathOrSelfusedrequire("node:fs")which throwsReferenceErrorin ESM and silently fell through to the catch — this causedpolicy.cwdto skip realpath and break every cwd-relative pattern. Replaced with a top-levelrealpathSyncimport. - Not yet implemented (deferred to pass 2): grants prompt + store
- persist, audit log +
/fence log, jsonc-parser/ajv editing of project-rootfence.jsonc. Pass-1 blocks return the structured error directly with a suggestion to use/fenceor edit the file by hand.
- persist, audit log +
-
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 namedpi-fence.log.YYYY-MM-DD, pruned past 7 days. All failures swallowed (audit must never block the agent).grants/store.ts: in-memoryGrantStorekeyed by${class}::${realpath}where class ∈ {read, write}.hydrate()validates each record viaisGrant; corrupted session entries are dropped silently.grants/prompt.ts:ctx.ui.selectwith deny first (default-focus), realpath shown above requested path,[p]opens a follow-upctx.ui.confirmbefore any disk write. 500ms anti-fatfinger implemented viactx.ui.onTerminalInputwithconsume: 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 viagit rev-parse --show-toplevel(fallback cwd). For existing files: jsonc-parsermodify(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: scaffoldsextends: "@base"if a non-project config sits in the chain, elseextends: "code". Refuses to write~/.config/fence/**,/etc/**, or paths inside pi-fence's own install dir (defense in depth on top offindProjectRoot).index.tstool_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 anotify(error). After successful persist werefreshPolicy()so the bash layer also sees the new rule on its next call.- Per-turn state (
deniedThisTurn,turnMode,batchPromptInFlight) is reset onturn_end.batchPromptInFlightis the only async guard against two concurrent denials both opening the batch confirm. /fencenow 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/
requiretrap 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: truealone. Probed it viafence -d -c true(Seatbelt profile) and catalogued the OS-fixed + dynamic paths in PLAN §15a. Decision: keep Pi-tool layer strict by-the-book; documentstrictDenyReadfor users who want symmetric behavior; file upstream for a documented API. README updated to recommendextends: "code"(matching fence's owndocs/agents.md) as the starter rather thandefaultDenyRead-based configs, which moves the asymmetry out of the common path entirely.
Last updated: 2026-05-10.