Block a bad commit with a PreToolUse hook
There’s one rule in the payments service you treat as sacred: no change to the ledger code lands without a test alongside it. The ledger is where money is counted; an untested change there is the kind of mistake that’s invisible until it’s expensive. You could write this rule into your project memory and ask the agent to honor it — and most of the time it would. But “most of the time” is precisely the wrong guarantee for the one thing that must never slip. You don’t want a request the agent can forget under a full context window. You want a gate that fires every single time, no matter what the model decided.
That’s a hook: a shell command Claude Code runs automatically at a fixed point in its lifecycle. The agent doesn’t choose to run it and can’t reason its way around it. It’s deterministic code on a rail, and that property — always runs, model-independent — is the whole reason it exists.
The right moment to fire
Section titled “The right moment to fire”Hooks attach to lifecycle events. There are many — SessionStart when a session opens, PostToolUse after a tool runs, Stop when the agent tries to finish, UserPromptSubmit when you send a message — and picking the right one is most of the design. The full catalog is on the hooks docs page.
For our wall we want PreToolUse: it fires before a tool call executes, and — this is the key — it can block that call from happening at all. We want to inspect the commit the agent is about to make and refuse it if the ledger changed without a test. PreToolUse is the only event that lets us intervene at that instant, before the bad commit exists.
Wire it up
Section titled “Wire it up”A hook is configured in settings.json under a hooks key. You name the event, a matcher that narrows which tool calls trigger it, and the command to run:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/ledger-needs-test.sh" } ] } ] }}The matcher: "Bash" means this only fires when the agent is about to run a shell command — git commit among them. The $CLAUDE_PROJECT_DIR placeholder resolves to your repo root, so the path holds no matter where the session was launched.
Now the script. Claude Code hands every hook a JSON blob on stdin describing the call it’s about to make — for a Bash call, that includes the exact command string under tool_input.command. The script reads it, decides, and signals its verdict through its exit code. The rule that matters: exit code 2 blocks the action, and whatever the script wrote to stderr is fed back to the agent as the reason. Any other exit code lets the call proceed.
#!/bin/bashinput=$(cat)command=$(echo "$input" | jq -r '.tool_input.command')
# Only care about commits.echo "$command" | grep -q 'git commit' || exit 0
staged=$(git diff --cached --name-only)touches_ledger=$(echo "$staged" | grep -E 'ledger/' || true)touches_test=$(echo "$staged" | grep -E '(test|spec)' || true)
if [ -n "$touches_ledger" ] && [ -z "$touches_test" ]; then echo "Blocked: this commit changes ledger/ but stages no test. \Add a test for the ledger change before committing." >&2 exit 2fiexit 0It’s plain shell, which is the point — nothing here is AI. It reads the staged files, and if the ledger changed but no test did, it exits 2 and the commit never happens.
Watch the wall hold
Section titled “Watch the wall hold”Now the agent finishes a ledger tweak and reaches for a commit:
> commit the rounding fix
● Bash git commit -m "fix: correct rounding in ledger balance"
⊘ Blocked by PreToolUse hook (ledger-needs-test.sh): Blocked: this commit changes ledger/ but stages no test. Add a test for the ledger change before committing.
The commit was refused — the hook requires a test alongside any ledger change. Let me add one for the rounding case and re-stage.
● Write ledger/balance.test.ts ● Bash git add ledger/balance.test.ts ● Bash git commit -m "fix: correct rounding in ledger balance" ✓ committedWatch what the agent did with the rejection. It didn’t get stuck — it read the stderr message the hook fed back, understood why it was blocked, wrote the missing test, and committed cleanly. The gate didn’t just stop a bad action; it steered the agent toward the right one. And it would have fired identically in accept-edits mode, in auto mode, at 2am with nobody watching. The rule no longer depends on anyone remembering it.
Beyond the exit-code path, a PreToolUse hook can return structured JSON for finer control — permissionDecision: "deny" with a reason, or even rewrite the tool input — all documented on the hooks page. But exit 2 is the whole gate, and it’s enough for the wall we needed.
You’ve now built something categorically different from everything earlier in this chapter. The deny rules and the scoped connection constrained the agent; this overrules it. That difference is sharp enough to be worth naming precisely — because knowing when to reach for a hook instead of a rule is its own skill, and it’s the next lesson.