<rss version="2.0">
  <channel>
    <title>Shell on LLBBL Blog</title>
    <link>https://llbbl.blog/categories/shell/</link>
    <description></description>
    
    <language>en</language>
    
    <lastBuildDate>Tue, 01 Sep 2026 10:00:00 -0500</lastBuildDate>
    
    <item>
      <title>I Wrote 993 Lines of Tests for a Shell Script, Then Deleted the Script</title>
      <link>https://llbbl.blog/2026/09/01/i-wrote-lines-of-tests.html</link>
      <pubDate>Tue, 01 Sep 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/09/01/i-wrote-lines-of-tests.html</guid>
      <description>&lt;p&gt;My MCP server has a &lt;code&gt;just mcp-install&lt;/code&gt; recipe that prints a &lt;code&gt;claude mcp add ...&lt;/code&gt; command you paste into a terminal. It reads your &lt;code&gt;.env&lt;/code&gt; and emits one &lt;code&gt;--env NAME=value&lt;/code&gt; pair per line. Above that block sits a banner:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Secret values are redacted below — substitute them by hand.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The thing doing the redacting was a single &lt;code&gt;sed&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;sed -E &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;s/^(EMBED_API_KEY|TEI_API_KEY|NEO4J_PASSWORD)=.*/\1=&amp;lt;redacted&amp;gt;/&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Three names on an allowlist. Everything else prints in full, under a banner that promises otherwise.&lt;/p&gt;
&lt;p&gt;It took me about &lt;strong&gt;19 hours&lt;/strong&gt; to go from noticing that to deleting the entire replacement I&amp;rsquo;d built. Here&amp;rsquo;s the trip.&lt;/p&gt;
&lt;h2 id=&#34;an-allowlist-fails-open-and-mine-had-already-failed-twice&#34;&gt;An allowlist fails open, and mine had already failed twice&lt;/h2&gt;
&lt;p&gt;The problem with an allowlist for secrets isn&amp;rsquo;t theoretical. It&amp;rsquo;s that the default is &lt;em&gt;print the value&lt;/em&gt;, so every new credential leaks until somebody remembers to extend the list.&lt;/p&gt;
&lt;p&gt;Mine had already broken twice, and I only worked that out while writing the fix. First, I renamed the &lt;code&gt;TEI_*&lt;/code&gt; env vars to &lt;code&gt;EMBED_*&lt;/code&gt; and left the allowlist matching &lt;code&gt;TEI_API_KEY&lt;/code&gt;, a name that no longer existed. Second, the grep feeding the sed was &lt;code&gt;^[A-Z_]+=&lt;/code&gt; with no digits in the character class, which silently dropped every &lt;code&gt;NEO4J_*&lt;/code&gt; row before the sed ever saw it. So the &lt;code&gt;NEO4J_PASSWORD&lt;/code&gt; arm of that allowlist was unreachable for its entire existence. It never redacted anything. It just sat there looking reassuring.&lt;/p&gt;
&lt;p&gt;Switching to a pattern match takes ten seconds:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;sed -E &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;s/^([A-Z0-9_]*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*)=.*/\1=&amp;lt;redacted&amp;gt;/&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I checked it against a synthetic &lt;code&gt;.env&lt;/code&gt; full of sentinel values. &lt;strong&gt;7 of 7&lt;/strong&gt; secrets redacted, including &lt;code&gt;OAUTH2_TOKEN&lt;/code&gt;, &lt;code&gt;S3_SECRET_KEY&lt;/code&gt; and &lt;code&gt;EMBED_API_KEY2&lt;/code&gt;, all three of which printed in cleartext before. All &lt;strong&gt;10&lt;/strong&gt; non-secret variables kept their values, which matters, because the emitted command is useless without &lt;code&gt;QDRANT_HOST&lt;/code&gt; and &lt;code&gt;EMBED_MODEL&lt;/code&gt; intact.&lt;/p&gt;
&lt;p&gt;Over-redacting a non-secret costs the user a hand-edit. Under-redacting a real one costs them a rotated key. Pick the direction you fail in.&lt;/p&gt;
&lt;h2 id=&#34;then-i-gave-it-tests-and-the-tests-got-mean&#34;&gt;Then I gave it tests, and the tests got mean&lt;/h2&gt;
&lt;p&gt;A security-relevant expression inlined in a &lt;code&gt;just&lt;/code&gt; recipe has no test coverage by construction. So I pulled it into &lt;code&gt;scripts/render-mcp-env.sh&lt;/code&gt; (&lt;strong&gt;73 lines&lt;/strong&gt;) and wrote &lt;code&gt;scripts/render_mcp_env_test.go&lt;/code&gt; (&lt;strong&gt;352 lines&lt;/strong&gt;) to drive the script through &lt;code&gt;os/exec&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The tests immediately found things the sed one-liner couldn&amp;rsquo;t have handled.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Credentials hiding in values, not names.&lt;/strong&gt; The rule only ever inspected the variable &lt;em&gt;name&lt;/em&gt;. A value like &lt;code&gt;bolt://neo4j:hunter2@host&lt;/code&gt; matches no keyword, so it printed in full. &lt;code&gt;NEO4J_URL&lt;/code&gt; and &lt;code&gt;DATABASE_URL&lt;/code&gt; are the obvious cases, and they&amp;rsquo;re exactly the vars people paste into chat.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;PAT&lt;/code&gt; is a trap.&lt;/strong&gt; Adding it for personal access tokens also swallows &lt;code&gt;PATH&lt;/code&gt;, &lt;code&gt;GOPATH&lt;/code&gt;, &lt;code&gt;CONFIG_PATH&lt;/code&gt;, &lt;code&gt;LOG_PATTERN&lt;/code&gt; and &lt;code&gt;COMPATIBILITY_MODE&lt;/code&gt;. A &lt;code&gt;*_PATH&lt;/code&gt; value is one of the things the command needs to keep. It now matches only as a whole underscore-delimited segment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And my redaction broke the output it was redacting.&lt;/strong&gt; This one&amp;rsquo;s my favorite. The emitted line was unquoted, so:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;--env FOO=&amp;lt;redacted&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;parses in a shell as the word &lt;code&gt;FOO=&lt;/code&gt; plus a redirection from a file named &lt;code&gt;redacted&lt;/code&gt;. The block failed to paste correctly whenever a secret was present, which is to say for every actual user. The feature that existed to protect people was the feature that broke the thing for them. &lt;a href=&#34;https://www.shellcheck.net/wiki/SC2086&#34;&gt;ShellCheck flags exactly this&lt;/a&gt; and I&amp;rsquo;d have caught it a week earlier if the logic had been in a file a linter could see, instead of hidden in a recipe body.&lt;/p&gt;
&lt;p&gt;Fixing the quoting meant leaving sed behind. Per-character shell quoting isn&amp;rsquo;t expressible in portable sed, so the engine became &lt;code&gt;grep | awk&lt;/code&gt;. I kept the awk POSIX-only and checked it byte-for-byte under mawk (what CI&amp;rsquo;s Ubuntu runner ships), gawk, and &lt;code&gt;gawk --posix&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The script was now &lt;strong&gt;238 lines&lt;/strong&gt;. The test file was &lt;strong&gt;993&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&#34;the-tests-were-right-and-the-script-was-wrong&#34;&gt;The tests were right and the script was wrong&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s what those 993 lines were actually telling me, once I stopped admiring them.&lt;/p&gt;
&lt;p&gt;The awk had to parse &lt;code&gt;.env&lt;/code&gt; to find names and values. The Go binary parses the same file with &lt;a href=&#34;https://github.com/joho/godotenv&#34;&gt;godotenv&lt;/a&gt; v1.5.1. Two parsers, one file, and nobody checking they agreed.&lt;/p&gt;
&lt;p&gt;They didn&amp;rsquo;t. Six shapes, each one verified rather than assumed:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;$VAR&lt;/code&gt; and &lt;code&gt;${VAR}&lt;/code&gt; expansion&lt;/li&gt;
&lt;li&gt;Whitespace trimming&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NAME=&lt;/code&gt; with an empty value, emitted by one and dropped by the other&lt;/li&gt;
&lt;li&gt;Names containing a &lt;code&gt;.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Indented assignments&lt;/li&gt;
&lt;li&gt;&lt;code&gt;#&lt;/code&gt; inline comments&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Every one of those is a case where the command I told you to paste differs from what the server actually reads. That&amp;rsquo;s a worse bug than the leak, because it&amp;rsquo;s silent and it looks like it worked.&lt;/p&gt;
&lt;p&gt;So I deleted it. The &lt;strong&gt;238-line&lt;/strong&gt; script and its &lt;strong&gt;993-line&lt;/strong&gt; test both went in a single commit, replaced by &lt;code&gt;internal/envblock&lt;/code&gt; at &lt;strong&gt;134 lines&lt;/strong&gt; with an &lt;strong&gt;868-line&lt;/strong&gt; test, called from the Justfile as:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;go run ./cmd/mem0-mcp --print-env-block .env
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The binary already links godotenv. Now the pasted block can&amp;rsquo;t drift from the server&amp;rsquo;s own parsing, because there&amp;rsquo;s one parser.&lt;/p&gt;
&lt;p&gt;The classifier also got simpler in a way that matters. It&amp;rsquo;s &lt;code&gt;strings.Contains&lt;/code&gt; over the uppercased name and nothing else:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;func&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;isSecretName&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;name&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;string&lt;/span&gt;) &lt;span style=&#34;color:#66d9ef&#34;&gt;bool&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;	&lt;span style=&#34;color:#a6e22e&#34;&gt;upper&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;:=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;strings&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;ToUpper&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;name&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;	&lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;_&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;keyword&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;:=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;range&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;secretKeywords&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;		&lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;strings&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Contains&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;upper&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;keyword&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;			&lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;		}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;	}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;	&lt;span style=&#34;color:#75715e&#34;&gt;// PAT only as a whole underscore-delimited segment, or it would swallow&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;	&lt;span style=&#34;color:#75715e&#34;&gt;// PATH, LOG_PATTERN, COMPATIBILITY_MODE and friends.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;	&lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;strings&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Contains&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_&amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;upper&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_PAT_&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No regexp is reachable from it, on purpose. An anchored or whole-name match classifies &lt;code&gt;MY.API_KEY&lt;/code&gt; as non-secret and prints it in the clear, which was a real bug in the awk version. You can&amp;rsquo;t write that mistake in this shape.&lt;/p&gt;
&lt;p&gt;The old test suite had a guard that scanned the script&amp;rsquo;s &lt;em&gt;source&lt;/em&gt; for forbidden patterns. The replacement is a behavioural test, and it&amp;rsquo;s stronger: it caught a deliberately mutated classifier with a hidden regexp by finding a sentinel value leaking into fixture output. Same evasion class, caught by observing output instead of reading code.&lt;/p&gt;
&lt;h2 id=&#34;the-part-that-made-me-laugh&#34;&gt;The part that made me laugh&lt;/h2&gt;
&lt;p&gt;I added a ShellCheck CI job, with a &lt;code&gt;lint-shell.sh&lt;/code&gt; that hard-fails if it discovers fewer than 4 shell scripts, so nobody can quietly delete one past the linter.&lt;/p&gt;
&lt;p&gt;I deleted one of the scripts it was guarding, and had to lower the floor from 4 to 3. There are three &lt;code&gt;.sh&lt;/code&gt; files in &lt;code&gt;scripts/&lt;/code&gt; now, and the default sits at &lt;code&gt;min_scripts=&amp;quot;${MEM0_MIN_SHELL_SCRIPTS-3}&amp;quot;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Five hours from &amp;ldquo;protect these scripts&amp;rdquo; to &amp;ldquo;one fewer script to protect.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;I don&amp;rsquo;t think the shell version was wasted. I couldn&amp;rsquo;t have argued for the Go rewrite on day one, because &amp;ldquo;awk might diverge from godotenv&amp;rdquo; is a hunch. It only became an argument once I&amp;rsquo;d written enough tests to enumerate six specific divergences and point at them. The tests didn&amp;rsquo;t make the script correct. They made the case for its deletion, which was the more useful outcome.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;ve got security-relevant logic inlined in a Makefile, a Justfile, or a CI step, that&amp;rsquo;s the same shape my bug was. Nothing lints it, nothing tests it, and it fails open. Pull it into a file first. You might end up deleting the file, and that&amp;rsquo;s a fine place to land.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/joho/godotenv&#34;&gt;godotenv&lt;/a&gt; — the Go dotenv parser the server links, &lt;a href=&#34;https://github.com/joho/godotenv/releases/tag/v1.5.1&#34;&gt;v1.5.1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.shellcheck.net/wiki/SC2086&#34;&gt;ShellCheck SC2086&lt;/a&gt; — unquoted expansion, word splitting and redirection&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.shellcheck.net/&#34;&gt;ShellCheck&lt;/a&gt; — the linter itself&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://pubs.opengroup.org/onlinepubs/9699919799/utilities/awk.html&#34;&gt;POSIX awk specification&lt;/a&gt; — the subset I held the script to for mawk/gawk parity&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html&#34;&gt;POSIX shell command language&lt;/a&gt; — quoting and redirection rules behind the &lt;code&gt;&amp;lt;redacted&amp;gt;&lt;/code&gt; bug&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
    </item>
    
  </channel>
</rss>