<?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: Ashton Du Toit</title>
    <description>The latest articles on DEV Community by Ashton Du Toit (@ashton_dutoit_c99f445ceb).</description>
    <link>https://dev.to/ashton_dutoit_c99f445ceb</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%2F4052973%2F404abd72-db36-4ac2-9915-278a9a395e09.jpg</url>
      <title>DEV Community: Ashton Du Toit</title>
      <link>https://dev.to/ashton_dutoit_c99f445ceb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashton_dutoit_c99f445ceb"/>
    <language>en</language>
    <item>
      <title>My release pipeline was about to ship my private notes</title>
      <dc:creator>Ashton Du Toit</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:32:41 +0000</pubDate>
      <link>https://dev.to/ashton_dutoit_c99f445ceb/my-release-pipeline-was-about-to-ship-my-private-notes-1b4m</link>
      <guid>https://dev.to/ashton_dutoit_c99f445ceb/my-release-pipeline-was-about-to-ship-my-private-notes-1b4m</guid>
      <description>&lt;p&gt;sell a small digital product, and the delivery side of it is deliberately&lt;br&gt;
boring. Push a git tag, a GitHub Action zips up the product folder, the zip&lt;br&gt;
gets attached to a release, buyer downloads it. Wrote the whole thing in&lt;br&gt;
about four minutes and never opened it again.&lt;/p&gt;

&lt;p&gt;Then I went looking through the product folder for something unrelated and&lt;br&gt;
noticed what was actually in there.&lt;/p&gt;

&lt;p&gt;Two files, sitting next to the guide and the configs people pay for.&lt;br&gt;
&lt;code&gt;source-material.md&lt;/code&gt;, which is my raw notes — unedited, real paths off my&lt;br&gt;
machine, half-finished thoughts, the ugly version of everything the&lt;br&gt;
polished guide says properly. And &lt;code&gt;SANITIZATION-CHECKLIST.md&lt;/code&gt;, which is the&lt;br&gt;
checklist I use to make sure none of that ever leaks.&lt;/p&gt;

&lt;p&gt;The zip step said, roughly, put this folder in a zip. Both files were in&lt;br&gt;
that folder. So the checklist for preventing the leak was part of the leak.&lt;/p&gt;

&lt;p&gt;Nobody had bought anything yet. That's the only reason I'm writing this up&lt;br&gt;
instead of emailing someone an apology.&lt;/p&gt;

&lt;p&gt;The step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Zip the product folder&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;SLUG=${GITHUB_REF_NAME%-v*}&lt;/span&gt;
    &lt;span class="s"&gt;cd products/$SLUG &amp;amp;&amp;amp; zip -r ../../$GITHUB_REF_NAME.zip .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no bug in it. It does what it says. What it says is "ship the whole&lt;br&gt;
directory", and directories fill up.&lt;/p&gt;

&lt;p&gt;Obvious patch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;zip -r ../../$GITHUB_REF_NAME.zip . \&lt;/span&gt;
  &lt;span class="s"&gt;-x "*.git*" -x "*source-material.md" -x "*SANITIZATION-CHECKLIST.md"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used &lt;code&gt;*source-material.md&lt;/code&gt; rather than the bare filename so a copy in a&lt;br&gt;
subfolder gets caught too. Small thing, but that's the sort of gap a&lt;br&gt;
denylist has.&lt;/p&gt;

&lt;p&gt;Except the exclude list is really just a list of the mistakes I've already&lt;br&gt;
thought of. It does nothing about the next one. &lt;code&gt;notes.md&lt;/code&gt;. A &lt;code&gt;TODO.md&lt;/code&gt;. An&lt;br&gt;
&lt;code&gt;.env.example&lt;/code&gt; that stopped being an example three commits ago. A&lt;br&gt;
screenshot with a browser tab visible in the corner. The build ships&lt;br&gt;
whatever's present and what's present grows every time I touch the product.&lt;/p&gt;

&lt;p&gt;Two better options depending on how organised you are. Either name what&lt;br&gt;
ships and let everything else be invisible by default, so adding a new file&lt;br&gt;
to the release takes one deliberate line — which feels like the right&lt;br&gt;
amount of friction for something going to customers. Or keep working files&lt;br&gt;
and shippable files in separate directories so the build only ever sees the&lt;br&gt;
clean one and there's nothing to exclude.&lt;/p&gt;

&lt;p&gt;I haven't restructured mine yet, honestly. What I did add is a check that&lt;br&gt;
looks at the finished zip instead of trusting the step that made it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Refuse to ship internal files&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;if unzip -l $GITHUB_REF_NAME.zip | grep -Ei 'source-material|SANITIZATION'; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "Internal file found in the release zip. Refusing to publish." &amp;gt;&amp;amp;2&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines, and a failed build is a much cheaper way to find out than a&lt;br&gt;
customer email.&lt;/p&gt;

&lt;p&gt;If you want to know what your own release actually contains, don't read&lt;br&gt;
the workflow, read the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unzip &lt;span class="nt"&gt;-l&lt;/span&gt; dist/whatever-you-ship.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it against the last release you cut rather than the next one. I went&lt;br&gt;
in expecting a tidy list of product files and about a third of the way&lt;br&gt;
down there was a file I'd written strictly for myself.&lt;/p&gt;

&lt;p&gt;Packaging steps get written early, when the folder has three files in it&lt;br&gt;
and the whole thing is obviously fine, and then they sit there unchanged&lt;br&gt;
while the folder fills up with the debris of actually doing the work.&lt;br&gt;
Nobody re-reads a build step that's never failed. The question isn't&lt;br&gt;
whether your build is correct, it's what your build includes today. Those&lt;br&gt;
had the same answer for me in April and a different one in July.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>devops</category>
      <category>cicd</category>
      <category>lessons</category>
    </item>
    <item>
      <title>y Claude Code safety hook wasn't blocking anything</title>
      <dc:creator>Ashton Du Toit</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:31:28 +0000</pubDate>
      <link>https://dev.to/ashton_dutoit_c99f445ceb/y-claude-code-safety-hook-wasnt-blocking-anything-5aag</link>
      <guid>https://dev.to/ashton_dutoit_c99f445ceb/y-claude-code-safety-hook-wasnt-blocking-anything-5aag</guid>
      <description>&lt;p&gt;I set up a hook in Claude Code a while back so it couldn't run anything&lt;br&gt;
destructive on my repo. Nine lines of bash, a list of patterns, exit 2 to&lt;br&gt;
block. Tested it once, saw it block, felt good about myself, forgot about&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;Found out two months later it had been letting everything through.&lt;/p&gt;

&lt;p&gt;The hook itself:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="o"&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="nv"&gt;CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&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; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"import sys,json;print(json.load(sys.stdin) &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  .get('tool_input',{}).get('command',''))"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;BLOCKLIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'rm -rf /|rm -rf ~|git push --force|chmod -R 777'&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&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;-qE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BLOCKLIST&lt;/span&gt;&lt;span class="s2"&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: matches destructive pattern."&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;Claude Code hands the hook the tool call as JSON on stdin, so that python&lt;br&gt;
one-liner is just pulling the command string out so grep can look at it.&lt;br&gt;
Exit 2 blocks, exit 0 lets it run. Reads fine.&lt;/p&gt;

&lt;p&gt;I'm on Windows, and &lt;code&gt;python3&lt;/code&gt; on Windows isn't necessarily Python. There's&lt;br&gt;
an app execution alias that ships with Windows 10/11 which exists to open&lt;br&gt;
the Microsoft Store when someone types &lt;code&gt;python3&lt;/code&gt; and hasn't installed&lt;br&gt;
anything. I &lt;em&gt;did&lt;/em&gt; have real Python, from python.org, and it didn't matter,&lt;br&gt;
because the alias sits in &lt;code&gt;WindowsApps&lt;/code&gt; near the front of PATH and it&lt;br&gt;
claims the name &lt;code&gt;python3&lt;/code&gt; specifically. Plain &lt;code&gt;python&lt;/code&gt; went to my actual&lt;br&gt;
install. &lt;code&gt;python3&lt;/code&gt; went to the stub.&lt;/p&gt;

&lt;p&gt;So the stub gets my JSON, does nothing useful with it, &lt;code&gt;CMD&lt;/code&gt; ends up as an&lt;br&gt;
empty string, grep finds nothing in an empty string, script falls through&lt;br&gt;
to &lt;code&gt;exit 0&lt;/code&gt;, and Claude Code reads exit 0 as the hook saying go ahead.&lt;/p&gt;

&lt;p&gt;Every dangerous command was being explicitly approved by the thing I wrote&lt;br&gt;
to stop dangerous commands.&lt;/p&gt;

&lt;p&gt;What got me is that it never looked broken. If it had crashed I'd have&lt;br&gt;
caught it in a day. Instead it ran, exited clean, printed nothing, and a&lt;br&gt;
hook that says "all clear" is indistinguishable from a hook that works&lt;br&gt;
unless you go looking.&lt;/p&gt;

&lt;p&gt;Fix was one word:&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;&lt;span class="nb"&gt;echo&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; | python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"import sys,json;print(json.load(sys.stdin) &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  .get('tool_input',{}).get('command',''))"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also just turn the aliases off in Settings → Apps → Advanced app&lt;br&gt;
settings → App execution aliases. I didn't, because the script lives in a&lt;br&gt;
repo and I don't want it depending on a checkbox somewhere in my Windows&lt;br&gt;
settings.&lt;/p&gt;

&lt;p&gt;The word swap fixes my machine but not the actual mistake, which is that&lt;br&gt;
the script's default answer was yes. Empty variable, malformed JSON,&lt;br&gt;
Python missing entirely, tool payload changing shape in some future&lt;br&gt;
version — all of those roads lead to &lt;code&gt;exit 0&lt;/code&gt;. It should refuse when it&lt;br&gt;
doesn't understand the question:&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;&lt;span class="nb"&gt;echo&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; | python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"import sys,json;print(json.load(sys.stdin) &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  .get('tool_input',{}).get('command',''))"&lt;/span&gt;&lt;span class="si"&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="s2"&gt;"Guard could not parse tool input — blocking."&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A guard that blocks too much annoys you by lunchtime and you fix it. A&lt;br&gt;
guard that allows too much just sits there.&lt;/p&gt;

&lt;p&gt;If you've got hooks, don't read them, run them. Reading mine is exactly&lt;br&gt;
what I did and it looked correct:&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":"rm -rf /"}}'&lt;/span&gt; | bash .claude/hooks/guard.sh
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit code: &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;Should be exit 2 and a message on stderr. If you get 0, it's decoration.&lt;/p&gt;

&lt;p&gt;Do that from a normal terminal, not through Claude Code, or your live hook&lt;br&gt;
sees the &lt;code&gt;rm -rf /&lt;/code&gt; sitting in the test payload and blocks the test itself.&lt;br&gt;
Took me a second to work out what was happening there.&lt;/p&gt;

&lt;p&gt;And the broader Windows version, for anything of yours that shells out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;which python3 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"print('real python')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing, or a &lt;code&gt;WindowsApps&lt;/code&gt; path with no output after it, means every&lt;br&gt;
script you've got calling &lt;code&gt;python3&lt;/code&gt; has been quietly handing back empty&lt;br&gt;
strings.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>microsoft</category>
      <category>bash</category>
      <category>devtools</category>
    </item>
  </channel>
</rss>
