— Insights
Making AI actually follow the rules with Claude Code hooks
CLAUDE.md gives you probabilistic compliance; Claude Code hooks turn the same rules into deterministic gates. A field report on four production traps and the fixes for each.
If you use Claude Code long enough, you will hit the same wall over and over: you tell it the same rule, in the same CLAUDE.md, and it still forgets. “Don’t commit secrets.” “Run typecheck before pushing.” “Format on save.” It’s all right there, and Claude nods, and then in the heat of a debugging session it does the thing anyway. Human reviewers can absorb that kind of drift. With an AI in the driver’s seat, it starts to feel like writing the rules was a waste of ink.
Rules in CLAUDE.md are, at best, reference material. Whether Claude honors them is a matter of probability. Hooks, by contrast, run every time a specific lifecycle event fires, and they can veto the tool call outright. That difference in kind — probabilistic versus deterministic — is what makes hooks worth the trouble.
This post is a field report from running hooks in production across our own repos. It’s a catalog of the traps that made us think the hooks were working when they weren’t, and the fixes that finally made them real gates. By the end, you should want to open your own ~/.claude/settings.json and change at least one thing.
Why hooks and not CLAUDE.md
Claude Code loads the CLAUDE.md chain (user global → org → repo) into context at session start. Powerful, but only as strong as the model’s willingness to consult it. Compliance decays in three predictable ways.
- Compaction pressure. When context fills up, the auto-summarizer collapses low-signal material. Rules that felt important when the session started can survive only as a paraphrase — and paraphrases lose enforcement teeth.
- High-heat moments. When Claude is racing to fix a broken test, “do not run
git push --force” reads as a suggestion. It has the same failure mode as a tired human. - Sub-agent handoffs. Prompts to sub-agents don’t carry the full CLAUDE.md. Rules that must apply everywhere have to be baked in somewhere the sub-agent can’t ignore.
Anywhere you actually need a guarantee — not a nudge — hooks are the right tool. Concretely, we route these through hooks:
- Secret scanning right before commit
- Typecheck / lint / test on push
- Blocking destructive commands (
rm -rf /,git push --force, etc.) - Auto-format after every edit
- LLM code review on push (more on this below)
Hooks bind shell commands (or, experimentally, sub-agents) to Claude Code lifecycle events like PreToolUse, PostToolUse, and Stop. Return a non-zero exit and the tool call is aborted. That’s the whole shift: from “written in a file we hope Claude reads” to “physically cannot happen.”
Trap 1: exit codes have specific semantics
This is where everyone loses a weekend first. Claude Code’s hook contract requires exit 2 to signal a block — that’s when stderr is forwarded back to Claude and the tool call is denied. exit 1 looks like a failure to you but reads as “something went wrong, keep going” to Claude, and the tool call sails through. We spent weeks with a dangerous-block script that was silently no-op’ing every git push --force we threw at it because the exit code was wrong.
The corrected shape:
#!/usr/bin/env bash
# dangerous-block.sh — deny destructive commands (PreToolUse, Bash matcher)
set -euo pipefail
# Pull the command Claude wants to run out of stdin
COMMAND="$(jq -r '.tool_input.command // ""')"
DANGEROUS_PATTERNS=(
'rm -rf /'
'rm -rf ~'
'sudo rm'
'git push --force'
'git reset --hard'
'git clean -fdx'
'chmod -R 777'
'dd of=/dev/'
'mkfs\.'
)
for pat in "${DANGEROUS_PATTERNS[@]}"; do
if [[ "$COMMAND" =~ $pat ]]; then
echo "🚫 blocked: matched '$pat'" >&2
# exit 2 is the ONLY code that blocks the tool call and feeds stderr to Claude
exit 2
fi
done
exit 0
Wire it into PreToolUse with matcher: "Bash", and the next git push --force main gets a hard stop. Claude receives the stderr, apologizes, and tries a different route. If you write exit 1 here, the exact same input would silently succeed. Make exit 2 an invariant across every hook that’s supposed to block.
Trap 2: watch mode will hang your whole session
Wiring pnpm test into a pre-commit hook is the obvious move. It’s also how you find out that vitest, jest, and playwright cheerfully default to watch mode when they detect a TTY-like environment. Once that runner grabs the shell and never returns, the hook — and therefore Claude Code — never returns either.
Two lines of defense: force CI=true so the runner switches to one-shot, and wrap the whole thing in an outer timeout so even a rogue runner can’t hold the process forever.
# pre-commit-check.sh (excerpt) — test invocation
# CI=true drops vitest/jest/playwright into run mode
# perl alarm guarantees a return even if the runner ignores CI
if [[ -f package.json ]] && command -v pnpm >/dev/null 2>&1; then
if jq -e '.scripts.test' package.json >/dev/null; then
CI=true perl -e 'alarm 300; exec @ARGV' pnpm test || {
echo "❌ pnpm test failed or timed out" >&2
exit 2
}
fi
fi
Note the command -v guard. A hook that fails hard because pnpm isn’t on this repo — while the developer is happily using npm — will teach Claude to treat the hook as flaky, which erodes the trust the whole gate depends on. Detect the toolchain first; skip cleanly when it isn’t present.
Trap 3: hook cwd drift silently disables formatting
We only noticed this one after CI kept flagging lint on branches that had “run through” auto-format. The PostToolUse hook was calling biome format --write <file>, but the process was running in whatever cwd the tool call happened to inherit — which was often unrelated to the file being edited. Biome could not find biome.json, so it fell back to defaults, and the format effectively became a no-op.
Fix by resolving the git root from the edited file’s path every time:
# post-edit-format.sh (excerpt) — walk from file path to git root
FILE="$(jq -r '.tool_input.file_path // ""')"
[[ -z "$FILE" ]] && exit 0
# Find the git root that owns this file; bail if there isn't one
REPO_ROOT="$(cd "$(dirname "$FILE")" && git rev-parse --show-toplevel 2>/dev/null || true)"
[[ -z "$REPO_ROOT" ]] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.json|*.jsonc)
if [[ -f "$REPO_ROOT/biome.json" ]]; then
(cd "$REPO_ROOT" && pnpm biome format --write "$FILE") || true
else
(cd "$REPO_ROOT" && npx --no-install prettier --write "$FILE") || true
fi
;;
esac
# Formatting is best-effort. Never block an edit.
exit 0
Silent failure is the worst kind of failure. Turn set -x on periodically and read the actual execution log — it’s the only way to catch “the hook fires but does nothing” before it wastes half a day of debugging.
Trap 4: matching command boundaries
Small trap, high-impact fallout: our dangerous-block started rejecting perfectly innocent commits like git commit -m "how to block 'git push --force' with hooks". The pattern matched anywhere in the command string, including inside quoted commit messages. Anyone writing docs about hooks discovers this immediately.
The fix is to anchor patterns at command boundaries:
# Only match when the pattern actually starts a command
DANGEROUS_PATTERNS=(
'(^|;|&&|\|\|)[[:space:]]*rm -rf /'
'(^|;|&&|\|\|)[[:space:]]*git push[[:space:]]+.*--force'
'(^|;|&&|\|\|)[[:space:]]*git reset[[:space:]]+--hard'
)
A stricter version explicitly excludes git commit -m payloads before the substring check runs. The text inside a commit message isn’t going to execute, so it has no business being scanned.
Extension: enforcing LLM review before push
Commit-time gates and push-time review answer different questions. Commit-time is “did I let something machine-detectable through?” — secrets, lint failures, broken types. Push-time review is “does this diff have a class of bug an LLM might catch that a linter can’t?” Claude Code’s experimental agent hook lets you fire a sub-agent right before git push runs, review the diff, and block the push if the review comes back negative.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(git push*)",
"hooks": [
{
"type": "agent",
"prompt": "Review the diff between the current branch and origin as a code-reviewer. Return {\"ok\": false, \"reason\": \"...\"} only if there is a clear, high-confidence issue in one of: (1) plaintext secrets or credentials, (2) missing auth checks, (3) injection / XSS / SSRF, (4) obvious crashes. Otherwise return {\"ok\": true}.\n\nDo NOT return advisory findings. False positives destroy trust in the review gate. If the latest commit message contains [skip-review], return {\"ok\": true} unconditionally."
}
]
}
]
}
}
The gotcha here is sub-agent permissions. The sub-agent needs to run git log / git diff / git show to see the diff at all. If those aren’t in permissions.allow, the sub-agent will return ok: false on the grounds that it can’t evaluate — and every push you make will be blocked. That’s the correct default (fail-closed), but it’s the correct default only if you actually grant the read permissions. Do that once at the user-global level:
{
"permissions": {
"allow": [
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
"Bash(git status:*)",
"Bash(git rev-parse:*)"
]
}
}
Also: keep the LLM review advisory by default, and only block on high-confidence issues. Cloudflare’s published rollout data is instructive here: across the first 30 days of company-wide AI code review (48,095 merge requests, 131,246 review runs), they tuned down to 1.2 findings per review on average and kept developer “break glass” overrides at 0.6%. Once your review gate starts crying wolf, engineers reach for [skip-review] reflexively and the whole thing becomes ceremonial. Bias the block criteria toward “we’d want CI to fail on this,” not “this could theoretically be better.”
Operational hygiene
Hooks are the kind of infrastructure that quietly stops working. A few habits keep them honest.
Version-control the hook scripts. The docs show hooks living in each project’s .claude/hooks/, but we keep ours in a single Obsidian vault and reference them by absolute path from ~/.claude/settings.json. The audit trail — “when did we change dangerous-block, and why?” — has been worth more than we expected.
Break them on purpose, on a schedule. Every quarter or so, deliberately try to git push --force through Claude. Deliberately fail a test and try to commit. A hook you haven’t tested is worse than no hook, because the presence of the hook is what stopped you from adding a real gate.
Watch the [skip-review] count. Any bypass mechanism is a load-bearing signal. If the frequency of [skip-review] on your commit log starts to climb, that’s your hook getting in the way — not your team getting sloppy. We flag three consecutive uses as a review of the hook itself.
Wrap-up
Think of AI-side rule enforcement in three layers: CLAUDE.md for philosophy and explanation, skills for reusable procedures, and hooks for the rules that must actually hold. Writing more prose in CLAUDE.md is cheap and feels productive, but the compliance you get from it is probabilistic. The rules you can’t afford to lose belong in hooks — and the hooks themselves need care: exit 2 everywhere, CI=true with outer timeouts, cwd resolved from file_path, patterns anchored to command boundaries. Layer commit-time deterministic gates with push-time LLM review, and you get the two safety nets working in complementary registers. Go open your own hook config and change one thing today.