Hook launch failures (exit 127) are silent and non-blocking — 6,865 skipped guardrail invocations in one session, with no visible signal

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 3 comments · opened Jul 26, 2026

Claude Code 2.1.220 · macOS 26.5.2 (arm64) · zsh

Summary

If a hook's command cannot be launched, Claude Code records
hook_non_blocking_error and lets the tool call proceed. Nothing is shown to the user, nothing is
logged outside the session transcript, and /hooks continues to list the hook as configured.

For hooks used as guardrails, this means a hook that never ran is indistinguishable from a hook
that approved the action
— and the failure can persist for an entire session without a single
symptom.

In my case, eleven PreToolUse hooks failed to launch 6,865 times across one session. Branch
protection, a destructive-command blocklist, a secret scanner, a critical-path guard and a commit
gate were all inert. I only discovered it by accident, and it then took roughly an hour to diagnose
— most of it spent on a wrong hypothesis, because every signal available to me pointed the wrong
way.

Root cause in my case (a one-character fix, and not the interesting part)

The project lives at /Users/whigician/Desktop/Courses Platform/courses-web. The path contains a
space.
The hook commands were written unquoted:

{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/secret-scanner.sh" }

/bin/sh splits on the space and tries to execute /Users/whigician/Desktop/Courses:

type:     hook_non_blocking_error
exitCode: 127
stderr:   /bin/sh: /Users/whigician/Desktop/Courses: No such file or directory
command:  $CLAUDE_PROJECT_DIR/.claude/hooks/secret-scanner.sh
durationMs: 5

Quoting the command fixes it completely. I am not asking for the quoting to be handled for me
(though see suggestion 4). The bug I want to report is that this was invisible.

The scale, from the session transcript

Counted over ~/.claude/projects/<slug>/<session>.jsonl:

| Attachment type | Count |
|---|---|
| hook_non_blocking_error | 6,865 |
| hook_success | 2 |
| hook_additional_context | 2 |

Every one of the 6,865 was exitCode: 127. Per hook:

724  secret-scanner.sh          724  block-dangerous-bash.sh
724  block-commit-on-main.sh    724  block-force-push-on-main.sh
724  adr-integrity.sh           724  tier-gate.sh
724  debt-id-check.sh           724  pre-merge-gate.sh
724  pre-push-gate.sh           154  block-edit-on-main.sh
154  pre-edit-section10-guard.sh  41  verify-on-stop.sh

The two successes were a plugin's SessionStart hook — whose config quotes
"${CLAUDE_PLUGIN_ROOT}/…".

Why it took an hour to find

Every diagnostic surface said the configuration was fine, because it was fine — as configuration.

  • The hook scripts are correct: piping a synthetic payload to each returns the right exit code and

refusal text.

  • They are executable, -rwxr-xr-x.
  • The config schema matches every installed plugin's hooks.json verbatim.
  • No disableAllHooks anywhere in the settings hierarchy.
  • Hooks were clearly not disabled globally — a plugin's SessionStart hook demonstrably fired.

That last point actively misled me. Because some hook worked, I concluded the mechanism was
healthy and the problem was specific to PreToolUse — and spent a long time on the hypothesis that
bypassPermissions skips PreToolUse. (Issue #74942 reports the opposite: Bash-matched hooks
firing normally under that mode. My hypothesis was wrong, and I had no way to test it.)

The actual discriminator — quoted config works, unquoted does not — was sitting in front of me the
whole time and is invisible unless you already suspect it.

The one place the truth exists is the session transcript JSONL. Nothing points you there. I found
it only because #74942's author mentioned diagnosing from the transcript.

What would fix this

1. Surface launch failures. Exit 127 / ENOENT is categorically different from a hook returning
non-zero. One does not mean "allow"; it means "your guardrail did not run". At minimum print it once
per session:

⚠ hook failed to launch: block-dangerous-bash.sh (exit 127) — command not found
   PreToolUse hooks for this project are NOT in effect.

2. Validate hook commands at session start, when the settings are read, rather than discovering
it 724 times per hook. A resolvable-path check would have caught this before the first tool call.

3. Make /hooks show last-execution status, not just configuration. "Configured" and "in
force" are different questions, and only the second one matters for a guardrail. Something as small
as last: exit 127 (2s ago) per hook would have ended this in seconds.

4. Consider not routing hook commands through a shell — or documenting loudly that the command
string is shell-interpreted and must be quoted. Paths with spaces are ordinary on macOS
(~/Documents, ~/My Project), and this failure mode is total: not degraded, absent.

5. Related, and cheap: exit 1 has the same problem. Documented semantics are 0 = allow,
2 = block; 1 is neither, and is treated as non-blocking. A hook that dies mid-script — e.g. under
set -euo pipefail, where a grep matching nothing returns 1 and set -e terminates the
script — silently stops enforcing. I found a real instance of this in my own hook while
investigating. Both cases share one root: any hook outcome that is not an explicit decision is
currently interpreted as approval, and never reported.

Suggested principle

A guardrail that cannot run should fail loudly, not open. If Claude Code cannot get a decision out
of a PreToolUse hook, the user should be told — once, clearly — rather than left believing a
protection is in force when it has never executed.

Reproduction

  1. Put a project at a path containing a space.
  2. Register a PreToolUse hook with an unquoted "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh"

that blocks a known command.

  1. Run that command in a session — it proceeds, with no indication the hook failed.
  2. grep -c hook_non_blocking_error ~/.claude/projects/<slug>/<session>.jsonl — the only evidence.

---

Related: #74942 reports PreToolUse hooks with an Edit|Write matcher never being invoked under bypassPermissions while Bash-matched hooks fired normally. That is a different cause from this one — there the hooks were reachable and not invoked; here they were invoked 6,865 times and failed to launch. Filing separately, but the two share the symptom that matters: no visible signal either way.

View original on GitHub ↗

3 Comments

yurukusa · 1 month ago

Same class of failure on a different setup (Linux/WSL2, bash, no spaces in any path). Counting my running session's transcript the way you did:

exit 127: 180   compiled-rules.sh          stderr: No such file or directory
exit 141: 136   self-audit-bloat-check.sh  stderr: (empty)

Two additions to your report: exit 141, and a failure mode that leaves no transcript evidence at all.

exit 141 — launched, ran, killed silently on SIGPIPE

141 is 128+13. Not a launch failure, not a refusal. The script starts, runs partway, and is killed. stderr is empty, so reading stderr from the transcript tells you nothing.

Cause, from two lines that are each considered good practice:

set -euo pipefail                                   # line 14
LATEST="$(ls -t "$SNAPSHOT_DIR"/*.md | head -1)"    # line 112

head -1 closes the pipe; if the upstream is still writing it takes SIGPIPE; pipefail promotes that to the pipeline's status; set -e ends the script. Everything below line 112, including the guardrail logic and the exit 0 at line 211, never runs.

Reproduced locally, no Claude Code needed:

$ bash -c 'set -euo pipefail; X="$(yes | head -1)"; echo reached; exit 0'; echo $?
141                     # "reached" never printed
$ bash -c 'set -eu;      X="$(yes | head -1)"; echo reached; exit 0'; echo $?
reached
0

Whether it fires depends on how much the upstream writes before the reader closes. My directory had grown to 245 files, so ls -t was still writing. The hook worked for weeks, then started failing as data accumulated — no change to the hook or the config. A guardrail that degrades on a schedule set by your data is hard to catch by review.

This widens your suggestion 5: the real set is every exit status that is not an explicit 0 or 2, and 141 gives you the least to go on because it produces no output.

Hooks that are never invoked leave no evidence

grep -c hook_non_blocking_error finds hooks Claude Code tried to run. It cannot find hooks that were never candidates, because those produce no attachment of any kind:

  1. Matcher mismatch — a PreToolUse hook with matcher Bash is not consulted for Edit/Write. No error, no record, and /hooks lists it identically to a working one.
  2. Wrong event for what the script reads — a script written to inspect a tool's output, registered under PreToolUse, launches, finds no result field, reads empty, and exits 0.

I had a real instance of the second: a cost circuit-breaker meant to warn at one threshold and block at another, registered on the wrong event. It read empty every time and computed zero every time — never warned, never blocked, exit 0 throughout. I established it only by noticing that every accumulated state file still contained zero after weeks.

Finding these needs a static comparison of what each hook script reads against what it is registered for, not a count over the transcript.

One amendment to your suggestion 3

/hooks showing last-execution status is the fix I most want — but please distinguish "exited 0" from "made a decision". For case 2 above, last: exit 0 (2s ago) is worse than no signal: it certifies as healthy a guardrail that has never evaluated anything. Showing which event and matcher the hook was consulted under, and when it last actually matched, would cover that.

Your principle holds for all of these. I would widen it slightly: a guardrail that ran but never looked at anything should also not count as approval.

avenna01-ceo · 1 month ago

A third failure mode worth adding, because it defeats the transcript-counting method in this thread: the hook launches fine, exits 0, and silently did nothing.

exit 127 and exit 141 at least leave a countable artifact. This one doesn't. hook_success, exitCode: 0, empty stderr — indistinguishable from a real approval, and there is no line in the transcript to grep for.

I hit it in my own PreToolUse guard. The hook reads its protected-path list from .claude/protected.txt, resolved relative to the cwd in the payload. When that path couldn't be resolved the same way by the hook process, is_file() returned false and the script fell back to its built-in defaults. It ran, it exited 0, it blocked .env — so a spot check looked fine — but every rule the user had actually written was being ignored. I only found it because I followed my own install instructions on a fresh temp dir instead of trusting the code.

That points at the same underlying gap you're describing: the runtime treats "the hook approved" and "the hook did not meaningfully execute" as the same event. Quoting fixes the 127 case, but a guard can also be inert while reporting success, and neither /hooks nor the transcript can tell you.

Two things that helped me, both cheap:

  1. Make the guard say what it loaded. Mine writes the source of its rules into the block message (listed in .claude/protected.txt vs listed in guard.py defaults). A wrong-source string in a real refusal is immediately visible, where a missing file is not.
  1. Assert the guard, don't trust it. A one-liner you can run any time, and after any settings change:

``bash
echo '{"tool_name":"Edit","tool_input":{"file_path":".env"},"cwd":"'"$PWD"'"}' \
| python3 .claude/guard.py; echo "exit=$?"
`
exit=2 means it is live *and* matching. This catches 127, 141, and the silent-no-op case in one shot, which /hooks` listing the hook as configured does not.

On the fail-open question raised implicitly here — I deliberately exit 0 on malformed stdin, because a guard that bricks every write when it breaks is worse than no guard. But this issue is a good argument that fail-open needs to be loud. A hook_non_blocking_error that never surfaces gives you the worst of both: no protection and no signal.

+1 on surfacing these in the UI. Even a session-end summary line — "N hook invocations failed to launch" — would have saved OP an hour and me considerably more.

(My implementation, if it's useful as a reference for the self-test pattern: https://github.com/avenna01-ceo/claude-code-survival-kr/tree/main/guard — MIT.)

yurukusa · 28 days ago

Your third mode is the one I spent yesterday on, from a different cause — and it has a property that defeats the self-test you posted: it passes on the machine that wrote the hook and fails on the machine that runs it.

Cause in my case: grep -P. It is a GNU extension. BSD grep (macOS default) rejects it. Inside a hook the sequence is: grep errors → the error goes to 2>/dev/null → the variable comes back empty → the script reads that as "nothing matched" → exit 0. Launch fine, exit 0, empty stderr, no transcript line. Exactly your signature, different root.

I measured it across the hook set I ship. Of 909 hooks, 39 hooks / 56 lines pass -P to grep on lines that actually execute. To see what that costs I put a stub first on PATH that fails on -P, which reproduces the BSD behaviour on Linux:

  • 2 hooks lose the block entirely (exit 2 → exit 0 on the same malicious input)
  • 6 lose their warning
  • 1 never fired at all, -P or not

The user sees no difference in any of those cases.

Two notes on your one-liner, both learned by getting them wrong:

Run it through the same resolution the hook gets. I verified a pattern in my interactive shell and it matched — but my interactive grep is ugrep, and the hook resolves /usr/bin/grep. The assertion passed while the hook was broken. command -v grep inside the hook, not in your shell.

Assert on the platform that will run it, or simulate that platform. The stub-on-PATH trick is cheap and needs no second machine:

mkdir -p /tmp/bsdstub && cat > /tmp/bsdstub/grep <<'EOF'
#!/bin/sh
for a in "$@"; do case "$a" in -*P*) echo "grep: invalid option -- 'P'" >&2; exit 2;; esac; done
exec /usr/bin/grep "$@"
EOF
chmod +x /tmp/bsdstub/grep
PATH=/tmp/bsdstub:$PATH sh your-hook.sh < payload.json; echo "exit=$?"

If that prints exit=0 where your real run prints exit=2, the hook is inert for every macOS user who installs it.

Your "make the guard say what it loaded" generalises to this: say what it depends on, and check it. The shape that survived for me is to test the capability first and refuse rather than continue:

if ! printf 'x\n' | grep -qP 'x' 2>/dev/null; then
  echo "guard: this system's grep has no -P; refusing to evaluate" >&2
  exit 2
fi

The probe needs input that actually matches. My first version was printf '' | grep -qP '', which returns 1 on a perfectly good GNU grep — empty pattern, empty input, no match — so the guard refused every write on a system where -P works fine. I only caught it because I ran a harmless command through as a control and it came back exit=2. A capability probe that can't distinguish "unsupported" from "didn't match" is its own outage.

Verified all four branches on this box (GNU grep, plus the stub above standing in for BSD):

| input | GNU | BSD-alike |
|---|---|---|
| dangerous | exit 2 | exit 2 |
| harmless | exit 0 | exit 2 (refuses to judge) |

Fail closed on a missing capability, fail loud. Your point about fail-open needing to be loud is the same argument one level down — a guard that cannot run its own check should not be the thing that decides the write is safe.

The wider version of the gap, for whoever picks this up: hook_success currently means "the process exited 0". It does not mean the hook evaluated anything. Those are different events and only the runtime can tell them apart.