Stop Asking Your AI Agent Nicely
Every Claude Code project I run has a CLAUDE.md file — a list of rules I want followed: use the virtual environment, not the system interpreter; no wildcard imports; write tests first. It reads like policy. It isn't. It's a prompt. The model reads it, weighs it against everything else competing for its attention that turn, and does something that's probably compliant. Probably is the word that should worry a security person.
I proved it to myself building this site. One of my rules is simple: "Use .venv, not global python environment." Straightforward, unambiguous, sitting right there in the system prompt. And deep into a task — the model mid-flow, attention on the actual problem — it reached for a bare python3 -c "..." anyway. Not malicious, not even careless by LLM standards. Just a model doing what models do: generating the statistically reasonable next action, and "run python3" is reasonable in a vacuum. The instruction didn't fail because it was badly written. It failed because instructions in a prompt are advisory, no matter how emphatically you phrase them.
Hooks are the fix, because they move enforcement outside the model entirely. A hook is a shell command the Claude Code harness runs at a defined point in the tool-call lifecycle — before a tool executes, after it finishes, on a prompt submission — and the harness, not the model, decides what happens with the hook's output. The model can't argue with a hook. It can't talk its way around one. It doesn't even see the hook coming; it proposes an action, the harness checks that action against the script, and the script returns a decision the model has to live with.
In this repo that looks like .claude/hooks/block-system-python.sh, wired up in .claude/settings.json to fire on every Bash tool call before it runs. It parses the proposed command, unwraps the stuff that doesn't change what's actually executing — env var assignments, timeout, nice, xargs, and so on — and checks whether what's left starts with python or python3 instead of a venv path. If it does, it returns a JSON deny decision with a reason, and the command never runs. No model judgment involved. The rule either held or it didn't, and the hook is the only thing that gets a vote.
The broader principle generalizes past this one script: write your rules file for the things that genuinely need judgment — collaboration style, architectural taste, how blunt to be in a commit message. Write a hook for the subset of rules that are mechanically checkable: a regex over a shell command, a file-existence check, a grep for a banned import pattern. If you can express the rule as "check X, then allow or deny," it belongs in a hook, not a paragraph of prose the model has to remember to re-read every turn.
It's the same instinct that got me into security in the first place: don't build your safety story on a layer that's allowed to be wrong some percentage of the time when a deterministic layer underneath it is available for free. A rules file is a policy. A hook is a control. I'd rather enforce the one I can actually verify.