In Claude Code hooks, exit 1 blocks nothing

If you write a Claude Code policy hook the way most Unix scripts fail, with exit 1, the tool call you meant to stop still runs. A hooks explainer on Blakecrosley calls this the biggest footgun in the system. The explainer covers all 33 hook events that Anthropic's reference lists as of September 6, 2026, and checks every API detail against the official docs.
At a glance
- Hooks are shell commands, HTTP endpoints, MCP tools or model prompts that Claude Code runs automatically at fixed lifecycle points. Unlike CLAUDE.md instructions, they run whether or not the model cooperates.
- A hook reads JSON on stdin and answers through exit codes or stdout JSON. Exit 0 allows, exit 2 blocks where blocking exists, and any other code just shows an error notice.
- The hooks API changed a lot across Claude Code v2.1.x releases, with new events, fields and matcher semantics, so the explainer says the official reference wins wherever the two disagree.
If you have not wired up hooks yourself, most developers run Claude Code with two layers of control. Permissions decide what the agent may do, and CLAUDE.md describes what it should do. Hooks are the third layer, and the explainer calls them the only one that guarantees anything. CLAUDE.md, skills and prompts are good suggestions that usually get followed, but nothing forces them.
A PreToolUse deny holds even under --dangerously-skip-permissions
The explainer uses Prettier as its example. Ask a coding agent to run it after every edit and it will most of the time. It may skip the step when a change looks trivial, when the context runs long, or when your phrasing lands differently. Anthropic's hooks guide describes the alternative as “deterministic control: certain actions always happen rather than relying on the LLM to choose to run them.”
If CLAUDE.md is a note on the fridge, a hook is a lock on the door. PreToolUse hooks fire before any permission-mode check. A hook that returns permissionDecision: “deny” therefore blocks the tool even in bypassPermissions mode or under --dangerously-skip-permissions. The lock only works one way, though: a hook that returns “allow” cannot loosen deny rules in your settings.
The reference lists 33 events, and production setups mostly use five
The 33 events fall into three cadences: once per session (SessionStart, SessionEnd), once per turn (UserPromptSubmit, Stop, StopFailure) and on every tool call (PreToolUse, PostToolUse). The rest fire on specific conditions such as config changes, compaction, subagents and MCP interactions. Nearly every production setup is built from just five: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart and Stop.
The list keeps moving. DirectoryAdded needs v2.1.219 or later. When forked sessions arrived in v2.1.214, SessionStart got a fifth source value, fork. A hook that matches on source and was copied from the older four-value list will quietly miss forks.
The Agent SDK fires the same events. There, hooks are callbacks registered in the hooks field of your agent options, and they return the same JSON shape a shell hook prints. Coverage differs, though. At the time of writing, SessionStart, SessionEnd, Setup, PostToolBatch, PermissionDenied and the compaction, model-switch, task, worktree, elicitation and file-watch events work only in TypeScript.
Exit 1 is a non-blocking error, and JSON is read only on exit 0
On paper the contract is simple. Exit 0 means success, and Claude Code parses stdout for JSON. Exit 2 is a blocking error: stdout is ignored and stderr goes back to Claude as the error message. Any other code, exit 1 included, puts a hook error notice in the transcript and execution continues. Policy hooks have to exit 2.
What “block” means depends on the event. On PreToolUse, exit 2 stops the tool call. On UserPromptSubmit, it stops processing and erases the prompt. On Stop, it keeps the conversation going. PostToolUse cannot block because the tool has already run, and StopFailure and PermissionDenied ignore the exit code entirely.
The second trap is mixing the two channels. JSON is processed only on exit 0, so if a script prints {“decision”: “block”} and then exits 2, Claude Code throws the JSON away. Matchers are case-sensitive as well, so bash never matches Bash. The explainer suggests confirming registration with /hooks.
A Stop gate gets 8 consecutive blocks by default
A hook config has three levels: an event such as PostToolUse, a matcher such as Edit|Write, and one or more handlers. Hooks in ~/.claude/settings.json apply to every project. Hooks in .claude/settings.json are committable, and .claude/settings.local.json is gitignored.
Handlers come in five types: command, http, mcp_tool, prompt and the experimental agent. Command hooks time out after 600 seconds by default, and a gate that times out is a gate that never ran.
Stop fires every time Claude finishes responding, and a hook that returns decision: “block” sends the agent back to work until, say, the tests pass. By default Claude Code caps this at 8 consecutive blocks, and CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises the limit. A gate that never checks stop_hook_active will burn through all 8.
The explainer is open about the limits. Hooks cannot call tools, PostToolUse cannot undo anything, PreToolUse never sees @-referenced files, and hooks run with your full user permissions. In our view, treating exit 1 as a pass is the design choice most likely to bite people, because it breaks a habit every script author brings along. Version churn adds risk too: since v2.1.214, if patterns like Edit(src/**) stopped matching nested paths without any warning.
Rechecking hooks after v2.1.x updates
The explainer checked the hooks reference on August 8, 2026, rechecked the Agent SDK page on September 6, and treats version-sensitive details as true only as of those dates. No schedule for further hook changes has been given. After each update, the explainer's own checks still apply: open /hooks to see which settings file each hook came from, then pipe sample JSON into a script and read echo $? to confirm it blocks.
Related stories
- Claude Code plugins become Claude Mods, shipping in weeks
- Function Hooks would wrap Claude Code like middleware
- Claude Code 2.1.283 keeps new models out until admins say so
- In Claude Code, one CLAUDE.md silently kills your AGENTS.md
- Without CLAUDE.md, Claude Code 2.1.277 reads AGENTS.md
- 57% of public Claude Code subagents inherit Bash
Comments
No comments yet. Be the first.
Join the conversation
Sign in with Google to leave a comment. Your name and avatar come from your Google profile, and the comment appears after moderation.
