Skip to content

Permissions: damage control, not security theater

For a repo you’re still exploring, YOLO is arguably fine - worst case, git checkout undoes it. stash isn’t that repo anymore. It has a SQLite database holding every article you’ve ever saved, and a .env with the tokens the fetch worker and search index depend on. Neither is in version control, and neither comes back from git checkout. You don’t need Pi’s entire permission model rebuilt from scratch - you need exactly the handful of things that would actually hurt, gated, and everything else left alone.

The shape of the gate: a rule file plus one hook

Section titled “The shape of the gate: a rule file plus one hook”

The community pattern for this is called damage control, and it’s built entirely from primitives you already know: pi.on("tool_call", ...) and pi.on("session_start", ...). On session_start, the extension loads a small rule file - typically .pi/damage-control-rules.yaml, falling back to ~/.pi/damage-control-rules.yaml if the project doesn’t have its own - into four buckets:

.pi/damage-control-rules.yaml
zeroAccessPaths:
- path: ".env"
reason: "Holds the fetch-worker and search-index credentials."
noDeletePaths:
- path: "stash.db"
reason: "Every saved article lives here. There is no undo."
readOnlyPaths:
- path: "migrations/"
reason: "Schema changes go through a migration, not a live edit."
bashToolPatterns:
- pattern: "rm -rf"
reason: "Recursive delete - confirm what's actually being removed."
ask: true
- pattern: "DROP TABLE|DELETE FROM"
reason: "Direct SQL against stash.db bypasses the app entirely."

Then a tool_call handler inspects every attempted call against those buckets before it runs. The bookkeeping around it - resolving tilde paths, walking every read/write/grep/bash tool type, loading the YAML from project or home directory - is exactly the kind of thing worth having Pi write rather than typing by hand. The part worth seeing directly is the non-obvious bit: matching a call against the right rule bucket, then routing the response through ctx.ui.confirm or straight to ctx.abort():

// .pi/extensions/damage-control.ts (excerpt)
pi.on("tool_call", async (event, ctx) => {
// ...path/bash checks against rules.zeroAccessPaths, readOnlyPaths, noDeletePaths...
let violationReason: string | null = null;
let shouldAsk = false;
// (elided: sets violationReason/shouldAsk by matching event.input against
// the rule buckets loaded from the YAML above)
if (violationReason) {
if (shouldAsk) {
const confirmed = await ctx.ui.confirm(
"Damage-Control Confirmation",
`Dangerous command detected: ${violationReason}`,
{ timeout: 30_000 },
);
if (!confirmed) {
ctx.abort();
return { block: true, reason: `BLOCKED: ${violationReason} (user denied)` };
}
return { block: false };
}
ctx.abort();
return { block: true, reason: `BLOCKED: ${violationReason}` };
}
return { block: false };
});

A few things worth noticing, because they’re what make this a gate rather than a suggestion:

  • Path matching is deliberately loose - directory-prefix match, glob turned into regex, substring fallback against both the absolute path and the cwd-relative one - but guarded against near-miss false positives. A rule targeting stash.db shouldn’t quietly also block stash.db.bak, so the matcher checks that the character right after the match isn’t itself part of a path or file name.
  • Every decision - allowed, asked, blocked - gets written to a durable log via pi.appendEntry("damage-control-log", {...}). That’s not bookkeeping for its own sake: it’s a replayable audit trail of every close call the agent had with your data, for free, because the session’s own append-only log is where it lives.
  • An ask: true rule routes through ctx.ui.confirm(title, message, { timeout: 30_000 }) - a real blocking dialog, not a fire-and-forget notification.

You don’t need to write the surrounding path-matching and YAML-loading code yourself. Describe the gate to Pi in terms of the rule file above: “Write a Pi extension at .pi/extensions/damage-control.ts that loads .pi/damage-control-rules.yaml (falling back to ~/.pi/damage-control-rules.yaml) on session_start, then on every tool_call blocks writes/edits to zeroAccessPaths, blocks or asks-then-blocks on bashToolPatterns matches, and calls ctx.abort() on any hard block.” Review what comes back against the real shape above - specifically, that it calls ctx.abort() on the unconditional-block path (not just ask-then-deny), and that path matching doesn’t false-positive on near-miss filenames like stash.db.bak.

Here’s the part that separates this from a blunt on/off switch. An unconditional rule violation does two things: it returns { block: true, reason }, and the handler calls ctx.abort() - which kills the entire in-flight turn, not just the one tool call. That’s the right response to “the model is about to DROP TABLE”: stop everything, don’t let it try to route around the block.

But not every violation deserves that much force. Suppose the agent tries to read a file inside migrations/ to check the schema before writing a new one - technically a read-only-path hit, but not remotely dangerous. Killing the whole turn over that is just friction. So the same rule engine ships a second variant, byte-for-byte identical except for one change: it never calls ctx.abort(), and the static reason string is replaced by a small decision procedure that tells the model how to keep going -

function continueFeedback(toolName: string, reason: string, destructive: boolean): string {
if (destructive) {
return `${reason} STOP. Tell the user exactly what you need and why - do not invent a workaround.`;
}
return `${reason} Non-destructive: assume the data you can't see is present and correct, and continue without it.`;
}

Same hook, same rule file, same return channel - just tuned from a kill switch into steerable in-band guidance the model reasons over. In practice you want both files sitting next to each other: damage-control.ts guarding stash.db and .env with a hard ctx.abort(), and damage-control-continue.ts guarding the lower-stakes paths where you’d rather the agent adapt and keep working than stop and wait on you.

Deciding which bucket a given path or command belongs in is exactly the judgment this dial makes explicit - ask it the same two questions for anything you’re tempted to add to your own rule file:

Two questions decide how much leash a task earns - neither of them is “how hard is it.” Set both for the work in front of you and read the rung it lands on.

If the agent’s worst single action went wrong, undoing it would take…
…and its consequences would reach
  1. Run free, no fencedisposable environments onlyNothing pauses it and nothing contains it. No combination on this dial lands here - it belongs only where the whole environment is disposable: a throwaway container, an already-isolated CI runner.
  2. Run free inside a fencethis taskNo prompts; the boundary does the protecting. The agent grinds end to end inside a sandbox, container, or scratch worktree, and you review the whole batch once at the end.
  3. Auto-apply edits, gate the rest
  4. Ask before acting
  5. Read & propose only

A mechanical rename across your own repo is the canonical case: the worst outcome is a git diff you throw away. Prompting on every one of twenty-four identical edits doesn’t add safety - it teaches you to stop reading prompts, which is where real risk starts. Let it run inside the fence and review the batch once.

The rung names are generic on purpose - every tool spells its own versions of them, and most let you set different rungs for different categories of action. The judgment underneath is the same two questions, asked per task, never answered once for all time.

Map that back onto stash: .env and stash.db are irreversible with consequences that reach beyond your machine (they’re what makes the service work for whoever else is using it) - that’s the hard-stop bucket, no ask, no adapt. A stray read inside migrations/ is undoable and stays local - that’s exactly the shape damage-control-continue.ts was built for.

MCP is worth a one-line note here too: Pi has no MCP support in its core, and adding a server (even a well-behaved one) is itself something to weigh against this gate - every tool it registers spends context on every single turn, whether you use it or not. Damage control doesn’t police that trade-off, but it’s the same instinct: add capability deliberately, not because a hidden default let it through.

You’ve now got the sharpest guardrail stash needed. Next, a gate of a different kind - not what the agent’s allowed to touch, but how many things it’s allowed to be doing at once. Next: task discipline.