<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Trimkeep</title>
    <description>The latest articles on DEV Community by Trimkeep (@trimkeep).</description>
    <link>https://dev.to/trimkeep</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4116666%2F1756b91f-7453-477e-bece-2dfbee6554ee.png</url>
      <title>DEV Community: Trimkeep</title>
      <link>https://dev.to/trimkeep</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/trimkeep"/>
    <language>en</language>
    <item>
      <title>Fail-open is the default failure mode of agent hooks</title>
      <dc:creator>Trimkeep</dc:creator>
      <pubDate>Wed, 09 Sep 2026 03:37:08 +0000</pubDate>
      <link>https://dev.to/trimkeep/fail-open-is-the-default-failure-mode-of-agent-hooks-30a3</link>
      <guid>https://dev.to/trimkeep/fail-open-is-the-default-failure-mode-of-agent-hooks-30a3</guid>
      <description>&lt;h1&gt;
  
  
  Fail-open is the default failure mode of agent hooks
&lt;/h1&gt;

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

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

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

&lt;p&gt;The failure mode matters because of &lt;em&gt;when&lt;/em&gt; it fires: exactly when the hook is under&lt;br&gt;
the most stress — weird input, a broken environment, a partial config — which&lt;br&gt;
correlates with exactly the moments a guardrail is most needed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pattern: always answer, and the default answer is deny
&lt;/h2&gt;

&lt;p&gt;The fix isn't clever. It's structural:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Wrap the whole hook body so that &lt;em&gt;any&lt;/em&gt; uncaught error — parse failure, exception,
timeout — is caught at the top level.&lt;/li&gt;
&lt;li&gt;The catch-all's output is a deny decision, not a silent exit.&lt;/li&gt;
&lt;li&gt;Parsing untrusted input (the tool-call JSON) never assumes well-formed data; a
parse failure is itself routed to step 2.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a minimal skeleton showing the shape (bash, illustrative — trimmed for a blog&lt;br&gt;
post, not a drop-in hook):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="c"&gt;# Any unhandled error below this line becomes a deny, not a silent allow.&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'echo "{\"decision\":\"deny\",\"reason\":\"hook error\"}"; exit 1'&lt;/span&gt; ERR

&lt;span class="nv"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"decision":"deny","reason":"no input"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-er&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // empty'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"decision":"deny","reason":"unparsable input"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"decision":"deny","reason":"empty command"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Explicit, narrow allow-list only. Anything not matched here falls through&lt;/span&gt;
&lt;span class="c"&gt;# to the default deny at the bottom — never the other way around.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;~ ^&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;|pwd|git&lt;span class="se"&gt;\ &lt;/span&gt;status&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"decision":"allow"}'&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"decision":"deny","reason":"not on allow-list"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  What this looks like as a test, not just a design note
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  What this doesn't claim
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  The artifact
&lt;/h2&gt;

&lt;p&gt;The free repo has the fail-closed harness above, the fixture suite, an installer that&lt;br&gt;
merges into &lt;code&gt;.claude/settings.json&lt;/code&gt; with a backup, and the six rules described above.&lt;br&gt;
MIT, no signup: &lt;a href="https://github.com/trimkeep/handrail-kit" rel="noopener noreferrer"&gt;https://github.com/trimkeep/handrail-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's also a paid early-access pack with a larger rule set, if you want more than&lt;br&gt;
the free six rules cover: &lt;a href="https://buy.polar.sh/polar_cl_xVkjNq9YQrEOJd25FaLVYBeXLqfgk4OU57LQZ4au38s" rel="noopener noreferrer"&gt;https://buy.polar.sh/polar_cl_xVkjNq9YQrEOJd25FaLVYBeXLqfgk4OU57LQZ4au38s&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
