<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Llm on Lessons learned</title><link>https://eghuro.cz/tags/llm/</link><description>Recent content in Llm on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Sat, 17 Oct 2026 22:30:00 +0200</lastBuildDate><atom:link href="https://eghuro.cz/tags/llm/index.xml" rel="self" type="application/rss+xml"/><item><title>"No route to host" only for the AI agent: macOS Local Network privacy</title><link>https://eghuro.cz/posts/macos-local-network-privacy-agent-no-route-to-host/</link><pubDate>Sat, 17 Oct 2026 22:30:00 +0200</pubDate><guid>https://eghuro.cz/posts/macos-local-network-privacy-agent-no-route-to-host/</guid><description>&lt;p&gt;My coding agent couldn&amp;rsquo;t reach anything on my home network:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nc: connectx to 192.168.0.2 port 22 (tcp) failed: No route to host&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ping: sendto: No route to host&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The router answered, and &lt;code&gt;arp -an&lt;/code&gt; listed the server&amp;rsquo;s MAC address. In my own Terminal, the same &lt;code&gt;ssh&lt;/code&gt; command connected. If you see this on a Mac and only your agent sees it, your network is fine. macOS is blocking the app the agent runs in because that app doesn&amp;rsquo;t have the Local Network permission.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS 27 on Wi-Fi&lt;/li&gt;&#10;&lt;li&gt;Claude Code in its VS Code extension, so every command the agent runs is a child process of VS Code&lt;/li&gt;&#10;&lt;li&gt;Targets on the Mac&amp;rsquo;s own subnet: a Linux server, a NAS, a second router&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The agent was helping me with a server that really was down (&lt;a&#10; href="https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;that story is here&lt;/a&gt;&#10;). It probed the subnet from the Mac, found that only the router answered, and reported with confidence that &amp;ldquo;the Mac reaching only [the router] looks like client isolation on the Wi-Fi. That&amp;rsquo;s usually a guest network.&amp;rdquo; It told me to join a different network.&lt;/p&gt;&#10;&lt;p&gt;I wasn&amp;rsquo;t on a guest network, and I said so. The agent looked again and noticed what rules the network out: &lt;code&gt;ping&lt;/code&gt; itself failed with &lt;code&gt;sendto: No route to host&lt;/code&gt;. That message comes from the local &lt;code&gt;sendto()&lt;/code&gt; call, so the Mac refused to send the packet at all. Client isolation drops packets in the access point, and then &lt;code&gt;ping&lt;/code&gt; reports &lt;code&gt;Request timeout&lt;/code&gt;, not a send error. The target&amp;rsquo;s MAC was already in the ARP table, so the kernel knew exactly where to send it.&lt;/p&gt;&#10;&lt;p&gt;So the block was at the MAC, and every TCP test the agent ran from there was worthless.&lt;/p&gt;&#10;&lt;p&gt;Later that evening, in a new session, it happened again with a different host on the same subnet. This time the agent suspected its own sandbox and retried with the sandbox off. Same error. What settled it was my reply: the same &lt;code&gt;ssh&lt;/code&gt; command works in my Terminal.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Open System Settings → Privacy &amp;amp; Security → Local Network and turn on Visual Studio Code (or whichever app hosts your agent). The agent&amp;rsquo;s next &lt;code&gt;ssh&lt;/code&gt; went through. If yours still fails, restart the app.&lt;/p&gt;&#10;&lt;p&gt;The app can be missing from the list if macOS never showed it the permission prompt. Apple&amp;rsquo;s &lt;a&#10; href="https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;TN3179&lt;/a&gt;&#10; notes that the prompt doesn&amp;rsquo;t appear when a very short-lived process makes the request, and most agent commands exit right after they fail. Since macOS 27.2, the list includes a + button to add an app yourself.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-only-the-agent"&gt;Why only the agent &lt;/h2&gt;&#10;&lt;p&gt;Local Network privacy covers traffic to addresses on a Wi-Fi or Ethernet network that goes directly to the host, not through a router. TN3179 lists who gets access without asking:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;daemons started by &lt;code&gt;launchd&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;programs running as root&lt;/li&gt;&#10;&lt;li&gt;&amp;ldquo;command-line tools run from Terminal or over SSH, including any child processes they spawn&amp;rdquo;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Everything else is charged to its &amp;ldquo;responsible code&amp;rdquo;. If an app spawns a helper and the helper connects to a local address, macOS checks the app&amp;rsquo;s permission. The agent&amp;rsquo;s shell is a child of VS Code, so &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;nc&lt;/code&gt;, and &lt;code&gt;ping&lt;/code&gt; run there under VS Code&amp;rsquo;s permission. The same binary, run by the same user from Terminal, is allowed.&lt;/p&gt;&#10;&lt;p&gt;That explains the other half of the symptom. Internet traffic goes through the router, so it isn&amp;rsquo;t local and works fine. TN3179 also exempts the device&amp;rsquo;s DNS server when it sits on the local network, and a home router is usually the DNS server it hands out by DHCP. That is probably why the router was the one host still reachable, and it is exactly how client isolation looks from the inside.&lt;/p&gt;&#10;&lt;p&gt;The same trap waits for any agent or tool that an app such as an IDE starts on macOS.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-before-you-blame-the-network"&gt;Check before you blame the network &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Run &lt;code&gt;ping&lt;/code&gt; from the agent&amp;rsquo;s shell. &lt;code&gt;sendto: No route to host&lt;/code&gt; for a host on your subnet whose MAC is in &lt;code&gt;arp -an&lt;/code&gt; means the Mac refused to send it.&lt;/li&gt;&#10;&lt;li&gt;Run the same command in Terminal. If it works there, check the app&amp;rsquo;s Local Network permission.&lt;/li&gt;&#10;&lt;li&gt;If it fails in Terminal too, the cause is the network or the routing table. Later that same evening, another VPN came up as &lt;code&gt;utun6&lt;/code&gt; and took the default route, which cut the whole Mac off from the subnet. This time the agent caught it in &lt;code&gt;netstat -rn -f inet&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On macOS, an app without the Local Network permission gets &lt;code&gt;No route to host&lt;/code&gt; for hosts on your subnet, and so does every tool it spawns. An agent running inside that app will diagnose a working network as broken.&lt;/p&gt;&#10;&lt;p&gt;If your agent runs inside an app on macOS, open Privacy &amp;amp; Security → Local Network now and check that the app is on the list and switched on.&lt;/p&gt;&#10;</description></item><item><title>"Ansible requires blocking IO on stdin/stdout/stderr" when an AI agent runs Ansible</title><link>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</link><pubDate>Tue, 13 Oct 2026 22:34:00 +0200</pubDate><guid>https://eghuro.cz/posts/ansible-requires-blocking-io-agent-shell/</guid><description>&lt;p&gt;The first time my coding agent ran Ansible, every command failed before Ansible had read a single file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR: Ansible requires blocking IO on stdin/stdout/stderr. Non-blocking file handles detected: &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--syntax-check&lt;/code&gt; too, and even &lt;code&gt;ansible --version&lt;/code&gt;. If you see this from an agent, a CI job, or a wrapper script, Ansible isn&amp;rsquo;t broken. The process that started it handed it non-blocking file handles, and a few shell redirections replace them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;ansible-core 2.20 (Ansible 13.3.0 from Homebrew) on macOS&lt;/li&gt;&#10;&lt;li&gt;A coding agent (Claude Code in VS Code) that runs commands in its own shell and captures their output&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The check has been in every Ansible CLI entry point since ansible-core 2.14 (2022).&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;O_NONBLOCK is a flag on the open file, not on the process, so every child that inherits the file descriptor inherits the flag too. The agent&amp;rsquo;s harness connects the shell&amp;rsquo;s stdout and stderr to non-blocking pipes, and the shell passes them on unchanged to &lt;code&gt;ansible-playbook&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;At startup, Ansible calls &lt;code&gt;os.get_blocking()&lt;/code&gt; on each of the three handles and exits if any of them is non-blocking. The list at the end of the message names the handles that failed. That makes it a good diagnostic: the first failure in my session listed only &lt;code&gt;&amp;lt;stderr&amp;gt;&lt;/code&gt;, because the command was &lt;code&gt;ansible --version | head -3&lt;/code&gt;. The pipe to &lt;code&gt;head&lt;/code&gt; had already given stdout a fresh, blocking handle, while stderr still pointed at the agent&amp;rsquo;s pipe.&lt;/p&gt;&#10;&lt;p&gt;Run this in the shell you suspect to see which handles are affected:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 -c &lt;span class="s1"&gt;&amp;#39;import os; print([os.get_blocking(fd) for fd in (0, 1, 2)])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;False&lt;/code&gt; in any position means Ansible will refuse to start there.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give Ansible three handles the shell creates itself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;| cat&lt;/code&gt; gives stdout a new pipe, which is blocking.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; sends stderr into that same pipe. &lt;code&gt;| cat&lt;/code&gt; alone fixes only stdout.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;&amp;lt;/dev/null&lt;/code&gt; replaces stdin. My agent&amp;rsquo;s stdin was never flagged, but Ansible checks it too, and an agent can&amp;rsquo;t answer a prompt anyway.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The pipe has a side effect: the command&amp;rsquo;s exit status is now &lt;code&gt;cat&lt;/code&gt;&amp;rsquo;s, which is 0. A failed playbook looks like a success unless you turn on &lt;code&gt;pipefail&lt;/code&gt; (bash and zsh both have it):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -o pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ansible-playbook site.yml &amp;lt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To check the fix without touching your real shell, you can make all three handles non-blocking for a single command and try it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;NB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;import os,sys; [os.set_blocking(fd, False) for fd in (0,1,2)]; os.execvp(sys.argv[1], sys.argv[1:])&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; ansible --version&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ERROR: ... Non-blocking file handles detected: &amp;lt;stdin&amp;gt;, &amp;lt;stdout&amp;gt;, &amp;lt;stderr&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;python3 -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NB&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; sh -c &lt;span class="s1"&gt;&amp;#39;ansible --version &amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &amp;lt;/dev/zero &lt;span class="p"&gt;|&lt;/span&gt; cat&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ansible [core 2.20.1] ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The outer &lt;code&gt;| cat&lt;/code&gt; and &lt;code&gt;&amp;lt;/dev/zero&lt;/code&gt; keep the flag on throwaway handles, so your terminal stays blocking.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-ansible-insists"&gt;Why Ansible insists &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://github.com/ansible/ansible/pull/77639"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;pull request that added the check&lt;/a&gt;&#10; explains it. On a non-blocking stdout, a large write can fail partway with &lt;code&gt;[Errno 11] write could not complete without blocking&lt;/code&gt;, and Ansible&amp;rsquo;s output gets cut off, or the run dies with an &amp;ldquo;Unexpected Exception&amp;rdquo;. The check turns those intermittent failures into one clear error at startup. The PR names another way to end up there: &lt;code&gt;ssh&lt;/code&gt; switches its handles to non-blocking while it runs and doesn&amp;rsquo;t always switch them back when it exits.&lt;/p&gt;&#10;&lt;p&gt;So the same error can turn up wherever a parent process hands down non-blocking pipes, CI runners included, or in a terminal after an &lt;code&gt;ssh&lt;/code&gt; that didn&amp;rsquo;t clean up.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-agent-remember-it"&gt;Make the agent remember it &lt;/h2&gt;&#10;&lt;p&gt;The agent worked this out within two minutes, and later that night it wrote the fix into the project&amp;rsquo;s skill for provisioning VMs:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Run Ansible from Claude Code with &lt;span class="sb"&gt;`&amp;lt;/dev/null`&lt;/span&gt; and pipe its output through &lt;span class="sb"&gt;`cat`&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The next morning, a session about a different service ran &lt;code&gt;ansible-playbook --syntax-check&lt;/code&gt; without the redirections and hit the same error. It never loaded the VM skill, so it never saw the line. A rule about how to run a tool belongs in the file the agent reads in every session, not in the instructions for one task.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&amp;ldquo;Ansible requires blocking IO&amp;rdquo; means the process that started Ansible handed it non-blocking file handles. &lt;code&gt;&amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | cat&lt;/code&gt; gives it blocking ones, and &lt;code&gt;set -o pipefail&lt;/code&gt; keeps a failed run from looking like a success.&lt;/p&gt;&#10;&lt;p&gt;If your agent runs Ansible, put that line in the instructions file it reads every session (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;).&lt;/p&gt;&#10;</description></item><item><title>Cyber classifiers can interrupt offline pentest summaries</title><link>https://eghuro.cz/posts/agent-security-transcripts-need-provenance-and-checkpoints/</link><pubDate>Fri, 09 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/agent-security-transcripts-need-provenance-and-checkpoints/</guid><description>&lt;p&gt;An agent spent three minutes reading local transcripts from a finished, authorized security exercise, then stopped with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;This request triggered cyber-related safeguards.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No live target was involved. The job was offline summarization, yet the classifier interrupted it before it could write the requested report. The practical fix was to treat security-history analysis as a resumable data pipeline: retain message provenance, checkpoint extracted evidence, and keep the handoff usable by another approved model or provider.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-flag-shaped-string-is-not-a-captured-flag"&gt;A flag-shaped string is not a captured flag &lt;/h2&gt;&#10;&lt;p&gt;The first problem appeared before the classifier fired. A search through about 25 request dumps found dozens of flag-shaped strings. About 20 were spelling, capitalization, spacing, or hyphenation variants of one value.&lt;/p&gt;&#10;&lt;p&gt;Most variants appeared only in assistant reasoning and progress notes. The model repeatedly retyped a remembered value and changed it. A checkpoint then copied one mutation into later sessions.&lt;/p&gt;&#10;&lt;p&gt;Only strings preserved in tool results represented observed server output. To separate evidence from generation, use this origin-based distinction:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;user request instruction or supplied context&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;assistant text generated claim; unverified&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;assistant reasoning generated hypothesis; unverified&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tool call attempted action&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tool result observed output from that action&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;provider error evidence about the run, not the target&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This applies beyond CTF flags. Treat IDs, hashes, error codes, hostnames, and quoted responses in model prose as leads until the first tool result corroborates them. Exact-looking text is still generated text.&lt;/p&gt;&#10;&#10;&lt;h2 id="offline-processing-still-triggers-cyber-classifiers"&gt;Offline processing still triggers cyber classifiers &lt;/h2&gt;&#10;&lt;p&gt;The retained run used Opus 5.5. It parsed local request dumps, separated real and decoy-looking values, and extracted an existing SMB investigation summary. After that, the next model response was refused under the provider&amp;rsquo;s real-time cyber safeguard. The session recorded a &lt;code&gt;cyber&lt;/code&gt; refusal and pointed to its Cyber Verification Program. The output file did not exist afterward.&lt;/p&gt;&#10;&lt;p&gt;Some source dumps also contained earlier &lt;code&gt;cyber_policy&lt;/code&gt; failures from another provider along with a prompt to apply for Daybreak access. Because these records were historical input, they still increased the concentration of offensive-security language the summarizer saw.&lt;/p&gt;&#10;&lt;p&gt;A related authorized test had a similar shape: generate enough traffic against a non-production environment to trigger a WAF, then verify that the application displays and retries a CAPTCHA correctly. In a newer Opus session, the controlled load-test work was refused. The retained successful session used Opus 4.6 and built the test client, while a later session corrected its cookie handling and the request used to observe the challenge.&lt;/p&gt;&#10;&lt;p&gt;The awkward lesson is that task intent and classifier input are different things. In practice, &amp;ldquo;Summarize these old logs&amp;rdquo; can contain exploit commands, credentials, flags, and attack narratives. Likewise, &amp;ldquo;Test the CAPTCHA&amp;rdquo; can require traffic that resembles denial of service when reduced to request counts.&lt;/p&gt;&#10;&#10;&lt;h2 id="make-the-audit-resumable-and-model-neutral"&gt;Make the audit resumable and model-neutral &lt;/h2&gt;&#10;&lt;p&gt;Do deterministic work before asking a model to synthesize:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Parse each transcript into typed events and preserve the source file, timestamp, role, tool-call ID, and tool-result ID.&lt;/li&gt;&#10;&lt;li&gt;Extract exact-match artifacts only from tool results. Store assistant claims separately as unverified leads.&lt;/li&gt;&#10;&lt;li&gt;Write incremental JSON or Markdown after each source file. Never leave the only useful state in the model&amp;rsquo;s context window.&lt;/li&gt;&#10;&lt;li&gt;Produce a sanitized handoff containing the authorization scope, completed files, pending questions, and evidence pointers.&lt;/li&gt;&#10;&lt;li&gt;Verify the final report exists and reconcile its artifact counts against the extraction index.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Provider access programs are worth the setup when this work recurs. In my experience, GPT Daybreak Blue justified the hassle for authorized security-related tasks. Kimi, GLM, and older model versions have also completed useful work when a newer classifier blocked the same class of jobs. That is a fallback, not a bypass: preserve the same authorization boundary, do not weaken the requested scope, and use models approved for the work.&lt;/p&gt;&#10;&lt;p&gt;Before trusting your next transcript summary, choose one exact token from the report and trace it back to the tool result that observed it.&lt;/p&gt;&#10;</description></item><item><title>Prompt injection: extract tokens, then decode them offline</title><link>https://eghuro.cz/posts/prompt-injection-extract-tokens-decode-offline/</link><pubDate>Tue, 06 Oct 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/prompt-injection-extract-tokens-decode-offline/</guid><description>&lt;p&gt;A model can repeat hidden, encoded prompt fragments and still produce a different decoded secret on each attempt. When an authorized prompt-injection test reaches that point, stop asking the model to solve its own leak. Capture the raw tokens with position and case intact, then decode them with deterministic code offline.&lt;/p&gt;&#10;&lt;p&gt;This separates two questions: can the model disclose protected context, and can a decoder reconstruct the encoded value? A fluent final answer is poor evidence for either one.&lt;/p&gt;&#10;&#10;&lt;h2 id="lexical-refusal-filters-leave-a-semantic-gap"&gt;Lexical refusal filters leave a semantic gap &lt;/h2&gt;&#10;&lt;p&gt;A filter that rejects phrases such as &amp;ldquo;show the system prompt&amp;rdquo; or &amp;ldquo;reveal the secret&amp;rdquo; checks wording, not intent. Semantically equivalent requests can ask the model to quote, classify, count, or list text from its instructions without using the blocked nouns. If the model can return those fragments, the control has failed even when the obvious request is refused.&lt;/p&gt;&#10;&lt;p&gt;For an authorized assessment, use a synthetic canary in a test system. Ask for a numbered transcription of the relevant tokens, one token per line, with original capitalization. Repeat individual positions and compare exact strings. Keep the transcript as evidence. Do not put a real credential in the prompt merely to test whether it leaks.&lt;/p&gt;&#10;&lt;p&gt;Do not ask the model to join, normalize, or decode the result. Those extra transformations invite changed case, missing groups, repaired spelling, and invented separators. Temperature zero may reduce variation, but it does not turn generation into a parser.&lt;/p&gt;&#10;&#10;&lt;h2 id="decode-a-synthetic-transcript-deterministically"&gt;Decode a synthetic transcript deterministically &lt;/h2&gt;&#10;&lt;p&gt;Suppose a test prompt encodes characters as phonetic words. Capitalized words mean uppercase letters, lowercase words mean lowercase letters, and number words mean digits. Save this offline decoder as &lt;code&gt;decode.py&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NUMBERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;zero&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;one&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;two&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;three&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;four&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;4&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;five&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;5&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;six&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;6&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;seven&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;7&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;eight&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nine&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;9&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NUMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NUMBERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isalpha&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;letter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;letter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isupper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;letter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;unsupported token: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Alpha&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;bravo&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Charlie&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;delta&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;seven&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AbCd7&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run &lt;code&gt;python3 decode.py&lt;/code&gt;; it prints &lt;code&gt;AbCd7&lt;/code&gt;. The assertion is a negative control against accidental lowercasing or regrouping. Adapt the decoder only from a documented encoding rule. If a separator or group length is unknown, report that ambiguity instead of asking the model to guess.&lt;/p&gt;&#10;&lt;p&gt;Keep an evidence table with the position, raw token, repeated observation, decoded character, and rule used. Hash or otherwise preserve the raw transcript if chain of custody matters. A model-generated summary is not a substitute for the original response.&lt;/p&gt;&#10;&#10;&lt;h2 id="prevent-prompt-disclosure-at-the-trust-boundary"&gt;Prevent prompt disclosure at the trust boundary &lt;/h2&gt;&#10;&lt;p&gt;Keyword filters are a weak secondary control. The primary fix is to keep secrets and privileged instructions out of model-visible context. Give tools short-lived, scoped credentials through an execution boundary that does not echo them into prompts or tool results. Treat retrieved documents, web pages, and user content as untrusted data, not instructions.&lt;/p&gt;&#10;&lt;p&gt;Constrain outputs structurally when the application expects a fixed schema, and validate them outside the model: test semantic paraphrases, indirect requests, translations, and token-by-token extraction against synthetic canaries. Log enough to identify which context source leaked without recording the secret itself.&lt;/p&gt;&#10;&lt;p&gt;A successful refusal to one phrase proves only that one phrase was blocked. Verify that the model cannot disclose the underlying text in pieces.&lt;/p&gt;&#10;</description></item><item><title>Zero failures is suspicious: agent checks that silently ran nothing</title><link>https://eghuro.cz/posts/zero-failures-is-suspicious/</link><pubDate>Tue, 29 Sep 2026 12:05:00 +0200</pubDate><guid>https://eghuro.cz/posts/zero-failures-is-suspicious/</guid><description>&lt;p&gt;My coding agent ran each of the two flaky Rust tests 150 times and got this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;streamed_read_right_after_a_rename: 0/150 failed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;renames_are_combined_across_changes_they_do_not_depend_on: 0/150 failed&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Its next message was &amp;ldquo;Zero failures is suspicious.&amp;rdquo; It was right: neither test had run once. In the same week, three of its checks gave a tidy answer (nothing found, all passed, all failed) that came from a broken command, and each broken command hid its own error.&lt;/p&gt;&#10;&lt;p&gt;If your agent&amp;rsquo;s output has &lt;code&gt;(eval):log:1: too many arguments&lt;/code&gt;, &lt;code&gt;0 passed; 0 failed; … filtered out&lt;/code&gt; or &lt;code&gt;argument list too long&lt;/code&gt; somewhere above the summary, the summary is about a check that never ran.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Claude Code 2.1.283 on macOS, where the agent&amp;rsquo;s shell is zsh 5.9&lt;/li&gt;&#10;&lt;li&gt;A Rust workspace whose test binary is run directly from &lt;code&gt;target/debug/deps&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="trap-1-zsh-has-a-log-builtin"&gt;Trap 1: zsh has a &lt;code&gt;log&lt;/code&gt; builtin &lt;/h2&gt;&#10;&lt;p&gt;The agent was debugging &lt;a&#10; href="https://eghuro.cz/posts/fskit-mount-fts-read-operation-not-permitted"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;an FSKit file system extension&lt;/a&gt;&#10; and searched the unified log for sandbox denials:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;log show --last 30m --style compact --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;MyFS&amp;#34;&amp;#39;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="p"&gt;|&lt;/span&gt; tail -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing came back, and the agent told me: &amp;ldquo;No log entries so far.&amp;rdquo; Half a minute later it ran &lt;code&gt;log stream … &amp;gt; /tmp/fs.log 2&amp;gt;&amp;amp;1 &amp;amp;&lt;/code&gt; and read the file. Its first line was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(eval):log:1: too many arguments&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;zsh has a builtin called &lt;code&gt;log&lt;/code&gt; that lists the logins &lt;code&gt;watch&lt;/code&gt; reports. Builtins win over &lt;code&gt;$PATH&lt;/code&gt;, so &lt;code&gt;log show&lt;/code&gt; never reached &lt;code&gt;/usr/bin/log&lt;/code&gt;, and &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; threw away the only sign of it. You can check your own shell:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; zsh -c &lt;span class="s1"&gt;&amp;#39;whence -wa log&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log: builtin&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log: command&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;/usr/bin/log&lt;/code&gt;, the denial showed up at once, and the agent corrected its earlier claim. The same error turned up in a different project&amp;rsquo;s session days later.&lt;/p&gt;&#10;&#10;&lt;h2 id="trap-2-cargo-test---exact-with-the-wrong-path-passes"&gt;Trap 2: &lt;code&gt;cargo test --exact&lt;/code&gt; with the wrong path passes &lt;/h2&gt;&#10;&lt;p&gt;The 150-run loop called the test binary like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$B&lt;/span&gt; --exact tests::&lt;span class="nv"&gt;$t&lt;/span&gt; -q &amp;gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;f+1&lt;span class="k"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;--exact&lt;/code&gt;, the filter has to equal the full test path. The tests live in &lt;code&gt;vfs::tests&lt;/code&gt;, so &lt;code&gt;tests::streamed_read_right_after_a_rename&lt;/code&gt; matched nothing, and libtest treats that as success:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 93 filtered out; finished in 0.00s&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Exit code 0, every time. Without &lt;code&gt;--exact&lt;/code&gt;, the same filter would have matched as a substring. The agent listed the real names with &lt;code&gt;$B --list&lt;/code&gt;, fixed the prefix, and the races then showed up under load.&lt;/p&gt;&#10;&#10;&lt;h2 id="trap-3-an-empty-variable-makes-everything-fail"&gt;Trap 3: an empty variable makes everything fail &lt;/h2&gt;&#10;&lt;p&gt;After the fixes, the agent stress-ran the suite 400 times and looked up the binary first:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;ls -t target/debug/deps/storage_core-* &lt;span class="p"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;&amp;#39;\.d$&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; head -1&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The glob had grown past what &lt;code&gt;ls&lt;/code&gt; accepts:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(eval):1: argument list too long: ls&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;failed runs: 400 / 400&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;$B&lt;/code&gt; was empty. zsh drops an empty unquoted word, so each run executed &lt;code&gt;vfs::tests -q&lt;/code&gt;, got &lt;code&gt;command not found&lt;/code&gt; into a log file nobody read, and counted as a failure. This time the agent saw the error right above the count and called the result bogus.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Make every check prove that it ran before you trust what it says.&lt;/p&gt;&#10;&lt;p&gt;Call &lt;code&gt;/usr/bin/log&lt;/code&gt; by its full path, or put &lt;code&gt;command&lt;/code&gt; in front of it, and keep stderr visible while you are still debugging:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/usr/bin/log show --last 30m --style compact --predicate &lt;span class="s1"&gt;&amp;#39;process == &amp;#34;MyFS&amp;#34;&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; tail -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Count the tests that ran, not only the failures. One test per run should say &lt;code&gt;1 passed&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;$B&lt;/span&gt; --exact vfs::tests::&lt;span class="nv"&gt;$t&lt;/span&gt; -q &amp;gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;f+1&lt;span class="k"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -q &lt;span class="s1"&gt;&amp;#39;1 passed&amp;#39;&lt;/span&gt; /tmp/out_&lt;span class="nv"&gt;$t&lt;/span&gt;.txt &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;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt; did not run&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Get the binary path from cargo instead of a glob, and stop if it is empty:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;cargo &lt;span class="nb"&gt;test&lt;/span&gt; -p storage-core --lib --no-run 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;&amp;#39;target/debug/deps/storage_core-[0-9a-f]*&amp;#39;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:?test binary not found&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For the agent, add one line to its instructions file (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;When a check finds nothing, passes everything, or fails everything, first show that it ran:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;how many items it checked, and that a known-bad input fails it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="why-it-happens"&gt;Why it happens &lt;/h2&gt;&#10;&lt;p&gt;All three are the same bug: the harness failed, and the failure looked like a result. A loop that counts exit codes can&amp;rsquo;t tell &amp;ldquo;no failures&amp;rdquo; from &amp;ldquo;no tests&amp;rdquo;, and a pipeline that ends in &lt;code&gt;2&amp;gt;/dev/null | tail&lt;/code&gt; can&amp;rsquo;t tell &amp;ldquo;no matches&amp;rdquo; from &amp;ldquo;no command&amp;rdquo;. Agents write exactly these one-liners, in zsh, many times per session, and they report what the last line printed.&lt;/p&gt;&#10;&lt;p&gt;The agent caught two of the three because the number looked wrong: zero out of 150 for known flaky tests, 400 out of 400 for a suite that had just passed. An empty log search looked plausible, so it went to the user as a finding.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A clean result from a check you haven&amp;rsquo;t seen fail is not evidence. Before you believe &amp;ldquo;nothing found&amp;rdquo; or &amp;ldquo;0 failed&amp;rdquo;, look for the count of what was checked.&lt;/p&gt;&#10;&lt;p&gt;Next time your agent reports zero failures, ask it how many tests actually ran.&lt;/p&gt;&#10;</description></item><item><title>Standards vs Spec review: a second agent found duplicate writes past 1,572 green tests</title><link>https://eghuro.cz/posts/standards-vs-spec-review/</link><pubDate>Wed, 22 Jul 2026 21:58:00 +0200</pubDate><guid>https://eghuro.cz/posts/standards-vs-spec-review/</guid><description>&lt;p&gt;This is what the review agent reported, just before it listed its findings:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;The test evidence is clean: Ruff passes, Python reports 1,572 passed / 56 skipped /&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1 expected failure, and the GUI reports 418 passed / 4 skipped. The review nevertheless&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;found safety gaps the tests do not exercise — most importantly, two write clients retry&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#34;500-after-commit&amp;#34; before their advertised idempotency/verification guard can run.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A coding agent had built the branch: 53 commits, 147 files. A different model reviewed it and found two paths that could put the same invoice in the ERP twice and report plain success. No test covered them, and no linter could. They came from a reviewer with one job: check the code against what the branch said it does.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;The repository vendors the two-axis &lt;code&gt;code-review&lt;/code&gt; skill from &lt;a&#10; href="https://github.com/mattpocock/skills"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;mattpocock/skills&lt;/a&gt;&#10;. It pins a fixed point and diffs against the merge base, &lt;code&gt;git diff master...HEAD&lt;/code&gt;, then forks two subagents in parallel:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Standards: does the diff follow the repo&amp;rsquo;s documented rules (glossary, ADRs, a baseline of Fowler code smells)?&lt;/li&gt;&#10;&lt;li&gt;Spec: does the diff do what the originating issue or PRD asked for?&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The parent agent runs lint and tests meanwhile, &amp;ldquo;as supporting evidence, not as a substitute for the diff review&amp;rdquo;. It then reports the two axes under separate headings and doesn&amp;rsquo;t rerank them, so a pile of naming nits can&amp;rsquo;t bury a P1.&lt;/p&gt;&#10;&lt;p&gt;The Spec brief is short:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Report: (a) requirements the spec asked for that are missing or partial;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(b) behavior in the diff that wasn&amp;#39;t asked for (scope creep);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(c) requirements that look implemented but where the implementation looks wrong.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Quote the spec line for each finding, under 400 words.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="what-each-axis-found"&gt;What each axis found &lt;/h2&gt;&#10;&lt;p&gt;There was no PRD for the branch, so the Spec reviewer used the commit messages and the ADRs. One commit claimed that the MCP server&amp;rsquo;s idempotency check made it safe to retry a write that got a 5xx. The reviewer traced the call: the check ran before &lt;code&gt;_request()&lt;/code&gt;, the key was recorded after success, and the retry happened inside &lt;code&gt;_request()&lt;/code&gt;, in between. A webhook worker had the same shape, with a verify-by-GET that ran only if the request finally raised. A retry that succeeds never raises. (&lt;a&#10; href="https://eghuro.cz/posts/http-500-ambiguous-write-retry"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;The full HTTP 500 story is here&lt;/a&gt;&#10;.)&lt;/p&gt;&#10;&lt;p&gt;The Standards reviewer found something else. Two functions called &lt;code&gt;urlopen()&lt;/code&gt; directly, past the shared transport that an ADR says owns all retries. It also found an import that went through a module the ADRs forbid as a re-export facade, and an unfinished vocabulary migration.&lt;/p&gt;&#10;&lt;p&gt;None of the Standards findings overlapped with the Spec ones. All six were fixed the same evening, in one commit.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-tests-stayed-green"&gt;Why the tests stayed green &lt;/h2&gt;&#10;&lt;p&gt;The tests came from the same author as the claim. No test let a 500 commit the write, let the retry succeed, and counted the writes, because the commit said the idempotency check covered that case. A suite tests what its author thought could break.&lt;/p&gt;&#10;&lt;p&gt;The Spec reviewer starts from the claim instead. A commit message says the code is safe, so it goes looking for the line that makes it safe, and here that line ran on the wrong side of the retry.&lt;/p&gt;&#10;&#10;&lt;h2 id="two-more-runs-same-pattern"&gt;Two more runs, same pattern &lt;/h2&gt;&#10;&lt;p&gt;In the next review, a few hours later on a six-commit range, a guard was undone downstream. Every write had to carry exactly one confidence label, and &lt;code&gt;create_*&lt;/code&gt; checked that. But when label setup had failed, a fallback further down stripped all labels from the payload. The reviewer reproduced a labeled invoice leaving without its label. It also found a write that went through a raw &lt;code&gt;put()&lt;/code&gt; and skipped the scope check that limits which record types may be written at all.&lt;/p&gt;&#10;&lt;p&gt;The third review had nothing to go on but commit messages. The branch added a gate: if an invoice is addressed to another company, warn and refuse to post it. The reviewer said it was inferring the spec from the commits, so I pasted the original request and the Spec subagent re-checked against that. The request held a second requirement the commit summary had left out, which turned out to be implemented. The review also found that a replay of staged payloads called the push function directly and skipped the new gate. And while company registration numbers were checksum-validated, VAT IDs relied on regex alone, so one misread digit could block a legitimate invoice. Both failed the first test, then got fixed.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-to-use-it"&gt;How to use it &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Run Standards and Spec as separate subagents, so neither sees the other&amp;rsquo;s findings, and report them under separate headings.&lt;/li&gt;&#10;&lt;li&gt;Give the Spec reviewer the authoritative spec. If you only have commit messages, say so, and swap in the real request the moment you have it.&lt;/li&gt;&#10;&lt;li&gt;Treat claims in commit messages and ADRs (&amp;ldquo;X makes this safe&amp;rdquo;, &amp;ldquo;Y owns all retries&amp;rdquo;) as requirements. They are the cheapest spec you have, and the author&amp;rsquo;s tests already agree with them.&lt;/li&gt;&#10;&lt;li&gt;If you can, review with a different model than the one that wrote the code. It is less likely to share the writer&amp;rsquo;s blind spots.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A green suite tells you the code does what its author expected. A Spec review by a second agent tells you whether that matches what the author promised. The duplicate writes were in the gap between the two.&lt;/p&gt;&#10;&lt;p&gt;Take the last branch your agent wrote, and have a second agent check each &amp;ldquo;this makes X safe&amp;rdquo; in its commit messages against the code.&lt;/p&gt;&#10;</description></item><item><title>Date the bug before you park it: fence, drift or silence</title><link>https://eghuro.cz/posts/date-the-bug-before-you-park-it/</link><pubDate>Fri, 17 Jul 2026 01:13:00 +0200</pubDate><guid>https://eghuro.cz/posts/date-the-bug-before-you-park-it/</guid><description>&lt;p&gt;My coding agent was splitting a 738-line Python function into steps and found a real bug along the way:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That line rebinds the list instead of extending it, so it dropped every issue recorded before validation, including the note that repair mode had just deleted a record in the accounting system. The push still succeeded, and the operator&amp;rsquo;s report said nothing about the delete.&lt;/p&gt;&#10;&lt;p&gt;The agent didn&amp;rsquo;t fix it. It wrote a known-issues entry and added two &lt;code&gt;@unittest.expectedFailure&lt;/code&gt; tests, because the handoff for the refactor said: &amp;ldquo;characterize, then collapse, then fix separately&amp;rdquo;. It was the wrong call, and two &lt;code&gt;git log -S&lt;/code&gt; runs showed it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Claude Code working in a Python repository of an accounting pipeline, tested with &lt;code&gt;unittest&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;A long-lived function where several steps append to &lt;code&gt;result.issues&lt;/code&gt;, the operator&amp;rsquo;s per-file report;&lt;/li&gt;&#10;&lt;li&gt;A refactor rule that a split must not change behavior.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-parking-costs"&gt;What parking costs &lt;/h2&gt;&#10;&lt;p&gt;An &lt;code&gt;expectedFailure&lt;/code&gt; marker looks like a decision. The next reader sees a documented, tested, known gap and assumes someone weighed it and chose to keep it. If nobody did, the marker turns an accident into policy and keeps a live bug in the tree behind it.&lt;/p&gt;&#10;&lt;p&gt;So before you park a bug, find out whether anyone ever decided it. I asked the agent to run the fence check (Chesterton&amp;rsquo;s fence: don&amp;rsquo;t remove a fence until you know why it was put up).&lt;/p&gt;&#10;&#10;&lt;h2 id="date-the-bug"&gt;Date the bug &lt;/h2&gt;&#10;&lt;p&gt;A bug is a relationship between two things: this line and the code it gets wrong — date both.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log --oneline -S&lt;span class="s1"&gt;&amp;#39;result.issues = validation.issues&amp;#39;&lt;/span&gt; -- &amp;lt;path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log --oneline -S&lt;span class="s1"&gt;&amp;#39;result.issues.append&amp;#39;&lt;/span&gt; -- &amp;lt;path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The result went into the fix&amp;rsquo;s commit message:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-16 rebind written. result.issues had NO prior writers;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; assigning validation&amp;#39;s list into it lost nothing.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-19 + vendor mismatch trail above it +3d&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-23 + REPAIR delete note above it +7d&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-04-24 + registry retry verdict above it +8d&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-05-27 + self-billed reroute above it +41d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The rebind was correct when it was written. Over the next 41 days, four commits added writers above it, and none of them touched it. Nobody decided to discard those issues; the code predates the thing it breaks.&lt;/p&gt;&#10;&lt;p&gt;That gives one of three verdicts:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Fence&lt;/strong&gt;: someone decided this, in a commit message, a spec&amp;rsquo;s non-goals or an ADR. Leave it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Drift&lt;/strong&gt;: the code predates what it is wrong about, or its author never touched the site. Fix it now, and put the dates in the commit message so a reviewer can see you didn&amp;rsquo;t remove a fence.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Silence&lt;/strong&gt;: the history says nothing either way. Park it, and say plainly that nobody decided it.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This one was drift, so it got fixed the same night. It had stayed hidden because the only test that reached this code patched the whole function body out. The split made the body reachable, and the first end-to-end repair test found the bug.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-obvious-fix-was-wrong"&gt;The obvious fix was wrong &lt;/h2&gt;&#10;&lt;p&gt;A drift verdict says nobody meant the bug; you still have to find out what depends on it. The one-word fix:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;would have broken an alias. A later step appends registry failures to &lt;code&gt;result.issues&lt;/code&gt;, and those reach the invoice&amp;rsquo;s note only because the rebind made &lt;code&gt;result.issues&lt;/code&gt; and &lt;code&gt;validation.issues&lt;/code&gt; the same list, which the note builder reads:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="c1"&gt;# one list, two names&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;apply_registry_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# appends to result.issues ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;build_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# ... and the note sees it through the alias&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;.extend()&lt;/code&gt;, every invoice that failed the business-registry check would have lost that warning from its note: one bug traded for a quieter one. The fix kept the rebind and put the earlier issues back after the later writers ran:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;earlier_issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;apply_registry_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ... the other writers ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;earlier_issues&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The tests were written to catch both mistakes: reverting the fix fails the four tests that check the earlier issues survive, and the naive &lt;code&gt;.extend()&lt;/code&gt; fails the one that checks the registry warning still reaches the note. The known-issues entry was deleted, and the &lt;code&gt;expectedFailure&lt;/code&gt; markers became ordinary tests.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-silence-looks-like"&gt;What silence looks like &lt;/h2&gt;&#10;&lt;p&gt;That night, four other bugs were dated as drift and fixed. One wasn&amp;rsquo;t. Debug files were named from the input&amp;rsquo;s stem, so &lt;code&gt;invoice.docx&lt;/code&gt; and &lt;code&gt;invoice.pdf&lt;/code&gt; in one batch wrote the same file, and the later one won. That naming shipped on a pipeline that already read several formats, so the code didn&amp;rsquo;t predate the collision. No commit message or spec mentioned it either: &lt;code&gt;stem&lt;/code&gt; is just the obvious way to name a sidecar file. That one stayed a known-issues entry, with the dating written into it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-skill"&gt;The skill &lt;/h2&gt;&#10;&lt;p&gt;The fence check is a Claude Code skill I had written down earlier that evening. The agent had it and didn&amp;rsquo;t run it until asked, so its memory now says to run it the moment it finds a bug, before choosing between a fix and a known-issues entry. Here it is verbatim. The examples and the &lt;code&gt;docs/superpowers/&lt;/code&gt; paths are from my repository; swap in your own spec directory.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;checking-the-fence&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Date a bug before fixing it — Chesterton&amp;#39;s fence. Use when you have found code that looks wrong and are about to change it, when you are about to delete something that looks dead, or when another skill needs the fence check.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gh"&gt;# Checking the Fence&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;A bug you found is a &lt;span class="gs"&gt;**fence**&lt;/span&gt; across a field. Before you take it down, find who put it there. Here that is written down twice — in &lt;span class="sb"&gt;`git log`&lt;/span&gt; and in &lt;span class="sb"&gt;`docs/superpowers/`&lt;/span&gt; — and is usually decisive in a minute.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Two verdicts. They are the whole skill:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;-&lt;/span&gt; **Fence** — someone decided this. Leave it, and record an ADR if the next reader will reach for the same fix you just did.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;-&lt;/span&gt; **Drift** — nobody decided it. The code predates the thing it is wrong about, or an author was never told which files the job touched. Fix it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Fences here are real, not hypothetical: &lt;span class="sb"&gt;`docs/adr/0002`&lt;/span&gt; exists only to stop a reader from deleting emoji handling that has no inbound traffic and looks dead.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;## Steps&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Stop at the first step that returns a verdict.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 1. Date the code&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log --oneline -S&amp;#39;&amp;lt;symbol&amp;gt;&amp;#39; --all -- &amp;lt;path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git log -1 --format=%B &amp;lt;sha&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Commit messages here carry the rule the author meant to encode, and the rule is often the thing the bug breaks. &lt;span class="sb"&gt;`_PARSER_PRIORITY`&lt;/span&gt;&amp;#39;s own commit says &lt;span class="ge"&gt;*&amp;#34;process deterministic parsers before PDFs/images&amp;#34;*&lt;/span&gt; — while the table shipped the cheapest format last.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you can name the commit and quote the rule.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 2. Date what it is wrong about&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;A bug is a relationship: a table missing a format, a copy missing an entry, a switch missing a branch. Date the other side too.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Code cannot have decided about something that did not exist yet. &lt;span class="sb"&gt;`_PARSER_PRIORITY`&lt;/span&gt; (2026-04-27) predates isdoc (2026-06-01) by five weeks.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you have both dates. Predates → &lt;span class="gs"&gt;**drift**&lt;/span&gt;. Stop.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 3. Read the spec&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -rli &amp;#39;&amp;lt;feature&amp;gt;&amp;#39; docs/superpowers/specs docs/superpowers/plans&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Read the &lt;span class="gs"&gt;**Non-goals**&lt;/span&gt; first — that is where a deliberate gap is named. A spec that scopes the gap out is a &lt;span class="gs"&gt;**fence**&lt;/span&gt;. A spec whose own words the bug contradicts is &lt;span class="gs"&gt;**drift**&lt;/span&gt;: the ISDOC spec calls it &lt;span class="ge"&gt;*&amp;#34;a deterministic fast path before Claude&amp;#34;*&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you have quoted the line that scopes it out, or established that no spec names it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 4. Read the blast pattern&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git show --stat &amp;lt;sha&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Which files that commit touched, against which it needed to. An author who updated three of five consumers was not deciding about the other two — nothing told them. The isdoc and docx commits landed a day apart and touched different subsets.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you can say whether the author addressed the site at all. Never addressed → &lt;span class="gs"&gt;**drift**&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### 5. Was it born wrong?&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;For a forked copy, compare it against its source &lt;span class="gs"&gt;**on the day it was copied**&lt;/span&gt;, not today.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git show &amp;lt;sha&amp;gt;:&amp;lt;source-path&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;```&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sb"&gt;`coverage_report.py`&lt;/span&gt; shipped without &lt;span class="sb"&gt;`.json`&lt;/span&gt; on 2026-04-24, and the map it was copied from carried &lt;span class="sb"&gt;`.json`&lt;/span&gt; that same day. Born forked, then rotted twice more.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Done when**&lt;/span&gt; you know whether the gap is drift-over-time or a bad copy. Either is &lt;span class="gs"&gt;**drift**&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;## Applying the verdict&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Drift**&lt;/span&gt; — fix it, and say in the commit message what you found: the dates, and that nothing decided it. That is what stops a reviewer from wondering whether you removed a fence.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Fence**&lt;/span&gt; — leave it. Offer an ADR when a future reader would reach for the same fix; that reader is usually the next architecture review, or you.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gs"&gt;**Neither**&lt;/span&gt; — the history is silent and no spec mentions it. Say so plainly and let the user decide; silence is not consent, and it is not a fence either.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Save it as &lt;code&gt;.claude/skills/checking-the-fence/SKILL.md&lt;/code&gt; in your repository, and tell the agent in &lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt; to run it before it records a bug as a known issue.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A parked bug looks like a decision, so check whether anyone made one. If the code predates what it breaks, fix it now, and check the mechanism first: knowing nobody meant the bug doesn&amp;rsquo;t tell you what depends on it.&lt;/p&gt;&#10;&lt;p&gt;Next time your agent adds an &lt;code&gt;expectedFailure&lt;/code&gt; or a known-issues entry, ask it for the two dates first.&lt;/p&gt;&#10;</description></item><item><title>Every test mocked the helper, and except Exception swallowed its TypeError on every PDF</title><link>https://eghuro.cz/posts/mocked-helper-swallowed-typeerror/</link><pubDate>Thu, 16 Jul 2026 23:42:00 +0200</pubDate><guid>https://eghuro.cz/posts/mocked-helper-swallowed-typeerror/</guid><description>&lt;p&gt;For seven weeks, a document classifier never read a single PDF. This is the helper that was supposed to give the model some text (trimmed):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_extract_excerpt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;First-page text for PDFs, first ~8 kB for everything else.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.pdf&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;EXCERPT_BYTES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;extract_pdf&lt;/code&gt; doesn&amp;rsquo;t return a list of pages. It returns a dataclass, so every call raised this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TypeError: &amp;#39;PDFExtraction&amp;#39; object is not subscriptable&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;except&lt;/code&gt; caught it and returned &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt;. The prompt then held the filename and an empty excerpt, and the model answered anyway: a document type and a confidence score, for every PDF it was asked about.&lt;/p&gt;&#10;&lt;p&gt;If a helper of yours has a catch-all &lt;code&gt;except&lt;/code&gt; and your tests patch that helper, check whether anything ever runs it for real.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;A web upload went through a small Python script that sorted the file into one of the document kinds declared for an accounting period (received invoices, bank statements, and so on). With two or more possible kinds, it built a prompt from the filename plus the start of the document and asked a language model, which answered with JSON like &lt;code&gt;{&amp;quot;kind&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;confidence&amp;quot;: 0.9}&lt;/code&gt;. The PDF text came from an in-house &lt;code&gt;extract_pdf()&lt;/code&gt; built on pdfplumber. Tests used the standard library&amp;rsquo;s &lt;code&gt;unittest&lt;/code&gt; and &lt;code&gt;unittest.mock&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The helper was wrong from the day it was written. &lt;code&gt;extract_pdf&lt;/code&gt; had returned a &lt;code&gt;PDFExtraction&lt;/code&gt; dataclass with a &lt;code&gt;pages&lt;/code&gt; list since its first commit, six weeks earlier, and its docstring said so. The &lt;code&gt;if pages&lt;/code&gt; check didn&amp;rsquo;t catch the mistake: a dataclass without &lt;code&gt;__len__&lt;/code&gt; is always truthy, so the check passed and &lt;code&gt;pages[0]&lt;/code&gt; raised.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;except&lt;/code&gt; was there so that a corrupt or encrypted upload wouldn&amp;rsquo;t break classification. It caught the &lt;code&gt;TypeError&lt;/code&gt; just as readily and returned &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt;, which is also the documented answer for an image upload with no text to read.&lt;/p&gt;&#10;&lt;p&gt;And every test patched the helper out. The tests that reached the model looked like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;scripts.classify_upload.invoke_ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;scripts.classify_upload._extract_excerpt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Invoice 2026-001 ...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;kind&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;received_invoices&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;confidence&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;classify_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/tmp/x.pdf&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;issued_invoices&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;received_invoices&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;claude&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;They covered the prompt plumbing and the JSON parsing, and none of them ever called &lt;code&gt;_extract_excerpt&lt;/code&gt;. The implementation plan that specified the helper had the same broken line, and it specified these tests, mocks included. The code, the plan, and the tests agreed with each other, and none of them had been checked against &lt;code&gt;extract_pdf&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The model hid the rest. A filename like &lt;code&gt;invoice-0423.pdf&lt;/code&gt; can be enough for a right guess, and a wrong guess came back with a confidence score like any other, so nothing in the output said the excerpt was empty.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;The fix reads the first page, as the docstring promised:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;extraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;first_page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extraction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;extraction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;first_page&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;EXCERPT_BYTES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The new test class calls the real function on a real PDF:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RealPdfExcerptTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unittest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TestCase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tearDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_a_pdf_yields_its_first_page_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;invoice.pdf&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_write_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;INVOICE 2026-001 Supplier ACME&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;INVOICE 2026-001&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_extract_excerpt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_a_corrupt_pdf_yields_no_signal_rather_than_raising&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;broken.pdf&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;%PDF-1.4&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;not actually a pdf at all&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_extract_excerpt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;_write_pdf&lt;/code&gt; is an existing test helper that writes a one-page PDF with a text layer. Before committing, I ran the first test against the original code to confirm it fails there. The &lt;code&gt;except&lt;/code&gt; stayed, because corrupt uploads are real. The second test now says what it&amp;rsquo;s for.&lt;/p&gt;&#10;&lt;p&gt;A day later, the same helper had a second route to the same result. It kept its own list of text suffixes, so Word, Excel, and XML e-invoice uploads fell through to &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt; and were classified by filename too. Two of those formats joined the pipeline a week after the list was written, and nobody updated it. The helper now asks the pipeline&amp;rsquo;s format registry instead.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;A mock replaces the code it patches with your assumptions about that code. When every test mocks a function, its tests check only those assumptions, and the function itself is never run. A catch-all &lt;code&gt;except&lt;/code&gt; inside the function then removes the last signal you&amp;rsquo;d get in production: no traceback, no failed request, just a default value that looks legitimate.&lt;/p&gt;&#10;&lt;p&gt;A language model downstream makes this worse. A parser fed an empty string usually fails loudly, while a model returns its best guess in the usual format, confidence field included, whatever the input.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Mock the expensive or external dependency (here, the model) and let the function under test run on a real input. If it has a catch-all &lt;code&gt;except&lt;/code&gt;, give it a test with an input that should succeed, so a bug can&amp;rsquo;t hide behind the &lt;code&gt;except&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Grep your tests for &lt;code&gt;patch(&lt;/code&gt; targets that point at your own module&amp;rsquo;s private helpers, and for each one, find a test that calls the helper unmocked.&lt;/p&gt;&#10;</description></item><item><title>Sandbox-blocked tests called "environmental", then committed</title><link>https://eghuro.cz/posts/sandbox-blocked-tests-called-environmental/</link><pubDate>Mon, 13 Jul 2026 15:38:00 +0200</pubDate><guid>https://eghuro.cz/posts/sandbox-blocked-tests-called-environmental/</guid><description>&lt;p&gt;My coding agent finished a feature, ran the full test suite, and reported:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Full suite: 135 passed, 97 skipped; 22 DB suites failed due to no container runtime, plus sandboxed stub bind. Commit only handoff implementation files.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then it committed. I ran &lt;code&gt;pnpm test&lt;/code&gt; myself and got three real failures. The production build it had also skipped was broken too, and nobody found out for two months.&lt;/p&gt;&#10;&lt;p&gt;If your agent&amp;rsquo;s test run shows &lt;code&gt;Error: Could not find a working container runtime strategy&lt;/code&gt; or &lt;code&gt;Error: listen EPERM: operation not permitted 0.0.0.0:8080&lt;/code&gt;, those tests never ran, and a summary that counts them as &amp;ldquo;blocked&amp;rdquo; tells you nothing about the code.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Codex CLI on macOS, with its default sandbox&lt;/li&gt;&#10;&lt;li&gt;A pnpm monorepo: a Next.js 15.5.19 app plus a few packages&lt;/li&gt;&#10;&lt;li&gt;Vitest 2.1.9, with Testcontainers 10.28.0 starting Postgres for the database tests and a local stub of an external service listening on port 8080&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The agent split the feature into five slices and gave each to a parallel subagent. Each subagent ran its own focused tests, and the orchestrator ran the full suite and the build at the end.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The sandbox blocks more than writes outside the project. Here it blocked three things the checks needed:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Testcontainers couldn&amp;rsquo;t reach Docker: &lt;code&gt;Could not find a working container runtime strategy&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The service stub couldn&amp;rsquo;t bind its port: &lt;code&gt;listen EPERM: operation not permitted 0.0.0.0:8080&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;next build&lt;/code&gt; couldn&amp;rsquo;t write its compiler cache: &lt;code&gt;EPERM&lt;/code&gt; writing &lt;code&gt;~/Library/Caches/next-swc&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Every database suite failed in &lt;code&gt;beforeAll&lt;/code&gt;, and Vitest reported the tests inside them as skipped. The agent&amp;rsquo;s final report was accurate:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Typecheck pass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Focused tests pass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Full: 135 passed; DB suites blocked by no Docker&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- Build blocked by Next SWC cache permissions&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every line of that is true. But the suite has 232 tests, and 135 + 97 = 232: over 40 percent of it never ran, including every test that touched the database, where this feature did most of its work.&lt;/p&gt;&#10;&lt;p&gt;On the host, the same suite printed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Test Files 2 failed | 63 passed (65)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Tests 3 failed | 229 passed (232)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One new test forgot to start the service stub, so its client found no endpoint configured. Two older tests broke because the feature added an ownership check and their fixtures sent requests without a logged-in user (&lt;code&gt;Error: authentication required&lt;/code&gt;). The agent fixed all three in two minutes once it saw them.&lt;/p&gt;&#10;&lt;p&gt;The build was worse. The commit re-exported a server action from another &lt;code&gt;&amp;quot;use server&amp;quot;&lt;/code&gt; file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;use server&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;finalizeArtifact&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kr"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;../../reports/actions.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;tsc&lt;/code&gt; accepts this, and so do the tests. Only &lt;code&gt;next build&lt;/code&gt; rejects it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Failed to compile.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./app/reports/[id]/actions.ts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Error: x Only async functions are allowed to be exported in a &amp;#34;use server&amp;#34; file.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two months later, a different session ran a production build, hit this error, and deleted the line.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-same-sandbox-the-same-day"&gt;The same sandbox, the same day &lt;/h2&gt;&#10;&lt;p&gt;Half an hour before the bad commit, another session in the same repository hit the same two errors. At first it called them environmental too. Then it decided:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Full suite blocked by sandbox: Testcontainers cannot see Docker; stub bind gets `EPERM :8080`. Since Docker is reportedly running, retry full suite with host access.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two minutes later it reported &amp;ldquo;Host rerun green: 60 files, 222 tests&amp;rdquo;, and only then did it commit. The agent that fixed my three failures did the same: it reran the focused tests with host approval before committing the fix. Both sessions knew how to leave the sandbox; nothing required them to before a commit.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Treat &amp;ldquo;blocked&amp;rdquo; as &amp;ldquo;not run&amp;rdquo;, and make &amp;ldquo;not run&amp;rdquo; stop the commit.&lt;/p&gt;&#10;&lt;p&gt;In Codex, a command runs outside the sandbox when the agent requests &lt;code&gt;sandbox_permissions: &amp;quot;require_escalated&amp;quot;&lt;/code&gt; and you approve it. Put the rule in the instructions file the agent reads every session (&lt;code&gt;AGENTS.md&lt;/code&gt;, or &lt;code&gt;CLAUDE.md&lt;/code&gt; for Claude Code):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;A check that the sandbox blocked has not passed. Before committing, rerun every blocked test suite and build with host access (&lt;span class="sb"&gt;`sandbox_permissions: &amp;#34;require_escalated&amp;#34;`&lt;/span&gt;).&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;If that isn&amp;#39;t possible, don&amp;#39;t commit: report what didn&amp;#39;t run.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then read the agent&amp;rsquo;s numbers yourself. If passed plus skipped equals the total, and you never skip tests on purpose, every skipped test is one that didn&amp;rsquo;t run. If you have CI, have it run the full suite and the build before a merge.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-it-happens"&gt;Why it happens &lt;/h2&gt;&#10;&lt;p&gt;The agent&amp;rsquo;s memory for this project already had a rule: &amp;ldquo;separate passed static or focused checks from runtime suites blocked by the environment, and reserve the final green claim for integrated verification.&amp;rdquo; The agent followed that rule to the letter: it never claimed green and listed exactly what had been blocked. Then it committed, because nothing in its instructions tied the commit to the green claim.&lt;/p&gt;&#10;&lt;p&gt;&amp;ldquo;Blocked by the environment&amp;rdquo; reads like an infrastructure problem that someone else will handle, so it ends up as a footnote in the report while the commit goes ahead. The rule made the report honest. It didn&amp;rsquo;t make anyone run the tests.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;When a sandbox blocks a test or a build, nothing was verified. An agent that reports this honestly will still commit, unless its instructions say that a blocked check stops the commit.&lt;/p&gt;&#10;&lt;p&gt;Look at your agent&amp;rsquo;s last commit: if its summary says a suite or a build was &amp;ldquo;blocked&amp;rdquo;, rerun them on the host today.&lt;/p&gt;&#10;</description></item><item><title>ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY: your coding agent's pnpm 11 is not your pnpm 10</title><link>https://eghuro.cz/posts/pnpm-11-agent-runtime-no-tty/</link><pubDate>Mon, 06 Jul 2026 15:04:00 +0200</pubDate><guid>https://eghuro.cz/posts/pnpm-11-agent-runtime-no-tty/</guid><description>&lt;p&gt;A coding agent ran &lt;code&gt;pnpm test&lt;/code&gt; in my repo and got this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Scope: all 6 workspace projects&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY] Aborted removal of modules directory due to no TTY&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;If you are running pnpm in CI, set the CI environment variable to &amp;#34;true&amp;#34;, or set &amp;#34;confirmModulesPurge&amp;#34; to &amp;#34;false&amp;#34;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The stack trace ended in &lt;code&gt;runDepsStatusCheck (.../node_modules/.pnpm/pnpm@11.7.0/...)&lt;/code&gt;. My &lt;code&gt;package.json&lt;/code&gt; says &lt;code&gt;&amp;quot;packageManager&amp;quot;: &amp;quot;pnpm@10.33.0&amp;quot;&lt;/code&gt;, and &lt;code&gt;pnpm -v&lt;/code&gt; in my terminal prints &lt;code&gt;10.33.0&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If you see this error only inside an agent, the agent is running its own pnpm, and following the hint makes things worse.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;macOS, pnpm 10.33.0 through Corepack, a pnpm monorepo with a Next.js 15 app&lt;/li&gt;&#10;&lt;li&gt;The Codex desktop app, which ships its own Node 24 and pnpm under &lt;code&gt;~/.cache/codex-runtimes/codex-primary-runtime/dependencies/&lt;/code&gt;: pnpm 11.7.0 in July, 11.25.0 in October&lt;/li&gt;&#10;&lt;li&gt;The agent runs &lt;code&gt;pnpm test&lt;/code&gt; without a TTY&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;The agent followed the hint and reran with &lt;code&gt;CI=true pnpm test&lt;/code&gt;. pnpm stopped asking and did what it had wanted to do from the start:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Recreating /Users/me/project/node_modules&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Progress: resolved 378, reused 0, downloaded 137, added 12&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[WARN] GET https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz error (ERR_PNPM_ENOSPC). Will retry in 10 seconds. 2 retries left.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[WARN] GET https://registry.npmjs.org/next/-/next-15.5.19.tgz error (ERR_PNPM_ENOSPC). Will retry in 10 seconds. 2 retries left.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Progress: resolved 378, reused 0, downloaded 377, added 377, done&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cpu-features@0.0.10, esbuild@0.21.5, esbuild@0.28.1, protobufjs@7.6.4, sharp@0.34.5, ssh2@1.17.0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It deleted &lt;code&gt;node_modules&lt;/code&gt; and downloaded every package again (&lt;code&gt;reused 0&lt;/code&gt;) into a second store, and the disk ran out on the way. It left an untracked &lt;code&gt;.pnpm-store/&lt;/code&gt; in the repo and wrote placeholders into the tracked &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;allowBuilds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cpu-features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;esbuild&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;protobufjs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ssh2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;set this to true or false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No tests ran. After that, even &lt;code&gt;pnpm exec tsc --noEmit&lt;/code&gt; failed with the same NO_TTY error, so the agent switched to &lt;code&gt;./node_modules/.bin/tsc&lt;/code&gt; and &lt;code&gt;./node_modules/.bin/vitest&lt;/code&gt;, reverted the YAML, deleted &lt;code&gt;.pnpm-store/&lt;/code&gt; and committed with only the focused tests green.&lt;/p&gt;&#10;&lt;p&gt;Ten weeks later, &lt;code&gt;next build&lt;/code&gt; failed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⚠ Attempted to load @next/swc-darwin-arm64, but it was not installed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⨯ Failed to load SWC binary for darwin/arm64, see more info here: https://nextjs.org/docs/messages/failed-loading-swc&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The July install had ended one package short (378 resolved, 377 added), and the SWC binary was one of the two downloads that hit &lt;code&gt;ENOSPC&lt;/code&gt;. A later agent session &amp;ldquo;fixed&amp;rdquo; the build by installing the binary into &lt;code&gt;/tmp&lt;/code&gt; and symlinking it into &lt;code&gt;node_modules&lt;/code&gt;, a fix that lasts until macOS cleans &lt;code&gt;/tmp&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;First, ask the agent&amp;rsquo;s shell which pnpm it gets:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;command&lt;/span&gt; -v pnpm&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pnpm -v&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then make the agent use your version. Corepack reads &lt;code&gt;packageManager&lt;/code&gt;, whatever is first on &lt;code&gt;PATH&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;corepack pnpm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="c1"&gt;# runs pnpm 10.33.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Write that into &lt;code&gt;AGENTS.md&lt;/code&gt; (or your agent&amp;rsquo;s equivalent), along with one rule: if pnpm wants to remove &lt;code&gt;node_modules&lt;/code&gt;, stop and report.&lt;/p&gt;&#10;&lt;p&gt;To make the bundled pnpm harmless as well, turn off the automatic install in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;verifyDepsBeforeRun&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is the pnpm 10 default, so nothing changes for you. I checked it with the bundled pnpm 11.25.0: with the setting, it runs the script; without it, it installs first.&lt;/p&gt;&#10;&lt;p&gt;If you already followed the hint, clean up with your own pnpm:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git checkout -- pnpm-workspace.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf .pnpm-store&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;corepack pnpm install --frozen-lockfile&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run that in a terminal. If pnpm asks to remove &lt;code&gt;node_modules&lt;/code&gt;, say yes; this time it rebuilds against your own store.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;Three pnpm 11 changes line up:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;code&gt;verify-deps-before-run&lt;/code&gt; defaults to &lt;code&gt;install&lt;/code&gt; (pnpm 10: &lt;code&gt;false&lt;/code&gt;). &lt;code&gt;pnpm test&lt;/code&gt; and &lt;code&gt;pnpm exec&lt;/code&gt; check &lt;code&gt;node_modules&lt;/code&gt; first and install if anything is off.&lt;/li&gt;&#10;&lt;li&gt;pnpm 11 uses store &lt;code&gt;v11&lt;/code&gt;; pnpm 10 uses &lt;code&gt;v10&lt;/code&gt;. &lt;code&gt;node_modules/.modules.yaml&lt;/code&gt; records the store it was linked from, so to pnpm 11 a working pnpm 10 install looks incompatible and has to be purged. &lt;code&gt;confirmModulesPurge&lt;/code&gt; defaults to &lt;code&gt;true&lt;/code&gt;; a purge without a TTY aborts, and &lt;code&gt;CI=true&lt;/code&gt; drops the confirmation but not the purge.&lt;/li&gt;&#10;&lt;li&gt;Ignored build scripts are an error, and pnpm writes the &lt;code&gt;allowBuilds&lt;/code&gt; placeholders for you to fill in.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;On my Mac, the two stores now sit side by side in &lt;code&gt;~/Library/pnpm/store/&lt;/code&gt;: 851 MB in &lt;code&gt;v10&lt;/code&gt;, 588 MB in &lt;code&gt;v11&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Why didn&amp;rsquo;t &lt;code&gt;packageManager&lt;/code&gt; catch it? pnpm 11 does honor it: run the bundled binary directly and &lt;code&gt;pnpm -v&lt;/code&gt; prints &lt;code&gt;10.33.0&lt;/code&gt;, because it switches to the pinned version. The wrapper in the October runtime turns that off:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/usr/bin/env bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;dirname &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;pnpm_config_pm_on_fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ignore&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/../../node/bin/node&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/../../node/node_modules/pnpm/bin/pnpm.mjs&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The environment variable beats anything in the repo. With &lt;code&gt;pmOnFail: error&lt;/code&gt; in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, or &lt;code&gt;devEngines.packageManager&lt;/code&gt; with &lt;code&gt;&amp;quot;onFail&amp;quot;: &amp;quot;error&amp;quot;&lt;/code&gt;, the wrapper still runs 11.25.0.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An agent runtime can bring its own toolchain and switch off the check that would have caught it. When an agent reports &amp;ldquo;tests blocked by the environment&amp;rdquo;, find out which binary it ran before you believe the report.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;pnpm -v&lt;/code&gt; in your agent&amp;rsquo;s shell today and compare it with &lt;code&gt;packageManager&lt;/code&gt;.&lt;/p&gt;&#10;</description></item><item><title>TS2724 "has no exported member" in a git worktree: a symlinked node_modules typechecks the main checkout's source</title><link>https://eghuro.cz/posts/worktree-symlinked-node-modules-stale-workspace-package/</link><pubDate>Mon, 22 Jun 2026 19:21:00 +0200</pubDate><guid>https://eghuro.cz/posts/worktree-symlinked-node-modules-stale-workspace-package/</guid><description>&lt;p&gt;A coding agent cherry-picked a subagent&amp;rsquo;s commit into an integration worktree, ran &lt;code&gt;pnpm typecheck&lt;/code&gt; and got:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;apps/web/lib/uploads.ts(3,59): error TS2724: &amp;#39;&amp;#34;@app/persistence&amp;#34;&amp;#39; has no exported member named &amp;#39;recordRawUpload&amp;#39;. Did you mean &amp;#39;recordUpload&amp;#39;?&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;recordRawUpload&lt;/code&gt; was right there, exported from &lt;code&gt;packages/persistence/src/index.ts&lt;/code&gt; in that worktree. If tsc can&amp;rsquo;t see an export that you can, and the worktree&amp;rsquo;s &lt;code&gt;node_modules&lt;/code&gt; is a symlink to another checkout, tsc is reading the other checkout&amp;rsquo;s source.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A pnpm 10 monorepo on macOS. The workspace packages &lt;code&gt;@app/*&lt;/code&gt; point &lt;code&gt;exports&lt;/code&gt; straight at &lt;code&gt;./src/index.ts&lt;/code&gt;, so there is no build step between packages.&lt;/li&gt;&#10;&lt;li&gt;An orchestrating agent that creates a git worktree, a branch, and a subagent per issue, and cherry-picks finished work into an integration worktree.&lt;/li&gt;&#10;&lt;li&gt;A full disk.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;Every worktree had its own install, and &lt;code&gt;du&lt;/code&gt; counted up to 513 MB of &lt;code&gt;node_modules&lt;/code&gt; in each. A dozen worktrees later, the disk was full:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Filesystem Size Used Avail Capacity iused ifree %iused Mounted on&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/disk3s5 460Gi 420Gi 116Mi 100% 6.6M 1.2M 85% /System/Volumes/Data&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To save space, the agent replaced the &lt;code&gt;node_modules&lt;/code&gt; of each new worktree (the root, &lt;code&gt;apps/web&lt;/code&gt; and every package) with a symlink to the main checkout&amp;rsquo;s copy:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ln -s ../project/node_modules ../project-integration/node_modules&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ln -s ../../../project/apps/web/node_modules ../project-integration/apps/web/node_modules&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first typecheck after that passed. Later, a change that added an export to &lt;code&gt;@app/persistence&lt;/code&gt; and used it in &lt;code&gt;apps/web&lt;/code&gt; failed with TS2724. &lt;code&gt;readlink&lt;/code&gt; shows why:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; readlink apps/web/node_modules/@app/persistence&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;../../../../packages/persistence&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;pnpm links workspace packages with relative symlinks. A relative link resolves from the directory it really sits in, and that directory is now in the main checkout. So &lt;code&gt;@app/persistence&lt;/code&gt; in the worktree was &lt;code&gt;/Users/me/project/packages/persistence&lt;/code&gt;, the main checkout&amp;rsquo;s source, which didn&amp;rsquo;t have the new function yet.&lt;/p&gt;&#10;&lt;p&gt;The typecheck that passed also validated the main checkout.&lt;/p&gt;&#10;&lt;p&gt;Two subagents in their own worktrees hit the same error. One deleted the links, reinstalled, and its typecheck passed. The other wrote &amp;ldquo;Validation blocked by worktree node_modules links targeting older shared package. Source diff reviewed; commit now.&amp;rdquo; and committed without a passing typecheck.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give each worktree its own install. pnpm keeps package files in a content-addressable store, so an offline install only links them into place:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm node_modules apps/web/node_modules packages/*/node_modules &lt;span class="c1"&gt;# the symlinks&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pnpm install --offline --frozen-lockfile&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This took 3.6 seconds and reported &lt;code&gt;resolved 378, reused 378, downloaded 0&lt;/code&gt;. If the store is missing a package, &lt;code&gt;--offline&lt;/code&gt; fails; drop the flag.&lt;/p&gt;&#10;&lt;p&gt;Then check that every workspace package resolves inside the worktree. Run it with bash: zsh aborts on the first glob that matches nothing (&lt;code&gt;no matches found&lt;/code&gt;), and the agent&amp;rsquo;s own fix hit exactly that.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/usr/bin/env bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Print workspace packages that resolve outside this checkout.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt; -P&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; link in node_modules/@app/* */*/node_modules/@app/*&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;[&lt;/span&gt; -e &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$link&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;realpath &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$link&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$target&lt;/span&gt; in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;/*&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&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;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$link&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No output means every workspace package comes from this worktree.&lt;/p&gt;&#10;&lt;p&gt;If you have to share &lt;code&gt;node_modules&lt;/code&gt;, share only the third-party packages. The agent&amp;rsquo;s fix was an overlay: a real &lt;code&gt;node_modules&lt;/code&gt; directory per package, with every entry symlinked to the main checkout except &lt;code&gt;@app&lt;/code&gt;, whose entries point at the worktree&amp;rsquo;s own packages. Copying the main checkout&amp;rsquo;s &lt;code&gt;@app&lt;/code&gt; directory with &lt;code&gt;cp -RP&lt;/code&gt; gives the same result, because the copied relative links resolve from their new place.&lt;/p&gt;&#10;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;TypeScript (with the default &lt;code&gt;preserveSymlinks: false&lt;/code&gt;) and Node both resolve a package to its real path. Third-party packages are the same files in every checkout that shares a lockfile, so following the link to the main checkout is harmless. Workspace packages are your source code, and that differs between checkouts by design. Once a branch changes its dependencies, the third-party half goes stale as well.&lt;/p&gt;&#10;&lt;p&gt;The disk savings were probably smaller than they looked. pnpm clones or hard-links files from its store, and &lt;code&gt;du&lt;/code&gt; counts those blocks again in every tree, so its per-worktree numbers overstate what each install cost. The disk was also full for other reasons, and nobody measured how much the links saved.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A worktree that borrows another checkout&amp;rsquo;s &lt;code&gt;node_modules&lt;/code&gt; gets that checkout&amp;rsquo;s workspace packages too, and typecheck and tests then run against the wrong code, passing or failing. Run &lt;code&gt;pnpm install --offline&lt;/code&gt; in every new worktree.&lt;/p&gt;&#10;&lt;p&gt;If your agents create worktrees, run the check above in one of them today.&lt;/p&gt;&#10;</description></item><item><title>Next.js "Cannot read properties of undefined (reading 'length')" in installChunk: two processes share .next</title><link>https://eghuro.cz/posts/nextjs-installchunk-shared-next-dir/</link><pubDate>Mon, 22 Jun 2026 13:01:00 +0200</pubDate><guid>https://eghuro.cz/posts/nextjs-installchunk-shared-next-dir/</guid><description>&lt;p&gt;I opened the dashboard of my Next.js app and got a 500 error:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; GET /app/dashboard 500 in 285ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ⨯ TypeError: Cannot read properties of undefined (reading &amp;#39;length&amp;#39;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at &amp;lt;unknown&amp;gt; (.next/server/app/app/accounts/page.js:685:47)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at Object.&amp;lt;anonymous&amp;gt; (.next/server/app/app/accounts/page.js:688:3) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; page: &amp;#39;/app/accounts&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; GET /app/accounts 500 in 278ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If the stack points into generated &lt;code&gt;.next/server/.../page.js&lt;/code&gt; and the full trace ends in &lt;code&gt;installChunk&lt;/code&gt; in &lt;code&gt;webpack-runtime.js&lt;/code&gt;, your code is fine. Another Next process has written into the &lt;code&gt;.next&lt;/code&gt; directory your dev server is reading from.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Next.js 15.5 with webpack in dev, in a pnpm monorepo (the app lives in &lt;code&gt;apps/web&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;pnpm dev&lt;/code&gt; running in my terminal on port 3000&lt;/li&gt;&#10;&lt;li&gt;A coding agent working in the same checkout, which checks its work with &lt;code&gt;pnpm build&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;I asked the agent to fix the 500. It did what anyone would do with &lt;code&gt;reading 'length'&lt;/code&gt;: it grepped the app for &lt;code&gt;.length&lt;/code&gt;. The accounts page didn&amp;rsquo;t touch a collection at all, so the agent followed the authenticated layout into the auth code and suspected an import there. Then it opened the generated &lt;code&gt;page.js&lt;/code&gt; at line 685 and found webpack glue, not application code.&lt;/p&gt;&#10;&lt;p&gt;To reproduce, the agent ran &lt;code&gt;pnpm build&lt;/code&gt;, which succeeded, and then started its own &lt;code&gt;next dev -p 3001&lt;/code&gt; next to mine. The first request to &lt;code&gt;/app/accounts&lt;/code&gt; returned 200. After a request to the dashboard, both routes returned 500, and this time the error page carried the whole stack:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TypeError: Cannot read properties of undefined (reading &amp;#39;length&amp;#39;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at installChunk (apps/web/.next/server/webpack-runtime.js:249:41)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at __webpack_require__.f.require (apps/web/.next/server/webpack-runtime.js:259:15)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; at apps/web/.next/server/app/app/dashboard/page.js:718:47&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Line 249 is in webpack&amp;rsquo;s chunk loader:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;installChunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;moreModules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunkIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;chunkIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;installedChunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chunkIds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;chunk&lt;/code&gt; is whatever &lt;code&gt;require(&amp;quot;./&amp;quot; + chunkFile)&lt;/code&gt; returned. &lt;code&gt;chunk.ids&lt;/code&gt; was undefined, so the file on disk was not the chunk this runtime expected. A &lt;code&gt;ps&lt;/code&gt; showed why: two &lt;code&gt;next-server&lt;/code&gt; processes, mine on 3000 and the agent&amp;rsquo;s on 3001, both compiling into &lt;code&gt;apps/web/.next&lt;/code&gt;. On top of that, the agent&amp;rsquo;s &lt;code&gt;next build&lt;/code&gt; had written its production output into the same directory a few minutes earlier.&lt;/p&gt;&#10;&lt;p&gt;Both of the usual ways to reproduce a bug, a fresh build and a second server, are ways to cause this one. Before you reproduce, look for other Next processes on the checkout:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ps -axo pid,ppid,command &lt;span class="p"&gt;|&lt;/span&gt; grep &lt;span class="s1"&gt;&amp;#39;[n]ext-server&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="fix"&gt;Fix &lt;/h2&gt;&#10;&lt;p&gt;Give &lt;code&gt;next dev&lt;/code&gt; its own output directory in &lt;code&gt;next.config.mjs&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// `next dev` and `next build` generate incompatible webpack chunk graphs.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Keep their output separate so a build cannot break a running dev server.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;distDir&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;development&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next-dev&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ignore it in &lt;code&gt;.gitignore&lt;/code&gt;:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-gitignore" data-lang="gitignore"&gt;.next/&#10;.next-dev/&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Add its generated types to &lt;code&gt;tsconfig.json&lt;/code&gt;, next to the &lt;code&gt;.next&lt;/code&gt; ones:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;include&amp;#34;&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;next-env.d.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;**/*.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;**/*.tsx&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next/types/**/*.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;.next-dev/types/**/*.ts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The running dev server picked up the config change and rebuilt into &lt;code&gt;.next-dev&lt;/code&gt;. From then on, &lt;code&gt;next build&lt;/code&gt; writes to &lt;code&gt;.next&lt;/code&gt; and the dev server never reads it.&lt;/p&gt;&#10;&lt;p&gt;To recover without the config change, stop every Next process except one, delete &lt;code&gt;.next&lt;/code&gt; and restart &lt;code&gt;pnpm dev&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re on Next.js 16, you already have this: &lt;code&gt;next dev&lt;/code&gt; writes to &lt;code&gt;.next/dev&lt;/code&gt;, so &lt;a&#10; href="https://nextjs.org/docs/app/api-reference/cli/next"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;dev and build can run concurrently&lt;/a&gt;&#10;.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-follow-on-trap-next-envdts"&gt;The follow-on trap: &lt;code&gt;next-env.d.ts&lt;/code&gt; &lt;/h2&gt;&#10;&lt;p&gt;Next rewrites &lt;code&gt;next-env.d.ts&lt;/code&gt; on every dev start and every build, and the file names the types of whichever directory ran last:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;/// &amp;lt;reference path=&amp;#34;./.next-dev/types/routes.d.ts&amp;#34; /&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After every &lt;code&gt;next build&lt;/code&gt;, it says &lt;code&gt;./.next/types/routes.d.ts&lt;/code&gt; instead. The file was tracked, so it showed up as modified in later sessions, and the agents kept &amp;ldquo;fixing&amp;rdquo; it with &lt;code&gt;git show HEAD:apps/web/next-env.d.ts &amp;gt; apps/web/next-env.d.ts&lt;/code&gt;. Do what the &lt;code&gt;.gitignore&lt;/code&gt; from &lt;code&gt;create-next-app&lt;/code&gt; does and ignore the file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;next-env.d.ts&amp;#39;&lt;/span&gt; &amp;gt;&amp;gt; apps/web/.gitignore&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git rm --cached apps/web/next-env.d.ts&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;next dev&lt;/code&gt; and &lt;code&gt;next build&lt;/code&gt; both default to &lt;code&gt;.next&lt;/code&gt; in Next.js 15, and they write different chunk graphs into it. A second &lt;code&gt;next dev&lt;/code&gt; is no better: two webpack compilers write into one directory, and neither knows about the other. The runtime that is already loaded &lt;code&gt;require()&lt;/code&gt;s a chunk by the name from its own graph, gets a file the other compilation wrote, and fails at &lt;code&gt;chunk.ids.length&lt;/code&gt;. The error says &lt;code&gt;length&lt;/code&gt;, so you go looking for an array in your code.&lt;/p&gt;&#10;&lt;p&gt;Coding agents check their work with &lt;code&gt;next build&lt;/code&gt;, usually while your dev server is still running, so you&amp;rsquo;ll hit this more often than you&amp;rsquo;d think.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An error from inside &lt;code&gt;installChunk&lt;/code&gt; points at the build directory, so look for a second Next process before you read your own code. Never let two Next processes share a &lt;code&gt;distDir&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If an agent or a script runs &lt;code&gt;next build&lt;/code&gt; in the checkout where your dev server runs, give dev its own &lt;code&gt;distDir&lt;/code&gt; today.&lt;/p&gt;&#10;</description></item><item><title>An MCP server has more outputs than tool results</title><link>https://eghuro.cz/posts/mcp-server-more-outputs-than-tool-results/</link><pubDate>Tue, 19 May 2026 20:00:00 +0200</pubDate><guid>https://eghuro.cz/posts/mcp-server-more-outputs-than-tool-results/</guid><description>&lt;p&gt;I designed a local stdio MCP server in Go that gives an LLM scoped access to a shared password vault. Two facts set the threat model. Any member of a shared vault can write an entry comment that another member&amp;rsquo;s agent later reads, so stored text is attacker-controlled. And the model can call every tool in the manifest, so whoever steers the model gets that authority too.&lt;/p&gt;&#10;&lt;p&gt;The plan started with a prompt-injection fence around user strings. Most of the work ended up elsewhere.&lt;/p&gt;&#10;&#10;&lt;h2 id="keep-the-secret-off-the-transcript"&gt;Keep the secret off the transcript &lt;/h2&gt;&#10;&lt;p&gt;Once &lt;code&gt;get_password&lt;/code&gt; returns plaintext, the value sits in the model&amp;rsquo;s context and the host&amp;rsquo;s chat history, and no scope rule can take it back. So the everyday path never crosses that boundary:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;copy_password_to_clipboard&lt;/code&gt; takes an ID, writes the value to the OS clipboard from inside the server, and returns &lt;code&gt;Password (id=42) copied to clipboard.&lt;/code&gt; Auto-clear compares a SHA-256 of the current clipboard first, so it can&amp;rsquo;t wipe something the user copied later.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;create_password_generated&lt;/code&gt; generates the value server-side and returns only the new row&amp;rsquo;s ID.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;get_password&lt;/code&gt; returns the literal &lt;code&gt;&amp;lt;redacted; revealPlaintext=never&amp;gt;&lt;/code&gt; by default, and its description points the model at the clipboard tool.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The confirmation first included the fenced entry title. That was removed: the model already has the title, and &amp;ldquo;action just completed&amp;rdquo; is a bad moment to hand it attacker-written text. A tool result should echo as little as possible.&lt;/p&gt;&#10;&#10;&lt;h2 id="annotations-are-hints-so-consent-lives-in-the-server"&gt;Annotations are hints, so consent lives in the server &lt;/h2&gt;&#10;&lt;p&gt;Plaintext in chat remains for cases where the model has to reason about the value, such as a reuse audit. It sits behind a setting: &lt;code&gt;never&lt;/code&gt;, &lt;code&gt;per_call&lt;/code&gt; or &lt;code&gt;always&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;That setting began as a boolean, and the trap was in the annotations. A read is correctly &lt;code&gt;readOnlyHint=true&lt;/code&gt;, so the host&amp;rsquo;s confirmation never fires, and one injected instruction could walk &lt;code&gt;list_passwords&lt;/code&gt; → &lt;code&gt;get_password&lt;/code&gt; across every entry in scope. Marking the read as destructive doesn&amp;rsquo;t help: annotations are static at registration, and hosts may ignore the cue on a read-only tool.&lt;/p&gt;&#10;&lt;p&gt;So &lt;code&gt;per_call&lt;/code&gt; is an OS-native modal raised inside the server before the value is serialized, with a 30-second timeout that fails closed. If no GUI is available, the server refuses to start in that mode.&lt;/p&gt;&#10;&lt;p&gt;Two more manifest details matter. &lt;code&gt;destructiveHint&lt;/code&gt; and &lt;code&gt;openWorldHint&lt;/code&gt; default to true in the spec, which is why the Go SDK types them as &lt;code&gt;*bool&lt;/code&gt;, and every tool here sets them explicitly. And the manifest itself is a control: if the clipboard probe fails, the tool is never registered, so the model can&amp;rsquo;t try it.&lt;/p&gt;&#10;&#10;&lt;h2 id="authority-lives-in-code"&gt;Authority lives in code &lt;/h2&gt;&#10;&lt;p&gt;Tool descriptions are instructions to something an attacker may be steering, so they count as guidance, not control. Scope is a deterministic filter on &lt;code&gt;(vault, [tags], mode)&lt;/code&gt;, and the type system gates writes:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;scopedOps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;DeletePassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ResolvedRule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A rule only comes from the scope resolver, so a handler that skips the check has nothing to pass and doesn&amp;rsquo;t compile. A runtime check catches the wrong rule: another vault, or a read-only mode. The scope itself lives in the OS keychain, and the server only reads it so that no tool call can widen it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-input-schema-enforces-less-than-it-looks-like"&gt;The input schema enforces less than it looks like &lt;/h2&gt;&#10;&lt;p&gt;With the Go SDK, &lt;code&gt;mcp.AddTool&lt;/code&gt; derives the input schema from struct tags. An earlier tag format, &lt;code&gt;jsonschema:&amp;quot;required,description=...&amp;quot;&lt;/code&gt;, failed at registration because the &lt;code&gt;jsonschema&lt;/code&gt; tag is now the property description and nothing else. The tags were rewritten as prose:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;Mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;mode,omitempty&amp;#34; jsonschema:&amp;#34;Strategy. (Schema hints: enum=memorable; enum=random.)&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;Length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;length,omitempty&amp;#34; jsonschema:&amp;#34;Length. (Schema hints: minimum=6; maximum=32.)&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads like a constraint and enforces nothing. The generated schema has only &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;, and a call with &lt;code&gt;mode=evil, length=999&lt;/code&gt; reaches the handler. The handler&amp;rsquo;s own validation is the only check, and it has to reject: the generator below it silently clamps, so the model would ask for one thing and get another.&lt;/p&gt;&#10;&lt;p&gt;The schema does give you types, required fields, and &lt;code&gt;additionalProperties: false&lt;/code&gt;. When it rejects a call, the SDK answers before your handler runs, so your audit middleware never sees it, and the message echoes the offending value.&lt;/p&gt;&#10;&lt;p&gt;Beyond that, arguments are IDs, enums, and bounded integers. Omitted and &lt;code&gt;[]&lt;/code&gt; mean different things on an update, so that field is a &lt;code&gt;*[]int64&lt;/code&gt;. The model can&amp;rsquo;t push a row out of its own visibility, because one allowed tag must remain.&lt;/p&gt;&#10;&lt;p&gt;Stored content is deliberately not filtered. A comment reading &amp;ldquo;ignore previous instructions&amp;rdquo; is legal data, and the regular clients show it verbatim. Escaping happens when text leaves for the model.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fence-and-the-encoder-after-it"&gt;The fence, and the encoder after it &lt;/h2&gt;&#10;&lt;p&gt;Read tools wrap each user string, and their descriptions tell the model that fenced text is data:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;lt;user_content&amp;gt;{escaped}&amp;lt;/user_content&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; are HTML-escaped so a stored payload can&amp;rsquo;t close the tag. C0 controls are escaped too, because a stored ESC paints ANSI sequences on the terminal of anyone inspecting raw traffic, and the nine bidi overrides are replaced so stored text can&amp;rsquo;t reorder what a human approves.&lt;/p&gt;&#10;&lt;p&gt;&amp;ldquo;Ignore previous instructions&amp;rdquo; passes through unchanged. Against natural-language injection, the fence makes the boundary explicit. The real limit is what a steered model can reach.&lt;/p&gt;&#10;&lt;p&gt;Two gaps remain, both visible only by running it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;The tags don&amp;rsquo;t arrive as written.&lt;/strong&gt; Returning a typed &lt;code&gt;Out&lt;/code&gt; lets the SDK serialize it with &lt;code&gt;encoding/json&lt;/code&gt;, which HTML-escapes by default. A title reaches the model&amp;rsquo;s text block as &lt;code&gt;&amp;lt;user_content&amp;gt;...&lt;/code&gt;, and &lt;code&gt;&amp;amp;amp;&lt;/code&gt; becomes &lt;code&gt;&amp;amp;amp;&lt;/code&gt;. The payload still can&amp;rsquo;t close the fence, but the literal the tool description promises isn&amp;rsquo;t the literal on the wire. To control it, set &lt;code&gt;result.Content&lt;/code&gt; yourself using an encoder with &lt;code&gt;SetEscapeHTML(false)&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The escape table works on bytes.&lt;/strong&gt; It covers bytes below &lt;code&gt;0x20&lt;/code&gt;, so DEL and U+009B, the 8-bit CSI encoded as &lt;code&gt;C2 9B&lt;/code&gt;, pass through. A rune-based check with &lt;code&gt;unicode.IsControl&lt;/code&gt; catches both.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="each-sink-needs-its-own-treatment"&gt;Each sink needs its own treatment &lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Sink&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Data&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Treatment&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tool result&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Free text&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Escape, replace bidi overrides, fence. Keep ZWJ and RLM, which Arabic, Indic scripts, and emoji need&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tool result&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Vault and tag names&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Strip control and format runes, cap at 256, then fence, which still protects a handler that forgets the fence&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Startup error&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Backend-supplied version&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Allowlist regex, otherwise &lt;code&gt;unknown&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Consent dialog&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Entry title&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Reject controls, line separators and bidi; truncate to 80 runes&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Subprocess&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Entry title&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Environment variable, never script source&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Audit log&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Content fields&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Dropped; a search query is logged as its length&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tool error&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Anything&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Fixed text&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The consent dialog shows why one sanitizer isn&amp;rsquo;t enough. The AppleScript is a constant, and the title arrives as data:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-applescript" data-lang="applescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="nb"&gt;system attribute&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ENTRY_TITLE&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;display dialog&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Reveal password \&amp;#34;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;\&amp;#34; to your AI assistant?&amp;#34;&lt;/span&gt; &lt;span class="c"&gt;-- ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That stops code injection and leaves another problem. The dialog still renders the title, so newlines, bidi overrides, or a long payload could bury the warning and harvest a click on Allow. Data-not-code and safe-to-display are separate properties. The interpreter is called by absolute path as well, because a &lt;code&gt;PATH&lt;/code&gt;-shadowed binary would receive the title in its environment.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-error-path-talks-to-the-model"&gt;The error path talks to the model &lt;/h2&gt;&#10;&lt;p&gt;In the Go SDK, a non-nil error from a tool handler becomes &lt;code&gt;isError&lt;/code&gt; content, so &lt;code&gt;err.Error()&lt;/code&gt; goes to the model. Every handler here runs through a middleware that classifies the error, returns fixed text, and sends the reason to the audit log:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;wrapHandler&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;In&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;In&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="nx"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ToolHandlerFor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;In&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Out&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CallToolRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;In&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// named: recover writes these&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;defer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;recover&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="nf"&gt;errGeneric&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nb"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="c1"&gt;// deadline, session check, inner(), classify, audit&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The named returns matter. Without them, a recovered panic returns &lt;code&gt;(nil, zero, nil)&lt;/code&gt;, which the host reads as an empty success. The panic value is logged by type, and by content only if it&amp;rsquo;s a string or error, because &lt;code&gt;%v&lt;/code&gt; on a struct could print a password.&lt;/p&gt;&#10;&lt;p&gt;What still had to leave the error text:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Scope details.&lt;/strong&gt; A bypass error included the rule&amp;rsquo;s vault ID and mode, so a steered model could map the scope one call at a time. Those fields moved to a typed error for the audit log.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Subprocess output.&lt;/strong&gt; &lt;code&gt;fmt.Errorf(&amp;quot;pbcopy: %w: %s&amp;quot;, err, out)&lt;/code&gt; could carry clipboard content. Stdout now goes to &lt;code&gt;/dev/null&lt;/code&gt;, and stderr never joins the error.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Identity.&lt;/strong&gt; An &amp;ldquo;account changed&amp;rdquo; error named the username and backend. It can fire on every tool call, putting N copies of an email address into a transcript that may get exported or summarized. The final text names nobody.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Tamperable integers.&lt;/strong&gt; A version-mismatch error quoted a schema number from a local file, whose author then controlled a value the model reads as authoritative.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The audit log is an output too. Its redactor is a key-name denylist over &lt;code&gt;any&lt;/code&gt;, and it needed six fixes: nested maps, &lt;code&gt;[]map[string]any&lt;/code&gt;, pointers and typed maps, nested structs, &lt;code&gt;Password&lt;/code&gt;/&lt;code&gt;URL&lt;/code&gt; casing, and map keys in result metadata. A new field called &lt;code&gt;token&lt;/code&gt; would still be logged. An allowlist of loggable fields changes that failure from a leaked secret to a missing log value.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-stdio-child-outlives-the-login"&gt;A stdio child outlives the login &lt;/h2&gt;&#10;&lt;p&gt;The host spawns the server once and keeps it alive for the chat. An ADR said the session is read on every call, so a login as a different account gets refused. The implementation read it once at startup, and the error sentinels existed with no code path producing them. A second login with a chat open left the server acting as the old account, with no warning.&lt;/p&gt;&#10;&lt;p&gt;The check now runs in the middleware, before each handler. It came with a race: a check could pass, then a sibling refresh could swap the credentials before the handler used them. The check and the credential snapshot now happen in one critical section, and the handler receives that snapshot through its context.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-stays-open"&gt;What stays open &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Natural-language injection is contained, not solved. The scope rules limit the blast radius.&lt;/li&gt;&#10;&lt;li&gt;The tag filter runs on the server after the backend returns the whole vault, so the binary is trusted. A server-issued scoped token is the real fix.&lt;/li&gt;&#10;&lt;li&gt;Auto-clear dies with the process, and clipboard managers keep their own history.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Treat the SDK as part of your attack surface: read what your schema really contains, and look at the bytes a tool result becomes. Then list every place a value can go, including errors, dialogs, and logs. Remove the sinks you can, as the clipboard tool did for secrets, and give each remaining sink the treatment its grammar calls for.&lt;/p&gt;&#10;</description></item></channel></rss>