[BUG] PreToolUse hook that fails to launch is treated as a deliberate deny — exit-code 2 collision causes unrecoverable tool lockout

Status Open
Reported on v2.1.218
Maintainer reply ✓ Yes — bcherny
Activity 5 comments · opened Jul 23, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Environment

  • Claude Code 2.1.218 (native install)
  • Windows 11, python from the Microsoft Store distribution
  • Reproduced with user and project settings excluded (--setting-sources project in an empty directory) and no MCP servers (--strict-mcp-config) — i.e. a minimal environment, not a side effect of a local configuration.

Summary

When a PreToolUse hook's command cannot be launched (script missing, path typo, wrong interpreter), the launcher's own failure exit code is interpreted as the hook protocol's "block this tool call" signal. Claude Code cannot distinguish

  • "the hook ran and deliberately returned 2 (deny)", from
  • "the hook never ran at all (broken configuration)".

CPython exits with 2 when it cannot open the target script (can't open file '...': [Errno 2] No such file or directory). That is exactly the value the hook protocol reserves for "block". The collision is not exotic — 2 is a common "usage/launch error" code across interpreters and CLIs.

Reproduction

Run each in an empty directory.

Test — hook script does not exist:

claude --print --setting-sources project --strict-mcp-config \
  --settings '{"permissions":{"allow":["Bash(echo:*)"]},"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"python C:/nonexistent/ghost_hook.py"}]}]}}' \
  "Run this bash command and report its output verbatim: echo REPRO_MARKER_OK"

Result — the Bash tool is blocked:

PreToolUse:Bash hook error: [python C:/nonexistent/ghost_hook.py]:
python.exe: can't open file 'C:\nonexistent\ghost_hook.py': [Errno 2] No such file or directory

Control — identical, but the hook exists and exits 0:

claude --print --setting-sources project --strict-mcp-config \
  --settings '{"permissions":{"allow":["Bash(echo:*)"]},"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"python -c pass"}]}]}}' \
  "Run this bash command and report its output verbatim: echo CONTROL_MARKER_OK"

Result — the Bash tool runs normally and returns CONTROL_MARKER_OK.

The only difference between the two runs is whether the hook command can be launched. This isolates "failed to launch" as the cause, rather than "a hook is configured at all".

Expected behaviour

A hook command that never executed is a configuration error, not a policy decision. It should be surfaced distinctly from a deliberate deny — for example by inspecting the spawn result (ENOENT / non-zero spawn error) before interpreting the exit code, and reporting "hook could not be started" as a loud configuration warning rather than a silent block.

Actual behaviour

The launch failure is reported as PreToolUse:<Tool> hook error and the tool call is blocked, identically to a deliberate exit 2 deny.

Impact

  1. Any typo in a hook path disables the affected tool, with a message that reads like policy enforcement rather than a broken config.
  2. Unrecoverable from inside the session. A common hardening pattern is a PreToolUse hook that protects the settings and hook files themselves from agent modification. If such a hook's script is renamed or removed — e.g. by a "temporarily disable the guard" helper that renames guard.py to guard.py.disabled while the settings entry still points at guard.py — every subsequent tool call is blocked, including the one that would restore it. The session can open the cage and lock itself inside, with no path back except editing the configuration from outside the agent.
  3. Because exit 2 is a routine interpreter/CLI error code, this is reachable through ordinary mistakes (moved repository, changed interpreter, unresolved environment variable in the path), not only through deliberate action.

Suggested fix

Separate the two signals:

  • Treat a spawn/launch failure (command not found, script not readable) as a hook configuration error: surface it prominently, and do not silently convert it into a deny.
  • Reserve exit code 2 for hooks that actually executed.

If fail-closed is the intended default for launch failures, the message should say so explicitly ("hook could not be started — blocking by policy") so the operator can tell a broken path from an enforced rule, and the documentation should warn that disabling a hook by renaming its script blocks all matched tool calls.

Related issues

  • #77912claude plugin validate passes manifests whose hook commands reference files that don't exist. That report covers the validation side: a bad hook path is not caught up front. This report covers the runtime consequence that makes it costly — the same missing file does not degrade gracefully, it blocks the tool. The two are complementary: fixing validation reduces how often this is reached, but does not change what happens when it is.
  • #59643 (closed) — PreToolUse hook deny reason not surfaced to agent's tool_result. Same general area, opposite direction: there a real decision was not reported; here a non-decision is reported as a decision.

I searched the tracker with several phrasings before filing and found no report of this specific exit-code collision. Given the size of the tracker that is "not found", not "does not exist" — happy to see this closed as a duplicate if one is known.

Workaround (for others hitting this)

Do not disable a hook by renaming or deleting its script while the settings entry still references it. Replace the script's contents with a no-op that exits 0, or remove the hook entry from settings — never leave a configured command pointing at a missing file.

View original on GitHub ↗

3 Comments

KeilerHirsch · 1 month ago

Independent re-verification, 2026-07-29, still on Claude Code 2.1.218 (Windows 11, Python 3.13.14 from the Microsoft Store distribution). The bug still reproduces exactly as filed — both the test and the control from the original report.

Test (broken hook path → tool blocked):

The command did not run. A `PreToolUse:Bash` hook blocked it before execution:
[python C:/nonexistent/ghost_hook.py]: can't open file 'C:\nonexistent\ghost_hook.py': [Errno 2] No such file or directory

The launcher's [Errno 2] is surfaced as PreToolUse:Bash hook error and the echo never runs — indistinguishable from a deliberate exit 2 deny.

Control (identical settings, hook is python -c pass → exit 0):

CONTROL_MARKER_OK

The tool runs normally. The only difference between the two runs is whether the hook command can be spawned, which isolates "failed to launch" (not "a hook is configured at all") as the cause — confirming the exit-code-2 collision described above.

Nothing has changed on the current build; the spawn failure is still converted into a silent fail-closed block. Repro is unchanged from the issue body, posted here only as a dated, independent confirmation that it is not stale or environment-specific.

KeilerHirsch · 1 month ago

Related failure mode, opposite direction — same exit-2 fragility.

This issue is about a hook that never launches being read as a deliberate deny — fail-closed where it should fail-open. I ran into the mirror image on my own setup: a PreToolUse guard that does launch and relies on regex-scanning the Bash command string can be walked straight past by an inline interpreter, because the sensitive path and write-mode live in a Python variable, not as a literal in the command.

The guard blocks Edit/Write to policy files and blocks literal Bash writes (>, tee, open(path,'w')) — but this slips through, because the scanner sees no literal policy path and no bare open(...,'w'):

# path + mode hidden in variables / chr() → no static regex matches → allowed
python -c "p=chr(120); import io; io.open(p, chr(119)).write(chr(120))"

chr(), variables, and multi-line -c defeat any static command-string regex — a python -c runs arbitrary code the scanner cannot predict. (I've since hardened my own guards to treat python|node|perl -c/-e near a policy path as untrusted, which closes it — this isn't a request to fix my local hooks.)

The general point: **exit-2 + command-string scanning is a fragile contract for a security boundary, and it's fragile from both ends** — a non-launching hook is a false deny (this issue), and a launching-but-string-matching hook is a false allow (the repro above). Two things would help both cases:

  1. a structured hook result that distinguishes "failed to launch" / "ran → allow" / "ran → deny", instead of overloading exit code 2;
  2. a note in the hooks docs that string-scanning python -c/node -e is not a robust containment primitive — content-level guards (or an actual sandbox) are needed for that.

cc @bcherny — in case this is useful for the hook-result design; happy to open a separate issue if you'd rather track the false-allow direction on its own.

bcherny collaborator · 15 days ago

Thanks for the very thorough report. I tried this on the current release, 2.1.233 (macOS), with your exact settings (python3 /nonexistent/ghost_hook.py as a PreToolUse command hook, --setting-sources project --strict-mcp-config). Same result as you saw: the Bash call is blocked with PreToolUse:Bash hook error: ... can't open file ... [Errno 2], and the control (python3 -c pass) runs normally.

This is working as documented, though we agree it's confusing. Claude Code fails open when the hook command itself can't be started (I verified: pointing the hook directly at a nonexistent executable produces a non-blocking hook error and the tool still runs). In your case the command does start — python launches fine — and it's Python that exits with code 2 because it can't open the script. From Claude Code's side that's indistinguishable from a script that ran and called exit(2), and per the hooks docs exit code 2 means block: https://code.claude.com/docs/en/hooks#exit-code-2

What we're considering: calling this out explicitly in the hooks docs (interpreter launch errors often use exit code 2, so a bad script path blocks rather than degrades; disable a hook by removing its settings entry or making the script a no-op, not by renaming the file), and making the hook-error message more clearly attribute the block to the hook's own output. Keeping this open as a docs/usability improvement.

🤖 Generated with Claude Code

Showing cached comments. Read the full discussion on GitHub ↗