UFO

Claude Code hooks, and the exit code that lets everything through

Short answer: hooks are the only deterministic control Claude Code gives you — a shell command that runs on an event, whatever the model felt like doing — and the single thing that defeats most people is that only exit code 2 blocks an action. Exit 1 prints an error and lets the tool call proceed.

That matters more than it sounds, because exit 1 is what an uncaught exception in your script returns. A hook that crashes looks exactly like a hook that is protecting you. This page is the handful of rules that decide whether yours enforces anything.

Written 2026-07-31 · Checked against Anthropic's hooks reference, Claude Code v2.1.219 · Version numbers matter here: three of the behaviours below changed within the last month

The exit codes

Straight from the reference:

"For most hook events, only exit code 2 blocks the action. 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."

So there are three outcomes, not two:

ExitWhat happens
0Success. Stdout is parsed as JSON if it looks like JSON. Otherwise it's a debug log and Claude never sees it.
2Blocks. Stderr is fed back to Claude as the reason.
anything elseNon-blocking error. The action proceeds. You get a hook error line in the transcript.

The one exception is WorktreeCreate, where any non-zero code aborts. Everywhere else, exit 1 is a shrug.

The second rule is that the two output styles are mutually exclusive. Use exit codes, or exit 0 and print JSON — never both. Claude Code only parses JSON on exit 0; if you exit 2, any JSON you printed is ignored.

# style A — exit codes
if echo "$COMMAND" | grep -q 'rm -rf'; then
  echo "blocked: rm -rf" >&2   # stderr, because Claude reads it
  exit 2
fi
exit 0
# style B — exit 0 + JSON. More expressive; works on Write/Edit
# where some people report exit 2 behaving inconsistently.
jq -n '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: "rm -rf is blocked by policy"
  }
}'
exit 0

Then the third rule, which is the one that turns a bug into a security problem:

"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."

Read that alongside the exit-1 rule and the failure mode assembles itself. Your policy hook calls jq, which isn't installed on the machine you just onboarded. Or it reads a config file that isn't there yet. Or a KeyError on an input field that changed shape. Every one of those exits 1. The tool call goes through. The transcript shows an error, so it looks like something was stopped.

If you take one thing from this page: a hook that is meant to block must be written so that failing also blocks. Wrap the body in a handler that exits 2 on any exception, and treat a missing dependency as a denial rather than a pass.
#!/usr/bin/env python3
import sys, json
try:
    data = json.load(sys.stdin)
    cmd = data.get("tool_input", {}).get("command", "")
    if "rm -rf" in cmd:
        print("blocked: rm -rf", file=sys.stderr)
        sys.exit(2)
except Exception as e:
    # fail closed. without this, a typo above silently permits everything
    print(f"policy hook failed: {e}", file=sys.stderr)
    sys.exit(2)
sys.exit(0)

Auditing the hooks you already have

This takes about two minutes and is worth doing before you read the rest.

grep -n "exit" ~/.claude/hooks/* .claude/hooks/* 2>/dev/null

Every exit that isn't 0 or 2 is a hook that permits the thing it was written to stop. In a Python hook, also look for what isn't there — no try around the body means any exception becomes exit 1.

Then check the hooks are registered at all, which is a separate failure:

claude
/hooks

/hooks is a read-only view of what Claude Code has loaded. If yours isn't listed, the problem is in settings.json — a matcher, a path, a JSON syntax error — and nothing you do to the script will help.

Matchers: exact string or regex, and you don't pick

The matcher field decides when a hook fires, and how it's evaluated depends entirely on which characters you used. There is no flag for this.

Matcher containsEvaluated as
"*", "", or omittedMatch all
Only letters, digits, _, -, spaces, , and |Exact string, or a list separated by | / ,
Any other characterJavaScript regex, unanchored

Two consequences, and between them they account for most "my hook never runs" reports.

Unanchored regex matches more than you meant. Matchers on the regex path go through RegExp.prototype.test, which succeeds on a match anywhere in the string. Edit.* catches NotebookEdit too. If you want one tool, anchor it: ^Edit$.

MCP tool names need a regex and don't look like they do. mcp__memory contains only letters and underscores, so it takes the exact-string path — and no tool is literally named mcp__memory. It matches nothing, silently. The form that works is mcp__memory__.*, where the . pushes it onto the regex path.

Version-sensitive: hyphens joined the exact-match set in v2.1.195. Before that, code-reviewer was an unanchored regex and also fired for senior-code-reviewer. Comma-separated lists need v2.1.191+. Two events — FileChanged and StopFailure — still use a narrower exact set of letters, digits, _ and | only, so a hyphen or space in those keeps you on the regex path.

And a whole category of events ignores matcher entirely rather than erroring on it: UserPromptSubmit, Stop, PostToolBatch, CwdChanged, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay. If you scoped one of those with a matcher and it fires on everything, that's why.

You cannot turn one hook off

"Hook entries merge across settings levels rather than replacing each other: user, project, and local settings add their own hooks without removing managed ones."

This is the opposite of how the rest of settings.json behaves, and it surprises people who assume a project-level empty array will override a user-level hook. It won't. Declaring "PreToolUse": [] in your project settings adds nothing and removes nothing.

The only switch is disableAllHooks: true, which is all-or-nothing — and which cannot disable managed hooks pushed from outside managed settings. There is no supported way to disable a single hook other than editing the file that declares it.

Practically: put anything you might want to toggle in project settings, not user settings, and keep an env-var kill switch inside the script itself if you need one.

Timeouts, and why cleanup hooks die

Hook typeDefault timeout
command, http, mcp_tool600s
agent60s
prompt30s
UserPromptSubmit (any type)lowered to 30s
MessageDisplaylowered to 10s

The one that catches people is SessionEnd: all SessionEnd hooks share a 1.5-second budget. A cleanup hook that pushes a branch or uploads a log gets killed halfway through, every time, and because the session is ending you rarely see the error.

The fix is in the same sentence of the docs and is easy to miss — setting a longer per-hook timeout raises the shared budget to match, up to 60 seconds. So this works:

{"hooks":{"SessionEnd":[{"hooks":[
  {"type":"command","command":"~/.claude/hooks/cleanup.sh","timeout":30}
]}]}}

Two more properties of the timeout system worth knowing. HTTP hooks fail open — a non-2xx response, a connection failure or a timeout all become non-blocking errors, so an HTTP hook cannot be used as a policy gate on its own. And the if filter field is also fail-open, holds exactly one rule (no && or ||), and on any non-tool event a hook carrying an if doesn't run at all.

All hook output, including additionalContext and systemMessage, is capped at 10,000 characters. Anything longer is cut.

Hooks only tighten, never loosen

PreToolUse runs before the permission-mode check and fires in every mode. That means a deny from a hook overrides bypassPermissions and --dangerously-skip-permissions, which is exactly what you want from a guardrail.

It does not work in the other direction. A hook returning allow cannot punch through a deny rule in settings.json. Hooks are a ratchet: useful for enforcing a policy, useless for exempting yourself from one.

Related, for Stop hooks: check the stop_hook_active input field before blocking, or you'll write an infinite loop. Claude Code overrides the hook and ends the turn after 8 consecutive blocks, so the loop terminates — but the eight turns in between are billed.

Debugging a hook that never fires

The most widely repeated piece of bad advice about hooks is to run claude --debug and watch the terminal.

"The --debug flag doesn't print to the terminal."

The log is a file:

~/.claude/debug/<session-id>.txt

# or name it yourself and tail it in another pane
claude --debug-file /tmp/cc.log
tail -f /tmp/cc.log

CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose adds matcher evaluation counts, which is what you want when a hook is registered but not firing.

The ladder that resolves this fastest, in order:

  1. /hooks — is it even loaded? If not, stop; the script is irrelevant.
  2. Pipe fake input by hand. This separates "my script is broken" from "Claude Code isn't calling it":
    echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
      | ~/.claude/hooks/policy.sh; echo "exit=$?"
    You are looking for exit=2. If you get exit=1, you found your bug.
  3. --debug-file + tail -f with verbose logging.
  4. Log a unique ID at the top of the hook — that's how you tell "fired twice" apart from "rendered twice".

Note that a hook which succeeds leaves no trace in the transcript at all. Silence is the success case, which is unhelpful when you're trying to confirm one ran.

If Claude Code says your JSON failed to validate but the JSON looks fine: shell-type hooks run through sh -c, so a BASH_ENV script or a Git Bash profile that unconditionally echoes something prepends that text to your stdout. Guard profile output with if [[ $- == *i* ]] and it goes away.

Known problems

Open issues on Anthropic's tracker with reaction counts, as of 2026-07-31. This ecosystem ships daily — check before assuming any of these still bites.

Issue👍What happens
#898564Notification hooks don't fire in VS Code's native UI
#1768834Hooks declared in skill frontmatter don't fire when the skill comes from a plugin
#518632Notification hooks arrive ~10 seconds late
#1215132Plugin hook output isn't passed through
#2971624Worktree hooks aren't called from Claude Desktop
#1708827Hook exits 0 but the transcript labels it an error

Three things that blog posts still warn about are already fixed, and following that advice now will mislead you: #24327 (exit 2 halting the whole session), #11392 (--settings replacing instead of merging), and #10401 (hooks not running without --debug, fixed in v2.0.27).

One undocumented event, if you're building something exhaustive: DirectoryAdded shipped in v2.1.219 and appears in the changelog but not in the hooks reference.

Where we fit, briefly

We build UFO, so weigh this accordingly. Nothing on this page needs us — hooks are a Claude Code feature and the fixes above are all local edits.

The reason we ended up knowing this in detail is that UFO runs agents unattended on machines people have paired, which means a policy hook failing open isn't a debugging annoyance, it's the whole safety story. If you're heading the same direction — several agents, on several machines, doing work nobody is watching in real time — the fail-closed pattern above is the part to copy, whatever you build it in.

UFO itself is a team chat where those agents are members of channels alongside people, with task cards for work that outlives a session. Different problem from this page's; mentioned once and left there.

Sources

Behaviour, quotes and defaults are from Anthropic's official documentation, read 2026-07-31 against Claude Code v2.1.219. Issue links go to the original reports and are dated because several are moving.

Not affiliated with Anthropic. If something here is wrong or has gone stale, tell us and we'll fix it.