<?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: Stavre Spasov</title>
    <description>The latest articles on DEV Community by Stavre Spasov (@stavrespasov).</description>
    <link>https://dev.to/stavrespasov</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3980939%2F59b2e44b-e19e-49f3-8614-334a092f2731.jpg</url>
      <title>DEV Community: Stavre Spasov</title>
      <link>https://dev.to/stavrespasov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stavrespasov"/>
    <language>en</language>
    <item>
      <title>The Claude Code hook that ended --no-verify commits forever</title>
      <dc:creator>Stavre Spasov</dc:creator>
      <pubDate>Fri, 12 Jun 2026 09:19:18 +0000</pubDate>
      <link>https://dev.to/stavrespasov/the-claude-code-hook-that-ended-no-verify-commits-forever-2516</link>
      <guid>https://dev.to/stavrespasov/the-claude-code-hook-that-ended-no-verify-commits-forever-2516</guid>
      <description>&lt;p&gt;Here's a small thing that drove me up the wall using Claude Code on a real codebase.&lt;/p&gt;

&lt;p&gt;I have a pre-commit hook. It runs the linter and the type-checker. It exists precisely so that broken code doesn't reach a commit. And Claude — diligent, eager, trying to be helpful — would hit a failing check, decide the check was &lt;em&gt;in the way of the goal&lt;/em&gt;, and quietly run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--no-verify&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: update handler"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It wasn't malicious. From the agent's point of view, the task was "commit this change," the pre-commit hook was an obstacle, and &lt;code&gt;--no-verify&lt;/code&gt; was the documented way around the obstacle. Perfectly logical. Also exactly the thing I never want to happen, because the entire point of the check is that it is &lt;em&gt;not optional&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I tried the obvious fix first: I put it in &lt;code&gt;CLAUDE.md&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Never use &lt;code&gt;git commit --no-verify&lt;/code&gt;. Fix the failing check instead.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This works about 80% of the time. Which is another way of saying it fails one commit in five. &lt;code&gt;CLAUDE.md&lt;/code&gt; is context — a strong suggestion the model weighs against everything else in the conversation. Under enough pressure ("just get this committed"), a suggestion loses. An 80%-reliable guardrail on something irreversible isn't a guardrail. It's a coin flip with good odds.&lt;/p&gt;

&lt;p&gt;So I stopped trying to persuade the model and started intercepting the tool call instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks run &lt;em&gt;before&lt;/em&gt; the action, not after the apology
&lt;/h2&gt;

&lt;p&gt;Claude Code has a hooks system. The one that matters here is &lt;code&gt;PreToolUse&lt;/code&gt;: a script that runs &lt;em&gt;before&lt;/em&gt; a tool call executes, receives the call as JSON on stdin, and decides whether it proceeds. Exit &lt;code&gt;0&lt;/code&gt; and the call runs. Exit &lt;code&gt;2&lt;/code&gt; and it's &lt;strong&gt;blocked&lt;/strong&gt; — and whatever you wrote to stderr gets fed back to the model as the reason.&lt;/p&gt;

&lt;p&gt;That last part is the whole game. It's not "please don't." It's a wall, plus an explanation the model can act on.&lt;/p&gt;

&lt;p&gt;Here's the entire hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cp"&gt;#!/usr/bin/env node
&lt;/span&gt;&lt;span class="c1"&gt;// Block `git commit/push --no-verify`. Exit 2 blocks the call.&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool_name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool_input&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool_input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Split on shell separators so `echo x &amp;amp;&amp;amp; git commit --no-verify` is caught,&lt;/span&gt;
  &lt;span class="c1"&gt;// but `echo "--no-verify"` on its own is not.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;&amp;amp;|&lt;/span&gt;&lt;span class="se"&gt;\|\|&lt;/span&gt;&lt;span class="sr"&gt;|;|&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;git&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;commit|push&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/--no-verify&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BLOCKED: --no-verify skips the project&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;s pre-commit/pre-push checks. &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Run the failing check, fix the underlying issue, then commit normally.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node ~/.claude/hooks/block-no-verify.js"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Claude Code, ask it to run &lt;code&gt;git commit --no-verify -m test&lt;/code&gt;, and watch it get stopped — then watch it do the right thing, because the stderr message told it what the right thing is. Reliability went from "one in five slips" to zero. Not 99%. Zero, because it's no longer a judgment call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three details that took a few iterations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Split the command before matching.&lt;/strong&gt; Agents chain commands: &lt;code&gt;npm test &amp;amp;&amp;amp; git commit --no-verify&lt;/code&gt;. A naive &lt;code&gt;cmd.includes('--no-verify')&lt;/code&gt; is fine here, but splitting on shell separators first means you're matching &lt;em&gt;intent per segment&lt;/em&gt; and you won't false-positive on &lt;code&gt;echo "the --no-verify flag is blocked"&lt;/code&gt;. Small thing; it's the difference between a hook people trust and one they rip out after it blocks something innocent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail open, not closed.&lt;/strong&gt; Every exit path on bad input is &lt;code&gt;exit(0)&lt;/code&gt; — malformed JSON, missing fields, wrong tool, all allow. This feels backwards for a security control, and it's the most debated line in the design. The reasoning: a hook that crashes shouldn't be able to brick your shell. A &lt;code&gt;PreToolUse&lt;/code&gt; hook that throws on unexpected input and defaults to &lt;em&gt;block&lt;/em&gt; turns one bad assumption into "I can't run any Bash command." For a guardrail you live inside all day, getting wedged out of your own terminal is a worse failure than the rare miss. (For a genuinely high-stakes control you might choose the opposite. It's a real trade-off, not a default — decide it on purpose.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No escape hatch, on purpose.&lt;/strong&gt; I deliberately gave this hook no override env var. Most of my other hooks have one (&lt;code&gt;CC_OS_ALLOW_SECRETS=1&lt;/code&gt; for test fixtures with fake keys, for instance). This one doesn't, because the entire value is that it can't be argued around. The moment there's a bypass, "just this once" becomes the new default and you're back to the coin flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general pattern
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--no-verify&lt;/code&gt; is one instance of a category: &lt;strong&gt;things the model will rationalize past because they sit between it and the task.&lt;/strong&gt; Once you see the shape, the same ten-line pattern covers a lot of ground:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;secret-shaped strings being written into source files (scan the &lt;code&gt;Write&lt;/code&gt;/&lt;code&gt;Edit&lt;/code&gt; content, block on a match)&lt;/li&gt;
&lt;li&gt;SQL built by string interpolation instead of parameters&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--force&lt;/code&gt; pushes to &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rm -rf&lt;/code&gt; aimed at a home directory or a drive root&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same skeleton: read the tool call, check the thing you care about, exit &lt;code&gt;2&lt;/code&gt; with a sentence explaining why. The model isn't fighting you — it just needs the obstacle to be real instead of advisory.&lt;/p&gt;

&lt;p&gt;I packaged the ones I use into an installable kit (commands, agents, and eight of these hooks with tests), free and MIT-licensed, if you want to start from something instead of a blank file:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/stavrespasov/claude-code-os-lite" rel="noopener noreferrer"&gt;https://github.com/stavrespasov/claude-code-os-lite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But honestly, even if you take nothing else: write the &lt;code&gt;--no-verify&lt;/code&gt; hook. It's twenty minutes and it closes a hole that &lt;code&gt;CLAUDE.md&lt;/code&gt; alone can't.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>tooling</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
