The exit code is the whole contract. Exit 2 stops the tool call. Exit 1 does not stop anything, and a hook that exits 1 on failure is decoration.
Last updated: 2026-09-21
A PreToolUse hook blocks a tool call by exiting 2, and the text it writes to stderr becomes the reason Claude is shown.
Everything else on this page follows from that. Anthropic’s hooks reference is explicit: exit 2 is a blocking error and on PreToolUse it blocks the tool call, while exit 0 with no output “means the hook has no decision to report, so the tool call continues through the normal permission flow.”
The trap. Shell instinct says a failing check exits 1. In a hook, any other exit code does not block on its own for most events. Write exit 1 where you meant to stop something and you get the worst outcome available: a hook that appears to work, prints its complaint into a log nobody reads, and lets the command run. It is not a partial protection. It is none.
| Exit | What happens | Use it for |
|---|---|---|
0 | No decision reported. The call continues through the normal permission flow. Staying silent is not the same as approving. | The allow path — which is most calls |
2 | Blocks. On PreToolUse the tool call does not run. Your stderr is the reason Claude sees, unless you printed a JSON blocking decision. | The one thing you are refusing |
| anything else | Does not block on its own. Reported as an error. | Nothing, on purpose |
There is a JSON route as well, where the hook prints a structured decision on stdout. It is more expressive and worth reading the reference for. For “refuse this one shape of command”, exit 2 plus a sentence of stderr is less code and harder to get wrong — and exit 2’s block is the one outcome JSON cannot override.
The event is JSON on stdin. A PreToolUse payload carries hook_event_name, tool_name, tool_input and tool_use_id. What you read out of tool_input depends on the tool:
Bash .tool_input.command
Edit / Write .tool_input.content (and .tool_input.file_path)
jq decide whether you are protectedThe obvious first line is cmd=$(jq -r '.tool_input.command'). On a machine without jq that produces an empty string, the hook finds nothing to object to, exits 0, and the command runs. A security hook that fails open is worse than no hook, because you stopped watching for the thing yourself.
Read the payload once, then degrade to matching the raw JSON text:
payload="$(cat)"
JQ="${JQ_BIN:-jq}" # override to test the fallback: JQ_BIN=/nonexistent
if command -v "$JQ" >/dev/null 2>&1; then
cmd="$(printf '%s' "$payload" | "$JQ" -r '.tool_input.command // empty' 2>/dev/null)"
else
cmd="$payload" # coarser, still catches the pattern
fi
[ -z "$cmd" ] && exit 0
The JQ_BIN indirection is there so the no-jq path is testable. A fallback nobody has ever executed is a guess.
This one refuses the flags that make a test run report success whatever happens — the failure mode where the suite is green and nothing ran.
#!/usr/bin/env bash
# PreToolUse(Bash): refuse the flags that make a test run lie.
set -uo pipefail
payload="$(cat)"
JQ="${JQ_BIN:-jq}"
if command -v "$JQ" >/dev/null 2>&1; then
cmd="$(printf '%s' "$payload" | "$JQ" -r '.tool_input.command // empty' 2>/dev/null)"
else
cmd="$payload"
fi
[ -z "$cmd" ] && exit 0
# only look at test commands; everything else is none of this hook's business
case "$cmd" in
*test*|*pytest*|*vitest*|*jest*|*go\ test*) ;;
*) exit 0 ;;
esac
bad="$(printf '%s' "$cmd" | grep -oEm1 -- '(--passWithNoTests|--no-verify|\|\| *true|; *true$)' || true)"
if [ -n "$bad" ]; then
echo "BLOCKED: '$bad' makes this run report success whatever happens." >&2
echo "Run the tests for real. If some genuinely cannot run here, say which and why." >&2
exit 2
fi
exit 0
Three things in there are the difference between a hook that survives a week and one you delete:
case exits 0 for every command that is not a test run, so the hook has no opinion about your git status.Hooks are configured in a settings file, by event, with a matcher that filters the tool. Put it in .claude/settings.json to commit it with the repo; ~/.claude/settings.json applies everywhere on your machine and travels with nobody.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/no-skip-tests.sh\"" }
]
}
]
}
}
$CLAUDE_PROJECT_DIR matters: without it the hook resolves against the working directory and stops firing the moment Claude changes it.
It reads stdin and sets an exit code, so a pipe is the whole test rig:
# should block -> 2
echo '{"tool_name":"Bash","tool_input":{"command":"npm test -- --passWithNoTests"}}' \
| bash .claude/hooks/no-skip-tests.sh; echo "exit=$?"
# should allow -> 0
echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
| bash .claude/hooks/no-skip-tests.sh; echo "exit=$?"
# the fallback path, deliberately
JQ_BIN=/nonexistent bash .claude/hooks/no-skip-tests.sh <<< '{"tool_input":{"command":"npm test || true"}}'; echo "exit=$?"
Test the allow cases, not just the blocks. A hook that returns 2 for everything passes a block-only test suite perfectly and makes the tool unusable within an hour. Every block case needs a near-miss beside it that must return 0 — rm -rf node_modules next to rm -rf ~, .env.example next to .env.
This is one hook. The set worth having is about eight, and the argument each one needs is not the code — it is the near-miss list that keeps it from blocking your actual work.
The Skills & Hooks Pack is 10 skills and 8 hooks as drop-in .claude/ files, with 134 offline tests covering every block case, every allow case, behavior outside a git repository, every skill’s frontmatter, and the no-jq path above. Six of the eight block rather than warn: a write containing a live credential, a commit straight onto main, rm -rf at a root or a bare wildcard, git clean -fd, a .env read into the transcript, and the test-masking flags in this guide.
The near-miss list is the part that does not travel. Every allow case in this guide came from somebody's actual repo, and a generic one will block work you do every day until you sit down and edit it. So the split is really: do you want to own that list, or hand it over?
Setup Sprint — $99. The hooks written against your repo in 48 hours, near-miss list built from what you actually run, tested on both sides before it lands. You get the allow cases without discovering them one interruption at a time.
Buy Setup Sprint — $99 Or the Pack, $29, and wire it yourself
Rather read before you decide? Two of the 25 Pack files are published in full, with the whole file list and a replay of the hooks refusing real mistakes. If those two are not worth $29 to you, the other 23 will not be either — which is the point of showing them.
Prefer free? kit-claude-code-starter is MIT and has a working .claude/ to copy, including a hook. Only need the repo configured, not the hooks written? That is Setup Lite $29.
Exit 2. Anthropic's hooks reference states that exit 2 means a blocking error, and on PreToolUse it blocks the tool call. Exit 0 with no output means the hook has no decision to report and the call continues through the normal permission flow. Any other exit code does not block on its own for most events. So a hook that exits 1 on failure has not blocked anything — it has reported an error and let the command run.
When your hook exits 2 without printing a JSON decision, the blocking message is your stderr text, and Claude reads it. That is why a blocking hook should print a reason and a correction to stderr rather than just failing: Claude acts on the reason and fixes its own command instead of retrying blindly.
For command hooks the event arrives as JSON on stdin. A PreToolUse payload includes hook_event_name, tool_name, tool_input and tool_use_id, so a Bash hook reads .tool_input.command and an Edit or Write hook reads .tool_input.content.
No, and depending on it is a real risk: if jq is missing, a hook written around it can fail open and silently stop protecting anything. Read the payload into a variable, use jq when it is present, and fall back to matching against the raw JSON text when it is not. Then test the fallback path deliberately by pointing the jq binary at a path that does not exist.
In a settings file, under hooks, keyed by event, with a matcher that filters the tool name. A project hook goes in .claude/settings.json so it can be committed; ~/.claude/settings.json applies to every project on your machine and is not shareable. The matcher is a pattern like Bash or Edit|Write|MultiEdit.
Pipe a payload into it and check the exit code: echo the JSON, pipe it to bash your-hook.sh, then echo $?. A 2 is a block and a 0 is an allow. Test the allow cases too — a hook that blocks everything passes a block-only test suite and makes the tool unusable.