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