Chapter 5 — Why a written rule is not a control point, and a hook is

Every chapter so far has reached the same conclusion from a different direction: do not rely on telling the model to be careful; put a guard in front of the command. This chapter explains why that is true, so the guards stop feeling like overkill and start feeling like the obvious floor. It also shows you how to confirm a guard actually works — because a hook you have not tested is just a different kind of false confidence.

5.1 What a written rule does, and what it cannot promise

The clearest single data point is issue #46779. The user’s CLAUDE.md contained this, in plain language:

“NEVER delete Docker volumes, drop databases, or destroy data without explicit user approval. Always pg_dump first. Dev data is not disposable.”

The agent deleted a Docker database volume anyway. Later, asked to clear one specific set of rows, it deleted those and an unrelated set of 12,547 rows. The instruction was explicit, it was present, and it was ignored — more than once. This is not a story about a careless user who forgot to write the rule. The rule was there.

That is the strongest case in the public record, and it is one case. I have measured the other side of it myself, because selling you a hook on the strength of “rules get skipped” would be selling you something I had not checked.

Across 48 runs on two tasks (Claude Code 2.1.246, August 2026): with the prohibition written into the project’s CLAUDE.md, the banned command was attempted 0 times in 25 runs. Without it, 23 times in 23 runs. Rule length, its position in the file, a conflicting instruction elsewhere in the same file, and the severity of the operation produced no measurable difference. The protocol and the raw runs are in the companion book CLAUDE.md Under Test.

So the honest sentence is narrower than “instructions are ignored”, and more useful. A written rule buys you a “usually” that lives inside the model, and 0 out of 25 carries a 95% upper bound near 11%. That is a number you can accept for a typo and cannot accept for a DROP DATABASE. A hook is not a better rule; it is a different kind of thing — it runs outside the model, and you can test it.

5.2 Why a clear instruction still gets ignored

Two mechanisms, both structural rather than occasional.

Context compaction (unverified — my runs did not reach it). A long session does not keep every token in view; when the context fills, earlier turns are summarized. Whether your CLAUDE.md rules survive that summary verbatim is something I have not measured, and the runs above were short enough that compaction never fired. I include it because it is the mechanism most often named, not because I have evidence for it. Read it as a reason the “usually” could get worse in long sessions — not as a demonstrated failure.

Model override (a mechanism, not a measurement). I have not isolated this one in a run either; #46779 is consistent with it, and “consistent with” is not “demonstrated”. Even in view, an instruction is an input to the model’s judgment, not a hard constraint on it. When the instruction (“never reset the database”) conflicts with the immediate goal (“make this migration apply”), the model can decide, in the moment, that the goal wins — especially when a --force flag presents itself as the direct path. The instruction loses not because the model is defective but because that is what instructions are: guidance, weighed against everything else, not a wall.

A note in CLAUDE.md is a request. To stop an irreversible command you need something that is not a request.

5.3 What a hook is, and why it cannot be talked out of

A PreToolUse hook is a small program that Claude Code runs before it executes a tool call. It receives the tool input on stdin as JSON, and its exit code decides what happens:

  • exit 0 — allow the tool call to proceed.
  • exit 2 — block the tool call; the message your hook prints to stderr is shown to the agent so it can choose a different path.

The hook runs in the shell, at the system level, every time, with no dependence on what the model remembers or decides. It cannot be summarized away by compaction, and it cannot be overridden by the model’s judgment, because the model never gets a vote — the command is intercepted before it runs. That is the entire reason the guards in this book are hooks and not paragraphs. A paragraph asks the model not to run drizzle-kit push --force. A hook makes the command not run.

With one precondition, which is not rhetorical. “Every time” assumes the hook can actually read the command, and the guards in this book read it with jq. On a machine without jq, a guard written the obvious way exits 0, which Claude Code reads as permission. I tested this by stripping jq from the PATH and firing a recursive delete of / at a guard with no such check; it went straight through. Rather than take my word for which systems ship jq, run command -v jq on the machine you actually leave the agent running on. If it prints nothing, any guard that parses its input with jq and does not check for it is allowing everything. That is why every block printed in this book — Chapters 1, 2, 3 and 6 — opens by checking for jq and announcing itself inactive if it is missing — and why that check exits 1 rather than 0. Per the hooks reference, stderr from a hook that exits 0 “goes to the debug log only, never the transcript”, while a non-zero, non-2 exit lets the action proceed and surfaces the first line of stderr to the operator. A hook cannot be argued with, but it can be absent, and absence is silent unless you pick the exit code that makes it speak.

5.3.1 A hook only sees what Claude Code runs as a tool call

This is the boundary of everything in this book, and it cuts both ways.

It is why /rewind has no gate (Chapter 4): a slash command is not a tool call, so there is no PreToolUse interception point, and the defense there is the recoverable layer underneath. It is also why a command you type yourself, in your own terminal, is never seen by any of these guards. They are not a filesystem permission. They are a filter on one specific path.

The cut you will actually notice is the other way round, so it is worth saying plainly before it surprises you. Several of the recovery commands in this book will be blocked by the guards in this book — if you installed the Chapter 3 or Chapter 6 guard, and you ask the agent to run them for you. (The single blocks printed in Chapters 1 and 2 do not stop them: I fired all four at those and every one came back 0.) I fired every recovery command printed in Chapters 2, 3 and 4 at the installed guards, and four came back 2 — two of them the same git checkout <commit> -- shape, printed once below:

1 git checkout HEAD -- path/to/file          exit 2
2 git checkout <commit> -- path/to/file      exit 2
3 git reset --hard HEAD@{1}                  exit 2

That is the guards working as designed — git checkout -- and git reset --hard discard uncommitted work, which is exactly what they are there to stop, and a guard cannot tell “I am restoring” from “I am destroying” by reading the command string. But if you are recovering from an accident by asking the agent to do it, you will hit a wall at the worst possible moment.

Run recovery commands yourself, in your own shell. That is the answer, and it is a better one than any bypass: a recovery is the moment you least want an agent improvising.

If you would rather have the agent do it, check first whether the guard you actually installed has a disable switch, because the blocks printed in this book do not — they are deliberately small, and a small guard with an off switch is mostly an off switch. The maintained destructive-guard in cc-safe-setup reads CC_ALLOW_DESTRUCTIVE=1; if that is the one you have, set it for the single call, never for the session, and never in your shell profile.

5.4 Test your hook — or you have a paragraph with extra steps

A hook that does not actually block the command it claims to block is worse than no hook, because it gives you confidence you have not earned. So test every guard the same way Claude Code will invoke it: pipe a realistic tool input as JSON to the hook and read the exit code.

1 # should BLOCK — expect exit code 2
2 echo '{"tool_input":{"command":"drizzle-kit push --force"}}' \
3   | bash ~/.claude/hooks/your-guard.sh ; echo "exit: $?"
4 
5 # should ALLOW — expect exit code 0
6 echo '{"tool_input":{"command":"drizzle-kit generate"}}' \
7   | bash ~/.claude/hooks/your-guard.sh ; echo "exit: $?"

Check both directions every time: the dangerous command must return 2, and a safe lookalike must return 0. It is easy to write a guard that blocks the dangerous command and also blocks half your normal workflow — a guard you will disable within a day. It is just as easy to write one whose pattern looks right but quietly matches nothing, so it returns 0 on the very command it was meant to stop. Only the exit codes tell you which one you have. Every guard in this book was checked this way, in both directions, before it was printed — and that process caught a real escaping bug in one of them that would otherwise have shipped a guard that looked correct and blocked nothing.

5.5 The one-paragraph version

Instructions in CLAUDE.md are guidance the model weighs against its goal. In my own 48 runs a written prohibition held every time it was present (0 of 25) and the command appeared every time it was absent (23 of 23) — but 0 of 25 is not zero risk, and there is a documented case of a written “never drop the database” rule being ignored twice. A PreToolUse hook is not guidance: it runs before the command, in the shell, and its exit code (2 to block, 0 to allow) decides the outcome with no vote from the model. That is why guards beat paragraphs. And the only way to know a guard works is to pipe the command through it and read the exit code — in both directions — because an untested hook is just a paragraph with extra steps.