What happens when a Claude Code hook is broken or not working (Chapter 9)

Chapter 8 was about hooks that were never able to refuse. This chapter is about hooks that were able to, and then stopped, because something in them broke.

The interesting part is that a broken hook does not have one behaviour. It has two, and which one you get depends on how it broke. One of them is loud and one of them is silent, and the silent one is the one with the defensive boilerplate on line 2.

The contract, and where it applies

Claude Code reads the hook’s exit code. On PreToolUse, code 2 means refuse; everything else — 0, 1, 127, anything — means carry on. There is no code for “I could not decide”. A hook that dies is a hook that said yes.

Everything in this chapter is about a hook registered on PreToolUse. That matters, because exit 2 does not mean the same thing everywhere. On PostToolUse the tool has already run and stderr is merely shown to Claude; on PermissionRequest the documentation says exit 2 is not honoured at all. On those events a broken hook and a working one look the same to Claude Code, always, so there is nothing here to measure. See Chapter 7.

What actually comes out

Measured on this machine on 2026-08-31, GNU bash 5.2, by running each broken script and reading $?:

How the hook broke Exit code Effect
Real syntax error (if with no fi) 2 refuses
Unbalanced parentheses 2 refuses
Unknown command, script then reaches its own exit 2 2 refuses
Unknown command, but the script has set -e 127 allows
Runtime failure under set -euo pipefail 1 allows
Python hook, uncaught exception 1 allows
Malformed test, e.g. [ 1 -eq 1 ; with no ] 0 allows

And when the file is not there at all, it depends on what your settings named as the interpreter:

Command in settings.json Missing file gives Effect
sh /path/hook.sh 2 refuses
python3 /path/hook.py 2 refuses
bash /path/hook.sh 127 allows
node /path/hook.js 1 allows

Read that second table with one qualification, because it is the sort of table that travels badly. It was measured where /bin/sh is dash — Debian, Ubuntu, WSL2. Where /bin/sh is bash, which is the case on macOS and in Git Bash, the first row gives 127 as well: I checked by invoking bash under the name sh and with --posix, and both returned 127. So the same accident either blocks everything or silently disappears, and the thing that decides which is one symlink:

1 readlink -f /bin/sh

That one line tells you which of these two tables you are living in.

The first table assumes your hook has a shebang

Every script above starts #!/bin/bash, so bash runs it whatever /bin/sh points at. Drop that line and the question comes back, because a hook registered by path alone is handed to /bin/sh. On 2026-09-03 the repository this book indexes had 49 of its 914 examples in exactly that state, 34 of them using [[ ]], which dash does not have. They were given shebangs the same day, so you will not find them there now; the point is that they shipped for months without one, and nothing inside the files said so.

So I re-ran the six break modes with sh instead of bash, on the same machine, where /bin/sh is dash. Five of the six return the same code. One does not, and it moves in the direction nobody expects:

How the hook broke bash sh (dash)
Real syntax error (if with no fi) 2 2
Unbalanced parentheses 2 2
Unknown command, script reaches its own exit 2 2 2
Unknown command, but the script has set -e 127 127
Runtime failure under set -euo pipefail 1 2
Malformed test, [ 1 -eq 1 ; with no ] 0 0

The flip has nothing to do with the runtime failure. pipefail is a bash option; dash does not have it. The script dies on its own first line:

1 hook.sh: 1: set: Illegal option -o pipefail

and dash exits 2 for that. On PreToolUse, 2 is a refusal. A hook with no shebang whose first line is set -euo pipefail therefore refuses every call on Debian, Ubuntu and WSL2 — before it has read a byte of its input, before any of its rules run. Take pipefail out and the same script is back to 1, which allows.

That is the one failure in this chapter that is loud: the message goes to stderr with a non-zero exit, so it reaches you. It is still worth knowing, because the symptom — every Bash call refused, with a message about an option you did not think you were setting — does not look like a hook problem, and the line that causes it is the line everyone is told to write.

Two commands settle it for any hook you did not write:

1 head -1 yourhook.sh      # is there a shebang at all?
2 readlink -f /bin/sh      # if not, this is what runs it

The one that will actually get you

The bottom row of the first table is the worst, and it is worth walking through, because it is not a crash and nothing in your terminal looks wrong.

1 #!/bin/bash
2 if [ 1 -eq 1 ; then     # note the missing ]
3   echo "BLOCKED" >&2
4   exit 2
5 fi

Run it and bash prints one line to stderr — on this machine, verbatim:

1 syntax-err.sh: line 2: [: missing `]'

then evaluates the condition as false, skips the branch, falls off the end of the script, and exits 0. The guard is intact, installed, listed by every tool that lists hooks, and permanently answering “yes”.

The defensive line that changes the answer

The second case is the opposite of what you would expect from good practice, and it has a second half that surprised me when I measured it.

1 #!/bin/bash
2 set -euo pipefail      # the line everyone tells you to write
3 INPUT=$(cat)
4 CMD=$(echo "$INPUT" | jq -r '.tool_input.command')
5 case "$CMD" in *"-rf"*) echo "BLOCKED" >&2; exit 2;; esac
6 exit 0

I ran this against two inputs — one safe, one that the hook is meant to refuse — on a PATH where jq does not exist. Both came back 127. set -e ends the script at the jq line and the exit 2 at the bottom is never reached. On a machine without jq, this guard is not there. Its own author would have to look at an exit code to find out.

The obvious repair is to make failure mean refusal:

1 trap 'exit 2' ERR      # if I break, refuse rather than wave it through

Measured, that does work — and it is almost certainly not what you want. With the trap in place and jq missing, both inputs came back 2. The safe command was refused too. Every tool call on that machine is refused, by a hook whose message says nothing useful, until somebody removes it. A guard that fails that way does not get debugged; it gets uninstalled, along with the ones that were working.

So decide on purpose, and write the decision down. Two shapes, both measured:

Fail closed, deliberately. Right for a narrow guard on something irreversible, on a machine you control.

1 if ! command -v jq >/dev/null 2>&1; then
2   echo "BLOCKED: no JSON parser (jq) - this guard cannot read the command" >&2
3   exit 2
4 fi

With jq present: safe 0, dangerous 2. Without it: both 2 — the same refuse-everything behaviour as the trap, but now it says why, which is the whole difference.

Fall back, then shout. Right for anything on a shared machine, in CI, or in a container you did not build.

1 if command -v jq >/dev/null 2>&1; then
2   CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
3 elif command -v python3 >/dev/null 2>&1; then
4   CMD=$(printf '%s' "$INPUT" | python3 -c \
5     'import sys,json;print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')
6 else
7   echo "WARNING: no JSON parser - this hook is NOT protecting you" >&2
8   exit 0
9 fi

With jq: safe 0, dangerous 2. With only python3: safe 0, dangerous 2 — it keeps working. With neither: it passes the call through and writes down that it did, which is the honest version of what the first script was doing silently. Be clear about who reads that line, though: stderr from a hook that exits 0 goes to the debug log, not to you and not to Claude. If you want it in front of a person, that is what systemMessage in the JSON output is for — and a warning nobody is shown is a warning you will stop looking for.

Neither of these is the right answer in general. The point is that leaving it to whatever exit code bash happens to produce is a decision too, and it is being made by accident.

Check yours

Three things, and they take a minute.

First, is every hook in your settings still where the settings say it is?

 1 # save as check-hooks.py, run with: python3 check-hooks.py
 2 import json, os, re, shlex, sys
 3 
 4 CFG = os.path.expanduser("~/.claude/settings.json")
 5 SCRIPT = re.compile(r"\.(sh|bash|py|js|mjs|ts)$")
 6 
 7 try:
 8     cfg = json.load(open(CFG))
 9 except FileNotFoundError:
10     sys.exit("no config at %s - nothing was checked" % CFG)
11 except json.JSONDecodeError as e:
12     sys.exit("could not parse %s (%s) - nothing was checked" % (CFG, e))
13 
14 missing = unchecked = nopath = total = 0
15 for event, groups in (cfg.get("hooks") or {}).items():
16     for g in groups:
17         for h in g.get("hooks", []):
18             total += 1
19             cmd = h.get("command", "")
20             try:
21                 toks = shlex.split(cmd)
22             except ValueError as e:          # unbalanced quote - report, do not crash
23                 print("UNPARSED", event, "(%s)" % e); unchecked += 1; continue
24             looked = False
25             for tok in toks:
26                 if not SCRIPT.search(tok):
27                     continue                 # jq expressions and redirects are not paths
28                 looked = True
29                 if tok.startswith("$"):      # $CLAUDE_PROJECT_DIR/... - cannot resolve here
30                     print("UNCHECKED", event, tok); unchecked += 1; continue
31                 p = os.path.expanduser(tok)
32                 if not os.path.isabs(p):
33                     print("UNCHECKED", event, tok); unchecked += 1; continue
34                 if not os.path.exists(p):
35                     print("MISSING", event, tok); missing += 1
36             if not looked:                   # inline jq, node -e ... - no file to check
37                 print("NOPATH", event, cmd[:60]); nopath += 1
38 
39 print("registered hooks:", total, "  missing script files:", missing,
40       "  paths this check could not resolve:", unchecked,
41       "  hooks with no script path at all:", nopath)
42 if total == 0:
43     sys.exit("no hooks registered in %s - look in .claude/settings.json in your project "
44              "and in settings.local.json before believing this" % CFG)

MISSING means a hook that is not running: depending on the interpreter on that line, it is either blocking everything or nothing. UNCHECKED means a path this script cannot resolve from outside a session — $CLAUDE_PROJECT_DIR/..., which is the form Claude Code’s own documentation recommends, and plain relative paths. NOPATH means the command has no script file in it at all: an inline jq one-liner, a node -e "...". Those are real hooks doing real work, and this script has nothing to say about them. UNPARSED means the command’s quoting is unbalanced. None of those four are clean results. Go and look at them by hand.

Four of those branches exist because earlier versions of this script did not have them, and each one was found the same way: by running it and disbelieving the output.

It first reported four hooks as missing on the author’s machine, and all four were false — fragments of jq filters and a 2>/dev/null redirect, matched because they contained a slash. It skipped $CLAUDE_PROJECT_DIR paths in silence. It printed a cheerful 0 missing for a config with no hooks in it at all. Then, while this chapter was being reviewed, two more turned up: a hook whose command holds no script path was counted and never mentioned — the exact silence this chapter is about, sitting inside the tool the chapter hands you — and a command with an unbalanced quote made shlex.split raise, so the script died with a traceback instead of a finding.

A check that cries wolf gets switched off; a check that stays quiet when it did not look is worse; and a check that dies on the first odd input never reaches the hook that mattered.

Prove it still has teeth before you trust its silence. Copy your config, plant hooks that cannot exist, and run against the copy — never against the original:

1 umask 077
2 cp ~/.claude/settings.json "${TMPDIR:-/tmp}/settings-probe.json"

settings.json can contain an env block with tokens in it, so set the umask before you copy it into a shared directory, and delete the copy when you are done. Add one hook with an absolute path that does not exist and one written as bash "$CLAUDE_PROJECT_DIR/.claude/hooks/g.sh", point the script at the copy, and run it. On the author’s machine the four cases give:

1 as-is              registered hooks: 32   missing: 0   unresolved: 0   nopath: 2
2 ghost, absolute    MISSING PreToolUse /no/such/dir/ghost-guard.sh          missing: 1
3 ghost, $PROJECT    UNCHECKED PreToolUse $CLAUDE_PROJECT_DIR/.claude/...    unresolved: 1
4 empty hooks {}     exits 1 with "no hooks registered ... before believing this"

Read the first line again. missing: 0 and unresolved: 0 look like a pass, and the fourth number says they are not: two of the hooks on this machine are inline jq one-liners with no script file in them at all, so the check looked at thirty of thirty-two and said nothing about the other two. Those two are real guards doing real work. I have to open them by hand. An earlier draft of this page printed only the first three numbers, which is the same silence the chapter is complaining about, in the chapter’s own demonstration.

If your fourth case prints 0 missing and exits 0, you are running the old version.

Second, does each one still parse?

1 for f in "$HOME"/.claude/hooks/*.sh; do
2   [ -e "$f" ] || { echo "no hooks in that directory"; break; }
3   bash -n "$f" || echo "SYNTAX  $f"
4 done

The [ -e "$f" ] line is there because an empty or missing directory makes the glob expand to itself, and without it the loop reports a syntax error in a file called *.sh — a false alarm in a chapter about false comfort.

bash -n parses without executing, so it is safe to run against a guard. Be clear about what it does and does not catch. Given the missing-fi script from earlier it exits 2 and reports the problem. Given the missing-] script — the one that silently answers “yes” forever — it exits 0 and says nothing, because that file is syntactically valid bash; the fault only appears when [ runs.

Third, and this is the one the other two cannot replace: feed the hook an input it should refuse and look at what comes back. That is Chapter 4. The first two checks tell you the hook is still there and still parses. Only the third tells you it still says no.

There is a fourth, and I learned it the expensive way: check that the event you registered for actually carries the thing you are trying to refuse.

I shipped a hook called clear-command-confirm-guard.sh for months. It matched ^/clear$ on UserPromptSubmit and exited 2, and its header told readers it “blocks /clear entirely”. It passed the third check — pipe it {"prompt":"/clear"} and it exits 2, every time. On 2026-09-19 I finally measured the thing the third check cannot see: with every other hook removed and one recording hook in their place, I typed /clear into a real session and watched what fired.

typed UserPromptSubmit SessionEnd
ordinary text fires, prompt holds the text —
/clear never fires fires, reason: "clear"
/exit never fires fires, reason: "prompt_input_exit"

Slash commands never reach the prompt hooks. The matcher I had been so careful about was never evaluated, not once, on any machine that installed it. Piping JSON at a hook proves the script’s logic; it says nothing about whether the runtime ever hands it that JSON.

So the check is: after you are satisfied the hook refuses what it should, do the thing for real — in a session, with your other hooks out of the way — and confirm the hook ran at all. A recording hook that writes its input to a file and exits 0 is enough. If nothing lands in that file, you have a guard that parses, refuses correctly when tested by hand, and has never once been asked.

That is the worst failure in this chapter, because every other kind leaves evidence. A syntax error prints something. A missing file errors. A hook wired to an event that never carries your input is silent, passes every check you thought to run, and reads as protection on the page where you wrote it down.