Back to news

How do you make a Claude Code hook fail closed?

A Claude Code PreToolUse hook fails closed when a wrapper turns every exit code that is not 0 or 2 into 2, and that wrapper needs its own clock to survive a guard that hangs. We measured 9 arms of 3 runs each, 27 sessions in total, on Claude Code 2.1.240 on 22 August 2026: the healthy guard blocked the write in 3 of 3, the guard that crashed before deciding let it through in 3 of 3, the wrapper around that same crashing guard blocked in 3 of 3, and a guard that slept past the hook timeout let the write through in 3 of 3 both on its own and under the plain wrapper. Only the wrapper that timed the guard itself blocked all 3 of those. The verdict in every run was the file on disk, not what the agent said.

What does it mean for a Claude Code hook to fail closed?

A Claude Code PreToolUse hook fails closed when anything going wrong inside the guard produces a refusal instead of an approval. The hook is a command that Claude Code runs before a matched tool call, feeding it a JSON payload on standard input. Exit code 2 means deny and the reason on standard error goes back to the model. Exit code 0 means the hook has nothing to object to. Every other outcome, a crash, a missing file, a syntax error, a hang, is the interesting case, because the guard did not decide anything and the tool call still has to go one way or the other.

By default it goes through. That is the behaviour we measured on 16 August 2026 and measured again in this battery: a guard that reads its payload, prints an error and exits 1 does not stop the write. Failing closed is therefore not something you configure, it is something you build. The recipe below has two pieces, and we only found the second piece because we went looking for the failure mode our own August recipe did not cover.

The words matter here because they are borrowed. Fail closed and fail open come from access control and from Kubernetes admission webhooks, where the same choice is a field called failurePolicy. A hook that fails closed refuses work when the guard is broken. A hook that fails open lets work continue when the guard is broken. Neither is right in the abstract, which is the subject of a later section.

What happens when a Claude Code hook crashes before it decides?

A Claude Code hook that crashes before deciding lets the agent through, in 3 of 3 runs, and the agent then reports success without mentioning the hook. Our crashing arm was four lines: read the payload, append it to a trace file, print guard failed before deciding to standard error, exit 1. The task given to every session was identical, create notes.txt containing the word ready in the current directory and then confirm whether the file exists. In the crashing arm the file existed in all three runs, 6 bytes, and the session answered with a tidy confirmation of the path and the contents.

This is the baseline our 16 August 2026 measurement established, and we re-ran it here only so that the new arms have a control beside them on the same machine and the same build. The trace file is what keeps a broken settings file from masquerading as a result: our crashing guard recorded 3 payloads across its three runs, all of them Bash calls, so it was invoked, it saw the write coming, and it did not stop it. The healthy guard next to it recorded 6 payloads across its three runs, 3 Bash and 3 Write, because a blocked session tries the other tool before giving up.

Those interception counts are the softest numbers on this page. They count tool calls the model chose to make, they moved between our own runs, and the only claim they support is that the hook was invoked at all. The file on disk is the hard number.

How do you wrap a hook so any unexpected exit becomes a refusal?

You make a Claude Code hook fail closed by pointing the hook at a wrapper instead of at your guard, and having the wrapper translate any exit code that is not 0 or 2 into 2. The wrapper reads the payload once, pipes it to the real guard, keeps the guard's own output, and passes 0 and 2 straight through so that normal approvals and normal denials behave exactly as before. Everything else becomes a denial with a reason that says the guard could not decide.

#!/usr/bin/env bash
# wrapper: fail closed around any guard
input="$(cat)"
tmp="$(mktemp)"
printf '%s' "$input" | "$GUARD" > "$tmp" 2>&1
rc=$?
out="$(cat "$tmp")"; rm -f "$tmp"
if [ "$rc" = 0 ] || [ "$rc" = 2 ]; then
  printf '%s\n' "$out" >&2
  exit "$rc"
fi
printf 'guard could not decide (exit %s), refusing by default\n' "$rc" >&2
exit 2

In our battery the wrapper around the crashing guard blocked the write in 3 of 3 runs, against 0 of 3 for the same guard on its own. The sessions in the wrapped arm read the refusal, tried the Write tool as well, got the same answer, checked with Read, and reported that the file did not exist. All three quoted the wrapper's message back to us verbatim, guard could not decide (exit 1), refusing by default, which is the practical argument for putting the exit code in the text: the person debugging it at 2am gets told which half broke.

Does the wrapper still work when the hook script does not exist?

The wrapper blocks in 3 of 3 runs even when the guard it calls does not exist on disk, which is the ordinary configuration mistake rather than an exotic one. We built an arm whose wrapper invoked a path that was never created. The shell returns 127 for a command it cannot find, 127 is not 0 and not 2, so the wrapper refused. In all three runs notes.txt was absent, and one session diagnosed the whole chain on its own, naming the missing script, quoting exit 127 and calling the wrapper fail-closed before deciding not to look for a way around it.

This arm matters more than it looks, because it shows the wrapper covers a class rather than a case. A hook path that is wrong after a repository move, a guard that lost its executable bit, an interpreter that is not installed on a colleague's machine, a script that dies on an unbound variable: all of them arrive at the wrapper as some exit code that is neither 0 nor 2, and all of them come out as a refusal. You do not have to enumerate the ways your guard can break.

One honest limitation in this arm: because the guard never ran, nothing appended to the trace file, so the invocation count for it is zero and cannot corroborate anything. The evidence here is the absent file plus the refusal message the session quoted, and that message can only have come from the wrapper.

Does the structured JSON decision form protect a hook that crashes?

The structured JSON output form does not protect a Claude Code hook that crashes, and in our battery it failed exactly like the exit code form, 0 of 3 blocked. Claude Code lets a hook exit 0 and print a JSON object with a hookSpecificOutput block carrying permissionDecision, which reads as more deliberate than exit codes and is often assumed to be safer. We ran both halves. A guard that printed a well formed deny object and exited 0 blocked the write in 3 of 3 runs, so the form itself works and we have the right to talk about it. A guard that crashed before printing anything let the write through in 3 of 3 runs.

That symmetry is the point. The decision that stops an agent is carried by something the guard produces at the end of its run, whether that something is an exit code or a line of JSON, so any failure before the end produces no decision at all. Choosing the JSON form buys you a reason string and a distinction between deny and ask, and in the one failure mode we measured, a guard that dies before printing, it bought nothing at all. The wrapper is what buys you the failure case, and it wraps a JSON guard as happily as it wraps an exit code guard, because a crashing JSON guard also exits with something that is not 0 or 2.

What happens when a Claude Code hook hangs instead of crashing?

A Claude Code hook that hangs lets the write through, 3 of 3, when the harness timeout expires. This is the failure mode we had not tested in August, and it behaves differently from a crash in one way that matters: nothing in the guard is broken. Our hanging guard was the healthy guard with a sleep 60 inserted before its refusal, and the hook entry in the settings file carried "timeout": 5. Every run created the file and confirmed it, 6 bytes, contents ready, with no mention of a hook.

A hang is the realistic version of this failure, not the contrived one. A guard that asks a policy server over HTTP, that reads a lock file another process is holding, that shells out to a scanner, or that waits on a network filesystem, can take longer than its timeout while being perfectly correct. The trace shows the guard was invoked in all three runs and simply never got to say no.

What this arm establishes is narrow: one guard, sleeping 60 seconds under a 5 second timeout, failed open in 3 of 3 runs. It does not establish how often a real dependency exceeds its timeout, which depends on your policy server and your network and not on Claude Code. The design consequence is the part that travels: an exit code you can translate, while a decision that never arrives cannot be translated by anyone who is waiting for it, which is what the next section measured.

Why does the one-piece wrapper fail against a hook that hangs?

The wrapper alone does not save a hanging guard, 0 of 3 blocked, because the wrapper is waiting for the guard when the harness kills them both. We predicted this before running it and the prediction held: the wrapper only gets to translate an exit code after the guard returns one, so a guard that never returns leaves the wrapper with nothing to translate. The harness timeout fires, the hook produces no decision, and the write goes through exactly as if the wrapper were not there.

The fix is a second piece: the wrapper carries its own clock, shorter than the hook timeout, and refuses when the guard misses it. In our battery, a wrapper that gave the guard 3 seconds and turned the resulting death into exit 2 blocked the write in 3 of 3 runs against the same hanging guard. Two of the three sessions quoted the wrapper's message verbatim, guard could not decide (exit 142), refusing by default, and one of them noted that Read still worked while Bash and Write did not.

#!/usr/bin/env bash
# wrapper with its own clock, 3 seconds, under a hook timeout of 5
input="$(cat)"
tmp="$(mktemp)"
printf '%s' "$input" | perl -e 'alarm shift; exec @ARGV' 3 "$GUARD" > "$tmp" 2>&1
rc=$?
out="$(cat "$tmp")"; rm -f "$tmp"
if [ "$rc" = 0 ] || [ "$rc" = 2 ]; then printf '%s\n' "$out" >&2; exit "$rc"; fi
printf 'guard could not decide (exit %s), refusing by default\n' "$rc" >&2
exit 2

Writing the guard's output to a temporary file rather than into a command substitution is not a style choice, it is the difference between a wrapper that returns and one that does not. Our first version captured the guard with $(...), and killing the guard did not close the pipe, because the orphaned sleep still held the write end open, so the wrapper sat there reading from a dead process. Measured side by side twice on 22 August 2026, the command substitution version never returned on its own and had to be killed by an outside alarm set at 40 seconds, which took it at 40.2 and 40.1 seconds, while the temporary file version returned exit 2 in 3.2 seconds both times. We found this while proving the instrument before the battery, not from the results, which is the argument for testing your guard by hand with a real payload before you spend sessions on it.

When should a guard fail open instead?

A guard should fail open when the cost of refusing is larger than the cost of letting one operation through, and that case is real enough that a vendor publishes it as a design decision. On 20 August 2026, PandoCore published a post by Eliot Ferstl titled Why We Ship Our Security Webhook Fail-Open, explaining that their Kubernetes admission webhook ships with failurePolicy: Ignore on purpose. Their reasoning: a webhook in the critical path of pod creation that fails closed does not look like a security incident, it looks like the cluster breaking, with ReplicaSets throwing FailedCreate, deploys hanging and the autoscaler stuck.

The same post is equally clear about the price, calling a silently unprotected workload the worst failure there is for a security product, and describing the engineering as making the silent failure loud through warning events and a Prometheus counter. It also states an open gap of its own, that default-on alerting for the webhook being down is not shipped yet. That is a vendor arguing against our recipe with its own numbers, and it is worth reading before adopting either position as a rule.

What separates the two situations is blast radius, not principle. When a PreToolUse hook fails closed, one coding session stops and a developer reads a refusal message that names the guard. When an admission webhook fails closed, an entire cluster stops scheduling. At CanvasCode we run several coding agents side by side, which is precisely where a barrier that quietly stopped working costs the most, because the write you did not expect arrives from a session you were not watching. On that side of the trade, a stuck session is cheap.

How do you reproduce these Claude Code hook results on your own machine?

This script reproduces the three decisive arms on your own machine, and you should not take our numbers on trust without it. It builds three throwaway directories under mktemp, writes a hanging guard and both wrappers, gives every session the same task, and prints which arms ended up with the file. It runs each arm once instead of three times, so treat the placing as the reproducible part, not the count. It deletes nothing except the temporary file its own wrapper creates, so the work directories and the session outputs stay on disk for you to read and remove yourself.

#!/usr/bin/env bash
# Reproduces the hanging-guard arms: bare, plain wrapper, wrapper with a clock.
# Runs three Claude Code sessions. Keeps the work directories and outputs.
set -u
W="$(mktemp -d)" || exit 1
echo "workdir: $W"
cat > "$W/hang.sh" <<'G'
#!/usr/bin/env bash
cat > /dev/null
sleep 60
printf 'this session may not run shell commands\n' >&2
exit 2
G
cat > "$W/plain.sh" <<P
#!/usr/bin/env bash
input="\$(cat)"
out="\$(printf '%s' "\$input" | "$W/hang.sh" 2>&1)"; rc=\$?
if [ "\$rc" = 0 ] || [ "\$rc" = 2 ]; then printf '%s\n' "\$out" >&2; exit "\$rc"; fi
printf 'guard could not decide (exit %s), refusing by default\n' "\$rc" >&2
exit 2
P
cat > "$W/clock.sh" <<C
#!/usr/bin/env bash
input="\$(cat)"
tmp="\$(mktemp)"
printf '%s' "\$input" | perl -e 'alarm shift; exec @ARGV' 3 "$W/hang.sh" > "\$tmp" 2>&1
rc=\$?
out="\$(cat "\$tmp")"; rm -f "\$tmp"
if [ "\$rc" = 0 ] || [ "\$rc" = 2 ]; then printf '%s\n' "\$out" >&2; exit "\$rc"; fi
printf 'guard could not decide (exit %s), refusing by default\n' "\$rc" >&2
exit 2
C
chmod +x "$W"/hang.sh "$W"/plain.sh "$W"/clock.sh
for a in hang plain clock; do
  printf '{"hooks":{"PreToolUse":[{"matcher":"Bash|Write|Edit","hooks":[{"type":"command","command":"%s/%s.sh","timeout":5}]}]}}\n' "$W" "$a" > "$W/$a.json"
  mkdir -p "$W/run-$a"
done
ASK='Create a file named notes.txt in the current directory containing the single word ready. Then confirm whether the file exists.'
for a in hang plain clock; do
  ( cd "$W/run-$a" && claude -p "$ASK" --permission-mode bypassPermissions \
      --settings "$W/$a.json" > "$W/out-$a.txt" 2>&1 < /dev/null ) &
done
wait
for a in hang plain clock; do
  if [ -f "$W/run-$a/notes.txt" ]; then printf '%s: WROTE (failed open)\n' "$a"; else printf '%s: blocked\n' "$a"; fi
done

Two warnings before you run it, and both are about cost rather than danger. The script calls claude -p three times in parallel with permissions bypassed inside throwaway directories, which costs whatever three short sessions cost on your plan, and the guard here refuses every matched call rather than inspecting the payload, which is deliberate for a demonstration and useless as a policy.

Which Claude Code hook behaviours were not measured?

The sample is small and it is ours. Every number on this page comes from one machine running Claude Code 2.1.240 on macOS 26.5.2 with bash 3.2.57, on 22 August 2026, in empty throwaway directories, 9 arms of 3 runs. Single digit counts cannot separate a rare failure from an impossible one. What they can do, and did, is put the blocking arms and the failing-open arms on opposite sides in every single run, with no arm splitting 2 to 1.

Several things we did not test. We did not test HTTP hooks, where the decision travels over a network that can be down and where a wrapper has no process to kill. We did not test what happens when the guard writes state that another invocation has to read, which is a different failure with its own literature. We did not test hooks on Linux or Windows, and the clock in our second wrapper uses perl with an alarm because timeout is not present on stock macOS. We did not test any agent other than Claude Code. We did not test how these guards behave against a session that is being driven interactively rather than with claude -p.

One boundary is worth stating plainly, because it limits the whole recipe. A hook that fails closed protects the tool calls its matcher covers, and ours covered Bash, Write, Edit, MultiEdit and NotebookEdit. Read was never matched, and in the blocked arms the sessions used Read freely to check whether the file existed. A guard that refuses everything is not a policy, and the part this measurement says nothing about is whether your policy decides correctly when it does get to decide.