Skip to content

Course · Pi · Rebuild the defaults

Permissions: damage control, not security theater

The last chapter ended on a promise: the one-regex tool_call hook you left guarding stash.db (the database of stash, the course’s running example, a small FastAPI feed reader) was a taste, and the real guardrail was the next chapter’s job. Here it is. The pricing rule hasn’t changed: a gate is free while it’s idle and costs you a real turn the moment it fires. Call that the turn tax - you’ll pay it four times this module, once per gate, and the bill is the whole point of building it yourself instead of trusting a default.

For a repo you’re still exploring, YOLO is arguably fine - worst case, git checkout undoes it. stash isn’t that repo anymore. stash.db holds the deduped, re-synced library: every feed, synced clean twice to prove the duplicate fix held. It isn’t in version control and nothing backs it up, so git checkout gives you none of it back. 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.

Here’s the claim to hold onto while you build it: the gate below turns on a hair trigger for anything deleting stash.db, but it’s supposed to leave near-miss filenames alone - a hand-made backup called stash.db_backup shouldn’t get caught by a rule that only ever named stash.db. Spoiler: it doesn’t, and by the end of this lesson you’ll have traced exactly why, character by character, instead of taking that on faith.

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

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

This pattern is called damage control, and it’s built from primitives you already know: pi.on("tool_call", ...) and pi.on("session_start", ...). It isn’t a community standard - it comes from one place: a single-author demo repo (pi-vs-claude-code, built by IndyDevDan, aka disler, to show off what Pi’s extension system can do) rather than any wider convention.

Search for it and you’ll mostly find that one repo and forks of it. None of that makes the pattern worse - it’s a solid piece of engineering either way - it just means you’re adopting one well-built example, not a norm the ecosystem has settled on.

On session_start, the extension loads a small rule file - .pi/damage-control-rules.yaml in the project, falling back to ~/.pi/damage-control-rules.yaml if the project doesn’t have its own. The loader expects four buckets: zeroAccessPaths, noDeletePaths, readOnlyPaths, and bashToolPatterns, and treats any you leave out as empty. Here is the entire rule file this repo needs - small enough to read in one glance, which is the point:

# .pi/damage-control-rules.yaml - the whole file
noDeletePaths:
- stash.db # the one file git can't give back
- .pi # deleting the rules disarms the gate
bashToolPatterns:
- pattern: "rm -rf"
reason: "Recursive force delete - it never names what it removes."
ask: true
- pattern: "DROP TABLE|DELETE FROM"
reason: "Raw SQL against the database bypasses stash's own write path."

Choosing the right bucket for stash.db is the first real decision in this lesson, because the buckets are not interchangeable. zeroAccessPaths is the bluntest one: for the file tools it blocks read, write, and edit alike, and for bash it is a plain substring check - any command containing the path string, read or write, no exceptions.

Put stash.db there and the model can no longer even count rows for you. Worse, you’d have blocked your own backups: rm stash.db_backup contains stash.db as a substring, so a zero-access rule swallows exactly the near-miss file it shouldn’t.

noDeletePaths is the precision instrument. It only inspects commands that delete or move - rm, mv - and it is the one bucket with a matcher that can tell stash.db from stash.db_backup; the trace below walks that matcher, character by character. Deleting is the unrecoverable act for a file with no backup, so delete is exactly the verb to gate. That’s the bucket stash.db belongs in, and .pi joins it because deleting the rule file - or the whole harness directory - disarms the gate itself.

bashToolPatterns still earns its place, because noDeletePaths never sees a command without rm or mv in it. stash/db.py writes raw SQL all day, which is fine - the app’s writes aren’t agent tool calls - but that means the model’s shell route to the same damage looks like a python -c one-liner against the database, no rm anywhere.

And rm -rf never names the file it eats: rm -rf . mentions no path at all, so no path rule can catch it. The split of labor is the lesson: path rules catch what a command touches, patterns catch what a command is. And note the same hole the SQL paragraph just exposed exists for the file itself: python -c "import os; os.remove('stash.db')" deletes the database with no rm anywhere, so it passes every bucket in this file - which is the pattern bucket’s job to close if you want it closed.

The two empty buckets are deliberate. readOnlyPaths has nothing to freeze in this repo - no lockfiles, and the schema lives in db.py, which a later migration will need to edit. zeroAccessPaths waits for the day stash grows a real secrets file: a .env carrying provider keys would be the textbook entry - nobody, you included, benefits from the agent reading it. That day, one line.

About the numbers in this module: most numbers here are small toy scenarios invented to make a trade hand-traceable - turn counts, dollar costs - and are labeled as toy where they appear. A few, like the character positions in the trace just below, are exactly what you’d get counting the string yourself. The one number that isn’t invented for teaching is the 30_000 millisecond confirm-dialog timeout later in this lesson, pulled directly from the extension’s own code.

A noDeletePaths rule naming stash.db has to stop rm stash.db cold. It should not also stop you from deleting a hand-made backup you named stash.db_backup before a risky change - a rule that blocks your backups along with your database is worse than no rule at all. Predict it before you look at the mechanism: does the command below get blocked?

rm stash.db_backup

The naive answer - the one you’d get from a plain substring check - is yes: "rm stash.db_backup".includes("stash.db") is true, so a dumb matcher blocks it. The actual matcher does one more thing before it decides. It finds where stash.db starts in the command - count it yourself if you want: r(0) m(1) (2) s(3) t(4) a(5) s(6) h(7) .(8) d(9) b(10) - so the match starts at index 3 and runs 8 characters, ending at index 10.

Then it looks at exactly one character: the one right after the match, at index 11. That character is _. The matcher only counts a hit if the next character isn’t one that could still be part of the same filename - letters, digits, underscore, or hyphen. _ is on that list, so this isn’t a real hit.

The matcher decides stash.db_backup is a different file that happens to start the same way, not stash.db with something appended, and keeps looking for another occurrence. It doesn’t find one. Not blocked - exactly the prediction.

Now run the same trace against stash.db.bak instead - a version of the backup with a dot instead of an underscore. Same lookup, same index 11, but this time the character there is .. A period is not on the safe list (only letters, digits, _, and - are), so the matcher counts it as a real hit and blocks the delete.

That’s worth sitting with for a second: the guard protects you from _backup- and -copy-style near-misses, but not from .bak-style ones, on the same rule, against the same file. It’s not a bug you need to fix before you trust this - over-blocking a backup costs you one killed turn and a manual delete by hand; under-blocking costs you the file stash.db exists to protect. Given that choice, this is the failure mode you want the matcher biased toward. But know it, rather than assume the guard is symmetric when it isn’t.

That’s the non-obvious part. 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:

// .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 more things worth noticing, because they’re what make this a gate rather than a suggestion:

  • 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. Thirty seconds is the actual default in the reference implementation, not a number this course picked, and a dialog that times out returns false - deny.

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 rm/mv commands that reference a noDeletePaths entry, blocks or asks-then-blocks on bashToolPatterns matches, and calls ctx.abort() on any hard block.”

Review what comes back against the trace above - specifically, that it calls ctx.abort() on the unconditional-block path (not just ask-then-deny), and that it gets the near-miss boundary right in both directions: stash.db.bak blocked (the near-miss the matcher is deliberately biased toward catching) while stash.db_backup goes through (a legitimate backup the same rule must not swallow).

The pair: hard-stop vs. let-adapt, and what each costs in turns

Section titled “The pair: hard-stop vs. let-adapt, and what each costs in turns”

Here’s the part that separates this from a blunt on/off switch, and where the turn tax actually gets paid. 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. The cost of that: one full turn.

The model’s current turn ends the moment it tries the dangerous call; nothing it was doing survives, and you have to send a fresh message telling it what you actually meant. That’s the right price for “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. Say the model, tidying up after a test run, reaches for rm -rf .pytest_cache - a real pattern hit, technically, on a cache directory pytest rebuilds on demand. Killing the whole turn over cache cleanup is friction for a turn that was never going to hurt you.

So the same rule engine ships a second variant: same hook, same rule file, but 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.`;
}

(Compressed to its two branches from the reference’s longer wording - same decision, less text.)

Same hook, same rule file, same return channel - just tuned from a kill switch into steerable in-band guidance the model reasons over. The turn cost here is zero: the blocked tool call comes back as a result the model can read and act on, in the same turn, with no round trip back to you. Put in the course’s terms: this whole page is the control principle’s enforcement arm - deterministic code deciding what may run - and its two modes are priced in that principle’s currency, the turn tax: one full turn when the gate fires hard, zero when it steers.

In practice you want both behaviors available: the hard ctx.abort() for stash.db, and the softer continue-feedback for the lower-stakes hits where you’d rather the agent adapt and keep working than stop and wait on you. Whether they live as two files (damage-control.ts and a damage-control-continue.ts sibling) or as one file that picks per rule is a packaging choice. For stash you keep the hard-stop file and one ask rule - the softer mode has nothing load-bearing to guard yet, so you note the pattern and move on.

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: a delete aimed at stash.db is irreversible and stays on this one machine - one repo, one machine, nobody downstream. That cell’s own reading is exactly the posture this lesson takes: keep every destructive command behind a yes, and behind a hard deny where the tool supports one - which is what the no-ask hard block on the delete path is. A pattern hit on a rebuildable artifact like .pytest_cache is local and undoable - exactly the shape the softer continue mode exists for, and it should never cost you a turn.

What this gate doesn’t do: process isolation

Section titled “What this gate doesn’t do: process isolation”

Everything above operates at the level of a single tool call - it inspects what the model is about to run and decides, per call, whether to let it through. That’s a real gate, but a narrower one than it might feel like. It has no idea what the process behind a bash call could still reach once it’s running: environment variables outside your rule file, network access, anything readable on the machine you didn’t think to list as a path.

Pi’s own stated position on this is blunt: sandboxing that isn’t full process isolation is theater, because once a process can read data and execute code, exfiltration doesn’t need a dangerous-looking command to happen - and the fix for that isn’t a smarter rule file, it’s a container or a VM around the whole session. Codex takes the opposite bet by default: OS-level sandboxing (Seatbelt, on macOS) wraps every command it runs, a real safety property that bash-level rule matching can’t replicate no matter how carefully you write the YAML.

Damage control is still worth building for stash - it stops the specific, likely, keyboard-adjacent mistake of the model doing exactly what it says it’s doing, just to the wrong file. It was never going to stop a determined or compromised process from doing something else entirely. If that’s the threat you’re actually worried about, the answer lives one layer down from anything in this module, and you’ll build it with a container, not a tool_call hook.

The same reasoning, one more time, names a hole this rule file leaves open on purpose: a write or edit tool call that overwrites stash.db whole uses no rm, no mv, and no listed pattern, so it passes every bucket above. The bucket built for it is zeroAccessPaths - which blocks write and edit on listed paths outright - left empty here because the model never needs to write the database through file tools. If that changes, one line closes it.

The other gate you’ll write this module, tilldone in the next lesson, registers its own tool_call handler on top of this one - which raises a question this lesson’s gate alone can’t answer: what happens when two hooks are both watching the same event? That’s the first thing the next lesson deals with.

You’ve now got the sharpest guardrail stash needed, and you know both what it costs when it fires and what it can’t see. One turn to stop a DROP TABLE. Zero turns, and a near-miss backup left alone, for everything else. Next: task discipline.