CCLearn

Exit codes and JSON — what actually blocks

verified against Claude Code 2.1.261 · 2026-09-0914 min

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 likeHow Claude Code reads it
Starts with { and ends with }Parsed as JSON
Starts with { but does not end with }Plain text
Starts with anything elsePlain 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 permissionDecision of "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:

EventExit 2 does what
PreToolUseBlocks the tool call
UserPromptSubmitBlocks prompt processing and erases the prompt
StopPrevents Claude from stopping, continues the conversation
PostToolUseCannot block; shows stderr to Claude, the tool already ran
PermissionRequestNot honored at all; the permission flow proceeds unchanged
WorktreeCreateAny 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 is WorktreeCreate, where any non-zero exit code aborts worktree creation. — Hooks reference

The full contract, for events using the standard decision model:

Exit codeStdoutOutcome
0NothingNo decision; the call continues through the permission flow
0JSON object that validatesThe JSON decides
0JSON object that fails validationNon-blocking error; action proceeds; hook error notice carrying the validation message
Any otherJSON object that validatesThe JSON decides; the exit code is ignored and the hook is not reported as an error
Any otherPlain text, or emptyNon-blocking error; action proceeds; notice plus the first line of stderr, prefixed Failed with non-blocking status code:
2AnythingBlocks, 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.json leaves 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 decision and reason fields, but these are deprecated for this event. Use hookSpecificOutput.permissionDecision and hookSpecificOutput.permissionDecisionReason instead. — Hooks reference

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Writes to migrations go through review"
  }
}
permissionDecisionEffectWho 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 itThe user, not Claude
"deny"Prevents the tool callClaude
"ask"Prompts the user to confirmThe user, not Claude
"defer"Exits gracefully so the tool can be resumed laterNobody; 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

PreToolUse hooks fire before any permission-mode check, in every permission mode, including dontAsk. A hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode 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 wantDoes a hook get you there
Block something even in bypassPermissionsYes — deny fires before the mode check
Force a prompt in auto mode on a call the classifier would wave throughYes — "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 forbidsNo
Skip the prompt for an MCP tool marked requiresUserInteractionNo — 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 — never exit 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

  1. Your PreToolUse hook prints a valid JSON object with permissionDecision: "allow" and exits 2. Does the tool call happen?

    Show answer

    No. Exit 2 blocks, on events that can block, whether or not you print JSON — even a JSON permissionDecision of "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.

  2. 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 answer

    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. (WorktreeCreate is the exception, where any non-zero exit aborts creation.)

    The message becomes a hook error notice in the transcript, carrying the first line of stderr prefixed Failed 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 to chmod +x exits with something like 127 and lands in exactly the same bucket. The fix is exit 2, or JSON that validates, and then watching the hook refuse something on purpose before trusting it.

  3. Two PreToolUse hooks match the same Bash call. 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 answer

    The tool call is blocked; the webhook post is not. When multiple PreToolUse hooks return different decisions the precedence is deny > 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 updatedInput for 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.

  4. 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 answer

    Yes to the first, no to the second. PreToolUse hooks fire before any permission-mode check, in every permission mode including dontAsk, so a hook returning permissionDecision: "deny" blocks the tool even in bypassPermissions mode 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.

Sources