DEV Community

Trimkeep
Trimkeep

Posted on

Fail-open is the default failure mode of agent hooks

Fail-open is the default failure mode of agent hooks

I'm the author of Handrail, a free, MIT-licensed hook pack for Claude Code and other
agent-CLI hook systems. Handrail works with Claude Code and other agent CLIs in plain
text only; it is not affiliated with, endorsed by, or a product of Anthropic. This post
is about one specific design bug I keep finding in hook scripts, including early
drafts of my own: they fail open.

What "fail open" means here

An agent-CLI hook is a small program the harness calls before (or after) a tool call —
a shell command, a file write, a publish step — and asks, in effect, "should this be
allowed?" The hook's job is to answer allow, ask, or deny. The interesting question
isn't what the hook does when it works. It's what the harness does when the hook
doesn't answer at all.

Malformed JSON on stdin. An unhandled exception three lines into the script. A
timeout because the hook shelled out to something slow. A config file that doesn't
parse. In each of these cases, the hook process either exits with no usable decision,
or crashes before it prints one. What happens next depends entirely on what the
calling harness does with a hook that didn't answer — and a lot of hook scripts
never think about that side of the contract, because the code path for "I don't know,
so deny" is extra code nobody wrote until something forced the question. Independent
write-ups on this exact gap describe it as a live, common problem across shared hook
scripts, not a hypothetical (dev.to/redpa, "Your Claude Code hooks probably fail open —
here's why that's dangerous," accessed 2026-09-08).

The failure mode matters because of when it fires: exactly when the hook is under
the most stress — weird input, a broken environment, a partial config — which
correlates with exactly the moments a guardrail is most needed.

The pattern: always answer, and the default answer is deny

The fix isn't clever. It's structural:

  1. Wrap the whole hook body so that any uncaught error — parse failure, exception, timeout — is caught at the top level.
  2. The catch-all's output is a deny decision, not a silent exit.
  3. Parsing untrusted input (the tool-call JSON) never assumes well-formed data; a parse failure is itself routed to step 2.
  4. The only way to get an allow decision is to hit an explicit, narrow match for a known-safe case. Everything else — including "the code didn't know what this was" — denies.

Here's a minimal skeleton showing the shape (bash, illustrative — trimmed for a blog
post, not a drop-in hook):

#!/usr/bin/env bash
set -euo pipefail

# Any unhandled error below this line becomes a deny, not a silent allow.
trap 'echo "{\"decision\":\"deny\",\"reason\":\"hook error\"}"; exit 1' ERR

input="$(cat)" || { echo '{"decision":"deny","reason":"no input"}'; exit 1; }

command="$(jq -er '.tool_input.command // empty' <<<"$input" 2>/dev/null)" \
  || { echo '{"decision":"deny","reason":"unparsable input"}'; exit 1; }

[ -n "$command" ] || { echo '{"decision":"deny","reason":"empty command"}'; exit 1; }

# Explicit, narrow allow-list only. Anything not matched here falls through
# to the default deny at the bottom — never the other way around.
if [[ "$command" =~ ^(ls|pwd|git\ status)$ ]]; then
  echo '{"decision":"allow"}'
  exit 0
fi

echo '{"decision":"deny","reason":"not on allow-list"}'
Enter fullscreen mode Exit fullscreen mode

The load-bearing lines are the trap and the fact that the script only ever prints
allow from one narrow branch. Delete the allow-list entirely and the script is still
safe — it just asks or denies everything. Delete the trap, or let a parse error exit
before printing anything, and you're back to fail-open, silently.

What this looks like as a test, not just a design note

A design principle that isn't tested is a design principle that regresses. Handrail's
CI spawns every shipped hook against a fixture set that includes malformed JSON, empty
stdin, and deliberately ambiguous config, and asserts the decision is deny in every
case — including when the hook process itself throws an uncaught exception. Any
fixture that returns allow fails the build. A second, related property is checked the
same way: nothing Handrail ships can widen or bypass an existing permission prompt or
default — only narrow it. That "only-tightens" property is a fixture-diff test in CI
too, not just a claim in a README.

What this doesn't claim

Handrail is a defence-in-depth layer — it reduces risk but does not eliminate it, is
not a security audit or certification, and does not replace backups, code review, or
your own judgment. It only covers the rule categories it ships (six today: destructive
shell, git force-push/reset, secret paths and credential-shaped content,
prod-environment commands, package/deploy publishing, and remote code piped to a
shell); anything outside that surface is unguarded unless you write your own rule.

The artifact

The free repo has the fail-closed harness above, the fixture suite, an installer that
merges into .claude/settings.json with a backup, and the six rules described above.
MIT, no signup: https://github.com/trimkeep/handrail-kit

There's also a paid early-access pack with a larger rule set, if you want more than
the free six rules cover: https://buy.polar.sh/polar_cl_xVkjNq9YQrEOJd25FaLVYBeXLqfgk4OU57LQZ4au38s

Top comments (0)