Your Claude Code hook exits 1. It is not blocking anything. A developer has documented a silent failure mode in Claude Code's PreToolUse hooks: a hook that exits 1 is logged as an error but does not block the tool call, meaning commands it objects to still execute. Only exit code 2 blocks a call, and the hook's stderr text is shown to Claude as the reason, so the developer argues stderr should be written as a message to the model rather than a log. The post also warns that hooks relying on jq fail open on machines without it, and provides a testable fallback plus a sample hook that refuses test-skipping flags such as --passWithNoTests. 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 https://docs.claude.com/en/docs/claude-code/hooks : | 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. On PreToolUse , 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. bash /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 narrow first: this hook has no opinion about your git status 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. php 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=$?" 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 https://kit.sdvsignal.com/guides/claude-code-hooks-that-block/ — the longer version of this post is there, and there is a free MIT starter repo at kit-claude-code-starter https://github.com/sdvsignal/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.