A hook returning block: true blocks nothing
Community hook configs return a top-level block: true and message to stop a tool call, but neither field exists in Claude Code's hook schema — what blocks is exit code 2, decision: "block" with reason, or PreToolUse hookSpecificOutput.permissionDecision: "deny".
This advice is common and does not hold. The official page saying otherwise is linked below.
What this costs you if it is wrong
This is about a guardrail you believe is enforcing a policy. If you copy the invented shape into a hook that exits 0, the tool call proceeds and you stop watching for the thing the hook was supposed to catch. Conversely, do not "fix" a working exit-2 guard by deleting the exit and keeping the JSON — verify the replacement blocks before you trust it, on a throwaway file, not on a real destructive command.
You copied a hook from a showcase repo. It has a JSON response shape documented in its README — a top-level block, a message, maybe a feedback — and the guard visibly works when you test it. So you reuse the shape in your own PreToolUse hook, drop the exit 2 because the JSON "handles it", and ship. Your guard is now a no-op.
Neither block nor feedback is a field Claude Code reads. Nothing in the hooks reference mentions either one. The original guard worked because the same command also ran exit 2 — the JSON on stderr was decoration sitting next to the thing that actually did the blocking.
What actually blocks
Three mechanisms, and which one applies depends on the event.
| Mechanism | Shape | Where it works |
|---|---|---|
| Exit code 2 | reason on stderr, no JSON needed | PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, PostToolBatch, ConfigChange, TaskCreated, TaskCompleted, TeammateIdle |
Top-level decision | "decision": "block" plus "reason" | UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact |
hookSpecificOutput | permissionDecision of allow / deny / ask / defer, plus permissionDecisionReason | PreToolUse (and PreModelSwitch) |
The docs are blunt about the second one: the only value decision accepts is "block". There is no "allow", no true, no boolean anywhere in the decision model.
A correct PreToolUse deny looks like this, and note the required hookEventName:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}Or skip JSON entirely:
#!/bin/bash
input=$(cat)
command=$(jq -r '.tool_input.command' <<<"$input")
if [[ "$command" == rm* ]]; then
echo "Blocked: rm commands are not allowed" >&2
exit 2
fi
exit 0Two failure modes for the invented shape
Where the fake shape lands depends on the event.
- On
PreToolUsewithexit 0: the object either fails schema validation (the docs say that is a non-blocking error — the action proceeds and the transcript shows ahook errornotice with the validation message) or the unknown keys are ignored and it is simply a decision-free exit 0. Both outcomes are the same for you: the tool call runs. The only difference is whether you get a notice hinting at the problem. - On
PostToolUse, blocking is not on the menu at all. The tool already ran. Exit 2 there shows your stderr to Claude; it does not undo anything. If you wanted a gate, you wantedPreToolUse.
Two more fields worth correcting while you are in here: suppressOutput is a real field name but the docs say it has no effect — Claude Code accepts it and does not act on it. And continue: false with stopReason is real, but it is the sledgehammer: it stops Claude processing entirely, not just this one call.
Do not mix, but know what mixing does
The docs' advice is to pick one approach per hook: exit codes alone, or exit 0 and print JSON. If you do mix them, the behavior is defined rather than broken — exit 2 keeps its blocking effect and Claude Code still reads the JSON fields. Since v2.1.214 an exit-2 hook that prints invalid JSON still blocks, using stderr as the reason and logging the validation failure to the debug log. That is precisely why the showcase guard passes its own test while teaching a shape that fails on its own.
One more trap in the same area: your stdout must contain only the JSON object. A shell profile that prints a banner on startup turns valid output into a parse failure.
What is unverified here
Nobody ran this. What the docs establish is the positive half — the real field names, the per-event decision table, the statement that decision takes only "block" — and the absence of block and feedback from all 77 documentation pages. What is inferred rather than observed is the exact failure mode of the invented shape: whether an object carrying only unknown keys trips schema validation and produces a visible hook error notice, or is silently ignored as a no-decision exit 0. The claim that matters — the tool call is not blocked — holds either way.
To settle the rest, wire a two-line PreToolUse hook that echoes the fake object and exits 0, then run a headless session that writes a file, and watch whether the write happens and whether a hook error appears in --debug output. That takes about two minutes and converts this entry from contradicted-by-docs to reproduced.
Checkpoint
-
Your
PreToolUsehook exits 0 and prints nothing. Did it approve the tool call, deny it, or neither?Show answerAnswer
Neither. Exit 0 with no output means "no decision" — the call falls through to the normal permission flow. A hook can deny; staying silent does not allow, so a guard that has never fired is indistinguishable from a guard that cannot.
The same fail-open shape catches a mistyped script path: the shell exits 127, the transcript shows a non-blocking
hook errornotice, and the action proceeds. -
You want a
PostToolUsehook to stop a bad write from landing. What is wrong with that plan?Show answerAnswer
By the time
PostToolUsefires the tool has already run, so blocking is not on the menu at all. Exit 2 there shows your stderr to Claude; it does not undo the write. If you wanted a gate, you wantedPreToolUse. -
Which field carries the reason a user sees when a
PreToolUsehook denies a call?Show answerAnswer
permissionDecisionReason, insidehookSpecificOutputalongsidepermissionDecision: "deny"and the requiredhookEventName. Notmessage: the hooks reference at code.claude.com/docs/en/hooks documents neithermessagenor the top-levelblockthat showcase configs pass around, which is why a hook printing that shape and exiting 0 stops nothing.The other correct route carries no JSON at all — print the reason on stderr and
exit 2.