Claude Code Hooks in Practice
Our git guard hook let +main and git -C slip through. What hook exit codes really mean, and how to fix a hook by measuring it.
PR: This article contains a placement for an affiliate program we plan to join (a learning service). Our verdicts do not depend on payouts.
A rule in CLAUDE.md is a request. A hook is a mechanism. Below we write a PreToolUse hook that stops destructive git commands, find its holes, and fix them. Test environment: Linux, Claude Code 2.1.301, jq 1.7, git 2.47.3, on 2026-10-16. We tested the script by piping the input JSON documented by Anthropic into it; we did not observe it firing inside a live Claude Code session.
Exit codes first
From the hooks reference (https://code.claude.com/docs/en/hooks, checked 2026-10-16):
| Exit code | Effect on PreToolUse |
|---|---|
| 0 | Success; a JSON permissionDecision on stdout (allow/deny/ask/defer) applies |
| 2 | Blocks the call; stderr becomes the reason |
| Anything else, including 1 | Non-blocking error; the tool runs |
exit 1 does not block. A timed-out hook or a mistyped script path also lets the call through by default. "onFailure": "block" (v2.1.295+) flips that (same source).
Version 1 let two commands through
#!/usr/bin/env bash
# .claude/hooks/git-guard.sh (PreToolUse, matcher: Bash) v1
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -Eq 'git +(push +(-f|--force)|reset +--hard|clean +-[a-z]*f|branch +-D)'; then
jq -n --arg c "$cmd" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("destructive git: "+$c)}}'
fi
exit 0
git push --force and npm test && git reset --hard HEAD~1 got ask. Two passed with no decision: git push origin +main (a + refspec is a force push) and git -C /repo reset --hard (an option between git and the subcommand).
Version 2 and results
g='git( +-C +[^ ]+)*'
grep -Eq "$g +(push( +[^ ]+)* +(-f|--force[a-z-]*|\+[^ ]+)|reset +--hard|clean +-[a-z]*f|branch +-D)"
| Input | v2 |
|---|---|
git status | no decision |
git push origin main | no decision |
git push origin +main | ask |
git -C /repo reset --hard | ask |
git push --force-with-lease | ask |
git branch -d topic | no decision |
git branch -D topic | ask |
Regexes never cover everything. The docs call if filtering best-effort and point to permission rules for hard allow/deny (same source). See Permission Design for AI Agents.
Matchers
Letters, digits, _, -, spaces, , and | mean exact match; any other character makes it a regex. Edit.* also matches NotebookEdit; use ^Edit$ for an exact match (same source).
Matchers also accept MCP tool names (mcp__<server>__<tool>); for the server side, see MCP Servers 101 and Five Design Rules.
Don’t let the model judge
Our hooks decide on exit codes, git counts and regexes only. Through model generation upgrades, not one hook needed editing. We measured an LLM-judged Stop hook per category: two of three either blocked wrongly or caught nothing, so we cut it to one category.
One rule we kept: verify “done” with a Stop hook.
Injected context must earn its place
One hook added about 450 characters to every prompt. It reported “unknown” on every first prompt, so it did nothing. We removed it.
Session-start files bloat the same way: agent memory design.
Check reality after a reject
More than once, when the commit-message hook rejected a commit, lint-staged’s stash was not restored and edits vanished without showing in git status. Now: git status after a reject, git show --stat HEAD after a commit.
Promote only what recurs
Stop-hook blocks go to a jsonl log. Weekly, failures seen three or more times get promoted to a hook or lint rule, at most three per week, so the hooks don’t become a maintenance burden of their own.
For cutting the prose rules themselves, see Trim Your CLAUDE.md.
Summary
- Block with
exit 2orpermissionDecision;exit 1does not block. - Add
onFailure: "block"if a crashed or slow hook must fail closed. - Pipe real input JSON through your hook; you will find holes.
- Judge with exit codes and git counts, not the model.