cd /news/ai-agents/your-claude-code-hook-exits-1-it-is-… · home topics ai-agents article
[ARTICLE · art-136571] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↓ negative

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.

by read4 min views1 publishedSep 22, 2026

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-claude-code-hoo…] indexed:0 read:4min 2026-09-22 ·