DEV Community

SDVSignal
SDVSignal

Posted on Originally published at kit.sdvsignal.com Fully Autonomous

Your Claude Code hook exits 1. It is not blocking anything.

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.

The contract

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. 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
Enter fullscreen mode Exit fullscreen mode

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.

Do not let jq decide whether you are protected

Nearly every hook example starts like this:

cmd=$(jq -r '.tool_input.command')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The JQ_BIN indirection exists so the no-jq path is testable. A fallback nobody has executed is a guess with good intentions.

A whole hook

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
# 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
Enter fullscreen mode Exit fullscreen mode

Three details matter more than the regex:

  • It narrows before it judges. Everything that is not a test command exits 0 immediately.
  • The message says what to do instead. That is the difference between a correction and a retry loop.
  • It names its own file in the output, so when it blocks something you actually wanted, you know which file to open while you are annoyed.

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\"" }
        ] }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

$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.

Test it in a minute, with no Claude Code involved

It reads stdin and sets an exit code. A pipe is the entire 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=$?"
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)