Exit codes and JSON — what actually blocks
By the end you can
- State what exit 0, exit 2 and every other exit code do
- Print JSON that the hook stdout parser will actually read
- Choose between exit 2 and a permissionDecision of deny
- Say where a hook's stderr goes on each exit code
- Predict the outcome when two PreToolUse hooks disagree
- Say which restrictions a hook can tighten and which it cannot loosen
Lesson 9.1 got the hook onto the right event with a matcher that matches, so the handler runs. It reads the tool input, decides the command is one you never want run, prints a refusal to stderr, and exits 1 — the code every shell script on your machine uses to mean "no".
The command ran. A hook error notice scrolled past in the transcript and nobody
read it.
That is not a bug in your matcher. It is the exit-code contract, and it is the one part of hooks where being nearly right leaves you a gate that is open and looks closed.
Exit 0: silence is not approval
Exit code 0 with no output means the hook has no decision to report, so the tool call continues through the normal permission flow. The hook can deny the call, but staying silent doesn't approve it. — Hooks reference
Read both halves. Exiting 0 does not wave the call through — it hands it back to the permission rules, which may still deny it or still ask you. Nor does it endorse the call, which matters when a hook's "pass" branch was meant to auto-approve something. It is declining to have an opinion. Exit 0 is also the intended code when you print JSON: use exit codes alone for signalling, or exit 0 and print JSON, one approach per hook.
The other half of exit 0 is where your diagnostics go:
Stderr from a hook that exits 0 goes to the debug log only, never the transcript, and Claude never sees it. — Hooks reference
The stdout rule: start with {, end with }
Claude Code decides whether your stdout is a decision or just text from its first and last characters, ignoring surrounding whitespace:
| What your stdout looks like | How Claude Code reads it |
|---|---|
Starts with { and ends with } | Parsed as JSON |
Starts with { but does not end with } | Plain text |
| Starts with anything else | Plain text — a JSON array or a quoted JSON string included |
A JSON array is not hook output, however valid it is as JSON. Neither is an
object with a log line printed after it. Where plain text then goes depends on
the event: for most, it lands in the debug log and is never shown. The exceptions
are UserPromptSubmit, UserPromptExpansion, SessionStart and
PostModelSwitch, where plain-text stdout is added as context Claude can see and
act on. The last of those is the newest arrival — the event itself only exists
from v2.1.251. Output strings are capped at 10,000 characters; anything
longer is written to a file and replaced with a preview and that path.
Exit 2: the code that blocks
Exit 2 means a blocking error. On events that can block, exit 2 blocks whether or not you print JSON: even a JSON
permissionDecisionof"allow"can't override it. — Hooks reference
Where the message comes from decides whether your refusal is legible: "the blocking message is the reason from your JSON's blocking decision when it makes one, and your stderr text otherwise." An exit-2 hook whose JSON fails schema validation still blocks — stderr becomes the reason, and the validation failure goes to the debug log. That behaviour arrived in v2.1.214; before it, the same combination was a non-blocking error and the action proceeded, which is one more reason not to mix the two approaches in one hook.
"Events that can block" is a real restriction. A sample of the per-event table:
| Event | Exit 2 does what |
|---|---|
PreToolUse | Blocks the tool call |
UserPromptSubmit | Blocks prompt processing and erases the prompt |
Stop | Prevents Claude from stopping, continues the conversation |
PostToolUse | Cannot block; shows stderr to Claude, the tool already ran |
PermissionRequest | Not honored at all; the permission flow proceeds unchanged |
WorktreeCreate | Any non-zero exit fails worktree creation |
PermissionRequest is the row that catches people: the event whose whole subject
is a permission decision ignores exit 2. You deny through its decision object.
Every other exit code, including the hook that could not start
Here is the warning the docs put in a box, and the reason your exit 1 gate let
the command through:
For most hook events, exit code 2 is the only exit code that blocks through the code alone. Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use
exit 2. The exception isWorktreeCreate, where any non-zero exit code aborts worktree creation. — Hooks reference
The full contract, for events using the standard decision model:
| Exit code | Stdout | Outcome |
|---|---|---|
| 0 | Nothing | No decision; the call continues through the permission flow |
| 0 | JSON object that validates | The JSON decides |
| 0 | JSON object that fails validation | Non-blocking error; action proceeds; hook error notice carrying the validation message |
| Any other | JSON object that validates | The JSON decides; the exit code is ignored and the hook is not reported as an error |
| Any other | Plain text, or empty | Non-blocking error; action proceeds; notice plus the first line of stderr, prefixed Failed with non-blocking status code: |
| 2 | Anything | Blocks, on events that can block, whatever the JSON says |
Two readings fall out. Once your JSON parses and validates, every exit code but
2 stops mattering — the JSON alone decides and the hook is not reported as an
error. Exit 2 stays outside that rule: it blocks, and a validating "allow" does
not rescue the call. And every non-blocking row proceeds, including the row
you did not write on purpose:
A hook that can't start lands in the same non-blocking bucket. When the script path doesn't exist or isn't executable, the shell exits with a code like 127 […] When you set up a policy hook, watch for this notice on its first run: a mistyped path in
settings.jsonleaves the gate silently disabled. — Hooks reference
JSON decision control on PreToolUse
PreToolUse carries most policy, and it does not use the top-level decision
field the other events use:
PreToolUse previously used top-level
decisionandreasonfields, but these are deprecated for this event. UsehookSpecificOutput.permissionDecisionandhookSpecificOutput.permissionDecisionReasoninstead. — Hooks reference
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Writes to migrations go through review"
}
}permissionDecision | Effect | Who sees the reason |
|---|---|---|
"allow" | Skips the permission prompt, except for the actions no permission mode auto-approves, and for AskUserQuestion and ExitPlanMode, which need updatedInput paired with it | The user, not Claude |
"deny" | Prevents the tool call | Claude |
"ask" | Prompts the user to confirm | The user, not Claude |
"defer" | Exits gracefully so the tool can be resumed later | Nobody; the reason is ignored |
"allow" is not a master key: "deny and ask rules are still evaluated
regardless of what the hook returns."
"deny" and exit 2 route the same way — "a hook that blocks by exiting 2 routes
the same way as deny: Claude sees the stderr message as the denial reason" —
so the choice is about what else you want to do. JSON reaches the outcomes an
exit code cannot express: "ask" instead of a flat refusal, or updatedInput to
rewrite the tool's arguments and let a narrowed call through. Exit 2 is the
shorter road when all you have is a refusal. "defer" is honored only in
non-interactive runs with -p; interactive sessions log a warning and ignore it.
On the events that keep the top-level pattern — PostToolUse, Stop,
UserPromptSubmit — the only value decision takes is "block". There is no
"approve": you let an action proceed by omitting the field or exiting 0 with no
JSON, which is the shape Lesson 5.2 used for a Stop hook.
When hooks disagree
Nothing stops two hooks matching the same call, and they do not take turns: matching hooks run in parallel (Lesson 9.1), so a block from one does not prevent another from running.
On PreToolUse the disagreement has a defined answer, and it is the safe one:
"when multiple PreToolUse hooks return different decisions, precedence is
deny > defer > ask > allow" — so a permissive hook cannot undo a
strict one. Everything else about the parallelism is yours to manage. A sibling's
side effects have already happened by the time another hook denies: the deny
stops the tool call, not the other handler. And when two hooks return updatedInput
for one tool, the last to finish wins, in an order that is non-deterministic
because they ran at once. The documented advice is not to have two hooks
rewriting the same input.
What a hook can tighten, and what it cannot loosen
PreToolUsehooks fire before any permission-mode check, in every permission mode, includingdontAsk. A hook that returnspermissionDecision: "deny"blocks the tool even inbypassPermissionsmode or with--dangerously-skip-permissions. — Hooks guide
A hook deny is stronger than any mode a user can switch into. The converse does
not hold: "a hook returning "allow" doesn't bypass deny rules from settings
[…] Hooks can tighten restrictions but not loosen them past what permission rules
allow."
| You want | Does a hook get you there |
|---|---|
Block something even in bypassPermissions | Yes — deny fires before the mode check |
| Force a prompt in auto mode on a call the classifier would wave through | Yes — "ask", which the classifier cannot silently approve past. Before v2.1.211 the classifier could clear a Bash command for outside the sandbox without ever showing the prompt your hook asked for |
| Allow something a settings deny rule forbids | No |
Skip the prompt for an MCP tool marked requiresUserInteraction | No — as of v2.1.199 an "allow" cannot skip that tool's approval prompt, with or without updatedInput, because Claude Code cannot confirm your hook collected the interaction. Earlier builds ignore the annotation entirely and give the tool the ordinary permission path |
A hook is a ratchet and it turns one way. When you catch yourself writing one to widen what is permitted, the mechanism you want is a permission rule — Level 10.
The gate that holds
exit 2, or JSON that validates — neverexit 1, and if you print JSON, print only the JSON.- Watch the first run. A
Failed with non-blocking status code:notice is the difference between a gate and a script that never ran. Make the hook refuse something on purpose before you trust it to refuse something by accident.
Checkpoint
-
Your
PreToolUsehook prints a valid JSON object withpermissionDecision: "allow"and exits 2. Does the tool call happen?Show answerAnswer
No. Exit 2 blocks, on events that can block, whether or not you print JSON — even a JSON
permissionDecisionof"allow"can't override it. This is the one place where the otherwise reliable rule breaks: once your JSON parses and validates, every exit code but 2 stops mattering, and 2 stays outside it.The blocking message is the reason from your JSON's blocking decision when it makes one, and your stderr text otherwise — and an
"allow"makes no blocking decision, so what Claude sees as the denial reason is whatever went to stderr. The hook is also doing the thing the docs tell you not to do: use exit codes alone for signalling, or exit 0 and print JSON, one approach per hook. -
A colleague's hook exits 1 with its refusal on stderr. Two things are wrong with that as a policy gate: what happens to the action, and what happens to the message?
Show answerAnswer
The action happens, and nobody reads the refusal. Without valid JSON on stdout, Claude Code treats exit 1 as a non-blocking error and proceeds, even though 1 is the conventional Unix failure code; for most events exit 2 is the only code that blocks through the code alone. (
WorktreeCreateis the exception, where any non-zero exit aborts creation.)The message becomes a
hook errornotice in the transcript, carrying the first line of stderr prefixedFailed with non-blocking status code:— a line that scrolls past, that Claude never receives as a denial reason, and that looks the same as several failures nobody wrote on purpose: a mistyped script path or a file you forgot tochmod +xexits with something like 127 and lands in exactly the same bucket. The fix isexit 2, or JSON that validates, and then watching the hook refuse something on purpose before trusting it. -
Two
PreToolUsehooks match the sameBashcall. One returns"allow"and also posts to a webhook; the other returns"deny". What is blocked, and what has already happened by the time the deny takes effect?Show answerAnswer
The tool call is blocked; the webhook post is not. When multiple
PreToolUsehooks return different decisions the precedence isdeny>defer>ask>allow, so the strict hook wins and a permissive one cannot undo it.By the time the deny takes effect the webhook has already been posted to. Matching hooks run in parallel rather than taking turns, so a block from one does not prevent another from running — the deny stops the tool call, not the sibling handler, and any side effect that handler had already performed stands. The same parallelism is why two hooks returning
updatedInputfor one call is documented as something not to do: the last to finish wins, in an order that is non-deterministic because they ran at once. -
You want to stop Claude editing
infra/even when a colleague starts their session with--dangerously-skip-permissions. Can a hook do that? Can a hook do the opposite — let them edit a path your settings deny?Show answerAnswer
Yes to the first, no to the second.
PreToolUsehooks fire before any permission-mode check, in every permission mode includingdontAsk, so a hook returningpermissionDecision: "deny"blocks the tool even inbypassPermissionsmode or with--dangerously-skip-permissions. A hook deny is stronger than any mode a user can switch into.The converse does not hold: a hook returning
"allow"doesn't bypass deny rules from settings, and deny and ask rules are still evaluated regardless of what the hook returns. Hooks can tighten restrictions but not loosen them past what permission rules allow — a ratchet that turns one way. When you catch yourself writing a hook to widen what is permitted, the mechanism you actually want is a permission rule.