Your Claude Code hook exits 1 when it wants to stop something. It is not stopping anything.
That is the whole post, really, but the reason is worth five minutes because the failure is silent. A hook that exits 1 runs, prints its complaint, gets logged as an error — and the command it objected to executes anyway. You get the feeling of protection with none of it, which is strictly worse than having written no hook at all, because you have stopped watching for the thing yourself.
From Anthropic's hooks reference:
| Exit | What actually happens |
|---|---|
0 |
No decision reported. The call continues through the normal permission flow. Silence is not approval, but it is not refusal either. |
2 |
Blocking error. OnPreToolUse , the tool call does not run. |
| anything else | Does not block on its own. Reported as an error. |
And the part people miss even after getting the exit code right: when you exit 2 without printing a JSON decision, your stderr text is the reason Claude is shown. So stderr is not a log. It is the message. Write it for the reader.
echo "BLOCKED: '--passWithNoTests' 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
Claude reads that and fixes its own command. Compare it to a bare exit 2 with nothing on stderr, which gets retried, because nothing told it what was wrong.
jq decide whether you are protected
Nearly every hook example starts like this:
cmd=$(jq -r '.tool_input.command')
On a machine without jq, that is an empty string. Your hook finds nothing to object to, exits 0, and the command runs. A security hook that fails open is the worst object in the repository.
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 exists so the no-jq path is testable. A fallback nobody has executed is a guess with good intentions.
This one refuses the flags that make a test run report success whatever happens — the failure mode where CI is green and nothing ran.
#!/usr/bin/env bash
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
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 details matter more than the regex:
Wire it up in .claude/settings.json so it can be committed:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/no-skip-tests.sh\"" }
] }
]
}
}
$CLAUDE_PROJECT_DIR is not optional decoration. Without it the path resolves against the working directory, and the hook stops firing the moment Claude runs cd.
It reads stdin and sets an exit code. A pipe is the entire test rig.
echo '{"tool_name":"Bash","tool_input":{"command":"npm test -- --passWithNoTests"}}' \
| bash .claude/hooks/no-skip-tests.sh; echo "exit=$?"
echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
| bash .claude/hooks/no-skip-tests.sh; echo "exit=$?"
Write the allow cases first. A hook that returns 2 for everything passes a block-only test suite with a perfect score and gets deleted within the hour. Every block needs a near-miss beside it that must return 0: rm -rf node_modules next to rm -rf ~, .env.example next to .env. The near-misses are where the actual thinking is.
I write this stuff up at kit.sdvsignal.com — the longer version of this post is there, and there is a free MIT starter repo at kit-claude-code-starter with a working .claude/ to copy if you would rather read one than assemble one.
What is your hook blocking that a CLAUDE.md line could not? I am genuinely collecting these — the interesting ones are always the project-specific rules, not the generic rm -rf guards.