Every Claude Code power user has a story: the force-pushed main, the "helpfully" rewritten .env, the API key committed at 2am. The fix has been sitting in the docs the whole time — hooks: shell scripts that run on every tool call, outside the model, so they hold even when the model has a bad day.
Almost nobody sets them up. Writing defensive hook scripts is genuinely fiddly. Here's what I learned building a full 10-hook guard-rail set, including the parts the docs make you discover the hard way.
The three mechanics that matter
1. Exit code 2 is the magic word. A PreToolUse hook that exits 2 blocks the tool call, and everything you wrote to stderr is fed back to Claude as the reason. Claude then adapts — tell it "force-push to main refused, use a feature branch" and it does exactly that. Exit 0 allows; other codes just log.
2. JSON decisions beat exit codes for nuance. Printing {"decision":"block","reason":"..."} on stdout does the same job and works for PostToolUse feedback loops too — that's how you pipe lint errors straight back to the model so it fixes its own warnings.






