{"slug": "fail-open-is-the-default-failure-mode-of-agent-hooks", "title": "Fail-open is the default failure mode of agent hooks", "summary": "A developer's analysis of agent-CLI hook scripts reveals a common design flaw: they fail open, allowing unsafe operations when the hook crashes or returns no decision. The author of Handrail, a free hook pack for Claude Code, demonstrates a structural fix using a trap and default-deny logic, and notes that Handrail's CI tests enforce fail-closed behavior.", "body_md": "I'm the author of Handrail, a free, MIT-licensed hook pack for Claude Code and other\n\nagent-CLI hook systems. Handrail works with Claude Code and other agent CLIs in plain\n\ntext only; it is not affiliated with, endorsed by, or a product of Anthropic. This post\n\nis about one specific design bug I keep finding in hook scripts, including early\n\ndrafts of my own: they fail open.\n\nAn agent-CLI hook is a small program the harness calls before (or after) a tool call —\n\na shell command, a file write, a publish step — and asks, in effect, \"should this be\n\nallowed?\" The hook's job is to answer allow, ask, or deny. The interesting question\n\nisn't what the hook does when it works. It's what the harness does when the hook\n\n*doesn't* answer at all.\n\nMalformed JSON on stdin. An unhandled exception three lines into the script. A\n\ntimeout because the hook shelled out to something slow. A config file that doesn't\n\nparse. In each of these cases, the hook process either exits with no usable decision,\n\nor crashes before it prints one. What happens next depends entirely on what the\n\n*calling* harness does with a hook that didn't answer — and a lot of hook scripts\n\nnever think about that side of the contract, because the code path for \"I don't know,\n\nso deny\" is extra code nobody wrote until something forced the question. Independent\n\nwrite-ups on this exact gap describe it as a live, common problem across shared hook\n\nscripts, not a hypothetical (dev.to/redpa, \"Your Claude Code hooks probably fail open —\n\nhere's why that's dangerous,\" accessed 2026-09-08).\n\nThe failure mode matters because of *when* it fires: exactly when the hook is under\n\nthe most stress — weird input, a broken environment, a partial config — which\n\ncorrelates with exactly the moments a guardrail is most needed.\n\nThe fix isn't clever. It's structural:\n\nHere's a minimal skeleton showing the shape (bash, illustrative — trimmed for a blog\n\npost, not a drop-in hook):\n\n``` bash\n#!/usr/bin/env bash\nset -euo pipefail\n\n# Any unhandled error below this line becomes a deny, not a silent allow.\ntrap 'echo \"{\\\"decision\\\":\\\"deny\\\",\\\"reason\\\":\\\"hook error\\\"}\"; exit 1' ERR\n\ninput=\"$(cat)\" || { echo '{\"decision\":\"deny\",\"reason\":\"no input\"}'; exit 1; }\n\ncommand=\"$(jq -er '.tool_input.command // empty' <<<\"$input\" 2>/dev/null)\" \\\n  || { echo '{\"decision\":\"deny\",\"reason\":\"unparsable input\"}'; exit 1; }\n\n[ -n \"$command\" ] || { echo '{\"decision\":\"deny\",\"reason\":\"empty command\"}'; exit 1; }\n\n# Explicit, narrow allow-list only. Anything not matched here falls through\n# to the default deny at the bottom — never the other way around.\nif [[ \"$command\" =~ ^(ls|pwd|git\\ status)$ ]]; then\n  echo '{\"decision\":\"allow\"}'\n  exit 0\nfi\n\necho '{\"decision\":\"deny\",\"reason\":\"not on allow-list\"}'\n```\n\nThe load-bearing lines are the `trap` and the fact that the script only ever prints\n\n`allow` from one narrow branch. Delete the allow-list entirely and the script is still\n\nsafe — it just asks or denies everything. Delete the trap, or let a parse error exit\n\nbefore printing anything, and you're back to fail-open, silently.\n\nA design principle that isn't tested is a design principle that regresses. Handrail's\n\nCI spawns every shipped hook against a fixture set that includes malformed JSON, empty\n\nstdin, and deliberately ambiguous config, and asserts the decision is deny in every\n\ncase — including when the hook process itself throws an uncaught exception. Any\n\nfixture that returns allow fails the build. A second, related property is checked the\n\nsame way: nothing Handrail ships can widen or bypass an existing permission prompt or\n\ndefault — only narrow it. That \"only-tightens\" property is a fixture-diff test in CI\n\ntoo, not just a claim in a README.\n\nHandrail is a defence-in-depth layer — it reduces risk but does not eliminate it, is\n\nnot a security audit or certification, and does not replace backups, code review, or\n\nyour own judgment. It only covers the rule categories it ships (six today: destructive\n\nshell, git force-push/reset, secret paths and credential-shaped content,\n\nprod-environment commands, package/deploy publishing, and remote code piped to a\n\nshell); anything outside that surface is unguarded unless you write your own rule.\n\nThe free repo has the fail-closed harness above, the fixture suite, an installer that\n\nmerges into `.claude/settings.json` with a backup, and the six rules described above.\n\nMIT, no signup: [https://github.com/trimkeep/handrail-kit](https://github.com/trimkeep/handrail-kit)\n\nThere's also a paid early-access pack with a larger rule set, if you want more than\n\nthe free six rules cover: [https://buy.polar.sh/polar_cl_xVkjNq9YQrEOJd25FaLVYBeXLqfgk4OU57LQZ4au38s](https://buy.polar.sh/polar_cl_xVkjNq9YQrEOJd25FaLVYBeXLqfgk4OU57LQZ4au38s)", "url": "https://wpnews.pro/news/fail-open-is-the-default-failure-mode-of-agent-hooks", "canonical_source": "https://dev.to/trimkeep/fail-open-is-the-default-failure-mode-of-agent-hooks-30a3", "published_at": "2026-09-09 03:37:08+00:00", "updated_at": "2026-09-09 03:49:08.341011+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools"], "entities": ["Handrail", "Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/fail-open-is-the-default-failure-mode-of-agent-hooks", "markdown": "https://wpnews.pro/news/fail-open-is-the-default-failure-mode-of-agent-hooks.md", "text": "https://wpnews.pro/news/fail-open-is-the-default-failure-mode-of-agent-hooks.txt", "jsonld": "https://wpnews.pro/news/fail-open-is-the-default-failure-mode-of-agent-hooks.jsonld"}}