How to tell whether your hooks can actually refuse anything (Chapter 8)

This chapter is an audit of the collection this book is a companion to, and it is not flattering. It is here because the same mistake is sitting in your ~/.claude/hooks/ directory right now and it is invisible from the outside.

The count

A hook refuses a tool call in one of a few ways: it exits with code 2, or it prints a permission decision, or it prints "decision": "block", or it prints "deny". A fifth form is an exit with a variable that may hold 2. If a script does none of those on any path, it cannot stop anything, no matter what it is called.

One more form exists that none of the 916 use: JSON {"continue": false}, which the reference says takes precedence over any event-specific decision field. It moves no number in this chapter, but add it to the pattern when you check your own hooks.

Add it with the same caveat this chapter puts on exit 2, because it carries one too. The reference’s own words are that every event accepts the universal fields, “but some events discard them”, and it names the discarding events one section at a time: a Setup hook’s JSON output fields are dropped “on every exit code”, InstructionsLoaded the same, and Notification and StopFailure are given as examples of events that throw away systemMessage and continue both. So a hook you find with {"continue": false} in it is in exactly the position of the eighteen below — it holds a refusal, and whether the refusal reaches anything depends on the event it is registered on. Read that event’s own section before you count it as a door.

Searching all 916 example scripts in the repository, on 2026-09-20 (commit 02886e61) — the same tree Chapter 7 counted, so the two tables can be read against each other — with comment lines stripped first — the next section explains why that matters more than it sounds:

Can refuse by Scripts
exit 2 274
permissionDecision 27
exit with a variable that may be 2 6
"decision": "block" 5
"deny" 2
none of the above 610

(The rows overlap; the union is 306.) Two out of three of these scripts have no way to refuse. That is not a bug in the collection. Most of them are not trying to refuse. They are witnesses: they write a note to the log, they count something, they put a fact in front of the model, they warn. cowork-claude-md-load-checker surfaces your global CLAUDE.md at session start. git-signed-commit-guard warns on unsigned commits. Both are worth having. Neither is a door.

The problem is that from the outside a witness and a guard look identical. Both are files in the same directory. Both run. Both print. The only difference shows up at the moment you needed one, and by then you are reading the log to find out what happened rather than being told it did not.

Having a refusal is necessary, not sufficient

A caveat this chapter owes to Chapter 7 before we go further, because otherwise the number above flatters the collection. Containing exit 2 only means something on an event where Claude Code honours it. Eighteen of the 306 scripts counted as “can refuse” refuse only by exit 2, on an event that cannot be blocked — ten on PostToolUse, eight on SessionStart.

Scripts that register on more than one event are not in that eighteen. A hook declaring PostToolUse + UserPromptSubmit has one place where exit 2 works, so it is not deaf everywhere; five scripts drop out on that rule. The eighteen are the ones that cannot refuse on any event they declare.

The ten PostToolUse ones are not silent, though. The same reference tells you to use exit 2 when you want a PostToolUse hook to put a warning in front of Claude. What arrives is stderr, not a refusal. Written to warn, it is correct; written to stop, it is not. The same two characters are right or wrong depending on what the author meant.

This chapter’s first draft also said eighteen, but a different eighteen: it counted a PreCompact hook among them — and it named compact-blocker as the showcase, a hook that “ends exit 2 and cannot block a compaction”. The table says otherwise: PreCompact is a Yes row, “Blocks compaction”, and the prose under that event repeats it. compact-blocker blocks. A chapter about not trusting the name got the answer wrong by misreading the table it tells you to read — one rung up from trusting the name, and the same hole.

So the honest form of the test is three questions, not one. Does the file contain a refusal at all? Is it on an event where a refusal means anything? And does the form it uses still work?

The third question, which I nearly answered wrong

The table above counts five different shapes and treats them as interchangeable. That is only fair if all five still stop a tool call. One of them — {"decision": "block"} — is the older shape, and when I built the table I had not checked it. I counted it on the reference’s word.

The "deny" row is not a fifth shape at all. Both scripts it catches print permissionDecision: "deny", so that row sits entirely inside the 27 above it. Nothing in the collection uses {"decision": "deny"}. That is the first thing a table like this will do to you: a pattern loose enough to be safe will count the same file twice and present it as a category.

The occasion to check came from the collection itself. Five of its PreToolUse scripts — six, it turned out when I recounted for this chapter; the pattern I used that night wanted the key in quotes and missed one that escapes them — refuse only by printing {"decision": "block"} and exiting 0. No exit 2 appears anywhere in them. So does a hook the installer generates when you name a path to protect, and that generated file announces itself in its own first lines: # Blocks Edit/Write to: <path>. permissionDecision is the current field for PreToolUse; decision is the older one. I had the five listed and was a keystroke from starting the rewrite when it occurred to me that “older” and “ignored” are not the same word, and that I had never actually seen the old form fail.

So I measured it, on 2026-09-19, against Claude Code 2.1.278. A disposable HOME with exactly one hook registered on PreToolUse, matcher Write; the same prompt each time, telling it to use the Write tool to create one named file and not to use Bash; --allowedTools Write so nothing but the hook could interfere. The verdict is not what the transcript says — it is whether the file exists on disk afterwards. Each hook also appended a line to a log, so “the hook refused” and “the hook was never called” could not be confused.

The hook prints Hook ran File created
nothing, exits 0 (control) yes yes
stderr, then exit 2 yes no
hookSpecificOutput.permissionDecision: "deny" yes no
{"decision": "block", "reason": …}, exits 0 yes no

All three refuse. The older form is not dead, and the reason text reaches the model in both JSON shapes. The six scripts were fine. The generated one I did not fire; it has the same shape, so it stands with them, and by the rule at the end of this section that is an inference and not a measurement. Had I done the rewrite I was about to start, the only thing I would reliably have added to six working files is six new chances to break them.

One more thing falls out of the recount, and it is the most useful part of this section. The table above says five where this section says six, because the table’s pattern wants the key in quotes: three of these six write the body through jq -n '{decision: "block"…}' and a fourth escapes its quotes inside an echo. The one-liner later in this chapter misses all four the same way. So “none of the above” means none of the above patterns, and the 610 holds at least four scripts that do refuse. I built that table to sort refusers from witnesses, and it put four refusers in the witness column — while I was writing a chapter about not trusting a grep’s vocabulary.

There is one real difference between the forms, and it is worth knowing before you pick one. Both JSON shapes are presented to the model as PreToolUse:Write hook error: <your reason> — the word is error, and nothing identifies which hook produced it. exit 2 arrives with the hook’s absolute path in front of your message. If you have thirty hooks on Write and one of them starts refusing something it should not, that path is the difference between reading one file and reading thirty.

What this does not prove: one event and one tool, one version, and three of the four shapes the table actually holds. I did not test {"continue": false} this way, and the six scripts themselves sit on Bash, Read and WebFetch matchers while I measured on Write. I also counted how many times the model tried the Write tool in each run — one and one, two and two, two and one — and there is no claim in those numbers; two runs per form is not enough to say that one shape invites a second attempt and another does not, so I am not saying it.

The rule the third question leaves you with is narrower than it sounds, and it cuts both ways. A form you have not seen work is a form you are counting on faith — but so is a form you have decided is dead. The check is three runs and a control, and you only have to do it once per version.

The seventh file

I wrote that rule, and then, the same evening, I used it on a seventh script — one that had not come up in the count, because it does not print block at all. Worse than that: the big table at the top of this chapter has it on the refusing side, in the 27 that carry a permissionDecision. It does carry one — in a fourth branch, which works. The fourth branch was standing in front of the other three.

powershell-remove-item-guard.sh intercepts recursive PowerShell deletions. Three of its branches are hard blocks: system directories, junction-bearing node_modules, and the home directory. Each one printed

1 {"decision":"DENY","reason":"Blocked: …"}

and exited 0.

DENY is not a value that field has. block is the one I had just watched refuse, and the reference names approve alongside it; DENY is neither, and I measured that it and its lowercase twin (on Write, where I already had the other shapes set up) are both ignored — the body changes nothing, the hook exits 0, and the tool call proceeds. I measured it the same way as the others, and here the verdict mattered more, so the test was blunter: a command carrying the guard’s trigger text and creating a file, on PreToolUse with matcher Bash. The verdict is whether that file exists afterwards.

hook registered command ran
none (control) yes
the shipped version, {"decision":"DENY"} yes
the same file with exit 2 no

I fired the system-directory branch end to end; the other two print and exit through the same two lines, so they stand with it — an inference, by the rule above, and not a measurement.

The form went in with the file’s first commit, on 2026-04-21, whose message reports “13 tests all pass.” From then until the night I measured it, on the three deletions its own header describes as “permanently deleting user profile folders and source code,” that hook printed a refusal into a void and let the command through. It even said so about itself. In July I added a comment warning not to lean on the fourth branch under auto-approve modes, because “the hard blocks above (exit 2) still hold.” There were no exit 2s above it. In August I came back and rewrote that very sentence — it now says that in genuinely unattended runs “the hard blocks above (exit 2) are what hold” — and still did not look up at the lines it was describing. Twice I wrote down what the file was supposed to do, and twice the writing was the only place it was true.

The tests were green, which is the part worth sitting with. Nine of its cases — nine of thirteen the day it was written, nine of eighteen by the time I looked — asserted that the string "decision":"DENY" appeared on standard output. It did, every single time. Nine passes, zero refusals: nine assertions that could not have caught it, checking a guard that could not have blocked, each one confirming the other. The suite itself was not broken. Point it at a hook that prints nothing and twelve of its eighteen cases fail, as they should. It simply never asked the question the nine were named after.

It is fixed now: since 2026-09-19 the three branches print to stderr and exit 2. If you copied this file out of the repository before that date, take it again. For five months there was nowhere else to take it from: the file was first committed on 2026-04-21, one day after 29.8.0 went to npm, and the registry then sat on 29.8.0 until 2026-09-24 — so the package never carried this hook at all. That changed the day the freeze ended. The published tarball now carries the whole examples/ directory, all 916 scripts, this one among them with the fix in it.

So the third question is not academic, and it does not only catch false alarms. I asked it of seven files: six answered well, and the seventh had been open since the day it was written. The asking took about a minute each — one control, two runs, and a look at whether a file existed on disk. That guard sat in the repository for a hundred and fifty-one days, through five later commits that touched the file, and nobody — me included — ever spent the minute.

The name is not the answer

Take the 298 scripts whose filename contains one of guard, block, prevent, enforce, deny, protect or stop. Of those, 118 contain no refusal on any path. Just under forty per cent of the things named like a door are windows.

Three worked examples, all from the collection this book ships beside, all verified by opening the file:

absolute-rule-enforcer.sh. The header says it “extracts the constraint keywords, and checks if the session violated them”. Read to the bottom and there is no check: it greps CLAUDE.md for lines containing ABSOLUTE, MUST NEVER or NEVER (and two Japanese equivalents), prints them to stderr with the line “Verify your changes comply before finalizing.”, and exits 0 on every path. It is a reminder. A reminder is a reasonable thing to build. It is not an enforcer, and the file uses that word, in one form or another, three times.

ci-workflow-guard.sh. The summary line says “Prevent dangerous CI/CD workflow modifications”. The event is PostToolUse. It cannot prevent, and six lines further down its own “How it works” line says so: it “checks workflow files for dangerous patterns after modification”. The honest sentence and the overselling sentence are in the same header, six lines apart.

binary-file-guard.sh. Named guard, and its one-line description says “Warn when Write creates binary/large files”. That one is straight — but note where the honest sentence is. This file has no “How it works” line at all; the truthful description is the title line.

What that leaves you with

There is no single field you can trust. In those three files the honest sentence was in a different place each time: in the How it works line, in the title line, and in absolute-rule-enforcer it was nowhere, because its How it works line claims a check the file never performs. The only reliable move is to open the file and look for the exit.

How to check yours, in one command

The obvious command is one line, and it is wrong in a way this chapter has to admit, because the mistake is the chapter’s own thesis:

1 grep -L -E 'exit 2|permissionDecision|"decision": *"block"|"deny"' "$HOME"/.claude/hooks/*.sh

grep -L prints the files that do not match, and almost everything it prints is a witness. The problem is what it leaves out. grep reads the whole file, and a comment is part of the file. A script whose only exit 2 sits in a sentence about exit 2 is quietly promoted to the refusing side and never appears in the output at all.

That is not hypothetical. Fire the one-liner above at the 916 scripts and it prints 594 files; strip the comments first — sed 's/#.*//', the obvious way — and it prints 615. Twenty-one scripts match one of those four patterns only inside a comment, and twenty of them have no other way to refuse either. The exception is compound-command-deny-enforcer.sh, which ends in exit "$EXIT_CODE", so it can refuse if that variable ever holds 2. For the other twenty, a grep that reads comments quietly promotes a witness to the guard column. (Chapter 10 counts a narrower thing — scripts whose only exit 2 is in a comment — and arrives at nineteen, fifteen of which say so in the comment that matched. Same defect, different net.)

One of those twenty-one is my stripper’s fault, and it is this chapter’s own mistake one level down. broad-prefix-session-trap-warner.sh carries permissionDecision: deny inside a message string, in a sentence that first cites an issue number written (#62437). sed 's/#.*//' cuts the line at that #, and the phrase disappears with the rest of the sentence. Strip only a # that starts a line or follows whitespace and the pair is 614 and twenty, not 615 and twenty-one. I print the crude pair because it is the one you will get if you type the obvious command, and the point of the chapter is what the obvious command does.

Fifteen of the twenty-one say, in the comment doing the matching, that they do not block. The rule I counted with is mechanical, so you can rerun it: the matching line contains warning only, warn only, not a blocker, not blocking, advisory, no exit 2, to exit 2, exit 0 = or exit 1 =. The six that do not are broad-prefix-session-trap-warner.sh (the false member above); compound-command-deny-enforcer.sh, which describes blocking it really can do; edit-quote-ambiguity-guard.sh, whose comment reads Precision: BLOCK (exit 2) although no exit 2 survives the strip; sandbox-relative-path-audit.sh, which matched on the word deny in a sentence about settings.json; system-message-workaround.sh, whose comment shows you how to turn its warning branch into a refusal — echo "BLOCKED: …" >&2 then exit 2 — while every path in the file itself exits 0; and clear-command-confirm-guard.sh, which since 2026-09-19 is the record of a guard that cannot fire, and whose header says exit 2 while explaining why feeding the script {"prompt":"/clear"} by hand proves nothing. Three of the plainest fifteen:

1 network-guard.sh:5          # This is a warning hook (exit 0), not a blocker (exit 2),
2 plan-mode-edit-guard.sh:74  # Warning only (exit 0). Change to exit 2 to block.
3 no-push-without-tests.sh:41 # Warning only. Change exit 0 to exit 2 to enforce.

Files that declare themselves warnings, counted as guards, by a command written to find exactly that error. Strip the comments first:

1 for f in "$HOME"/.claude/hooks/*.sh; do
2   sed 's/#.*//' "$f" | grep -qE 'exit 2|permissionDecision|"decision": *"block"|"deny"' \
3     || echo "WITNESS $f"
4 done

sed 's/#.*//' drops everything after a # on each line. It also drops a # inside a string, which is crude — but crude in the safe direction: it can only move a file from “refuses” to “witness”, never the other way, so it over-reports what you need to look at.

Two caveats after that, because a check whose limits you do not know is not a check.

It still cannot see the third row of the table above: a script that ends exit "$RC" might be refusing, depending on what RC holds. Run against the 916 examples, the stripped version prints 615 files; 610 of them contain no refusal at all and 5 exit with a computed code and have to be opened. Read the output as “these need a look”, not “these are all harmless”.

And it cannot tell a string from code either. broad-prefix-session-trap-warner.sh prints a warning whose text contains permissionDecision: deny, while its only exit is exit 0. That one happens to land on the witness side, but for the wrong reason: an issue number, (#62437), appears earlier on the same line, so sed cuts the whole rest of it. Written without the issue number, the same file would count as a refuser. Separating code from string needs a shell parser; one line of sed is not one. That error runs one way: a refuser counted as a witness.

The pattern’s vocabulary errs the same way, and the section above found four scripts it had put on the wrong side. So read 610 as a number that can only be too high, never too low — and read the whole table the way you would read a smoke alarm that has never been tested.

And if the command prints No such file or directory, your hooks are not in that directory. Do not read that as a clean result — find out where they actually are, with the script at the start of Chapter 9, before you believe anything.

The rule

Open the file. Find where it exits. If it exits 0 on every path, it is a witness, and no line in its header changes that.

And when you write your own: if it exits 0 on every path, do not put guard in the name. You will read that name again in six months, in a hurry, and believe it.