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

> Source: <https://dev.to/david_lee_bf42eec7236085d/your-claude-code-hook-exits-1-it-is-not-blocking-anything-225k>
> Published: 2026-09-22 02:29:32+00:00

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.
