<?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: SDVSignal</title>
    <description>The latest articles on DEV Community by SDVSignal (@david_lee_bf42eec7236085d).</description>
    <link>https://dev.to/david_lee_bf42eec7236085d</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%2F4134521%2Fb4832a08-6cd7-4e32-ba3e-99b066f001ff.png</url>
      <title>DEV Community: SDVSignal</title>
      <link>https://dev.to/david_lee_bf42eec7236085d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/david_lee_bf42eec7236085d"/>
    <language>en</language>
    <item>
      <title>You changed the file. Your buyers are still downloading the old one.</title>
      <dc:creator>SDVSignal</dc:creator>
      <pubDate>Tue, 22 Sep 2026 05:16:33 +0000</pubDate>
      <link>https://dev.to/david_lee_bf42eec7236085d/you-changed-the-file-your-buyers-are-still-downloading-the-old-one-23dp</link>
      <guid>https://dev.to/david_lee_bf42eec7236085d/you-changed-the-file-your-buyers-are-still-downloading-the-old-one-23dp</guid>
      <description>&lt;p&gt;This one cost me an evening, so here it is in case it saves you the same evening.&lt;/p&gt;

&lt;p&gt;I sell a few small zip files. Templates, config packs, that kind of thing. They are paid downloads, so they cannot sit in a public bucket where anyone with the URL can grab them. The setup I landed on is a Cloudflare Pages Function that checks a key in the query string and streams the archive back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/d/my-pack?k=&amp;lt;key&amp;gt;   -&amp;gt;  200 + application/zip
/d/my-pack           -&amp;gt;  403
/d/my-pack?k=wrong   -&amp;gt;  403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file itself is not an asset in the repo. It is base64 in the function, generated by a build step. That part is deliberate. If the zip were a static file in the Pages project, Pages would serve it at its own path and the key check would be theatre, because anyone could skip the function and hit the file directly.&lt;/p&gt;

&lt;p&gt;That design is fine. The trap is what it does to your update loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;I changed a template inside one of the packs. Re-zipped. Committed. Moved on.&lt;/p&gt;

&lt;p&gt;Buyers kept getting the old pack.&lt;/p&gt;

&lt;p&gt;Nothing failed. No error, no warning, no red anywhere. The zip on my disk was correct. The zip in git was correct. Every check I had was looking at the zip on disk, and the zip on disk was right.&lt;/p&gt;

&lt;p&gt;The bytes buyers actually receive live in the function, and the function is only as fresh as the last time I ran the build step and deployed. Re-zipping updates one of three copies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the source folder&lt;/li&gt;
&lt;li&gt;the built zip in &lt;code&gt;out/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the base64 blob compiled into the function&lt;/li&gt;
&lt;li&gt;and then, separately, whatever is actually deployed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had been treating that as one step. It is four, and three of them can silently disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is not a better memory
&lt;/h2&gt;

&lt;p&gt;My first instinct was a note in the runbook. "Remember to re-run the build step." That is not a fix, that is a wish. The runbook already said it. I had read it. I still shipped the stale pack, because reading a step and doing it are different events and only one of them leaves a trace.&lt;/p&gt;

&lt;p&gt;What actually fixed it was making the smoke test compare the &lt;strong&gt;deployed&lt;/strong&gt; bytes, not the built ones. The check downloads the live file over https, exactly the way a buyer does, and diffs it against the build output:&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;# what a buyer gets, right now, from production&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/live.zip &lt;span class="s2"&gt;"https://your.site/d/my-pack?k=&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# what the build says they should get&lt;/span&gt;
&lt;span class="nv"&gt;BUILT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/packs/out/my-pack.zip

&lt;span class="c"&gt;# compare CONTENTS, not the archive bytes&lt;/span&gt;
cmp &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;unzip &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/live.zip | shasum&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;unzip &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BUILT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | shasum&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deployed == built"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"STALE: production is serving an older pack"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what that last bit is doing. Do not &lt;code&gt;shasum&lt;/code&gt; the two &lt;code&gt;.zip&lt;/code&gt; files against each other. Zip archives store a modification time per entry, so rebuilding the exact same content a second later gives you a different archive hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;shasum a1.zip a2.zip          &lt;span class="c"&gt;# same files inside, rebuilt 1s apart&lt;/span&gt;
&lt;span class="go"&gt;3db6cd34...  a1.zip
dbf6dcd8...  a2.zip

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;unzip &lt;span class="nt"&gt;-p&lt;/span&gt; a1.zip | shasum      &lt;span class="c"&gt;# the contents&lt;/span&gt;
&lt;span class="go"&gt;f572d396...
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;unzip &lt;span class="nt"&gt;-p&lt;/span&gt; a2.zip | shasum
&lt;span class="go"&gt;f572d396...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the moment your build step runs for any reason, an archive-bytes check goes red while nothing is actually wrong. You will learn to ignore it, and then it is worse than having no check at all. Unpack and compare what came out.&lt;/p&gt;

&lt;p&gt;Same reason &lt;code&gt;unzip -l&lt;/code&gt; counts are a bad assertion: it lists directory entries too, so "7 files" in your build script and "8 lines" from &lt;code&gt;unzip -l&lt;/code&gt; are both correct and will still make you doubt a green run at 1am.&lt;/p&gt;

&lt;h2&gt;
  
  
  While you are in there, check the other end
&lt;/h2&gt;

&lt;p&gt;The download route was only half of it. The other half was the redirect.&lt;/p&gt;

&lt;p&gt;Stripe Payment Links have an "after payment" setting. If you never set it, the default is a hosted confirmation page, which is a bare Stripe receipt with no link to anything. The buyer paid. Nothing arrives. Nothing is broken enough to alert you, and they are not going to email you about it, they are going to file a dispute or just eat it and never come back.&lt;/p&gt;

&lt;p&gt;Worth reading the config back from the API rather than trusting your memory of a dashboard click:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.stripe.com/v1/payment_links?limit&lt;span class="o"&gt;=&lt;/span&gt;100 &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,sys
for p in json.load(sys.stdin)['data']:
    ac = p.get('after_completion') or {}
    url = (ac.get('redirect') or {}).get('url','')
    print(p.get('active'), ac.get('type'), url)
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then take every URL that prints and actually curl it. A redirect Stripe is holding is worth exactly what that URL returns today. The key embedded in it can be stale, the route can have moved, and Stripe will keep cheerfully sending people there forever.&lt;/p&gt;

&lt;p&gt;When I ran that across my own account I found 15 of 16 links fine and one leftover from a product I had retired months earlier, still active, still chargeable, still pointed at a dead end. Nothing links to it. An old bookmark would have been enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I would tell past me
&lt;/h2&gt;

&lt;p&gt;A green build is not proof that a feature works. I knew that. What I did not have was a check positioned on the buyer's side of the wire.&lt;/p&gt;

&lt;p&gt;Every verification I owned was upstream of the deploy. Source correct, zip correct, commit correct, all green, all true, all useless, because none of them could see what production was serving. The check has to start from the public URL and work backwards, or it is checking your intentions rather than your product.&lt;/p&gt;

&lt;p&gt;Two questions worth asking about any paid download you run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If I change the file and forget a step, does anything tell me, or do buyers just quietly get the old one?&lt;/li&gt;
&lt;li&gt;If I never set the after-payment behaviour on that checkout link, where does the buyer land?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the honest answer to either is "I would find out when someone complains", that is the thing to go fix tonight.&lt;/p&gt;




&lt;p&gt;I write these up as I hit them while building &lt;a href="https://kit.sdvsignal.com" rel="noopener noreferrer"&gt;Kit&lt;/a&gt;, a set of small Claude Code and MCP setup packs. The checks above are the ones running against my own delivery rail right now. Steal them, they are not complicated.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloudflare</category>
      <category>stripe</category>
    </item>
    <item>
      <title>Two of our products cost the same, and pointed at each other's checkout</title>
      <dc:creator>SDVSignal</dc:creator>
      <pubDate>Tue, 22 Sep 2026 04:29:20 +0000</pubDate>
      <link>https://dev.to/david_lee_bf42eec7236085d/two-of-our-products-cost-the-same-and-pointed-at-each-others-checkout-4m2</link>
      <guid>https://dev.to/david_lee_bf42eec7236085d/two-of-our-products-cost-the-same-and-pointed-at-each-others-checkout-4m2</guid>
      <description>&lt;p&gt;We keep a public changelog, and the rule I set for it is that it has to include the things we broke. Otherwise it is marketing with a date column on it.&lt;/p&gt;

&lt;p&gt;Here is the entry from last week I would rather not have written, and the boring thing that actually caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;We sell a handful of small packs. Two of them are priced the same, $149 each.&lt;/p&gt;

&lt;p&gt;Every product on the site carries JSON-LD in the page head, and each one has an &lt;code&gt;Offer&lt;/code&gt; block with a &lt;code&gt;url&lt;/code&gt; that goes straight to a Stripe checkout. That is the path a lot of machine traffic takes now. Search engines read it, and so do the AI assistants that answer "where do I buy X" without anybody clicking a link.&lt;/p&gt;

&lt;p&gt;Those two &lt;code&gt;url&lt;/code&gt; values were swapped. Pack A's structured data pointed at Pack B's checkout, and the other way around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing looked wrong
&lt;/h2&gt;

&lt;p&gt;This is the part worth sitting with. Both packs cost $149.&lt;/p&gt;

&lt;p&gt;So the checkout opened, and it said $149, which is the number the buyer expected. The Stripe page was real. The payment would have gone through. Nothing in the flow contradicted anything the buyer had read. They would have paid the correct price and been handed the wrong product, and the first person to find out would have been them, after paying.&lt;/p&gt;

&lt;p&gt;If the two packs had been priced differently the mismatch would have been obvious in about four seconds. Matching prices is what made it invisible.&lt;/p&gt;

&lt;p&gt;I have no evidence anyone hit it. I also have no evidence nobody did, which is a worse sentence to write than the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What found it
&lt;/h2&gt;

&lt;p&gt;Not a test. Not a customer email. A script that does one dumb thing.&lt;/p&gt;

&lt;p&gt;It fetches the live page, parses every JSON-LD block, collects every &lt;code&gt;Offer.url&lt;/code&gt;, and asserts that the count of offers equals the count of distinct URLs. That is the whole check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;offers: 12   distinct urls: 12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When those two numbers disagree, two products are pointing at the same checkout, or at each other's. It cannot tell you which, and it does not need to. It tells you to go look.&lt;/p&gt;

&lt;p&gt;It runs against production, not a fixture, because the thing I care about is what the page is actually serving right now. A fixture would have passed happily the entire time the live site was wrong.&lt;/p&gt;

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

&lt;p&gt;The checks that have earned their keep here all have the same feel. They are not "does this function return the right value." They are closer to "do two things that must agree still agree."&lt;/p&gt;

&lt;p&gt;A few that now run before every deploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every &lt;code&gt;Offer.url&lt;/code&gt; is distinct.&lt;/li&gt;
&lt;li&gt;Every page's &lt;code&gt;dateModified&lt;/code&gt; equals its &lt;code&gt;lastmod&lt;/code&gt; in the sitemap. Two freshness signals that drift apart are worse than one.&lt;/li&gt;
&lt;li&gt;Every internal link on a page returns 200. Cheap, and it catches renames.&lt;/li&gt;
&lt;li&gt;Every secret the build needs was actually bound at the last deploy, read back from the platform rather than from the config file that claims to set it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are clever. Every one of them has caught something real, and each one exists because it caught something the first time I ran it.&lt;/p&gt;

&lt;p&gt;The crossed checkout is the one I think about, though, because it is the only one where a paying customer was the next line of defense. The other bugs on that list were embarrassing. That one was going to cost somebody money and take their afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;I found this while writing a changelog entry about something else.&lt;/p&gt;

&lt;p&gt;The honest version of ship-in-public is that the changelog itself is a check. Writing down what shipped forces you to go look at whether it shipped the way you think it did, and about one time in five, it did not.&lt;/p&gt;

&lt;p&gt;Full entry and the rest of the log: &lt;a href="https://kit.sdvsignal.com/changelog/" rel="noopener noreferrer"&gt;https://kit.sdvsignal.com/changelog/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>seo</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your Cloudflare Pages secret is set. Production cannot see it.</title>
      <dc:creator>SDVSignal</dc:creator>
      <pubDate>Tue, 22 Sep 2026 03:38:25 +0000</pubDate>
      <link>https://dev.to/david_lee_bf42eec7236085d/your-cloudflare-pages-secret-is-set-production-cannot-see-it-4gf4</link>
      <guid>https://dev.to/david_lee_bf42eec7236085d/your-cloudflare-pages-secret-is-set-production-cannot-see-it-4gf4</guid>
      <description>&lt;p&gt;Your Cloudflare Pages secret is set. &lt;code&gt;wrangler pages secret list&lt;/code&gt; shows it. Production cannot see it.&lt;/p&gt;

&lt;p&gt;I lost most of a day to this on a paid download route, so here is the mechanism, the one-line reason, and a check you can run that answers it exactly instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Two delivery routes on a Pages project started returning 403 to everyone. The routes read a secret, the secret was missing, so they failed closed, which is correct behaviour and also completely invisible from outside.&lt;/p&gt;

&lt;p&gt;Every obvious thing looked right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx wrangler pages secret list &lt;span class="nt"&gt;--project-name&lt;/span&gt; my-project
&lt;span class="go"&gt;The "production" environment of your Pages project "my-project" has access to the following secrets:
  - DELIVERY_KEYS_VAULT49: Value Encrypted
  - DELIVERY_KEYS_PERMIT39: Value Encrypted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There they are. The code reads &lt;code&gt;env.DELIVERY_KEYS_VAULT49&lt;/code&gt;. The names match. The route 403s.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reason, from Cloudflare's own docs
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"When setting secrets with Wrangler or in the Cloudflare dashboard, it needs to be done before a deployment that uses those secrets."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one sentence is the whole bug. &lt;strong&gt;Pages binds environment variables and secrets at deploy time, into that deployment.&lt;/strong&gt; They are not looked up per-request from some live project-level store. So &lt;code&gt;wrangler pages secret put&lt;/code&gt; does not change what the currently-serving build can see. It changes what the &lt;em&gt;next&lt;/em&gt; build will see.&lt;/p&gt;

&lt;p&gt;Set a secret and walk away, and it sits in the project doing nothing at all until something deploys. The secret list is telling you the truth about the project. It is telling you nothing about production.&lt;/p&gt;

&lt;p&gt;This is different from Workers, which is where the intuition comes from. On a Worker, &lt;code&gt;wrangler secret put&lt;/code&gt; applies to the running script. The same muscle memory on Pages gets you a secret that exists, reads correctly in every tool you check, and is invisible to the thing serving traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is so hard to see
&lt;/h2&gt;

&lt;p&gt;Every signal points the wrong way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The secret list shows it. Correct, and irrelevant.&lt;/li&gt;
&lt;li&gt;The dashboard shows it. Same.&lt;/li&gt;
&lt;li&gt;The code is right, so a code review finds nothing.&lt;/li&gt;
&lt;li&gt;The route fails closed, so you get a 403 and not a stack trace. A good failure mode hides this one.&lt;/li&gt;
&lt;li&gt;Redeploying for an unrelated reason fixes it silently, so it looks intermittent, which sends you hunting for a caching problem that does not exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ours only surfaced because someone asked why a paid link had never worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler pages deploy &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--project-name&lt;/span&gt; my-project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. Deploy anything, even a no-op, and the secret binds. &lt;strong&gt;Order matters: &lt;code&gt;secret put&lt;/code&gt; first, then deploy.&lt;/strong&gt; Both orders "work" in the sense that neither errors, and only one of them serves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth keeping: how to detect it
&lt;/h2&gt;

&lt;p&gt;"Redeploy after setting a secret" is a rule you will follow for two weeks and then forget at 11pm. I wanted a check instead, and my first instinct was to compare timestamps: when was this secret last changed, versus when did we last deploy?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare does not give you that.&lt;/strong&gt; There is no modification time on a secret. Not in &lt;code&gt;wrangler pages secret list&lt;/code&gt;, not on the project's &lt;code&gt;deployment_configs&lt;/code&gt; (every entry is literally &lt;code&gt;{"type": "secret_text", "value": ""}&lt;/code&gt;), nowhere I could find.&lt;/p&gt;

&lt;p&gt;But you do not need a timestamp, because &lt;strong&gt;every deployment record carries its own &lt;code&gt;env_vars&lt;/code&gt; snapshot&lt;/strong&gt; — the exact set of names that were bound when that build ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT&lt;/span&gt;&lt;span class="s2"&gt;/pages/projects/&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;/deployments?per_page=25"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.result[] | {id: .short_id, on: .created_on, vars: (.env_vars // {} | keys)}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which makes the question a set difference, with no clock involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set-but-not-bound  =  (project's current secrets)  −  (last successful deploy's secrets)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is strictly better than the timestamp comparison I originally wanted. An mtime would tell me when a secret changed. This tells me whether production can actually see it, which is the thing that 403s a user.&lt;/p&gt;

&lt;p&gt;Walking my own history, the snapshots grow &lt;code&gt;3 → 4 → 5 → 6 → 8&lt;/code&gt;, and each step is a secret becoming visible. The two that broke the routes first appear in a specific deploy — which is exactly when those routes started working. The outage window is sitting there in the data, if you know to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two traps if you build this into a gate
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Skipped and failed builds carry an empty &lt;code&gt;env_vars&lt;/code&gt;.&lt;/strong&gt; Pick your baseline by recency alone and you will land on one of those, and then every secret in the account reports as stale. A gate that goes red on a perfectly healthy project gets deleted within a week. Filter to: production environment, not skipped, status success, and a non-empty snapshot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it after the deploy, not before.&lt;/strong&gt; As a pre-deploy check this is red for every legitimate &lt;code&gt;secret put&lt;/code&gt; → &lt;code&gt;deploy&lt;/code&gt;, which is the correct workflow and the one you are trying to protect. Run it after, and a red means the binding genuinely did not happen. I got this backwards first and the check immediately failed on the exact thing it was written to support.&lt;/p&gt;




&lt;p&gt;I write this kind of thing up at &lt;a href="https://kit.sdvsignal.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pages-secrets" rel="noopener noreferrer"&gt;kit.sdvsignal.com&lt;/a&gt;, and there is a free MIT starter repo at &lt;a href="https://github.com/sdvsignal/kit-claude-code-starter" rel="noopener noreferrer"&gt;kit-claude-code-starter&lt;/a&gt; if you want a working &lt;code&gt;.claude/&lt;/code&gt; to copy rather than assemble. The &lt;a href="https://kit.sdvsignal.com/guides/claude-code-hooks-that-block/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pages-secrets" rel="noopener noreferrer"&gt;hooks guide&lt;/a&gt; is the same flavour of "this looks like it works and does not".&lt;/p&gt;

&lt;p&gt;Has anyone found a way to get a real modification time out of a Pages secret? I could not, and the snapshot trick is a workaround for something I would rather just ask the API directly.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>devops</category>
      <category>serverless</category>
    </item>
    <item>
      <title>The 15-minute Claude Code setup, and the four things people get wrong</title>
      <dc:creator>SDVSignal</dc:creator>
      <pubDate>Tue, 22 Sep 2026 03:32:28 +0000</pubDate>
      <link>https://dev.to/david_lee_bf42eec7236085d/the-15-minute-claude-code-setup-and-the-four-things-people-get-wrong-2j5d</link>
      <guid>https://dev.to/david_lee_bf42eec7236085d/the-15-minute-claude-code-setup-and-the-four-things-people-get-wrong-2j5d</guid>
      <description>&lt;p&gt;Claude Code works the moment you install it. Then it spends your first week asking permission for &lt;code&gt;npm test&lt;/code&gt; and forgetting how your repo builds.&lt;/p&gt;

&lt;p&gt;Fixing both takes about fifteen minutes and four plain-text files. I have done this on nine repos now, so here is the version without the ceremony, plus four things I had wrong for longer than I would like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clock
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm install -g @anthropic-ai/claude-code&lt;/code&gt;, run &lt;code&gt;claude&lt;/code&gt; once in your repo to sign in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2–5&lt;/td&gt;
&lt;td&gt;Copy &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.claude/&lt;/code&gt; and &lt;code&gt;.mcp.json.example&lt;/code&gt; into your repo root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5–10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Fill in your four commands and your never-do list in &lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10–12&lt;/td&gt;
&lt;td&gt;Edit &lt;code&gt;permissions.allow&lt;/code&gt; to match your real test and lint commands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12–15&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;claude&lt;/code&gt;, then your ship-check skill. It should run tests and lint and report PASS/FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Minutes 5 to 10 are the job. The rest is typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 5 to 10: the only step that matters
&lt;/h2&gt;

&lt;p&gt;Claude Code starts every session knowing nothing about your repo. &lt;code&gt;CLAUDE.md&lt;/code&gt; is what it reads first, every time. Most people write an essay here. The essay is what makes it useless.&lt;/p&gt;

&lt;p&gt;Four commands and a short list of things not to do beats three paragraphs of architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# My Project&lt;/span&gt;

&lt;span class="gu"&gt;## Commands&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; install: &lt;span class="sb"&gt;`pnpm install`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; dev: &lt;span class="sb"&gt;`pnpm dev`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; test: &lt;span class="sb"&gt;`pnpm test`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; lint: &lt;span class="sb"&gt;`pnpm lint --fix`&lt;/span&gt;

&lt;span class="gu"&gt;## Layout&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`src/`&lt;/span&gt; app code · &lt;span class="sb"&gt;`src/db/`&lt;/span&gt; migrations, hand-written · &lt;span class="sb"&gt;`e2e/`&lt;/span&gt; Playwright

&lt;span class="gu"&gt;## Never&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never edit anything in &lt;span class="sb"&gt;`src/generated/`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never commit to &lt;span class="sb"&gt;`main`&lt;/span&gt;; branch first
&lt;span class="p"&gt;-&lt;/span&gt; Migrations are forward-only. No editing an applied one
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you do one thing from this post, write the four commands. Everything downstream is about commands Claude already knows how to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 10 to 12: why it stops asking
&lt;/h2&gt;

&lt;p&gt;Approving a command approves &lt;em&gt;that run&lt;/em&gt;. It is not remembered. That is why you can approve &lt;code&gt;npm test&lt;/code&gt; forty times and get asked a forty-first.&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;"permissions"&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;"allow"&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="s2"&gt;"Bash(git status)"&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(git diff:*)"&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(npm test:*)"&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(npm run lint:*)"&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;"deny"&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="s2"&gt;"Bash(rm -rf:*)"&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(git push --force:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env*)"&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;The bit that catches everyone: &lt;code&gt;Bash(npm test)&lt;/code&gt; matches that exact string, so &lt;code&gt;npm test -- --watch&lt;/code&gt; still prompts. &lt;code&gt;Bash(npm test:*)&lt;/code&gt; covers the arguments too.&lt;/p&gt;

&lt;p&gt;And do not allow everything. The point of an allowlist is that you can stop reading the prompts, which is only safe while the prompts you &lt;em&gt;would&lt;/em&gt; want to read still show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things I had wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. You do not need to restart after editing settings
&lt;/h3&gt;

&lt;p&gt;I restarted Claude Code after every settings change for weeks. You do not have to. It watches the files. If a change looks like it did not take, the configuration is usually wrong rather than stale.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A hook matcher is not a glob
&lt;/h3&gt;

&lt;p&gt;I wrote &lt;code&gt;Edit(*)&lt;/code&gt; and wondered why nothing fired. A plain matcher value is an &lt;strong&gt;exact, case-sensitive tool name&lt;/strong&gt;, or a pipe-separated list of them. Only a value containing regex characters gets treated as a regex. So &lt;code&gt;"Edit|Write"&lt;/code&gt; works and &lt;code&gt;Edit(*)&lt;/code&gt; matches nothing at all.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Bash(npm test:*)&lt;/code&gt; shape is &lt;em&gt;permission&lt;/em&gt; syntax, which is a different mechanism with different rules. I had been mixing them.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Exit 1 does not block anything
&lt;/h3&gt;

&lt;p&gt;A hook returns &lt;strong&gt;exit 2&lt;/strong&gt; to block a tool call. &lt;code&gt;exit 0&lt;/code&gt; is success and &lt;code&gt;exit 1&lt;/code&gt; is a non-blocking error. So the Unix reflex of returning 1 on failure gets you a hook that reports a problem and then lets the action through. Mine "worked" for a week in the sense that it never blocked anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A failing hook says nothing where you can see it
&lt;/h3&gt;

&lt;p&gt;Hook stderr goes to the debug log, never to the transcript. So a hook that crashes on line one and a hook that was never wired up look identical from the chat. This is the single most confusing thing in the whole setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--debug&lt;/span&gt; hooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints hook resolution, the source file for each hook, the process spawn, and the error. &lt;code&gt;/hooks&lt;/code&gt; in-session lists what is actually loaded. If it is not in that list it is a config problem; if it is, it is a script problem. Two different afternoons.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can skip on day one
&lt;/h2&gt;

&lt;p&gt;Four words get used as one thing, and three of them can wait:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A skill&lt;/strong&gt; — yes, one. It is a &lt;code&gt;SKILL.md&lt;/code&gt; and it is also the slash command. Start with a ship-check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks&lt;/strong&gt; — later. Useful once you know what should be &lt;em&gt;enforced&lt;/em&gt; rather than requested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An MCP server&lt;/strong&gt; — only if Claude needs to reach something outside your machine. Words cannot query your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plugin&lt;/strong&gt; — no. Plugins are about handing a setup to other people. Nothing to package on day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Copy a working one
&lt;/h2&gt;

&lt;p&gt;All four files, filled in, MIT, nothing held back: &lt;a href="https://github.com/sdvsignal/kit-claude-code-starter" rel="noopener noreferrer"&gt;kit-claude-code-starter&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sdvsignal/kit-claude-code-starter
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; kit-claude-code-starter/&lt;span class="o"&gt;{&lt;/span&gt;CLAUDE.md,.claude,.mcp.json.example&lt;span class="o"&gt;}&lt;/span&gt; your-repo/
&lt;span class="nb"&gt;cd &lt;/span&gt;your-repo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no paid tier of that repo. If you already know your build commands, this post plus that clone is the whole job.&lt;/p&gt;

&lt;p&gt;Longer version of this with the tables and the fix pages it links to: &lt;a href="https://kit.sdvsignal.com/guides/claude-code-15-minute-setup/" rel="noopener noreferrer"&gt;the 15-minute Claude Code setup&lt;/a&gt;. We do also sell the fifteen minutes done for you, which is on that site, but the free repo is the honest recommendation for most people and I would rather say so than not.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Checked against Claude Code 2.1.278.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Your agent said the tests passed. Check whether they ran.</title>
      <dc:creator>SDVSignal</dc:creator>
      <pubDate>Tue, 22 Sep 2026 03:23:03 +0000</pubDate>
      <link>https://dev.to/david_lee_bf42eec7236085d/your-agent-said-the-tests-passed-check-whether-they-ran-4njm</link>
      <guid>https://dev.to/david_lee_bf42eec7236085d/your-agent-said-the-tests-passed-check-whether-they-ran-4njm</guid>
      <description>&lt;p&gt;There is a failure mode in agent-assisted coding that does not look like a failure. You ask for a fix, the agent works for a while, and the summary says the tests pass. They did not fail. They also did not run.&lt;/p&gt;

&lt;p&gt;A failing test is information. A test that never ran, reported as a pass, is worse than having no tests at all, because now you believe something.&lt;/p&gt;

&lt;p&gt;This is not about any one tool being bad. It is a reporting gap, and it shows up the same way in Claude Code, Cursor, Copilot Workspace or a shell script somebody wrote in 2014. Here is what causes it and the smallest thing you can do about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways a test run turns into a lie
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The command was never found.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent does not know how you run tests. It guesses &lt;code&gt;npm test&lt;/code&gt;, your project uses &lt;code&gt;pnpm vitest run&lt;/code&gt;, the guess errors out, and the error gets folded into the summary as "no test failures". Technically true. Completely useless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A flag made nothing count as something.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--passWithNoTests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That flag does exactly what it says. Zero tests collected, exit code 0, green checkmark. Jest and Vitest both have it, and it exists for good reasons in a monorepo, which is precisely why it turns up in commands where it does not belong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Somebody appended a shrug.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the command cannot fail. Whatever pytest thinks, the shell reports success. You see this a lot in CI files that somebody was trying to unblock at 6pm on a Friday, and once it is there nobody removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell, in one question
&lt;/h2&gt;

&lt;p&gt;When an agent tells you the tests pass, ask for three things. It takes one line and it is the difference between a verified change and a claimed one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ask for&lt;/th&gt;
&lt;th&gt;Good answer&lt;/th&gt;
&lt;th&gt;Bad answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The command it ran&lt;/td&gt;
&lt;td&gt;The exact string, matching your repo&lt;/td&gt;
&lt;td&gt;A paraphrase, or nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The output&lt;/td&gt;
&lt;td&gt;A count: "42 passed, 0 failed"&lt;/td&gt;
&lt;td&gt;"Tests pass"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The exit code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0&lt;/code&gt;, and it says so&lt;/td&gt;
&lt;td&gt;Not mentioned&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you get a count and an exit code, something ran. If you get the word "pass" and nothing else, treat it as unverified. That is not cynicism, it is the same standard you would apply to a pull request from a contractor you have not met.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half the fix is writing the command down
&lt;/h2&gt;

&lt;p&gt;If you use Claude Code, the file it reads at the start of every session is &lt;code&gt;CLAUDE.md&lt;/code&gt; at the root of your repo. Put the real commands in it, verbatim, flags included. It is copied, not interpreted, so a paraphrase is worse than nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Commands&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Test: &lt;span class="sb"&gt;`pnpm vitest run --silent`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Lint: &lt;span class="sb"&gt;`pnpm eslint .`&lt;/span&gt;

&lt;span class="gu"&gt;## Never&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never add &lt;span class="sb"&gt;`--passWithNoTests`&lt;/span&gt; or &lt;span class="sb"&gt;`|| true`&lt;/span&gt; to a command that is supposed to prove a change works
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then let it actually run them without asking, 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;"permissions"&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;"allow"&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="s2"&gt;"Bash(pnpm vitest:*)"&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(pnpm eslint:*)"&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;Miss that second half and you have traded a "cannot find it" prompt for a "may I run it" prompt, every single time, forever. Most people quietly stop reading those prompts after a day, which is its own problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half is a hook, because writing it down is not enforcement
&lt;/h2&gt;

&lt;p&gt;An instruction in a markdown file is a request. Some days it gets followed. If you want the masked test run to be impossible rather than discouraged, Claude Code will run a script before it executes a Bash command, and if that script exits with code 2, the command is blocked and the reason goes back to the agent so it can correct itself.&lt;/p&gt;

&lt;p&gt;Here is the whole thing. Save it as &lt;code&gt;.claude/hooks/no-skip-tests.sh&lt;/code&gt;:&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="c"&gt;# Blocks the flags that make a test run report success whatever happens.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;payload&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="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; jq &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;cmd&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# no jq: match against the raw payload rather than failing open&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;--passWithNoTests&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"|| true"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;--exitcode-zero&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED: that flag makes the test run report success even when nothing ran."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run the suite for real, or say out loud that you are skipping it."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;2 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it up 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="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;"bash &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR/.claude/hooks/no-skip-tests.sh&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;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;Two details that matter more than they look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit 2, not exit 1.&lt;/strong&gt; Exit 2 is the code that blocks the tool call and hands your message back to the agent. Other non-zero codes get treated as the hook itself being broken, which is not what you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handle a missing &lt;code&gt;jq&lt;/code&gt;.&lt;/strong&gt; If your hook parses the payload with &lt;code&gt;jq&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt; is not installed, the naive version silently allows everything through. A security or safety hook that fails open is worse than no hook, because you think you are covered. The fallback above matches against the raw payload instead, which is cruder and still blocks the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the hook, because an untested hook is a comfort blanket
&lt;/h2&gt;

&lt;p&gt;You can check it without launching an agent at all. The hook reads JSON on stdin, so hand it some:&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_input":{"command":"npm test -- --passWithNoTests"}}'&lt;/span&gt; | bash .claude/hooks/no-skip-tests.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# exit 2&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_input":{"command":"npm test"}}'&lt;/span&gt; | bash .claude/hooks/no-skip-tests.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# exit 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run both. A hook that blocks everything passes the first check and fails you on a Tuesday, so the allow case matters as much as the block case. That is also the difference between a hook you keep and one you delete in a fortnight when it gets in the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually buys you
&lt;/h2&gt;

&lt;p&gt;Not much, individually. It stops one specific lie. The reason it is worth ten minutes is that it moves a rule out of your head and into the repo, where it applies to every session, every contributor and every agent that touches the project, without anybody remembering to be careful.&lt;/p&gt;

&lt;p&gt;That is the whole pattern: write the commands down so they cannot be guessed, allowlist them so they actually run, and hook the two or three things you never want to happen so being tired is not enough to cause them.&lt;/p&gt;




&lt;p&gt;If you want the starting point rather than building it from scratch, our setup is public and free: &lt;a href="https://github.com/sdvsignal/kit-claude-code-starter" rel="noopener noreferrer"&gt;kit-claude-code-starter&lt;/a&gt; is a real CLAUDE.md, an allowlist and a doctor script, and &lt;a href="https://github.com/sdvsignal/kit-plugins" rel="noopener noreferrer"&gt;kit-plugins&lt;/a&gt; is four small Claude Code plugins that each install on their own. Nothing is gated behind an email.&lt;/p&gt;

&lt;p&gt;There is a longer write-up of this specific problem here: &lt;a href="https://kit.sdvsignal.com/fixes/claude-code-cannot-find-my-test-command/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=kit_fixes" rel="noopener noreferrer"&gt;Claude Code cannot find my test command&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if you would rather not do the reading, we set it up for one repo for $29 and hand it back as a pull request you review: &lt;a href="https://kit.sdvsignal.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=kit_fixes#setup-lite" rel="noopener noreferrer"&gt;kit.sdvsignal.com&lt;/a&gt;. Take the free starter first though. We would rather you had that than bought something you did not need.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>testing</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your Claude Code hook exits 1. It is not blocking anything.</title>
      <dc:creator>SDVSignal</dc:creator>
      <pubDate>Tue, 22 Sep 2026 02:29:32 +0000</pubDate>
      <link>https://dev.to/david_lee_bf42eec7236085d/your-claude-code-hook-exits-1-it-is-not-blocking-anything-225k</link>
      <guid>https://dev.to/david_lee_bf42eec7236085d/your-claude-code-hook-exits-1-it-is-not-blocking-anything-225k</guid>
      <description>&lt;p&gt;Your Claude Code hook exits 1 when it wants to stop something. It is not stopping anything.&lt;/p&gt;

&lt;p&gt;That is the whole post, really, but the reason is worth five minutes because the failure is silent. A hook that exits 1 runs, prints its complaint, gets logged as an error — and the command it objected to executes anyway. You get the feeling of protection with none of it, which is strictly worse than having written no hook at all, because you have stopped watching for the thing yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract
&lt;/h2&gt;

&lt;p&gt;From Anthropic's &lt;a href="https://docs.claude.com/en/docs/claude-code/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit&lt;/th&gt;
&lt;th&gt;What actually happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No decision reported. The call continues through the normal permission flow. Silence is not approval, but it is not refusal either.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Blocking error.&lt;/strong&gt; On &lt;code&gt;PreToolUse&lt;/code&gt;, the tool call does not run.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anything else&lt;/td&gt;
&lt;td&gt;Does not block on its own. Reported as an error.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the part people miss even after getting the exit code right: when you exit 2 without printing a JSON decision, &lt;strong&gt;your stderr text is the reason Claude is shown&lt;/strong&gt;. So stderr is not a log. It is the message. Write it for the reader.&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED: '--passWithNoTests' makes this run report success whatever happens."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run the tests for real. If some genuinely cannot run here, say which and why."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude reads that and fixes its own command. Compare it to a bare &lt;code&gt;exit 2&lt;/code&gt; with nothing on stderr, which gets retried, because nothing told it what was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not let &lt;code&gt;jq&lt;/code&gt; decide whether you are protected
&lt;/h2&gt;

&lt;p&gt;Nearly every hook example starts like this:&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="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a machine without &lt;code&gt;jq&lt;/code&gt;, that is an empty string. Your hook finds nothing to object to, exits 0, and the command runs. &lt;strong&gt;A security hook that fails open is the worst object in the repository.&lt;/strong&gt;&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="nv"&gt;payload&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="nv"&gt;JQ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;JQ_BIN&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;jq&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# override to test the fallback: JQ_BIN=/nonexistent&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JQ&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;cmd&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JQ&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // empty'&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="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;     &lt;span class="c"&gt;# coarser, still catches the pattern&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;JQ_BIN&lt;/code&gt; indirection exists so the no-jq path is &lt;em&gt;testable&lt;/em&gt;. A fallback nobody has executed is a guess with good intentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A whole hook
&lt;/h2&gt;

&lt;p&gt;This one refuses the flags that make a test run report success whatever happens — the failure mode where CI is green and nothing ran.&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="c"&gt;# PreToolUse(Bash): refuse the flags that make a test run lie.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;payload&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="nv"&gt;JQ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;JQ_BIN&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;jq&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JQ&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;cmd&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JQ&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // empty'&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="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="c"&gt;# narrow first: this hook has no opinion about your git status&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;pytest&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;vitest&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;jest&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;go&lt;span class="se"&gt;\ &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="k"&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;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;

&lt;span class="nv"&gt;bad&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oEm1&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'(--passWithNoTests|--no-verify|\|\| *true|; *true$)'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&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;$bad&lt;/span&gt;&lt;span class="s2"&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="s2"&gt;"BLOCKED: '&lt;/span&gt;&lt;span class="nv"&gt;$bad&lt;/span&gt;&lt;span class="s2"&gt;' makes this run report success whatever happens."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run the tests for real. If some genuinely cannot run here, say which and why."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter more than the regex:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It narrows before it judges.&lt;/strong&gt; Everything that is not a test command exits 0 immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The message says what to do instead.&lt;/strong&gt; That is the difference between a correction and a retry loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It names its own file&lt;/strong&gt; in the output, so when it blocks something you actually wanted, you know which file to open while you are annoyed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wire it up in &lt;code&gt;.claude/settings.json&lt;/code&gt; so it can be committed:&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="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;"bash &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR/.claude/hooks/no-skip-tests.sh&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;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;&lt;code&gt;$CLAUDE_PROJECT_DIR&lt;/code&gt; is not optional decoration. Without it the path resolves against the working directory, and the hook stops firing the moment Claude runs &lt;code&gt;cd&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it in a minute, with no Claude Code involved
&lt;/h2&gt;

&lt;p&gt;It reads stdin and sets an exit code. A pipe is the entire test rig.&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;# should block -&amp;gt; 2&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"npm test -- --passWithNoTests"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | bash .claude/hooks/no-skip-tests.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# should allow -&amp;gt; 0&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"Bash","tool_input":{"command":"npm test"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | bash .claude/hooks/no-skip-tests.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Write the allow cases first.&lt;/strong&gt; A hook that returns 2 for everything passes a block-only test suite with a perfect score and gets deleted within the hour. Every block needs a near-miss beside it that must return 0: &lt;code&gt;rm -rf node_modules&lt;/code&gt; next to &lt;code&gt;rm -rf ~&lt;/code&gt;, &lt;code&gt;.env.example&lt;/code&gt; next to &lt;code&gt;.env&lt;/code&gt;. The near-misses are where the actual thinking is.&lt;/p&gt;




&lt;p&gt;I write this stuff up at &lt;a href="https://kit.sdvsignal.com/guides/claude-code-hooks-that-block/" rel="noopener noreferrer"&gt;kit.sdvsignal.com&lt;/a&gt; — the longer version of this post is there, and there is a free MIT starter repo at &lt;a href="https://github.com/sdvsignal/kit-claude-code-starter" rel="noopener noreferrer"&gt;kit-claude-code-starter&lt;/a&gt; with a working &lt;code&gt;.claude/&lt;/code&gt; to copy if you would rather read one than assemble one.&lt;/p&gt;

&lt;p&gt;What is your hook blocking that a CLAUDE.md line could not? I am genuinely collecting these — the interesting ones are always the project-specific rules, not the generic &lt;code&gt;rm -rf&lt;/code&gt; guards.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>bash</category>
    </item>
  </channel>
</rss>
