Aldo Saavedra

Notes

Guards that fail closed: what blocked commands taught me about agent permissions

A rule in CLAUDE.md is a suggestion. A hook that exits with code 2 is a wall. But a wall in the wrong place gets torn down, so it has to be precise.

Most of my code is written by coding agents now. I review, decide and steer; Claude Code and Codex type. That only works if the agent cannot do the handful of things that would really hurt: delete the repository, read a production secret into a transcript, push to main.

My first instinct was to write those rules down. CLAUDE.md got a section of things the agent must never do. It mostly worked, which is exactly the problem with prose rules: mostly.

Prose rules get read, then weighed

One of my rules said: open a pull request only when the branch is ready. It was a sensible rule, written in plain language, and the agent agreed with it every time I asked.

On September 18 my GitHub Actions quota ran out. The whole account's 2,000 minutes for the month were gone, because pull requests had quietly become the way to find out whether the code worked. Every "let me check if CI passes" was a run.

So the rule became a hook: a script that runs before every shell command and blocks gh pr create unless the same commit already passed the local CI script. My first version started with a quick grep for gh. A command written as g''h slipped right past it: the shell reads it as gh, grep does not. The guard now parses the command properly, with no shortcut.

That was the lesson I keep relearning: anything that must never happen belongs in a hook, not in a paragraph.

Exit code 2, and only 2

Claude Code runs PreToolUse hooks before a tool call, and blocks the call only when the hook exits with code 2. Any other non-zero code, including the one you get when your script crashes on unexpected input, lets the command through.

For a guard, that is the wrong failure mode. A guard that throws on a malformed event is a guard that allows everything. Every hook I write now runs inside a small wrapper that turns any exception into exit code 2. Guards fail closed, and there is a test that feeds each one broken JSON and expects a block.

Blanket denies train you to remove them

The opposite mistake is just as real. My first rm rule was a blanket deny on rm -rf. In three weeks it blocked eleven commands. Every single one was legitimate: rm -rf dist before a build, throwaway copies from mutation testing. It blocked zero dangerous ones.

The same happened with secrets. A deny on reading .env.* also blocked .env.example, the file that is tracked in git precisely because it holds no real values. That added up to dozens of blocked commands in three weeks. Worse, the deny globs turned out to be inconsistent: one pattern blocked a backup file, while a close variant let the end-to-end test credentials through.

A guard that cries wolf gets switched off, and then you have nothing. So the guards became precise:

  • The rm guard blocks only catastrophic targets: the root, the home directory, the project root, src, .git. Everything else is allowed.
  • The secrets guard blocks real secret files by explicit pattern, exempts .env.example, and ignores quoted prose that merely mentions .env.
  • When the agent genuinely needs to know which flags an environment has, it runs a small script that prints the .env with every secret redacted.

Each of these has a test suite with both lists: what must be blocked, and what must stay allowed. The second list matters as much as the first.

What a guard cannot do

Guards read the literal command. rm -rf $DIR, where $DIR expands to something precious, is invisible to them. I write that limitation at the top of the file instead of pretending it is solved. Version control is the safety net there, not the hook.

The setup

I extracted the generic version of all this into claude-code-harness: permission tiers, the three guards, review and security agents, and the tests. It is small on purpose. The point is not the code, it is the habit: when you catch yourself writing "the agent must never" in a markdown file, write a hook and a test instead, and make the hook narrow enough that you will never be tempted to turn it off.