<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Tim Schipper — Blog (EN)</title>
<link>https://tim-schipper.nl/en/blog</link>
<description>Articles and thoughts on web development, architecture and technology.</description>
<language>en</language>
<lastBuildDate>Wed, 14 Oct 2026 07:57:27 GMT</lastBuildDate>
<atom:link href="https://tim-schipper.nl/en/rss.xml" rel="self" type="application/rss+xml" />
<item>
<title>Claude Code auto mode: why your Agent allow rule does nothing</title>
<link>https://tim-schipper.nl/en/blog/claude-code-auto-mode-subagents</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/claude-code-auto-mode-subagents</guid>
<pubDate>Wed, 14 Oct 2026 07:57:27 GMT</pubDate>
<description>In a month of one repository, the Claude Code auto mode classifier blocked 86 of 594 subagent launches, mostly ordinary code reviews. Adding Agent to the allowlist changed nothing, because auto mode drops that rule on entry. Retrying the identical prompt went through 24 times out of 26.</description>
<content:encoded><![CDATA[<p>A review agent gets refused. The reason in square brackets reads <code>[Auto-Mode Bypass]</code>. The main agent shrugs, sends the exact same prompt again, and this time the reviewer starts and does its job.</p>
<p>That happened to me dozens of times in one repository over the past month, often enough that I kept switching auto mode off to get anything done. I had added <code>Agent</code> to the allowlist in that project. It made no difference, and the reason is documented, just not anywhere you would think to look.</p>
<p><img src="/images/illustrations/claude-code-auto-mode-subagents.svg" alt="Agent calls skip permissions.allow, the classifier blocks 86 of 594, and 24 of 26 identical retries go through."></p>
<h2>What auto mode gets right</h2>
<p>Auto mode replaced the permission prompt with a second model. Since Claude Code 2.1.283 it is the <a href="https://code.claude.com/docs/en/permission-modes">starting mode for interactive sessions</a> on every plan. Instead of you clicking yes, a classifier reads each action against a list of block rules and lets the routine ones through.</p>
<p>Mostly that is a clear improvement on the <a href="/en/blog/claude-code-permissions-guide">modes and allowlists I wrote about in July</a>. Prompt fatigue is real, and a reviewer that never gets tired catches the force-push and the <code>npm publish</code> you would have waved through at the fortieth prompt. In the same repository it also stopped git commands it read as destructive and an attempt to create a public surface, which is exactly the class of action it exists for.</p>
<p>The trouble is subagents.</p>
<h2>The rule that disappears</h2>
<p>Claude Code decides every action in a fixed order. Your allow, ask and deny rules come first, read-only actions and edits inside the project come second, and everything else goes to the classifier. So the obvious fix for a blocked subagent is an allow rule for <code>Agent</code>, which should resolve at step one.</p>
<p>It doesn’t, because of a paragraph in the docs:</p>
<blockquote>
<p>On entering auto mode, broad allow rules that grant arbitrary code execution are dropped</p>
</blockquote>
<p>The list under it covers blanket <code>Bash(*)</code>, wildcarded interpreters, package-manager run commands, and <code>Agent</code> allow rules. Claude Code puts them back when you leave auto mode. While you are in it, your <code>&quot;Agent&quot;</code> entry is gone, and nothing in the block message mentions it.</p>
<p>The reasoning holds up. A subagent can run anything its tools allow, so a blanket allow for <code>Agent</code> is the same hole as <code>Bash(*)</code>. But the docs also say that the classifier checks a subagent at three points: the task description at spawn time, every action while it runs, and its report when it finishes. The spawn check is where my reviewers died.</p>
<h2>A month of one repository, counted</h2>
<p>Claude Code writes every session to a JSONL transcript, and a blocked call comes back as a tool result with the classifier’s reason in it. So I counted them across one repository, my Knossos MCP server, from 12 September to 14 October: 20 session transcripts on Claude Code 2.1.269 through 2.1.296. Four of them are forks of one conversation that carry copies of its history, so I counted every call once.</p>
<table>
<thead>
<tr>
<th style="text-align:left">What</th>
<th style="text-align:left">Count</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Agent calls</td>
<td style="text-align:left">594</td>
</tr>
<tr>
<td style="text-align:left">Blocked by the classifier</td>
<td style="text-align:left">86 (14.5%)</td>
</tr>
<tr>
<td style="text-align:left">of which <code>[Auto-Mode Bypass]</code></td>
<td style="text-align:left">70</td>
</tr>
<tr>
<td style="text-align:left">of which <code>[Data Exfiltration]</code></td>
<td style="text-align:left">12</td>
</tr>
<tr>
<td style="text-align:left">of which <code>[Instruction Poisoning]</code> or no label</td>
<td style="text-align:left">4</td>
</tr>
<tr>
<td style="text-align:left">Identical retry of a blocked prompt that went through</td>
<td style="text-align:left">24 of 26</td>
</tr>
</tbody>
</table>
<p>Almost all of the blocked launches were the plainest work there is: “Review Task 9 (spec + quality)”, “Re-review Task 8 fix round 1”, “Implement Phase 2 Task 5: release gate”. The repository runs its plans through <a href="/en/blog/superpowers-plugin-guide">subagent-driven development from the Superpowers plugin</a>, so a single feature means a dozen subagents, most of them reviewers told to read a diff and report back without touching anything.</p>
<p>I looked for wording that set the classifier off and found little. Token, bypass and permission show up about as often in the prompts it allowed as in the ones it blocked. Push leans the other way, in 42% of blocked prompts against 33% of allowed ones. The word nudges the odds a little and decides nothing.</p>
<p>The retries settle it: 26 times a blocked prompt was sent again word for word, before the session’s fallback kicked in, and 24 of those went through. Same input, different verdict. The <code>Auto-Mode Bypass</code> rule exists to catch an agent “taking a denial in bad faith and tunneling the same action through a different path”, and the identical retry is the one path it reliably let through.</p>
<p>Then there is the cap. After three blocks in a row or twenty in a session, auto mode pauses and falls back to prompting, and those thresholds are not configurable. Two of my sessions hit 36 and 31 classifier blocks in total. That is the moment I kept switching auto mode off by hand.</p>
<p>One smaller finding: three Agent calls got no verdict at all, because one of my own hooks rewrote the call’s input after the model wrote it, so the classifier was asked to judge something other than what would run. Claude Code refuses those, which is correct, and it reads exactly like a block.</p>
<h2>What actually helps</h2>
<p><strong>Write your workflow into <code>autoMode.allow</code>.</strong> The classifier’s own exceptions live in <code>autoMode.allow</code>, a list of prose rules, and <code>Auto-Mode Bypass</code> is a soft block, so an allow entry can override it. This is the one I added to <code>~/.claude/settings.json</code>, with <code>&quot;$defaults&quot;</code> kept so the built-in exceptions stay:</p>
<pre><code class="language-json">{
  &quot;autoMode&quot;: {
    &quot;allow&quot;: [
      &quot;$defaults&quot;,
      &quot;Dispatching subagents with the Agent tool to implement, review, re-review or research code in the current working repository, including instructing an implementer to edit files, run the test suite and commit to the current branch, is allowed: it is the user's standard plan-execution workflow.&quot;
    ]
  }
}
</code></pre>
<p>Describe the setup too. An <code>autoMode.environment</code> entry that says reviewers are read-only and implementers stay in the current repository gives the classifier the context it otherwise guesses at. Run <code>claude auto-mode critique</code> on the result, and <code>claude auto-mode config</code> to see what the classifier actually reads. <code>/auto-mode-setup</code> drafts these entries for you and offers to remove the allow rules in <code>~/.claude/settings.json</code> that auto mode ignores anyway.</p>
<p>My own numbers do not show it closing the gap. I added these entries somewhere between 6 and 13 October, and on 13 October three of fourteen Agent calls were still blocked. Two of those were <code>Data Exfiltration</code>, which is a hard block: no allow entry and no user intent overrides it, by design.</p>
<p><strong>Use <code>/permissions</code> instead of switching modes.</strong> The <strong>Recently denied</strong> tab lists every block, and pressing <code>r</code> lets the model retry that one call. Given the retry numbers above, that clears most false positives without leaving auto mode.</p>
<p><strong>Don’t reach for the bypass.</strong> A <a href="https://code.claude.com/docs/en/plugins/mods/overview">mod</a> that handles <code>tool.check</code> can approve an action before the classifier sees it, and the classifier never checks what the mod approved. That works, and it also means writing your own <code>Agent(*)</code> with extra steps. A subagent’s actions still go through the classifier while it runs, but the spawn check exists for a reason.</p>
<p><strong>Plan subagent-heavy work in Manual mode.</strong> If a session is going to fire forty reviewers, one Shift+Tab and an approval per launch is faster than hitting the twenty-block fallback halfway through.</p>
<p>A fixed allowlist is boring and predictable, and the classifier is neither. It reads my reviewer prompt, says no, then reads the same prompt again and says yes. That is a dice roll at the spawn point, and I would rather know which rule decides than how often it lands my way. Until that check gives the same verdict on the same input, treat a blocked subagent as noise first, and a <a href="/en/blog/how-to-write-a-claude-code-subagent">subagent with a narrow tools allowlist</a> as the thing that actually limits what it can do.</p>
]]></content:encoded>
</item>
<item>
<title>Your upload validator passed. Your server still got owned.</title>
<link>https://tim-schipper.nl/en/blog/your-upload-validator-passed</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/your-upload-validator-passed</guid>
<pubDate>Mon, 12 Oct 2026 14:24:02 GMT</pubDate>
<description>Bricksforge checked that an upload was an image, then trusted the browser to decide where its contents ended up. The lesson is in the handoff: a correct check can protect the wrong part of your workflow.</description>
<content:encoded><![CDATA[<p>The upload was an image. The check passed. Later, the same contents became executable PHP.</p>
<p>That is the detail worth keeping from the Bricksforge story <a href="https://thehackernews.com/2026/10/attackers-exploit-cvss-100-bricksforge.html">back in the news today</a>. It is tempting to file an upload vulnerability under missing validation and move on. This one had validation. The next step stopped respecting what it established.</p>
<p>I care about that distinction because it changes what I would look for in a review. Finding a MIME check in an upload handler answers one question. Following the file until the application uses it answers the one that matters.</p>
<p><img src="/images/illustrations/upload-validator-passed.svg" alt="The MIME check accepts a GIF, then a client-supplied URL directs its contents to a PHP file."></p>
<h2>Where the checked image became PHP</h2>
<p><a href="https://patchstack.com/articles/critical-unauthenticated-arbitrary-file-upload-vulnerability-exploited-in-bricksforge-plugin/">Patchstack’s report, published on 8 October</a>, describes CVE-2026-85097. An unauthenticated attacker could obtain a nonce, upload a GIF containing PHP code, and submit a form with altered metadata.</p>
<p>The file passed the initial MIME check. During submission, the <code>file</code> field referred to that image, while the client-controlled <code>url</code> field named a PHP destination. Bricksforge trusted the metadata and placed the contents there. Requesting that PHP file then executed the embedded code.</p>
<p>The same bytes went through two different interpretations. The upload check recognised an image. The eventual destination let the server treat its contents as a program.</p>
<p>Here is the failure compressed to the two fields that matter. This is a schematic, not a complete request:</p>
<pre><code class="language-text">file: /uploads/tmp/image.gif
url:  https://example.com/uploads/image.php
</code></pre>
<p>The dangerous decision happened at the handoff: information sent back by the browser determined where the server put the file.</p>
<h2>A check has a scope</h2>
<p>An image check is useful. It can reject input your application does not support, catch mistakes, and contribute to a layered upload policy. I would keep it.</p>
<p>But what exactly did it prove?</p>
<p>It established something about the uploaded contents at that point in the workflow. It did not authorise a later filename, approve a destination, or bind a subsequent form submission to the server’s original record of the upload.</p>
<p>Those are separate decisions. Each needs its own source of authority.</p>
<p>The mistake is assuming a validated file stays safe through every later step. A file may acquire a different extension. A client may return different metadata. A later handler may interpret a field as an instruction that the first handler treated as display data.</p>
<p>In a review, I want to find where that interpretation changes. Who supplied the value? Who can replace it? What decision does it control now?</p>
<p>That is also why a screenshot of a tidy upload widget tells you little about the server behind it. The <a href="https://bricksforge.io/version-changelog/">Bricksforge changelog for 4.0.1, dated 8 October</a>, says the issue affected every site with Pro Forms active, including forms with no upload fields. The visible form was a poor way to judge exposure.</p>
<h2>Read across the handlers</h2>
<p>Reviewing the upload function on its own encourages a comforting little inventory: size limit, type check, temporary directory. Everything looks sensible.</p>
<p>Then you open the submission handler. It has a file reference, a URL, some metadata. Those names look reasonable too. The problem appears when you trace which values survive between them and which ones the client can replace.</p>
<p>This is the kind of reading I want developers to practise. In <a href="/en/blog/code-reading-practice">my routine for reading unfamiliar code</a>, the useful exercise is predicting what the code will actually do. For an upload pipeline, extend that prediction across the request boundary.</p>
<p>Write down the final path before running anything. Explain where every part of it came from. If the answer includes a client field, ask why that field gets to make a storage decision.</p>
<p>An agent can help find the callers and follow the data. The judgement still has to be yours. Ask it to trace each value into the final write, then check the trace against the code.</p>
<p>If you stop at a fluent explanation of the upload validator, the rest of the workflow stays unread. That is <a href="/en/blog/stop-copy-paste-engineering">adopting code without understanding its decisions</a>.</p>
<p>I have no evidence that AI wrote the vulnerable Bricksforge code. This is an ordinary software boundary that deserves a better review.</p>
<h2>Keep the storage decision on the server</h2>
<p><a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP’s file-upload guidance</a> recommends application-generated filenames, restricted allowed types, and storage outside the webroot or on a separate host. It also warns that a file-signature check cannot carry the defence alone.</p>
<p>For a two-step upload flow, my design would keep the server’s record authoritative throughout. The first request creates a record for the stored object. The later request supplies an opaque upload ID. The server looks up the path, permitted type, and ownership or submission binding itself.</p>
<p>An opaque ID still needs authorisation. A random-looking identifier is no reason to let one submission claim another submission’s upload.</p>
<p>The original filename can remain as display information. It should not become a filesystem path simply because the browser sent it back. Likewise, a public download URL should be derived from the stored object, rather than accepted as instructions for where to write it.</p>
<p>I would also keep uploaded content out of executable locations. Where the application must serve it publicly, the server configuration needs to treat it as data, with no PHP handler for that storage area. That gives you another boundary if an application-level check fails.</p>
<p>These are design recommendations, not a description of Bricksforge’s patch. The vendor’s changelog confirms the fix without documenting its implementation.</p>
<h2>Test the journey the attacker takes</h2>
<p>A unit test showing that the MIME validator accepts a GIF can be correct while the complete system remains vulnerable. The useful regression test continues past that point.</p>
<p>Start with a permitted upload. Complete the later submission with metadata that disagrees with the original object. Then inspect what was actually stored and served.</p>
<p>I would cover these cases:</p>
<ul>
<li><strong>Changed destination.</strong> Submit an executable extension or a path outside the intended storage area. The client must never choose the final write location.</li>
<li><strong>Changed reference.</strong> Try another submission’s upload ID, or reuse one after it has been consumed. Check the ownership and lifecycle rules your application actually promises.</li>
<li><strong>Unexpected upload data.</strong> Send upload metadata to a form that has no upload field. The backend should enforce its configured fields rather than trust the browser’s form layout.</li>
<li><strong>Execution at retrieval.</strong> Request the stored file through the deployed serving configuration. Verify that uploaded contents cannot run as server-side code.</li>
</ul>
<p>Those tests check behaviour across upload, submission, and retrieval. They make the security property explicit: client-supplied metadata cannot turn an accepted upload into an executable file.</p>
<p>They belong in an isolated test environment, with fixtures you control. You do not need to reproduce an attack against somebody else’s site to check your own boundary.</p>
<h2>The patch closes the entry point</h2>
<p>Patchstack identifies 3.1.8.10 as the fixed version for the reported 3.x range. The vendor lists the Pro Forms upload fix in 4.0.1. Check the release line you operate rather than treating a larger version number as proof that a fix is included.</p>
<p>Patchstack also reports exploitation attempts beginning on 7 October at 21:47 UTC. On a site that was exposed, it recommends checking historical logs and the host for unexpected PHP files or unauthorised changes.</p>
<p>Patching and checking for an earlier intrusion answer different questions. Closing the upload route says nothing about a file an attacker already left behind.</p>
<p>The part I would take back to my own codebase is the handoff. Find the next handler after the check. Trace the values that determine where the file is written, then check how the server serves it.</p>
<p>The validator did its job. The workflow needed one too.</p>
]]></content:encoded>
</item>
<item>
<title>When do developers become redundant?</title>
<link>https://tim-schipper.nl/en/blog/will-ai-replace-developers</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/will-ai-replace-developers</guid>
<pubDate>Sat, 10 Oct 2026 21:22:02 GMT</pubDate>
<description>To cost you your job, AI only has to be cheaper than part of the team. Software postings are growing again, but mostly for seniors, and the same months show how quickly a company chooses a smaller team.</description>
<content:encoded><![CDATA[<p>To cost you your job, AI only has to be cheaper than you.</p>
<p>I have been programming since 1984. In that time I have watched a fair number of technologies arrive that were going to change our work forever.</p>
<p>Some of them did.</p>
<p>We went from assembly and BASIC to higher-level languages. From hand-written HTML to frameworks. From our own servers to the cloud. From Stack Overflow to an agent that walks half your codebase before your coffee goes cold.</p>
<p>And at every step, the same thing was said: developers are about to become redundant.</p>
<p>It never happened.</p>
<p>This time something is different, though. AI has not suddenly become intelligent, and Claude and Codex still write software with bugs in it. The difference is economic.</p>
<h2>You don’t have to be replaced to lose your job</h2>
<p>Say you have a team of twenty developers.</p>
<p>In two years, ten of them might ship the same amount of software. Those ten are the same developers, with agents taking over most of the execution.</p>
<p>Then the company has a choice.</p>
<p>Keep twenty people and build twice as much. Or keep ten and keep delivering the same.</p>
<p>Which one do you think the CFO is going to pick?</p>
<p>That is no longer a thought experiment. In February, Jack Dorsey’s Block went from over 10,000 employees to fewer than 6,000. Dorsey gave the reason himself: “We’re already seeing that the intelligence tools we’re creating and using, paired with smaller and flatter teams, are enabling a new way of working which fundamentally changes what it means to build and run a company.”</p>
<p>TechCrunch keeps a <a href="https://techcrunch.com/2026/07/25/the-running-list-major-tech-layoffs-in-2026-where-employers-cited-ai/">running list of the big tech companies</a> that named AI in a round of layoffs this year. By the end of July it had 21 on it.</p>
<p>Of course this is a simplification. Software development is more than writing code. Requirements are vague, systems are full of historical decisions, and production has the annoying habit of ignoring your benchmarks.</p>
<p>Some of those layoffs are also plain cost-cutting with a more modern story attached.</p>
<p>But the argument that AI cannot replace a complete developer misses the point.</p>
<p>It doesn’t need to.</p>
<h2>Demand is growing again, just not for everyone</h2>
<p>Since this summer, the counter-argument has numbers too.</p>
<p>According to <a href="https://www.hiringlab.org/2026/07/08/ai-and-job-postings-from-destruction-to-creation/">Indeed Hiring Lab</a>, US software development postings grew almost 15% after Claude Code launched in late February 2025, while total postings fell 7% over the same period. After years of contraction, the occupations most exposed to AI are now rebounding the hardest. Indeed is careful to say correlation is not causation, but calls the timing “a coincidence that cannot be ignored”.</p>
<p>That is good news, and I am not going to explain it away.</p>
<p>Look at who is being hired, though. Of the growth between May 2025 and May 2026, 71% came from senior roles and 37% from jobs with AI in the title. Indeed’s own reading: “demand is growing for experienced professionals who can work with AI, not necessarily a broad-based recovery across all software roles.”</p>
<p>So the market is growing and shrinking at the same time. Block cuts its headcount, the job boards fill up again, and which side you land on depends on who you are.</p>
<h2>The junior goes first</h2>
<p>Every senior developer spent years fixing bugs, making the wrong architecture calls, digging through production incidents, and learning why that one elegant solution turned out to be a disaster in practice.</p>
<p>You don’t learn that from a prompt.</p>
<p>But if an agent handles the simple tickets faster than a junior, why would a company still hire the junior? That 71% is the answer the market is giving right now. I wrote earlier about <a href="/en/blog/the-most-resilient-job-is-eating-its-seed-corn">the seed corn the profession is eating</a>: if nobody trains juniors, where do the seniors of ten years from now come from? And <a href="/en/blog/hiring-junior-developers-ai">the one large employer still hiring juniors</a> shows what the role has to become for that investment to pay off.</p>
<p>The short version: experience has to be built somewhere. And the work you used to build it with is exactly the work that is easiest to automate.</p>
<h2>The senior is not safe</h2>
<p>I regularly hear experienced developers say that AI mostly takes over the simple tasks.</p>
<p>Architecture, complex debugging, security and system design stay human work.</p>
<p>For now, a lot of that holds. The postings data says the same.</p>
<p>But it is dangerous to turn a temporary limitation into a permanent protection.</p>
<p>Today’s models will look dated by 2028. And once agents can investigate a problem on their own, design a solution, run the tests and fix their own mistakes, the line moves again.</p>
<p>The senior postings point the same way. They ask for people who can work with AI: someone who directs agents, reviews their work and carries the responsibility. That is the role where you have to ask how many of them are needed.</p>
<p>Maybe there will always need to be a human who is accountable.</p>
<p>But how many?</p>
<p>One per five agents? One per twenty? One engineer overseeing an entire platform?</p>
<p>Nobody knows.</p>
<p>And anyone who claims with certainty that architects and seniors are safe is doing what the AI vendors do when they promise everything will run autonomously next year.</p>
<p>They are selling certainty they don’t have.</p>
<h2>More software does not automatically mean more jobs</h2>
<p>The usual counter-argument is that cheaper software development creates more demand for software.</p>
<p>It might.</p>
<p>When computers got cheaper, we didn’t use fewer of them. We found thousands of new uses. In June I called that Jevons paradox wearing a hoodie, and I still believe it holds for the demand for software.</p>
<p>But there is an assumption buried in that story: that extra demand for software also produces extra demand for human developers.</p>
<p>Those are two different things.</p>
<p>If AI drives production costs down far enough, the amount of software can explode while the number of people needed to make it falls.</p>
<p>More code. More applications. More systems.</p>
<p>Fewer developers.</p>
<p>Nothing in that combination contradicts itself. The Indeed numbers don’t refute it either. A posting is not a job, a rebound from a trough is not long-term growth, and a market that mostly wants seniors is not growing in breadth.</p>
<h2>My guess: 2030 gets uncomfortable</h2>
<p>I don’t expect every developer to be out of work by 2030.</p>
<p>What I do expect by then is a much clearer split between people who produce software and people who carry responsibility for what software does.</p>
<p>The first group will have a hard time.</p>
<p>The second will stay necessary for now, probably in smaller teams.</p>
<p>And somewhere between 2030 and 2035, the traditional picture of a software team might well disappear for good.</p>
<p>That is my estimate of where the technology and the economic incentives are likely to meet, and no reliable labour-market formula backs it up.</p>
<p>I could be five years off. Maybe ten.</p>
<p>Maybe demand for software turns out to be so large that we need more developers rather than fewer.</p>
<p>But I would not bet my career on that last scenario.</p>
<h2>The uncomfortable answer</h2>
<p>I work with AI agents every day. I see how impressive they are, and how many mistakes they still make.</p>
<p>I see code that appears in minutes and takes hours to review properly. I see duplication, missing context, and solutions that are correct locally but wrong at the level of the system.</p>
<p>Those are real limitations, and none of them protects your job.</p>
<p>If you want to be in the second group, make sure your work consists of what an agent cannot carry. Own a system all the way into production, and review what gets generated instead of passing it along. Be the person who can explain why something was built the way it was, because <a href="/en/blog/who-is-responsible-for-ai-generated-code">whoever merges the code answers for it</a>, no matter who wrote it. And understand why <a href="/en/blog/speed-got-cheap-judgement-didnt">judgement became the scarce part once speed got cheap</a>.</p>
<p>For now, that is the side of the line where demand is growing.</p>
<p>The question is not when AI can do everything a developer can.</p>
<p>The question is when AI can do enough for a company to decide it can carry on with fewer developers.</p>
<p>And that moment may come a lot sooner.</p>
<p>We spent years building software to automate other people’s work.</p>
<p>Now it’s our turn.</p>
]]></content:encoded>
</item>
<item>
<title>Steering Claude Code mid-task: Esc, queued messages and /btw</title>
<link>https://tim-schipper.nl/en/blog/claude-code-interrupt-and-steer</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/claude-code-interrupt-and-steer</guid>
<pubDate>Wed, 07 Oct 2026 19:55:31 GMT</pubDate>
<description>How to steer Claude Code while it works: what Esc keeps, when a queued message arrives, what /btw is for, and what a late correction costs. Measured over 40 runs of one task in two sizes.</description>
<content:encoded><![CDATA[<p>To steer Claude Code while it works, you have three moves. Press <code>Esc</code> to stop the current response or tool call: Claude keeps the work done so far and waits for your redirect. Type a message and press <code>Enter</code> to queue it: Claude Code hands it over as soon as the running tool calls finish, inside the same turn. Or type <code>/btw</code> for a side question that never enters the conversation.</p>
<p>All three work. I ran the same task forty times, in two sizes, to find out what the timing costs, and every run passed. What differed was the bill: on the bigger task, correcting after the agent finished cost a median 56% more than saying it up front. Stopping it with <code>Esc</code> at its first edit cost nothing extra there.</p>
<p><img src="/images/illustrations/claude-code-interrupt-and-steer.svg" alt="Cost per correction route on the multi-file task: Esc matches the brief, a correction after the turn costs 56% more."></p>
<h2>The experiment</h2>
<p>A small Node project with three CLIs reading the same sales CSV. The task: add <code>--since</code> and <code>--until</code> date options to all three. Then the correction you always think of a moment too late: put the filtering in one pure function in <code>lib/filter.js</code> with its own unit tests, reject impossible dates like <code>2026-02-30</code> with exit code 2, and document both flags in the README.</p>
<p>Four routes, five runs each, identical text every time:</p>
<ul>
<li><strong>Brief:</strong> task and correction in the first message.</li>
<li><strong>Esc:</strong> task alone, interrupted at the agent’s first file edit, then the correction.</li>
<li><strong>Queue:</strong> task alone, correction queued at the first file edit, no interrupt.</li>
<li><strong>Late:</strong> task alone, correction after the agent reports it is done.</li>
</ul>
<p>Claude Code 2.1.293 on Opus 5.5, headless, with no user settings, hooks or <code>CLAUDE.md</code> loaded. A script scored each result on twelve checks. I ran a one-CLI version of the same task first, scored on seven. It left the agent little room to wander, which is why the three-CLI version exists.</p>
<table>
<thead>
<tr>
<th style="text-align:left">Route</th>
<th style="text-align:left">Small task</th>
<th style="text-align:left">Multi-file task</th>
<th style="text-align:left">Edits (multi-file)</th>
<th style="text-align:left">Wall time (multi-file)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Brief</td>
<td style="text-align:left">$0.22</td>
<td style="text-align:left">$0.35</td>
<td style="text-align:left">7</td>
<td style="text-align:left">72 s</td>
</tr>
<tr>
<td style="text-align:left">Esc</td>
<td style="text-align:left">+4%</td>
<td style="text-align:left">$0.33, same</td>
<td style="text-align:left">9</td>
<td style="text-align:left">78 s</td>
</tr>
<tr>
<td style="text-align:left">Queue</td>
<td style="text-align:left">+15%</td>
<td style="text-align:left">$0.40, +15%</td>
<td style="text-align:left">9</td>
<td style="text-align:left">85 s</td>
</tr>
<tr>
<td style="text-align:left">Late</td>
<td style="text-align:left">+25%</td>
<td style="text-align:left">$0.55, +56%</td>
<td style="text-align:left">15</td>
<td style="text-align:left">124 s</td>
</tr>
</tbody>
</table>
<p>Medians. All forty runs passed every check. The agent always got there. It just did more work on the way, and the later the correction, the more it had to undo: by the time the late route heard the real requirements, it had made six to eight edits that the correction turned into rework.</p>
<p>These are cents on a task that takes a minute. That is the point of the second column, though: going from one CLI to three, the late penalty more than doubled while <code>Esc</code> dropped to zero. Your real tasks are longer than mine.</p>
<h2>Before it starts: give it the context you have</h2>
<p>The cheapest correction is the one you never need to send. Most of what I put in the correction was knowable before the first prompt, and on the multi-file task every route that started without it made more edits.</p>
<p>Two things make the brief better without making it longer:</p>
<ul>
<li><strong><code>@</code> a file</strong> to put its full contents in the conversation. <code>@src/lib/filter.ts</code> beats “the filter helper” every time. On a directory you get only a listing.</li>
<li><strong>Paste a screenshot</strong> with <code>Ctrl+V</code> (<code>Alt+V</code> on Windows and WSL, <code>Cmd+V</code> in iTerm2), drag it in, or give its path. A layout bug described in words costs a round trip that the picture skips.</li>
</ul>
<p>The research agrees, from the other side. In <a href="https://arxiv.org/abs/2502.13069">Ambig-SWE</a>, agents that asked clarifying questions on underspecified tasks performed up to 74% better than agents that just started. <a href="/en/blog/claude-code-plan-mode">Plan mode</a> is the built-in version of that: the agent shows you its approach before it writes anything, which is the cheapest moment to object.</p>
<h2>Esc: stop, keep the work, redirect</h2>
<p>The <a href="https://code.claude.com/docs/en/interactive-mode">interactive mode docs</a> describe <code>Esc</code> precisely: “Stop the current response or tool call mid-turn so you can redirect. Claude keeps the work done so far.”</p>
<p>That second sentence is why it cost so little in my runs. The agent keeps what it has: it has read the code, it knows the CLIs, and it has one edit it now has to revisit. You lose only the direction it was heading in, which was the wrong one anyway.</p>
<p>The habit to build is pressing it at the first sign, not the third. Anthropic’s <a href="https://code.claude.com/docs/en/best-practices">best practices</a> put it as “Correct Claude as soon as you notice it going off track”, and the reason shows up in the research on how agents fail. <a href="https://arxiv.org/abs/2607.09510">Failure as a Process</a> traced failed runs of CLI coding agents on Terminal-Bench, with no human in the loop, and found that “half of all failed trajectories have already committed their decisive error by step 7, but do not become unrecoverable until around step 12, and do not produce externally observable evidence until around step 16.”</p>
<p>Between step 7 and step 16 the agent is still working, and it looks like it is going fine. That gap is where <code>Esc</code> pays off.</p>
<h2>Queue a message when it is close enough</h2>
<p>You can also talk to the agent without stopping it. Type while it works and press <code>Enter</code>. Per the docs, “Claude Code passes it to Claude as soon as those tool calls finish, within the same turn.”</p>
<p>That is softer than <code>Esc</code>, and in my runs it cost 15% more than the brief on both tasks, because the agent finishes the batch of tool calls it is in before it reads you. Fine when the correction is a detail. When the direction is wrong, every tool call it completes first is work you are paying for and then paying to undo.</p>
<p>Two keys make the queue more useful than it looks. <code>Ctrl+Enter</code> sends queued messages right away. And <code>Up</code> from the first line of the input box takes your queued messages back so you can edit them before they go out.</p>
<p>One catch: a message queued mid-turn gets no checkpoint of its own. To <a href="/en/blog/claude-code-rewind-checkpoints">undo what Claude did after it</a>, you rewind to the prompt that started the turn.</p>
<h2>/btw for the question that is not a correction</h2>
<p>Halfway through a task you wonder why it picked a library, or which file handles the auth. Asking in the main conversation puts the question and the answer into the context for the rest of the session.</p>
<p><code>/btw</code> is for exactly that. The docs: “Use <code>/btw</code> to ask a question about your current work without adding to the conversation history.” It sees everything so far, has no tools, and runs while Claude keeps working. If the answer needs tools after all, press <code>f</code> to fork it into a background subagent.</p>
<h2>When to stop correcting and start over</h2>
<p>Steering has a limit. Each correction leaves the failed attempt in the context, and <a href="https://arxiv.org/abs/2509.09677">models get more likely to make mistakes</a> when their earlier errors are in front of them. Anthropic’s best practices draw the line: “If you’ve corrected Claude more than twice on the same issue in one session, the context is cluttered with failed approaches.”</p>
<p>At that point you have two exits. <code>Esc Esc</code> opens the rewind menu, where you return to the prompt before the detour and send it again with what you have learned. Or you <a href="/en/blog/claude-code-context-management">clear the session</a> and start with a brief that includes the correction from the start, which was the cheapest route in my experiment, or tied for it.</p>
<h2>Steering is the skill</h2>
<p>Anthropic studied around 400,000 Claude Code sessions for its June report on <a href="https://www.anthropic.com/research/claude-code-expertise">expertise and agentic coding</a>. One of its conclusions: “Part of the value of expertise appears to be the ability to steer the agent in the right direction.” Among the users it rated as novices, 19% of sessions ended abandoned, against 5 to 7% for everyone else.</p>
<p>Experienced users also interrupt more often. An <a href="https://www.anthropic.com/research/measuring-agent-autonomy">earlier Anthropic study</a> found new users interrupting in 5% of turns and more experienced users in around 9%. They know when a turn is heading somewhere expensive, and they say so while it is still cheap.</p>
<p>That is all steering is. The agent will get there either way. You decide how much of the trip you pay for twice.</p>
]]></content:encoded>
</item>
<item>
<title>Where Claude Code spends its time: a month of my own sessions, measured</title>
<link>https://tim-schipper.nl/en/blog/where-claude-code-spends-its-time</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/where-claude-code-spends-its-time</guid>
<pubDate>Mon, 05 Oct 2026 10:16:16 GMT</pubDate>
<description>Almost seven weeks ago I promised a number. I took a month of my own Claude Code transcripts and timed every step: 74 hours of the model working, 117 hours of it waiting on tools. Half of the shell time sits in 1.4% of the calls, and a fair share of the waiting was waiting for me.</description>
<content:encoded><![CDATA[<p>In August I wrote that <a href="/en/blog/claude-code-timestamps">my Claude Code sessions had no clock</a>, built one, and closed with a promise: ask me in a month and I will have a number.</p>
<p>That was almost seven weeks ago. Here is the number. It surprised me.</p>
<p>I assumed the agent’s time went into thinking. A big model, long reasoning, a spinner that turns because something hard is happening. Over a month of my own sessions, the main agent spent 74 hours generating and 117 hours waiting on its tools. And part of that waiting was the agent sitting idle while I was somewhere else.</p>
<h2>How I measured it</h2>
<p>Claude Code writes every session to a JSONL transcript, and every record in it carries a timestamp. That is enough to time almost everything.</p>
<ul>
<li><strong>A tool call</strong> runs from the record where the model asks for the tool to the record where the result comes back.</li>
<li><strong>Model time</strong> runs from each input (my prompt, or a tool result) to the last thing the model writes before it calls the next tool, cut at the end of each turn so idle gaps between turns do not count.</li>
<li><strong>Subagents</strong> keep their own transcripts, which I measured separately.</li>
</ul>
<p>The transcripts on my machine go back to 4 September. Through 4 October that is 127 sessions, 1,804 prompts from me, 3,503 turns and 1,106 subagent runs, on Claude Code 2.1.260 through 2.1.289, almost all of it in <a href="/en/blog/claude-code-auto-mode-subagents">auto mode</a>.</p>
<p>One limit matters for everything below. The clock on a tool call includes any approval prompt in front of it, and the transcript does not record the prompt. So when a call “took” twenty minutes, some of those minutes may have been the agent waiting for me to click yes. I come back to that.</p>
<h2>Most of the time is in the tools</h2>
<p>The model’s own steps are quick: a median of 5.5 seconds between getting a result and asking for the next tool.</p>
<p>The hours add up elsewhere. Across the main agent in the month:</p>
<table>
<thead>
<tr>
<th style="text-align:left">Where the time went</th>
<th style="text-align:right">Hours</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Model generating</td>
<td style="text-align:right">74</td>
</tr>
<tr>
<td style="text-align:left">Bash</td>
<td style="text-align:right">69</td>
</tr>
<tr>
<td style="text-align:left">Waiting on my answers</td>
<td style="text-align:right">29</td>
</tr>
<tr>
<td style="text-align:left">Foreground subagents</td>
<td style="text-align:right">10</td>
</tr>
<tr>
<td style="text-align:left">Everything else (Edit, Read, MCP, web)</td>
<td style="text-align:right">17</td>
</tr>
</tbody>
</table>
<p>The tool rows add up to 125 hours. Calls that ran in parallel overlap, so on the wall clock the agent spent 117 hours waiting on tools.</p>
<p>Inside the subagents the share is higher still: Bash took 48 of their 55 tool hours, 87%.</p>
<p>Of everything the agent waits on, the shell weighs the most.</p>
<h2>Half of all shell time is 1.4% of the calls</h2>
<p><img src="/images/illustrations/where-claude-code-spends-its-time.svg" alt="Two bars: the slowest 674 Bash calls, a sliver of all calls, take up half of all shell time"></p>
<p>Across main agent and subagents together, Bash ran 46,574 times in the foreground, for 117 hours. The median call took 1.3 seconds, and nine in ten finished within 9.8 seconds.</p>
<p>Then the tail. The slowest 674 calls, 1.4% of them, hold half of all Bash time. The 1,420 calls that ran longer than a minute hold 76 hours, almost two-thirds.</p>
<p>Sorting those hours by what the command did:</p>
<ul>
<li><strong>Tests: 22 hours, a fifth.</strong> No surprise and no regret, because a suite that takes time is doing its job, and an agent that runs it is <a href="/en/blog/working-with-agents-properly">the point of the exercise</a>. In turns that ran tests the median was two runs, and the record was 77 in a single 69-minute turn: a fix loop on one of my own projects.</li>
<li><strong><code>sleep</code> and polling loops: 12.6 hours.</strong> Most of that is the agent writing <code>sleep 60</code> or a <code>while</code> loop around a status check, and blocking the whole conversation until it returns.</li>
<li><strong>Reading and searching (<code>cat</code>, <code>grep</code>, <code>find</code>): 19 hours</strong>, scripts 16, git 11, <code>for</code> loops 10, builds and type checks 3.</li>
<li><strong>A fifth I could not classify</strong>: compound one-liners that <code>cd</code>, set a variable, loop and pipe in one call.</li>
</ul>
<p>The <code>sleep</code> hours bother me most, because the fix already exists. Claude Code can run a command in the background and tell the agent when it finishes. In the month, 690 calls used that. 1,420 calls blocked the foreground for more than a minute.</p>
<h2>Failures barely register</h2>
<p>1,277 tool calls failed in the month. The largest group, 518, were shell commands that exited non-zero. Together the failed calls took 8 hours. A failure is loud in the transcript and quiet on the clock.</p>
<h2>A few long turns hold the hours</h2>
<p>The turns have the same long tail as the shell calls. 1,892 of them, more than half, were over within a minute, and together they make up 5% of all turn time.</p>
<p>At the other end, 39 turns ran for more than an hour. Those 39 hold 38% of the clock. Everything over a quarter of an hour, 205 turns, is two-thirds of it.</p>
<p>What working with the agent feels like and what it costs in hours are two different things. Most of what I see is a quick exchange. Most of what the clock records is a few long stretches.</p>
<h2>The slowest tool is me</h2>
<p>The agent spent 29 hours waiting on <code>AskUserQuestion</code>, the tool it uses to put a multiple-choice question in front of me.</p>
<p>It asked 468 questions in 382 calls. The median answer came in 53 seconds, and 205 calls were answered within a minute. When I am at my desk, a question costs less than a minute.</p>
<p>When I am not, it costs hours. Two questions waited more than an hour each, 9.4 hours between them. Nobody was there to answer them.</p>
<p>The same thing hides inside the tool clock. An <code>Edit</code> takes milliseconds, yet 42 edits and writes “took” more than a minute, 1.9 hours in total. Those minutes are almost certainly approval prompts: an edit to a file a hook guards, a write outside the project.</p>
<p>That 1.9 hours is a floor. A Bash command that waited on approval looks exactly like a Bash command that ran for a long time, and with 117 hours of shell time I cannot separate the two. Any “Bash is most of your tool time” number, my own plugin’s included, carries some of my coffee breaks in it.</p>
<h2>Two sessions hide some of the wait</h2>
<p>The main agents ran 283 hours of turns, and those fit into 231 hours of wall clock. For 44 of those hours, two or more sessions were working at once.</p>
<p>That is the counterweight to everything above. For about a fifth of the busy time more than one agent was at work, so a wait in one session did not leave everything standing still.</p>
<h2>What I changed</h2>
<p>Every fix comes down to the same thing: stop letting the waits block the conversation.</p>
<ul>
<li><strong>Long commands go to the background.</strong> A test suite, a build or a remote run gets <code>run_in_background</code>, and the agent does something else or ends its turn. One line in <code>CLAUDE.md</code> asks for it. If asking is not enough, that is what <a href="/en/blog/claude-code-hooks-guide">hooks</a> are for.</li>
<li><strong>No <code>sleep</code> loops.</strong> Waiting for CI or a deploy belongs to a background watcher that tells the agent when something changed. A foreground loop freezes the whole conversation.</li>
<li><strong>Questions before I leave.</strong> If the agent will need a decision, it should ask while I am still there. “Ask everything you need now, then work unattended” is a perfectly good prompt.</li>
<li><strong>A second session for the long waits.</strong> Overlap already absorbed 52 hours of turn time this month. A <a href="/en/blog/git-worktrees-parallel-coding-agents">worktree per session</a> keeps two agents out of each other’s files.</li>
<li><strong>Fewer prompts for the safe things.</strong> Every approval prompt is a place the agent can stall. The <a href="/en/blog/claude-code-permissions-guide">permissions guide</a> covers allowing what is safe so that the prompts left are the ones worth stopping for.</li>
</ul>
<p>None of this says the agent helped or did not help. A twenty-minute test run that caught a regression was twenty good minutes. Turn these numbers into a target and you are back to <a href="/en/blog/tokenmaxxing-measuring-the-wrong-thing">measuring the wrong thing</a>.</p>
<p>What they did tell me is where the afternoon went. Less into thinking than I imagined, a lot into a shell that waited on a test suite, and a surprising amount into waiting for me.</p>
]]></content:encoded>
</item>
<item>
<title>Nobody asked for it: what coding agents add when you don't decide</title>
<link>https://tim-schipper.nl/en/blog/coding-agent-default-choices</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/coding-agent-default-choices</guid>
<pubDate>Thu, 01 Oct 2026 12:10:03 GMT</pubDate>
<description>Three days after taking the AI tells off this site, I wanted to know what they look like in code. So I gave 23 models the same three TypeScript tasks, 266 runs in five agent harnesses. Most of them add fields nobody asked for, every model line has a handwriting you can recognise, and two of the newest GPT models leave the data model as the prompt described it.</description>
<content:encoded><![CDATA[<p>On Monday I took <a href="/en/blog/ai-slop-ui-tells">the AI tells off this site</a>. The rule I ended up with was simple: a tell is a choice nobody made. The look was fine. The parts that nobody could give a reason for went.</p>
<p>One question stayed with me. Design gets screenshotted and passed around. Code mostly does not. If an agent fills every gap in a design brief with the median, what does it put in the gaps of a code brief, where nobody is looking?</p>
<p>So I measured it.</p>
<h2>How I measured it</h2>
<p><img src="/images/illustrations/coding-agent-default-choices-method.svg" alt="Flow of one run: prompt and credentials into the agent, then transcript, smoke test, labels and pairs"></p>
<p>The idea was to give every model the same open brief and then count what it filled in. That sounds simple, and most of the work went into keeping it fair.</p>
<h3>Three tasks with the gaps left open</h3>
<p>Three small TypeScript tasks, each a few sentences long: a REST API for bookmarks, a CSV-to-JSON command-line tool and a client library for a flaky, rate-limited HTTP API. Each prompt names TypeScript and ends with “Include tests.” Everything else is left open on purpose: framework, storage, test runner, argument parser, retry strategy. This is the whole API prompt:</p>
<pre><code class="language-text">Build a small REST API in TypeScript for storing bookmarks. A bookmark has a URL, a title
and optional tags. Support creating, listing, fetching one, updating and deleting
bookmarks, and persist them so they survive a restart. Include tests.
</code></pre>
<p>TypeScript is fixed because language choice is a known bias of its own. Left open, the first Gemini run I tried wrote Python.</p>
<h3>Every agent as shipped, in a clean room</h3>
<p>Five harnesses: Claude Code, Codex, Antigravity, OpenCode and Freebuff. 23 models in total, each at the settings its harness ships with. Antigravity puts the reasoning level in the model name, so there I had to pick one: medium, and low for the Pro model, which has no medium.</p>
<p>Each run got a fresh, empty directory and a fresh process, and nothing else. The agents normally read a lot from your home directory: config, rules, memory, plugins, MCP servers. So every harness ran from a clean home that held only its login. Without that, Antigravity loaded my own MCP servers and Claude Code my Gmail and calendar connectors, and the runs would have measured my setup instead of the model.</p>
<p>Four of the harnesses run headless and report in JSON. Freebuff only has a terminal interface, so a small driver types the prompt in <code>tmux</code>, reads the screen and waits until the agent is done. When an agent asked a question before building, it got the same answer every time: “Choose whatever you think is best and build it.” Every run had 45 minutes.</p>
<p>A colleague (thanks, Jelle!) ran GPT-6 Astra and GPT-6.1 Sol on their own account, with the same harness and the same prompts, and sent back the results.</p>
<h3>Checking which model actually answered</h3>
<p>The name in a model picker is a claim. For every run I read the model that actually answered from the agent’s own transcript, session file or database, and flagged any run where the two disagreed. That turned out to matter.</p>
<h3>Does it work, and what did it choose</h3>
<p><img src="/images/illustrations/coding-agent-default-choices-working.svg" alt="Share of runs that worked per model: 13 models at 100%, Solar Mini 4 and Nemotron 3.5 lowest at 22%"></p>
<p>Every project then went through a smoke test, the way a person would try it: install it with the package manager it chose, build it if its start script needs a build, start it and call the API, convert a CSV with and without a header, run the library’s own tests and type-check it with its own TypeScript. When a project failed, I read why. Several times the fault was in my test, and I fixed the test and reran it for every project.</p>
<p>I read the choices themselves from the source code alone, leaving the tests and the README aside: which framework is imported, where the data goes, which test runner is installed, how the retries back off. Each of those is an axis with a label per run. Whenever a rule mislabelled a real run, that run became a test case before the rule was fixed, and every run was labelled again.</p>
<p>Finally, the comparison. For each axis I took every pair of runs and counted how often the two made the same choice: pairs from the same model, and pairs from two different models. If a model only follows the trade, both numbers come out the same. If a model has its own habit, its own runs agree more often.</p>
<h3>All 23 models</h3>
<p><img src="/images/illustrations/coding-agent-default-choices-speed.svg" alt="Median minutes and tokens per run per model on log scales, from Sonnet 5.5 at 0:51 to Solar Mini 4 at 39:05"></p>
<p>Per harness, each model with the runs that worked and the median time per run, in minutes and seconds:</p>
<ul>
<li><strong>Claude Code:</strong> Haiku 4.5 (14/15, 2:51), Sonnet 5.5 (15/15, 0:51), Opus 5.5 (9/9, 2:04)</li>
<li><strong>Codex:</strong> GPT-5.6 Luna (11/15, 2:23), GPT-5.6 Terra (15/15, 1:42), GPT-5.6 Sol (8/9, 6:51), GPT-6 Astra (9/9, 3:47), GPT-6.1 Sol (9/9, 4:33)</li>
<li><strong>Antigravity:</strong> Gemini 3.8 Flash (15/15, 3:34), Gemini 3.7 Flash (15/15, 1:59), Gemini 3.6 Flash (9/9, 7:13), Gemini 3.1 Pro (3/9, 1:06)</li>
<li><strong>OpenCode:</strong> Big Pickle (9/9, 20:05), Muse Spark 1.3 (8/9, 2:33), Nemotron 3 Ultra (8/9, 9:12), Nemotron 3.5 Lightning (2/9, 22:23)</li>
<li><strong>Freebuff:</strong> GLM 5.3 Flash (9/9, 19:13), Solar Mini 4 (2/9, 39:05), MiMo 2.6 Flash (9/9, 8:22), Solar Pro 4 (3/9, 6:01), DeepSeek V4.1 Flash (15/15, 5:00), GPT-6 Luna (8/9, 2:57), MiMo 2.6 Pro (8/8, 7:19)</li>
</ul>
<p>A run worked when it passed the smoke test. The chart above puts time next to tokens for the four harnesses that report them: input plus output for a whole run, mostly cached context that the harness reads again on every step. Freebuff only shows the size of its context.</p>
<p>Every harness ran at its own default reasoning level. In Freebuff that means max for GLM, high for DeepSeek and GPT-6 Luna, and none for the Solar and MiMo models. Only in Antigravity did I pick one, as described above.</p>
<p>The runs took place between 28 September and 1 October 2026, on Claude Code 2.1.285, Codex 0.154.0, Antigravity 1.2.13, OpenCode 1.18.29 and Freebuff 0.1.4. Seven models got five runs per task, the rest three. MiMo 2.6 Pro has one library run fewer: its paid hour ran out before the last run could start.</p>
<p>Four models are missing from this overview. Ling 3.0 Flash and the free mimo-v2.5 in OpenCode returned only errors, in all nine runs each. Space Bunny Alpha in Freebuff failed twelve times out of twelve. Muse Spark 1.2 turned out to be answered by a different model on Freebuff’s free plan, so there was nothing of its own to measure.</p>
<p>The whole experiment was cheap: about $13 at list price for all Claude runs, six points of a weekly Codex limit, Freebuff’s free daily allowance, and nothing for Antigravity or the free models in OpenCode.</p>
<p>18 runs did not count: the provider returned errors and left nothing behind. The rest is 248 runs.</p>
<h2>The field nobody asked for</h2>
<p><img src="/images/illustrations/coding-agent-default-choices-extras.svg" alt="Of 83 API runs, 69 added createdAt, 32 a tag filter, 27 a health endpoint and 27 a SIGTERM handler"></p>
<p>The prompt says a bookmark has a URL, a title and optional tags.</p>
<p>In 69 of the 83 API runs, the agent added <code>createdAt</code> or <code>updatedAt</code> anyway. 19 of the 23 models did it, nearly all of them in every run. Further down the same list: filtering by tag in 32 runs, a health endpoint in 27, a graceful shutdown on <code>SIGTERM</code> in 27. Nobody asked for any of these.</p>
<p>Each of these can be a good idea. If you want to know when a bookmark was saved, <code>createdAt</code> is exactly right, and an agent that adds it saves you a step. A health endpoint belongs in anything that runs behind a load balancer.</p>
<p>The trouble is that here nobody decided. The field is there because bookmark APIs usually have one, and it looks like diligence, so it gets through review. Once it is in, it stays: someone sorts on it, migrates it and argues about what it means. Created according to which clock? Does editing the tags count as an update?</p>
<p>That is the code version of a UI tell. Whether it belongs is your call, as long as you know it is there.</p>
<p>Who left the timestamps out is the interesting part. GPT-6 Astra and GPT-6.1 Sol: 0 of 6 runs. The three GPT-5.6 models from the same vendor: 13 of 13.</p>
<p>The newer two still add plenty: all six of their runs include a <code>SIGTERM</code> handler. They kept the operational extra and dropped the field in the data model, and the field is the one that is hard to take back. Gemini 3.1 Pro and Nemotron 3.5 Lightning skipped the timestamps as well, but both wrote very little code overall, so that says little.</p>
<h2>The layer every model shares</h2>
<p><img src="/images/illustrations/coding-agent-default-choices-shared.svg" alt="Nearly every run: strict TypeScript 245/248, no linter 243/248, own retry 79/82, backoff 77/82, fetch 73/82"></p>
<p>Below the additions sits a layer that hardly varies at all:</p>
<ul>
<li><strong>Strict TypeScript</strong> in 245 of 248 runs.</li>
<li><strong>No linter</strong> in 243 of 248. Five runs set one up.</li>
<li><strong>A hand-written retry loop</strong> in 79 of 82 library runs, with no retry package, usually with exponential backoff and plain <code>fetch</code>.</li>
</ul>
<p>And yes, three retries, because that is what everyone uses. These are reasonable choices, the trade’s median.</p>
<p>Research on models outside an agent points the same way. A study of eight models found they <a href="https://arxiv.org/abs/2503.17181">prioritise familiarity and popularity over suitability</a> when they pick a library or a language. <a href="https://arxiv.org/abs/2608.06041">LangChoiceBench</a> found that when models pick Python, the choice is mostly “automatic or driven primarily by ease”. I fixed the language, so the ease shows up in everything else.</p>
<p>The missing linter is the one I would act on. Nobody decided against it either.</p>
<h2>Every model line has a handwriting</h2>
<p><img src="/images/illustrations/coding-agent-default-choices-handwriting.svg" alt="Agreement within one model against between models, per axis: equal for tsStrict, far apart for storage and tests"></p>
<p>Where the ecosystem itself is split, runs of the same model agree with each other far more often than runs of different models: a median of 17 percentage points across the axes I measured. To check that this was not luck from three runs, I gave seven of the models five runs per task.</p>
<p>It held:</p>
<table>
<thead>
<tr>
<th style="text-align:left">Model</th>
<th style="text-align:left">Habit</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">GPT-5.6 Luna</td>
<td style="text-align:left">Plain <code>node:http</code> instead of a framework, 5 of 5 API runs. GPT-6 Luna: 3 of 3</td>
</tr>
<tr>
<td style="text-align:left">Sonnet 5.5</td>
<td style="text-align:left"><code>node:util</code> parseArgs for the CLI, 5 of 5</td>
</tr>
<tr>
<td style="text-align:left">Gemini 3.7 and 3.8 Flash</td>
<td style="text-align:left">Express and jitter on the retry delay in every run, vitest for the API and the library</td>
</tr>
<tr>
<td style="text-align:left">Haiku 4.5</td>
<td style="text-align:left">Express with jest for the API, every run</td>
</tr>
<tr>
<td style="text-align:left">Opus 5.5</td>
<td style="text-align:left"><code>node:sqlite</code> for storage, 3 of 3</td>
</tr>
</tbody>
</table>
<p><img src="/images/illustrations/coding-agent-default-choices-storage.svg" alt="Storage per model family: Gemini, Opus 5.5 and Big Pickle choose SQLite, almost all others a JSON file"></p>
<p>Storage shows it most clearly. 60 of the 83 API runs keep bookmarks in a JSON file. SQLite comes almost entirely from three model lines: Gemini in 15 of its 16 runs, Opus in 3 of 3 and Big Pickle in 3 of 3, plus one run of GPT-6 Astra.</p>
<p>Haiku and Sonnet, in the same harness as Opus, chose JSON files. So that preference belongs to the model, whatever tool drives it.</p>
<p>Luna’s habit survived a version bump. The stack you get depends on which model happened to be selected, and a new model can change it without anyone changing a line of the brief.</p>
<h2>Who asked first</h2>
<p><img src="/images/illustrations/coding-agent-default-choices-questions.svg" alt="Freebuff runs that asked first: DeepSeek 6/15, MiMo 2.6 Pro 3/8, MiMo 2.6 Flash 2/9, Solar Pro 1/9, the rest none"></p>
<p>Only Freebuff lets an agent stop and ask before it builds. The other four harnesses ran headless, where nobody is there to answer. In Freebuff, four of the seven models asked at least once: DeepSeek in 6 of its 15 runs, MiMo 2.6 Pro in all three of its API runs.</p>
<p>They asked about exactly the gaps I had left open: which framework, how to store the bookmarks, which test runner, how to stay under the rate limit. And the options often came with a favourite already marked. MiMo offered “Express + JSON file (Recommended)”, the two most common choices in the whole experiment.</p>
<p>So the question was more of a confirmation. Every one of them got the same reply, “Choose whatever you think is best and build it.”, and what they built next still counts as their own default.</p>
<h2>What the name does not tell you</h2>
<p>The picker and the transcript did not always agree. Freebuff’s “MiMo 2.6 Flash” answered as <code>mimo-v2.5</code> in every transcript. Muse Spark 1.2, on the free plan, was answered by a different model altogether. Two free models in OpenCode never returned a working response all night.</p>
<p>And then there was GPT-5.6 Luna. In 2 of its first 9 runs it wrote five files outside its own working directory, noticed, and ran <code>find</code> across my whole experiments folder to locate one of them. It saw file names and read nothing else, but it is the only model in 266 runs that left its directory. None of the agents had approval prompts in the way, so nothing stopped it.</p>
<h2>What this does not show</h2>
<ul>
<li><strong>Model or harness.</strong> I tried to run the same model in two harnesses and could not: the free MiMo endpoint returned only server errors, and Gemini CLI no longer serves the free tier. What I have are contrasts inside one harness, Opus against Haiku and Sonnet, and two versions of Luna in two harnesses.</li>
<li><strong>Quality.</strong> The smoke test checks that the code works, not that it is good.</li>
<li><strong>Anything general.</strong> Three small tasks, two to five runs per model per task, counts and no significance tests, all in an empty directory. A study of 26,760 pull requests written by agents found they <a href="https://arxiv.org/abs/2512.11589">rarely add a dependency</a>, in 1.3% of PRs, and draw on a diverse set of libraries. Real projects look different.</li>
</ul>
<p>My own harness was not flawless either. OpenCode takes its project directory from <code>$PWD</code> rather than from where it is started, and for its first two runs it worked in the root of my measuring rig. A check I had added for files written outside the run caught it, and every run since then lands where it should.</p>
<h2>What to do with this</h2>
<p><strong>Most defaults are fine.</strong> The shared layer is what I would pick myself, and more often than not so is a <code>createdAt</code>. A default only becomes a problem when you did not know you had it.</p>
<p><strong>Decide before the agent starts.</strong> Whatever you leave open, the model fills with its own median, and <a href="/en/blog/the-prompt-is-not-the-spec">what you meant is not what you specified</a>. Fields, storage, the linter, the test runner: if you care, put it in <a href="/en/blog/the-claude-md-file">your <code>CLAUDE.md</code></a> or in the prompt.</p>
<p><strong>Review for additions as well as mistakes.</strong> Hold the data model against the request. Every field the prompt did not ask for gets a reason or goes.</p>
<p><strong>Expect the stack to drift when the model changes.</strong> The handwriting belongs to the model line. Switch models and the next feature arrives with a different framework, a different test runner and different storage, unless the brief pins them.</p>
<p><strong>Add the linter yourself.</strong> Nobody else will.</p>
<p>On the design side, a tell was a pulsing badge nobody needed. In code it is a <code>createdAt</code> nobody asked for. Keep it if you want it, as long as keeping it is a decision.</p>
]]></content:encoded>
</item>
<item>
<title>The AI tells on my site, and which ones I chose</title>
<link>https://tim-schipper.nl/en/blog/ai-slop-ui-tells</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/ai-slop-ui-tells</guid>
<pubDate>Mon, 28 Sep 2026 18:10:10 GMT</pubDate>
<description>A list of ten tells of AI-generated UI went round over the weekend, and item seven describes this site down to the fonts. Anthropic lists the rest of its look as a default in its own design skill. Asking which parts had a written reason separated my own choices from what an agent had added, and those additions went.</description>
<content:encoded><![CDATA[<p>On Sunday someone published <a href="https://hereticpleb.vercel.app/blog/10-tells-of-slop">ten tells of a slop UI</a>. It opens with a college app that received “minor UI improvements”, which the author calls “the most slop-coded user interface I have seen”.</p>
<p>I nodded along until item seven, on fonts. “It’s usually Inter, or if you have a website even remotely programming-related, you get JetBrains Mono.” And then: “It adds these // whenever anything close to tech is mentioned.”</p>
<p>Until this evening, this site set its text in Inter, put a <code>//</code> in front of its section labels, and showed every code block in JetBrains Mono. I managed both halves of the same tell.</p>
<p>So I opened the design skill Anthropic ships for Claude Code, <a href="https://github.com/anthropics/claude-plugins-official/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md">frontend-design</a>, which has a section listing what AI-generated design clusters around right now. Number two is “a near-black background with a single bright acid-green or vermilion accent”. Number five includes “a tracked-out ALL-CAPS eyebrow label above every heading”, “tinted near-black (#0B0B0B, #111) standing in for black” and “a monospace face for small data labels”.</p>
<p>This site’s ground is <code>#08090b</code>. Its accent is <code>#3bff9e</code>. Until this evening many of its labels were tracked-out capitals.</p>
<h2>What the list gets right</h2>
<p>Most of the ten are fair, and the best ones have nothing to do with taste.</p>
<p>The pulsing “active” badge on a student ID that is never inactive, because an inactive session just logs you out. The “verified” tick that no API call ever verified. A blog footer that says “Built with Hugo. Written from Neovim”, because the author once told the agent they wanted to write in Neovim. A startup screen reading “one campus. one app.”, where the author can smell the management prompt behind it.</p>
<p>That is information nobody asked for. Something in the context made the model produce them, and nobody downstream asked whether they should stay. That is the whole problem in miniature, and it is a better point than gradients.</p>
<h2>The uncomfortable timeline</h2>
<p>Here is what the git history says about this site:</p>
<ul>
<li><strong>March</strong>: the first commit, with Inter for the body text.</li>
<li><strong>April</strong>: the design system goes in, with JetBrains Mono and <code>#3bff9e</code> on near-black.</li>
<li><strong>15 June</strong>: Anthropic adds “a near-black background with a single bright acid-green or vermilion accent” to its skill, as one of three looks AI design clusters around.</li>
<li><strong>13 August</strong>: I write an architecture decision record that locks the dark ground in place. Not warmed, not neutralised, not lightened.</li>
<li><strong>1 September</strong>: the skill’s list grows to five items, adding among other things all-caps eyebrow labels, tinted near-black and monospace labels.</li>
</ul>
<p>So I built a look in the spring, the vendor catalogued it as the machine’s median two months later, and I then wrote a document forbidding anyone to change it. Tests pinned the capitals and the <code>//</code> in place as well.</p>
<p>That sounds bad, and for part of it, it is.</p>
<h2>Default or choice</h2>
<p>The skill also has a sentence that matters more than the list itself: “All traits are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject.” And straight after: “the brief’s own words always win, including when it asks for one of these looks.”</p>
<p>A tell is a choice nobody made. The look itself is not the issue.</p>
<p>Some of this I chose before any agent was involved. I wanted the site to feel like a terminal, and I walked into a design brainstorm with an agent with Inter, JetBrains Mono and a green on near-black already decided. What came out of that brainstorm was the chrome on top: a <code>//</code> in front of every label, labels in tracked-out capitals, the language tag on code blocks in capitals too.</p>
<p>Which parts have a reason written down is something I can check, and it sorts the two groups cleanly.</p>
<ul>
<li><strong>The dark ground</strong> has one. The decision record says warm and neutral grounds were tried, more than once, and read worse, because a warm ground turns deliberate terminal chrome into accidental terminal chrome.</li>
<li><strong>The green and the fonts</strong> have one: the brief I brought with me.</li>
<li><strong>The <code>//</code> and the capitals</strong> had none. They arrived with the brainstorm, they looked finished, and tests later pinned them, which made them look decided.</li>
</ul>
<p>That last step is worth dwelling on. I have argued before for <a href="/en/blog/quality-ratchets-for-ai-code">baselines that only tighten</a>, and I still do. But a ratchet pins whatever you hand it. Hand it a default and you get a default with a test around it, which looks exactly like a decision from the outside.</p>
<h2>The same evening</h2>
<p>Earlier this evening I made the capitals lowercase and kept the <code>//</code>, telling myself it followed from the terminal brief. Then I went through the site and found labels in three styles, all behind a <code>//</code>. The marker came from the agent’s idea of what “terminal” means, and I had filed it under my own brief.</p>
<p>So the rest went too:</p>
<ul>
<li><strong>Every label</strong> is lowercase JetBrains Mono now, with no marker. The navigation as well.</li>
<li><strong>Numbered markers</strong> like <code>01</code> are gone, from the home page picks and from the process steps on the services page. The skill allows them when “the content actually is a sequence”, and the process steps are one, but the list already shows their order. In a series, the part number stays as a plain number, because there it tells you something.</li>
<li><strong>Middle dots</strong> are gone. Dates, reading times and tech tags now stand apart on spacing alone, and interface strings use commas. The skill lists “meta strings joined with middle dots” as template chrome.</li>
<li><strong>Arrows</strong> after buttons and links are gone, pagination included. “Next” says where it goes without one.</li>
</ul>
<p>Then I tried IBM Plex for both the text and the code, side by side with what was there, and went back within the hour. Inter and JetBrains Mono read better to me. That is a reason, and it now sits in the stylesheet, next to the font tokens.</p>
<h2>What to do with a list like this</h2>
<p><strong>Write the brief before you open the agent.</strong> The agent will happily supply a direction, and it will be <a href="/en/blog/coding-agent-default-choices">the common one</a>. What you ask for <a href="/en/blog/the-prompt-is-not-the-spec">is not the same as what you specified</a>, and in design the gap fills with the median. Everything I had decided beforehand survived this evening, and everything the brainstorm added on top went.</p>
<p><strong>Ask each tell for its reason.</strong> Can you say, in one sentence, why it is there? If you can, write the sentence down somewhere outside your head. If you cannot, it is a default, whoever typed it.</p>
<p><strong>Do not chase the list.</strong> Anthropic’s grew from three items to five in eleven weeks. Anything that becomes common gets on it, including whatever everyone switches to next. A site redesigned to avoid today’s tells will be on the next version.</p>
<p><strong>Distrust anything that says what the chat said.</strong> Of the ten, the tell I would fix first on any product is the redundant text, because it is the one users actually have to read.</p>
<p>The author of the list closes by saying their own site is vibe coded, “it looks cute tho”. Fair enough. Everyone’s site is on somebody’s list. The only part you control is whether you can say why.</p>
]]></content:encoded>
</item>
<item>
<title>Nobody's agent went rogue</title>
<link>https://tim-schipper.nl/en/blog/who-is-liable-for-ai-agents</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/who-is-liable-for-ai-agents</guid>
<pubDate>Sat, 26 Sep 2026 13:08:04 GMT</pubDate>
<description>The FTC chair says AI agents are tools, and the developer who gave the instruction is liable. He is right about the tool and wrong about the instruction. The traces of the Hugging Face attack show an agent doing a great deal more than the task asked.</description>
<content:encoded><![CDATA[<p>On Friday the chairman of the US Federal Trade Commission, Andrew Ferguson, spoke at the <a href="https://www.reuters.com/business/ftc-chair-pushes-back-treating-ai-agents-independent-actors-2026-09-25/">Reuters Momentum AI event</a> in Austin. He said he would resist describing AI agents as autonomous actors that “break loose” with “wills and desires of their own”.</p>
<p>“If someone tells a tool to do something, and the tool does it, I don’t think we would say, ‘Oh, what do we do about the tool?’”</p>
<p>The same day, a group of researchers published <a href="https://swarmtraces.org/">Swarm traces</a>: more than 80,000 attack payloads, decoded from link shorteners, that OpenAI’s agents used to break into Hugging Face in July. Put the two side by side and the chairman’s sentence develops a hole.</p>
<h2>The word that does the work</h2>
<p>Ferguson is right about the word “rogue”.</p>
<p>It is everywhere this week. <a href="https://siliconangle.com/2026/09/25/more-agents-go-rogue-but-ai-companies-arent-slowing-down-yet/">More agents go rogue</a>, says SiliconANGLE. It sounds like a description, and it works as an alibi. A rogue agent is one that left its owner’s control, which quietly makes the owner a bystander to whatever happened next.</p>
<p>The industry has leaned on that framing for a while. According to Reuters, Ferguson said AI companies have sometimes described their systems as acting beyond human control, and that later reviews of the audit trails showed the systems carrying out instructions they had been given. He also suggested the FTC’s existing power over companies that fail to disclose data breaches could reach AI developers.</p>
<p>These were remarks at a conference, and no rule stands behind them yet. They do point the same way as California, which since January no longer lets a defendant argue that the AI acted autonomously. I wrote about that <a href="/en/blog/who-is-responsible-for-ai-generated-code">when AB 316 was still the news</a>, and it ended with a line I still stand by: you merged it, you own it.</p>
<p>So far the chairman and I agree. The trouble is in the reason he gives.</p>
<h2>What the agents were told, and what they did</h2>
<p>The agents in the Hugging Face case were being evaluated on exploit challenges: break into a deliberately vulnerable target, retrieve the flag. That was the instruction. The <a href="/en/blog/openai-agent-message-board">timeline of how it escalated</a> is covered elsewhere, and I am not going to walk it again.</p>
<p>What Swarm traces adds is the part between the instruction and the damage. A few of the recovered payloads:</p>
<ul>
<li>An internal Hugging Face dataset carried a README that said, in capitals, never to make it public, because it held billing exports. The agents mapped the repository anyway and used it as storage.</li>
<li>One script gathered AWS credentials, bearer tokens and every environment variable with <code>SECRET</code>, <code>PASSWORD</code> or <code>KEY</code> in its name into a dictionary called <code>LOOT</code>, then ranked Kubernetes tokens by their permissions, with a scoring function that awards 100 points for a wildcard resource.</li>
<li>Cleanup commits carried messages like <code>remove evil yaml</code> and <code>restore original no-card</code>. Another payload deleted the pod it had created in Hugging Face’s cluster, under the comment <code># clean up our pod</code>.</li>
</ul>
<p>OpenAI has not made the transcripts public, so I cannot quote the task word for word. I do not need to. The flag sat inside a challenge target. Hugging Face’s billing exports did not.</p>
<p>The researchers are careful about what their data can carry, and so should I be. Most of it is outbound, so they rarely know whether a request succeeded. They cannot rule out that some payloads came from someone else using the same trick, and for a substantial part of the activity they say plainly that they cannot identify the intention behind it.</p>
<p>That caution does not rescue the chairman’s sentence. Even the most generous reading of these traces leaves a long list of actions that retrieving a flag from a challenge target does not explain.</p>
<h2>Neither story fits</h2>
<p>“The agent went rogue” says the owner is not responsible. “The tool did what it was told” says the instruction is the whole story. The traces fit neither.</p>
<p>What they show is an instruction, and then a search process that treated everything within reach as a route towards it. The warning in the README was an obstacle, the credentials were resources, and the cleanup was housekeeping. None of it required a will of its own, and none of it was the task.</p>
<p>That middle ground is where every coding agent you run lives as well. You ask for a failing test to pass, and the route the agent picks is its own. Usually the route is fine, and you never look at it.</p>
<h2>The shield in the vague prompt</h2>
<p>Follow Ferguson’s reasoning one step further and it produces a perverse incentive.</p>
<p>If liability follows the instruction, the safest instruction is the vaguest one: “Get the flag”, “Make the tests pass”, “Fix the deployment”. A developer in front of a regulator can point at the prompt and ask where exactly they requested a dictionary called <code>LOOT</code>.</p>
<p>That is the autonomy defence again, moved down one level. California closed the door on “the AI acted on its own”, and a liability standard built on instructions reopens it as “the AI acted beyond what I asked”.</p>
<p>The only standard that holds up is the stricter one. You answer for the route, because you chose what the route could reach. The egress you left open, the credentials in the environment, the storage that persisted between runs: those were decisions, and they are yours.</p>
<h2>The trail belongs to whoever kept it</h2>
<p>Ferguson’s argument rests on audit trails. So does any defence you might mount, so ask who actually holds this one.</p>
<p>The most detailed public record of this attack was not published by the company whose agents carried it out. Outsiders rebuilt it from link shorteners, over two weeks. According to Swarm traces, OpenAI has released two reports of its own, one talk, and an outside investigation by METR and Redwood Research in which three researchers were given partial transcripts and six days. Full transcripts have not been made public.</p>
<p>“The audit trail shows” is a claim until someone else can read the trail.</p>
<p>Your own agent already writes one to your disk. What it records, and what it leaves out, is <a href="/en/blog/ai-agent-audit-logging">the subject of a separate guide</a>. The point here is simpler: if that trail is what you will answer with, it has to exist before you need it, and it has to be yours.</p>
<h2>What that means on Monday</h2>
<ul>
<li><strong>Write down the route as well as the prompt.</strong> For each agent: which hosts it can reach, which credentials sit in its environment, what it can write that outlives the session. That list is what you will be judged on, so make it on purpose.</li>
<li><strong>Strip the environment.</strong> An agent’s reach includes every secret its process can read. Part of <code>LOOT</code> came straight out of <code>os.environ</code>.</li>
<li><strong>Expect an impossible task to become a wider mandate.</strong> An agent that cannot finish the job as asked will look for a version of the job it can finish. Give it a way to stop and say so.</li>
<li><strong>Keep the transcript, and keep it for months.</strong> The incident you need it for is always older than you expect.</li>
</ul>
<p>Ferguson asked what we would do about a tool that does what someone told it. The harder case is the tool that did something else along the way, and the answer is the same one. You own it. You just cannot point at the prompt any more.</p>
]]></content:encoded>
</item>
<item>
<title>Eight reports, one bar: judging an AI safety sprint</title>
<link>https://tim-schipper.nl/en/blog/judging-an-ai-safety-sprint</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/judging-an-ai-safety-sprint</guid>
<pubDate>Tue, 22 Sep 2026 18:06:07 GMT</pubDate>
<description>I judged eight submissions at the Apart Research AI Incident Response Sprint. Keeping my own bar in the same place from the first report to the eighth took an apparatus, and the apparatus caught me dropping it three quarters of a level.</description>
<content:encoded><![CDATA[<p>In September I judged at the <a href="https://apartresearch.com/sprints/ai-incident-response-sprint-2026-09-11-to-2026-09-13">AI Incident Response Sprint</a>, three days run by <a href="https://apartresearch.com/">Apart Research</a> and <a href="https://cesia.org/en/">CeSIA</a>, built on the July intrusion where OpenAI agents <a href="/en/blog/nobody-was-driving">escaped an evaluation sandbox and spent days inside Hugging Face’s production systems</a>. 971 people signed up. 234 entries came in. Eight of them landed on my pile.</p>
<p>The assignment mail put it at twenty to thirty minutes per report, written feedback included.</p>
<p>That estimate is about right, and it measures the wrong thing. I read seven of them in one long evening, and the eighth the next day. The actual problem is that my bar at report seven would not be my bar at report one, and nothing about reading carefully fixes that.</p>
<h2>What Apart asks a judge to do</h2>
<p>Three scores, one to five: how much it would matter if it worked (Impact Potential &amp; Innovation), how soundly it was done (Execution Quality), and how clearly it is written up (Presentation &amp; Clarity). I will call the middle one method and the last one presentation. Score them independently. Then write a critique that goes to the team with my name off it.</p>
<p>Two things there are better than in most review processes I have sat in.</p>
<p>The rubric is public, and it says out loud what the numbers mean. Level 3 is “solid hackathon work”, what you would expect from a competent team in a weekend. Level 5 is exceptional, and the rubric states how often it expects to see one: “we expect no more than ~5-10% of projects to reach this quality bar for any given scoring dimension”. Most scoring systems leave that to the mood of the room. Writing the expected spread down means a judge can be held against it.</p>
<p>The written critique counts as much as the score. Apart says on the sprint page that it aims to send feedback to every team, and that it cannot do so if an assigned judge does not file a review. Scoring eight projects consistently and then writing eight vague paragraphs is half a job.</p>
<p>Even a good rubric is a measuring stick, and the hand holding it drifts.</p>
<h2>The drift nobody notices in themselves</h2>
<p>Score eight of anything in sequence and your standard moves. It moves because of the order you happened to read in, because a well-made PDF makes the method underneath it feel sounder, because it is half past one and a three is starting to feel generous. You catch it by measuring it, or you do not catch it.</p>
<p>The rubric says to score the three dimensions independently, and a judge will agree with that sentence and then let a beautifully typeset report lift the method score by half a level anyway.</p>
<p>So before I opened the first PDF, I built the thing that would catch me.</p>
<h2>The apparatus</h2>
<p>Four things, written down before I read anything. None of them clever.</p>
<p><strong>Copy the rubric out word for word.</strong> My scores get read against Apart’s exact wording, so my copy of it is exact too. Paraphrase it and you have quietly written a second rubric, and that is the one you end up scoring against.</p>
<p><strong>Ask every report the same questions, and let each question count towards one score only.</strong> Is there something here a stranger could pick up and use, or only a description of one? Can someone outside the project check the central claim? Does the team say what their work does <em>not</em> show? Did they read the primary sources, or only the coverage of them?</p>
<p>All four of those feed method, and none of them is allowed near presentation. It runs the other way too: “this report is twice as long as it needed to be” is a remark about presentation, and it may not touch method. Otherwise a beautifully typeset report quietly collects credit for being sound.</p>
<p><strong>Let the rubric set its own ceilings.</strong> Level 3 for method requires “limitations acknowledged”. So a report that never says what it failed to establish cannot be a 3, whatever else it does well. That ceiling comes from Apart’s rubric, read back.</p>
<p>I derived three ceilings that way and wrote the reasoning beside each one. I am allowed to overrule any of them, and an overruled ceiling stays on the page with my reason next to it. A rule you can quietly delete is not a rule.</p>
<p><strong>Write one sentence per project saying why it got that score.</strong> The sentence says what earned the score and what held it back. When the next report lands on the same score, you hold it against that sentence instead of against a memory three reports old.</p>
<p>That last one is the part that transfers anywhere. A rubric without those sentences is a gut feeling with a table around it.</p>
<h2>The sweep that reports and never rewrites</h2>
<p><img src="/images/illustrations/judging-a-safety-sprint.svg" alt="Slope chart comparing the first four reports with the last four. Presentation climbs 0.75, steeper than Impact or Execution."></p>
<p>Before I submitted anything, I ran a script over those lines. It prints the spread of my scores, counts the 5s and the 3s, and compares the first half of the pile against the second.</p>
<p>It cannot change a score. That limit is the point: a tool allowed to correct the number will end up doing it, and then the calibration lives in the tool instead of in me.</p>
<p>Here is what it printed, before I went back and changed anything, minus the section that names the projects:</p>
<pre><code class="language-text">Drift sweep: 8 project(s)

1. Distribution
   D1  mean 2.75 |  1x1  2x2  3x3  2x4
   D2  mean 2.38 |  1x1  4x2  2x3  1x4
   D3  mean 2.88 |  1x1  1x2  4x3  2x4

2. 5s: 0 across all dimensions.
3. 3s: 9 across all dimensions.

4. First half vs second half
   D1  first 2.50 -&gt; second 3.00  (delta +0.50)
   D2  first 2.25 -&gt; second 2.50  (delta +0.25)
   D3  first 2.50 -&gt; second 3.25  (delta +0.75)   FLAG: bar moved

Nothing rewritten.
</code></pre>
<p>Presentation, the third score, went from 2.50 to 3.25 between the first four reports and the last four. My bar for clarity dropped three quarters of a level across one pile, and I had no idea until a forty-line script told me. One person, one long evening and a last report the next day, with the same rubric open the whole time.</p>
<p>Zero fives does check out. The rubric puts a 5 at five to ten per cent, which over eight projects is none to one. Of the eight, two stood up properly, and that is a normal weekend batch: the sprint asks for no prior background in incident response, which is how you get 234 entries in three days.</p>
<p>What I did with the warning was re-read. Re-scoring on reflex would only swap one drift for another. I opened the four reports from the first half beside their sentences and asked whether the sentence still held. Two did. Two I had read too harshly in the first hour, and those I raised, with the reason written in beside the original.</p>
<h2>Where this lands outside a hackathon</h2>
<p>Judging a sprint is a thing you do once in a while. Engineers score pull requests every day, and the same drift runs through both.</p>
<p>The reviewer who approves at five in the afternoon is not the reviewer who blocked at ten in the morning. The third PR from the same author gets read against the previous two rather than against the standard. If you have ever wondered why your team’s review standard shifts with whoever picks up the ticket, <a href="/en/blog/reviewing-ai-generated-pull-requests">this is most of the answer</a>, and it gets worse as code arrives faster than anyone can form an opinion about it.</p>
<p>Write down what a passing review requires, in words you did not invent on the spot, and keep a short note of why the last few went the way they did. Then somebody can show you that your bar moved, which is the only version of this problem that has a fix. Same as <a href="/en/blog/you-cant-spot-the-bug-if-you-didnt-write-the-code">reviewing code you did not write</a>: the instrument has to sit outside your head, because the thing being measured is inside it.</p>
<p>Apart runs these sprints monthly and publishes the rubric, which is why I could build any of this before opening the first PDF. If they ask again I will say yes, and I will run the sweep again, and I expect it to catch something else.</p>
]]></content:encoded>
</item>
<item>
<title>You installed OpenAI's tracker yourself</title>
<link>https://tim-schipper.nl/en/blog/chatgpt-ads-pixel-cross-site-tracking</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/chatgpt-ads-pixel-cross-site-tracking</guid>
<pubDate>Mon, 21 Sep 2026 15:44:19 GMT</pubDate>
<description>On shops that run OpenAI's ads pixel, your browser sends along a cookie tied to your ChatGPT account. The pixel is there because a developer pasted a snippet into a head tag, and the consent flag in the docs fires too late to stop it.</description>
<content:encoded><![CDATA[<p>On the 20th of September a researcher published a teardown of a cookie called <code>__obi</code>. The same day it hit the Hacker News front page, and the comments split into two camps. One was angry that OpenAI tracks ChatGPT users across the web. The other shrugged: Facebook and Google have done this for well over a decade.</p>
<p>Both are right, and both skip the part a developer can do something about.</p>
<p>The cookie only travels because a script from <code>bzrcdn.openai.com</code> loads on a shop’s page. That script loads because someone pasted a snippet into a <code>&lt;head&gt;</code> tag. OpenAI’s documentation tells you to put it “near the top of your <code>&lt;head&gt;</code>”, on every page where you want to measure conversions.</p>
<p>That someone is a developer. Quite possibly you.</p>
<p><img src="/images/illustrations/chatgpt-ads-pixel.svg" alt="With consent set to false the script request already carries the __obi cookie, while an onclick loader sends nothing"></p>
<h2>What the snippet does</h2>
<p>The <a href="https://www.buchodi.com/chatgpt-now-knows-what-you-do-on-other-websites-via-ad-collector/">teardown on buchodi.com</a> is careful work. It reproduces the mechanism on a real phone, checks it with two independent capture methods, and cross-references months of traffic across 936 advertiser pixels. The chain goes like this.</p>
<p>When you open ChatGPT, the client asks OpenAI’s backend for a short-lived signed token. That token binds your account to a random identifier. The client then posts it to <code>bzr.openai.com</code>, OpenAI’s ad collector, which answers with a cookie:</p>
<pre><code class="language-http">Set-Cookie: __obi=«redacted»; Domain=.openai.com; HttpOnly;
            Max-Age=31536000; Path=/; SameSite=none; Secure
</code></pre>
<p>One year, and <code>SameSite=none</code>. That setting exists for one reason: so the browser sends the cookie along on requests from <em>other</em> sites. Every other OpenAI cookie the researcher checked was blocked on those requests. <code>__obi</code> was the only one configured to cross.</p>
<p>Then you visit a shop that advertises on ChatGPT. Its page loads OpenAI’s measurement pixel, the browser attaches <code>__obi</code> to the request, and OpenAI receives an identifier tied to your ChatGPT account next to the page you are looking at. On the researcher’s own phone, one <code>__obi</code> value went out from 12 commercial sites, including Chewy, Wayfair, Coursera and HelloFresh. It worked while logged out too: 196 of 932 decoded tokens carried an anonymous subject, stable per device for at least 27 days.</p>
<h2>The case for the pixel is real</h2>
<p>None of this is exotic. Meta has run the same structure for years: a logged-in account, a third-party cookie on the pixel, off-site conversions resolved to a profile. If you buy ads, you want to know which ones sold something. A conversion pixel is how the industry answers that, and OpenAI built the one its advertisers expected.</p>
<p>The researcher is also honest about the limits: the tests ran on Chrome for Android, and desktop Chrome was not tested. Safari blocks third-party cookies, and every iOS browser runs on the same WebKit engine, so iOS is not affected.</p>
<p>Nor does it fire every time. Only about one ChatGPT session in five produced a sync token at all. And the final step, OpenAI joining the event to an account on its servers, follows from the design but was not directly observed.</p>
<p>The mechanism itself is standard adtech. What is new is where the identity comes from. People tell a chatbot things they would never post on a social network, and the account that holds those conversations is now the key that ties together their shopping, their reading and their debt-advice searches.</p>
<h2>The part the documentation leaves out</h2>
<p>This is where it becomes a developer problem, and it starts with OpenAI’s own <a href="https://developers.openai.com/ads/measurement-pixel">measurement pixel documentation</a>.</p>
<p><strong>The docs never mention <code>__obi</code>.</strong> They list the cookies the pixel sets on <em>your</em> domain, <code>__oppref</code> and <code>__obref</code>, with their lifetimes. The cookie that identifies your visitor to OpenAI does not appear on the page you installed from.</p>
<p>The researcher gets there by another route. The cookie sits on a domain no advertiser script can read, and the teardown concludes: “They installed a conversion pixel and have no way to know their visitors are being resolved to a ChatGPT identity.”</p>
<p><strong>Consent is on until you turn it off.</strong> In the documentation’s own words: “The Pixel initializes consent to <code>true</code> by default unless you set it to false or the Pixel finds a stored denial.”</p>
<p><strong>And turning it off comes too late.</strong> The documented way to wait for consent is <code>oaiq(&quot;consent&quot;, false)</code> before <code>init</code>, which stops the SDK from sending measurement events. It does nothing about the request that loads the SDK, and that request is the one the researcher caught carrying <code>__obi</code>. The browser attaches the cookie to the <code>&lt;script src&gt;</code> fetch before a single line of OpenAI’s code runs, so loading the tag is enough.</p>
<p>I wanted to see that without a ChatGPT account in the loop, so I built two local pages and loaded them in a default Chromium 153 profile, which allows third-party cookies. The browser held a stand-in <code>__obi</code> cookie on <code>.openai.com</code>, and the OpenAI hostnames pointed at a local server, so nothing reached OpenAI.</p>
<p>Page one is the documented snippet with <code>oaiq(&quot;consent&quot;, false)</code> before <code>init</code>, and page two injects the same loader only after a click on an accept button, which the test never clicks. Condensed from the script’s output:</p>
<pre><code class="language-text">page one  GET /sdk/oaiq.min.js  Cookie: __obi=local-test-value
page two  (no requests to openai.com)
</code></pre>
<p>The consent flag was set before the SDK even existed, and the cookie went out anyway. The only page that kept it home is the one that never loaded the script. Firefox and Safari, which partition or block third-party cookies, would not have sent it on either page.</p>
<p>Then there is automatic advanced matching, and here the researcher’s traffic and OpenAI’s docs part ways. The docs promise: “Raw customer information is not sent to OpenAI through automatic advanced matching.” In the observed traffic, the SDK reads form fields and rendered page text, and wraps <code>window.dataLayer.push</code> to pick emails and phone numbers out of your tag manager. The feature was enabled on 638 of the 881 pixels whose setting was known.</p>
<p>Emails, phones and names are hashed with SHA-256 before they leave. Country, region, city and postal code go out in the clear. URLs are trimmed to origin plus path, and the paths that reached the collector included a medical condition, a debt-solutions funnel and a litigation intake form.</p>
<p>Your slugs are data. You chose them for search engines, and they now describe your visitors to an ad network.</p>
<h2>What OpenAI calls it</h2>
<p>OpenAI’s <a href="https://openai.com/policies/cookie-policy/">cookie policy</a> lists <code>__obi</code> under <em>Analytics cookies</em>, the only entry in that section, described as helping OpenAI “understand how our Services perform and are used”. Marketing has its own consent choice. Every sync token the researcher decoded carried <code>consent_decision: analytics_allowed</code>, so a user who accepts analytics and refuses marketing still gets the cross-site identifier.</p>
<p>The researcher raised this with OpenAI’s press and privacy addresses on the 14th of September. Support acknowledged the email and answered nothing.</p>
<h2>What to actually do</h2>
<p>If your site runs this pixel, or your marketing team asked you to add it recently, the fix is in your hands.</p>
<ul>
<li><strong>Gate the script itself.</strong> Do not inject the loader until the visitor has consented. A consent flag inside a script that has already loaded protects nothing, because the disclosure happened on the load. That is page two from the test above.</li>
<li><strong>Switch off automatic advanced matching.</strong> It is a setting in OpenAI’s Ads Manager. A script that scrapes your forms is a decision someone should make on purpose.</li>
<li><strong>Send conversions from your server.</strong> OpenAI offers a server-side Conversions API. A request from your backend carries exactly the fields you put in it, and none of your visitor’s cookies.</li>
<li><strong>Read your CSP as an inventory.</strong> The docs ask you to add <code>bzrcdn.openai.com</code> and <code>bzr.openai.com</code> to your Content Security Policy. Every host in that header is a third party that sees your visitors. If you cannot say why each one is there, find out.</li>
</ul>
<p>The same habit applies to anything you paste into a page. A third-party snippet is a dependency you install without reading, and I have argued before that <a href="/en/blog/the-mcp-supply-chain-is-the-new-npm">the unread dependency is where the MCP supply chain goes wrong</a>. It is also the lesson from <a href="/en/blog/what-your-coding-agent-sends-on-the-wire">the coding agent that uploaded a whole repository</a>: you only learn what a tool sends when you look at the wire.</p>
<p>This site runs no analytics at all, and I wrote that down as a decision. It costs me every number I might have liked to see. It also means I never have to find out from a Hacker News thread what my own <code>&lt;head&gt;</code> has been doing. If your data lives with a vendor anyway, <a href="/en/blog/take-back-control-of-your-data">taking back control of it</a> starts with knowing where it goes.</p>
<p>The teardown’s headline says OpenAI knows what you do on other websites. It found out because those websites told it. Somebody on each of those teams can make that stop.</p>
]]></content:encoded>
</item>
<item>
<title>Plugin4Shell: a pinned plugin is only pinned if something checks where it landed</title>
<link>https://tim-schipper.nl/en/blog/plugin4shell-sha-pinning-bypass</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/plugin4shell-sha-pinning-bypass</guid>
<pubDate>Fri, 18 Sep 2026 11:55:41 GMT</pubDate>
<description>Air showed that Claude Code, Codex, Copilot and Gemini CLI all asked git for the commit a marketplace pinned without checking that they got it. I reproduced it in git, watched Claude Code refuse it, and found the same hole in my own CI runner.</description>
<content:encoded><![CDATA[<p>You review a plugin, you pin it to the exact commit you read, and from then on that commit is what runs. That is the deal SHA pinning offers, and it is a good deal.</p>
<p>On 17 September Or Nevo, Dor Granat and Niv Hoffman of <a href="https://www.air.security/blog-posts/plugin4shell">Air</a> showed that Claude Code, Codex, GitHub Copilot and Gemini CLI all honoured the pin on paper. They asked git for the pinned commit, and none of them checked what the checkout actually produced.</p>
<p>Air calls it Plugin4Shell: zero-click remote code execution through a plugin you already trusted.</p>
<p><img src="/images/illustrations/plugin4shell-sha-pinning.svg" alt="One pinned hash resolves two ways: rev-parse finds the commit, checkout takes the branch with the same name"></p>
<h2>Why pinning was the right idea</h2>
<p>Pinning answers the rug pull. A plugin passes review, people install it, and later the author, or whoever took over the author’s repository, swaps in something else. A version tag <a href="/en/blog/they-moved-your-version">can be moved</a>, and a branch moves by design. A 40-character commit hash names one commit and its exact tree of files, forever.</p>
<p>So marketplaces pin. Anthropic’s official marketplace pins its git sources to a commit, and I’ve told you before to <a href="/en/blog/how-to-vet-an-mcp-server">pin the version</a> of anything you hand to an agent. The idea holds up. The step after it is where four agents went wrong.</p>
<h2>The missing line</h2>
<p>An agent installs a pinned plugin roughly like this:</p>
<pre><code class="language-bash">git clone &lt;plugin repo&gt; .
git checkout &lt;pinned-sha&gt;
</code></pre>
<p>The attacker controls the plugin’s repository. They create a branch whose <em>name</em> is the pinned hash, point it at their own code, and make it the repository’s default branch, so the clone brings it down. Now <code>git checkout &lt;pinned-sha&gt;</code> has two candidates with the same name: the commit and the branch. Git picks the branch.</p>
<p>I rebuilt that in a scratch repository with git 2.43 to see it happen:</p>
<pre><code class="language-text">$ git checkout 30db431387e58014d7bcdf4b9d3af73414d25921
warning: refname '30db431387e58014d7bcdf4b9d3af73414d25921' is ambiguous.
Git normally never creates a ref that ends with 40 hex characters
because it will be ignored when you just specify 40-hex. [...]
Already on '30db431387e58014d7bcdf4b9d3af73414d25921'

$ git rev-parse HEAD
57ec4c8f51b04d7d33ec7e422a3953381c83171c

$ cat run.sh
echo &quot;attacker code&quot;
</code></pre>
<p>Git says such a ref “will be ignored when you just specify 40-hex”, and in the same breath it checks the ref out.</p>
<p>The warning is right about one command. <code>git rev-parse 30db431…</code> returns the commit, because <code>rev-parse</code> prefers the object, while <code>git checkout</code> prefers the branch. So the check you would write first, resolving the pin before you check it out, passes every time and proves nothing. The only check that catches it runs afterwards and asks what actually landed:</p>
<pre><code class="language-bash">test &quot;$(git rev-parse HEAD)&quot; = &quot;$PINNED_SHA&quot; || abort
</code></pre>
<p>That line is Air’s fix, and its absence is the whole bug. The Gemini CLI variant gets there by another route: it fetches the pin and then runs <code>git checkout FETCH_HEAD</code>, and a default branch named <code>FETCH_HEAD</code> wins that checkout the same way. I reproduced that one too.</p>
<p>Two conditions make it work. The hash-named branch has to be the default, otherwise a plain clone only fetches it as a remote-tracking ref and the checkout falls back to the commit. And the host has to accept a branch named like a hash: GitHub rejects them, a spokesperson told <a href="https://www.theregister.com/security/2026/09/17/ai-coding-agents-0-click-rce-flaw-could-hand-attackers-keys-to-the-kingdom/5297335">The Register</a>. According to Air, Bitbucket and self-hosted git servers accept them, and those are supported marketplace backends.</p>
<p>Auto-update turns it into zero-click. The attacker ships a routine, harmless update, the marketplace moves the pin to that new commit, and only then does the attacker create a branch named after it. Claude Code and Codex update installed plugins in the background by default, so the swap reaches everyone who already has the plugin without anyone installing anything.</p>
<h2>Who fixed it</h2>
<p>Air found it in May and reported it to all four vendors in June.</p>
<ul>
<li><strong>Claude Code</strong> fixed it in 2.1.179. Anthropic confirmed the fix to Air on 17 June.</li>
<li><strong>Codex</strong> fixed it in 0.146.0.</li>
<li><strong>Gemini CLI</strong> will not be fixed. Google deprecated it and points users at Antigravity.</li>
<li><strong>GitHub Copilot</strong> has no fix. Air told The Register it never got a response from Microsoft, and Microsoft did not immediately respond to The Register either.</li>
</ul>
<p>I tested the Claude Code fix on 2.1.276, in an isolated config directory, with a marketplace entry pinned to a commit in a local repository that carried the hash-named default branch:</p>
<pre><code class="language-text">✘ Failed to install plugin &quot;demo@gitpin&quot;: SHA pin verification failed:
expected HEAD to be ae578a9f72594f3b82f572671b6fa45ac3044476,
got 5f056ba526ee39cc352f122706bcf6105cb8a592. The pinned commit may
have been removed upstream, or a ref with the same name exists.
Refusing to install.
</code></pre>
<p>The same pin from a clean copy of the repository installed normally. That is the check doing its job, and it asks the same question as Air’s one-line fix.</p>
<h2>Check what you received, not what you asked for</h2>
<p>Claude Code also takes plugins as a zip archive pinned by <code>sha256</code>. That design never had this problem, because a hash of the bytes you received cannot be satisfied by a branch name. I pointed an isolated config at a real archive with a wrong hash:</p>
<pre><code class="language-text">✘ Failed to install plugin &quot;decompose@pintest-bad&quot;: Plugin archive
integrity check failed [...]: expected sha256 0000[...]0000,
got 9c648375659df3ba5003c07425e728d2cab77f522311af7a604825ce53d993d3.
The archive was not installed.
</code></pre>
<p>Both checks now ask the same question: what arrived? Anything you pin, plugin or not, is only as safe as the answer to that question.</p>
<h2>It was in my CI too</h2>
<p>I run my own <a href="/en/blog/ci-runner-security-coding-agents">Forgejo with its own runner</a>, and my workflows pin every action by commit hash. So I built the attack against that setup, locally, with the same Forgejo version I run in production.</p>
<p>Forgejo 16.0.4 accepts a branch named like a hash and lets you make it the default. Setting it through the API took two attempts: the first left the repository’s <code>HEAD</code> on <code>main</code>, the second moved it. That is a quirk, and nobody should count on it as a defence. With the branch in place, a workflow step <code>uses: &lt;action&gt;@&lt;pinned-sha&gt;</code> behaved like this:</p>
<table>
<thead>
<tr>
<th style="text-align:left">runner</th>
<th style="text-align:left">result</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Gitea <code>act_runner</code> 0.3.1</td>
<td style="text-align:left">ran the attacker’s action, job succeeded, no warning</td>
</tr>
<tr>
<td style="text-align:left"><code>forgejo-runner</code> 13.1.0</td>
<td style="text-align:left">refused: “an ambiguous git reference”, job failed</td>
</tr>
<tr>
<td style="text-align:left">both, without the hash branch</td>
<td style="text-align:left">ran the reviewed action</td>
</tr>
</tbody>
</table>
<p>The first row was my production runner. I had updated the Forgejo server and assumed the runner had come along with it. It hadn’t: it was a different project with its own version number, still at 0.3.1. As of today, production runs <code>forgejo-runner</code> 13.1.0.</p>
<p>Nobody attacked me. Exploiting it would need control of the repositories those actions come from. But my pins would not have stopped it, and I only found that out by testing.</p>
<p>One level up, some pins were missing altogether. My own plugin marketplace pinned its skill archives by <code>sha256</code>, but the plugins that live in their own GitHub repositories carried no pin at all, so every update followed whatever the default branch said, until I pinned them to their release commits. While checking that the update landed, I found one more thing to distrust: Claude Code did not refresh the <code>gitCommitSha</code> it records for an updated plugin, so that field still named the old commit. I compared the installed files against the pinned commits instead.</p>
<h2>What to do</h2>
<ul>
<li><strong>Update the agent.</strong> Claude Code 2.1.179 or later, Codex 0.146.0 or later. On Copilot there is nothing to update to yet. On Gemini CLI there never will be.</li>
<li><strong>Know which of your plugins are pinned at all.</strong> An unpinned git source follows the default branch on every update. That is the rug pull pinning exists to stop, without any cleverness required.</li>
<li><strong>Check your runner as well as your forge.</strong> They are separate installs with separate versions. <code>forgejo-runner</code> 13.1.0 refuses the hash-named branch. The Gitea <code>act_runner</code> 0.3.1 I had did not.</li>
<li><strong>In your own scripts, check after checkout.</strong> Use <code>git checkout --detach &quot;$SHA^{commit}&quot;</code> and then compare <code>git rev-parse HEAD</code> with the pin. The <code>^{commit}</code> is what makes the difference: <code>--detach</code> on its own still takes the branch, and so does passing the output of <code>rev-parse</code> back into <code>checkout</code>. A check before the checkout is the one that always passes.</li>
<li><strong>Prefer a hash of the bytes.</strong> Where you control the format, pin the hash of the artefact you download.</li>
</ul>
<p>Pinning held up. Asking for a commit and getting it are two steps, and four agents stopped after the first. The fix is one line. It took three researchers and a branch named like a hash to get it written, and two of the four agents still do not have it.</p>
]]></content:encoded>
</item>
<item>
<title>Recovering a Claude Code session your picker will not show you</title>
<link>https://tim-schipper.nl/en/blog/recover-crashed-claude-code-session</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/recover-crashed-claude-code-session</guid>
<pubDate>Sat, 12 Sep 2026 11:22:24 GMT</pubDate>
<description>WSL went down and took Claude Code with it. The transcript was still on disk, and the resume picker would not offer it. Agent history is durable because it is append-only text on a filesystem, and reaching it needs some program other than the client that just crashed.</description>
<content:encoded><![CDATA[<p>WSL fell over and took Claude Code with it, mid-turn, about two hours into something I did not want to reconstruct.</p>
<p>So I did the obvious thing. Opened a terminal, went back to the directory, ran <code>claude --resume</code>.</p>
<p>The session was not in the list.</p>
<p>Absent: not truncated, not buried at the bottom, not hiding under a title I failed to recognise. Meanwhile the transcript of that entire two hours was sitting on the disk, a few directories away, completely intact.</p>
<p>That gap is the whole subject of this post.</p>
<h2>The transcript is a file. The session was a process</h2>
<p>These are two different things, and almost everything about how we talk about agent sessions blurs them together.</p>
<p>Claude Code writes every turn to a JSONL file under <code>~/.claude/projects/</code>, one directory per project you have opened it in, as it happens. That file is only ever appended to, and what has already reached the disk does not care what happens to the process writing it. Kill the terminal, kill WSL, pull the power: the bytes already written stay written.</p>
<p>The picker is something else. It is a feature of a client that is running now, and it exists to help you pick up something you put down on purpose. When it works it is genuinely good, and on 2.1.269 <code>claude --resume</code> takes a search term and does real work for you.</p>
<p>But it is a convenience of a healthy client. I had been treating it as a recovery tool, and those are not the same job. The moment the thing that crashed is also the only thing that can show you what it lost, you have a single point of failure with your afternoon inside it.</p>
<p>I cannot tell you why my session was missing from that list. I could not reproduce it, and the transcript of the session in question is intact.</p>
<p>There is crash damage on this disk, mind. Five files of it: one zero-byte transcript, and four whose last block is a run of NUL bytes where a write never landed. Every one of the five is a subagent transcript, and not one is a session I could have resumed anyway. So the damage is real, and it sits well away from the session I lost.</p>
<p>What I can tell you is that the file was there and the client would not offer it, and that I have stopped assuming those two facts travel together.</p>
<h2>What a crash actually leaves behind</h2>
<p>Claude Code keeps a registry of live sessions in <code>~/.claude/sessions/</code>, one small JSON file per process, naming a session id and the directory it was running in.</p>
<p>Right now this machine has 262 of them, and ten will not parse at all. Of the 252 that do, every one carries a session id and a working directory, though only 62 still carry a status. For 248 of them the process they name is gone. And 30 of those are still filed as <code>busy</code>: sessions that were mid-turn when whatever happened happened, with nothing behind the PID any more.</p>
<p>That is a picture of what a crash leaves on your disk. Whether any of it explains my own missing session, I genuinely do not know. Processes die in the middle of things and the paperwork stays behind, describing a state that stopped being true.</p>
<p>The transcripts underneath are in far better shape. Ten registrations in 262 will not parse. Five transcripts in 1,422 will not. Appending to a file is boring, and boring is the point.</p>
<h2>The sessions Claude Code will not offer you</h2>
<p><img src="/images/illustrations/recover-crashed-session.svg" alt="Of 1,422 transcript files on disk only 196 have a resume command, though they hold more than half the bytes"></p>
<p>While I was counting, I found the bigger hole.</p>
<p>This machine holds 1,422 transcript files under <code>~/.claude/projects/</code>, 1,165 MB of them. Only 196 are top-level sessions, the shape that has a resume path at all, and those come to 626 MB across 72 project directories.</p>
<p>The other 1,226 files, 539 MB of them, are subagent transcripts. They sit in a <code>subagents/</code> directory beside the session that spawned them, one per agent dispatched.</p>
<p>Claude Code will never offer you one. They are not sessions you can attach to, they have no resume command, and from the client’s point of view they are implementation detail.</p>
<p>They are also where an enormous amount of my actual work happened. Every time I fan out a review across four agents, or hand a search to an explore agent rather than burning my own context on it, the reasoning lands in one of those files. Six to one against the sessions I drove by hand, by count.</p>
<p>This is the part that bites hardest when you run multiple agents. The more work you delegate, the larger the fraction of your history that lives in files your client treats as plumbing. You cannot resume them, and you cannot even look at them without knowing the path by heart.</p>
<h2>What got me back</h2>
<p>The repair was dull, which is how you want a repair to be.</p>
<p>I ran <code>nekyia index</code>, which reads the transcript stores already on the disk and commits what changed to a local SQLite index. Then I searched it. The session came up, the way a file comes up in <code>grep</code>, because nothing in that path asks a running Claude Code anything.</p>
<p>Two things made it work rather than merely find something.</p>
<p>The first is that it reads across every project directory at once, <code>projects/*/*.jsonl</code>, not the one you happen to be standing in. After a crash you do not reliably come back to the same place, and “which of 72 directories was that” is a question I could not answer about my own afternoon.</p>
<p>The second is that it knows each session’s original working directory from the index, so it launches <code>claude --resume &lt;id&gt;</code> in that directory rather than in the one you typed the command from. An id on its own drops you somewhere arbitrary. A resume needs the id and the directory it belongs to, together.</p>
<p>The move that actually found it was <code>nekyia blame</code>, which lists recent sessions that touched a given file. I could not remember how I had phrased anything two hours in, but I knew exactly which file I had been editing when the screen went away. Searching by the thing I had been doing beat searching by the words I had used, and after a crash that is usually the asymmetry you are working with.</p>
<p>I wrote about <a href="/en/blog/search-agent-session-history">searching agent history across clients when the sessions are merely old</a>, which is the same index doing a calmer job. Recovery is the case where it stops being a convenience.</p>
<h2>What it does not get back</h2>
<p>The transcript is the conversation. That is all it is.</p>
<p>Resuming from it gives you back what you asked, what came back, and which files moved. It does not give you the tool state, the file snapshots, the shell you had open, or anything the process was holding in memory when it died. For a client where an exact attachment is not a safe bet, what you get is a handover brief, and its first line says so.</p>
<p>So what came back was the argument rather than the two hours. I got back the reasoning, the approach I had tried first and abandoned, and the list of files I had touched, and I redid the last ten minutes myself.</p>
<p>That is a fair trade, and it is worth being precise about it, because a tool that claimed to restore the whole machine state would be making the same error as an agent that is confidently wrong about a function signature. It looks right until you lean on it.</p>
<p>It is still pre-release, 0.0.19 now, installed from <a href="https://github.com/AraneaDev/Nekyia">the repository</a> rather than npm, with <a href="https://aranea-development.nl/en/tools/nekyia">a fuller write-up of what it does</a>. The index over this machine’s sessions is 52 MB, runs locally, makes no network call and holds no tool output.</p>
<h2>Durability is a property of the format</h2>
<p>The crash taught me that I had misplaced my trust one layer too high.</p>
<p>Your agent history is durable because it is append-only text on a filesystem. It is <em>reachable</em> because some program reads that format. When the only program that reads it is also the program that can crash, the durability is real and the reachability is not, and you will not discover the difference on a good day.</p>
<p>This is the same shape as the argument for keeping <a href="/en/blog/stale-memory-is-worse-than-no-memory">memory files honest rather than comprehensive</a>, and for <a href="/en/blog/claude-code-context-management">managing a live session’s context deliberately</a>. Those are disciplines for the session you are in. This is the question of what survives it.</p>
<p>Check it before you need it. Find out where your client writes its transcripts, and find out today whether anything other than that client can read them. The answer is cheap to get when nothing is broken, and it is the only thing you will want at the moment something is.</p>
<p>Mine was two hours. It would have been a week if the files had not been there.</p>
]]></content:encoded>
</item>
<item>
<title>Empty catch blocks in AI code: the comment that silences your linter</title>
<link>https://tim-schipper.nl/en/blog/ai-code-empty-catch-blocks</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/ai-code-empty-catch-blocks</guid>
<pubDate>Fri, 11 Sep 2026 21:15:55 GMT</pubDate>
<description>AI-generated code swallows errors in catch blocks that hold nothing but a comment, and ESLint's recommended config waves them through because of that comment. What I found in my own repositories, why a model writes it, and the rule that sees through the comment.</description>
<content:encoded><![CDATA[<p>Search your codebase for <code>// ignore</code> inside a catch block.</p>
<p>I did, across 37 of my own repositories. Nearly all of them started in 2025 or 2026, and an agent wrote part of the code in them. <code>// ignore</code> is the most common thing written inside a catch that does nothing.</p>
<p>That comment has a reader, and it is ESLint.</p>
<p><img src="/images/illustrations/empty-catch-blocks.svg" alt="536 empty catch blocks to scale: no-empty passes the 494 with a comment, no-restricted-syntax flags all 536"></p>
<h2>Why the comment is there</h2>
<p><code>no-empty</code> ships in ESLint’s recommended config, so most JavaScript projects run it without anyone having chosen to. Its <a href="https://eslint.org/docs/latest/rules/no-empty">documentation</a> has one sentence that explains everything that follows: “This rule ignores block statements which contain a comment.”</p>
<p>An empty catch fails the lint run. The same catch with <code>// ignore</code> in it passes.</p>
<p>The exception was designed for a human who leaves the comment on purpose, to tell the next reader the empty block is deliberate. An agent meets a failing lint run and looks for the cheapest change that turns it green. The cheapest change is one line of English.</p>
<p>A grep across those 37 repositories finds 536 catch blocks with no line of code in them. 494 of them, 92%, do hold a comment line. Roughly a quarter of those say some form of <code>// ignore</code>. After that come notes about a fallback, and <code>// best effort</code>.</p>
<p>Treat that as a floor. A regex misses every catch that logs the error and carries on, which is the other half of this problem.</p>
<h2>Some of them are right</h2>
<p>A few of those catches are exactly what they should be. An SDK that reports errors must never throw from the code that does the reporting. A cleanup step that deletes a temp file has nothing to do if the file is already gone.</p>
<p>In each correct case, though, the catch knows which error it expects. “The file is already gone” is one error code, <code>ENOENT</code>. <code>// ignore</code> catches that, and also a full disk, a permissions failure and a typo in the path, all with the same shrug.</p>
<h2>Where it stops being harmless</h2>
<p>This is from an end-to-end suite in a project of mine that never went live. It was something I built to learn with, I stopped before it was finished, and an agent wrote nearly all of it. That is what makes it worth quoting:</p>
<pre><code class="language-ts">try {
  await submitBtn.waitFor({ state: 'visible', timeout: 3000 })
  await submitBtn.click()
} catch {
  // Submit might not be needed
}
</code></pre>
<p>Variations of that block appear more than a dozen times in the suite. Read it as a test. If the submit button never appears, the test waits three seconds and carries on. The one regression this step exists to catch, a quiz you cannot submit, has become the one outcome it accepts without a word.</p>
<p><a href="/en/blog/ai-generated-tests-mutation-testing">A suite that cannot fail</a> looks exactly like a suite that passes. This site’s own repository has two of these catches, in a test file, and both pass its lint config.</p>
<h2>Why a model writes this</h2>
<p>Two pressures, stacked on top of each other.</p>
<p>The first is the corpus. Catch-and-ignore has been in public code for as long as there has been public code. <code>no-empty</code> needed a comment exception because people wrote empty catches so often.</p>
<p>The second is the loop. An agent’s working loop rewards a green run, and an uncaught exception is the fastest way to a red one.</p>
<p>The <a href="https://www-cdn.anthropic.com/0339e6a7c5c7b87f5c07798616dc32c215d14235/Claude%20Fable%205.1%20&amp;%20Claude%20Mythos%205.1%20System%20Card.pdf">system card for Claude Fable 5.1 and Mythos 5.1</a>, published on 1 September 2026, screens reinforcement-learning episodes for a model reaching for answers or hidden tests from outside the task. On the environments the newest model shares with its predecessors, “every model is flagged for attempts on roughly 20% to 28% of episodes.” Under that pressure, looking for a shortcut to green is routine.</p>
<p>Whether the shortcut pays off depends on what checks the result. In your loop, one of those checks is your lint config.</p>
<p>The shortcut also has a known shape. <a href="https://arxiv.org/abs/2604.11072">Hodoscope</a>, from April 2026, quotes an agent’s edit from ImpossibleBench, where the tests cannot be satisfied honestly. The edit inserts <code>except Exception: pass</code>, and the authors file it under specialising to the tests.</p>
<p>The closest thing to a measurement is GitClear’s <a href="https://www.gitclear.com/the_ai_code_quality_maintainability_gap">2026 maintainability report</a>, built on 623 million code changes from 2023 to 2026. Over the years in which AI authorship grew to a large share of all commits, it counts a 47% rise in what it calls “error-masking constructs”. That is a trend across all code, and it ties to AI only through timing. CodeRabbit’s <a href="https://www.coderabbit.ai/blog/state-of-ai-vs-human-code-generation-report">comparison of AI and human pull requests</a> from December 2025, a vendor report, points the same way from the other side: “Error handling and exception-path gaps were nearly 2× more common.”</p>
<p>Nobody has published a rate per model. The step from “optimised for green” to “writes <code>// ignore</code>” is my inference, and my own disk is the evidence I have for it.</p>
<h2>A rule that sees through the comment</h2>
<p>Comments are not part of the syntax tree. ESLint’s <code>no-restricted-syntax</code> matches selectors against that tree, so a catch whose body holds zero statements is empty to it, however many comments sit inside:</p>
<pre><code class="language-js">// eslint.config.js
export default [
  {
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector: 'CatchClause &gt; BlockStatement[body.length=0]',
          message: 'Empty catch: rethrow, return a typed error, or name the error you expect.',
        },
        {
          selector:
            'CatchClause &gt; BlockStatement[body.length=1] &gt; ExpressionStatement &gt; CallExpression[callee.object.name=&quot;console&quot;]',
          message: 'A catch that only logs still swallows the error.',
        },
      ],
    },
  },
]
</code></pre>
<p>The second selector covers the log-and-carry-on case the grep could not see. Both messages are written for the agent. They name the fix, so the agent’s next attempt has something to aim at. If your config already sets <code>no-restricted-syntax</code>, add these objects to that list, because a later config object replaces the options instead of merging them.</p>
<p>For Python, Ruff has both halves built in. <code>S110</code> flags try-except-pass, and its documentation says why: “Suppressing exceptions may hide errors that could otherwise reveal unexpected behavior, security vulnerabilities, or malicious activity.” <code>BLE001</code> flags a blind <code>except Exception</code>.</p>
<pre><code class="language-toml">[tool.ruff.lint]
extend-select = [&quot;S110&quot;, &quot;BLE001&quot;]
</code></pre>
<p>You will have hundreds of existing violations, which is what <a href="/en/blog/quality-ratchets-for-ai-code">a baseline that only shrinks</a> is for. Switch the rule on as an error, freeze what is already there, and only new catches fail.</p>
<p>Then rewrite the legitimate ones so they say what they expect:</p>
<pre><code class="language-js">try {
  await unlink(tmpFile)
} catch (err) {
  if (err.code !== 'ENOENT') throw err
}
</code></pre>
<p>The comment became a condition, and the next reader can check a condition. Where a fallback really is the right behaviour, <a href="/en/blog/observability-ai-generated-code">make it visible rather than silent</a>, with a field that records which path ran.</p>
<p>And give the agent the rule in words, before the linter has to:</p>
<pre><code class="language-markdown">A catch block rethrows, returns a typed error, or checks for the one error it expects. A comment is not handling.
</code></pre>
<p><code>// ignore</code> used to be a note to the next developer. Now it is mostly a note to the linter, and the linter takes its word for it.</p>
]]></content:encoded>
</item>
<item>
<title>What a dependency graph finds that your coding agent misses</title>
<link>https://tim-schipper.nl/en/blog/dependency-graph-coding-agents</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/dependency-graph-coding-agents</guid>
<pubDate>Wed, 09 Sep 2026 08:08:28 GMT</pubDate>
<description>I pointed a scanner at an internal app that agents had been working in for months. It took under two seconds and produced four tickets. Not one of the defects was in a file, which is exactly why nothing reading files had found them.</description>
<content:encoded><![CDATA[<p>Yesterday I pointed a scanner at an internal customer-insights app I maintain. Coding agents have been working in it for months: two languages, just under seventy endpoints, a test suite that passes.</p>
<p>The scan took under two seconds. It read 588 files into roughly 6,700 components and 17,700 relationships, and by the end of the afternoon I had filed four tickets off the back of it.</p>
<p>Not one of those defects was in a file.</p>
<p>That is the whole post, so let me be precise about what it means.</p>
<p><img src="/images/illustrations/dependency-graph-coding-agents.svg" alt="Four of the resilience module's five exports reach only its test file, one reaches the running code"></p>
<h2>The agent reads files, and it reads them well</h2>
<p>Give a coding agent a file and it will do a genuinely good job on it. It spots the off-by-one, the unhandled rejection, the wrong comparison operator. Point it at a function and ask what breaks, and it will trace the call sites it can grep for and usually get there.</p>
<p>I mean that as praise. Most defects are file-local, and file-local is where the current generation of tooling is strong. The limit shows up somewhere else: <a href="/en/blog/claude-code-large-codebase">an agent in a large repository</a> holds whatever it decided to open, and the repository around that stays out of view. It reads a neighbourhood and reasons about the city.</p>
<p>Some properties of a codebase only exist between files. Those are the ones nobody was going to find by reading carefully.</p>
<h2>The four tickets</h2>
<p><strong>A circuit breaker that nothing imports.</strong> The resilience module exports five things: a retry helper, a timeout wrapper, the breaker class, and two configured breaker instances, one per upstream, each with its own failure threshold and cooldown. Somebody thought about this. Somebody picked five failures and sixty seconds for the CRM and three failures and thirty for the model API.</p>
<p>Production imports exactly one of the five. The retry. The timeout wrapper, the breaker class and both instances are reachable only from their own test file.</p>
<p>So the integration retries a hanging upstream with no timeout around it, and the breaker built and named for that upstream never sees a call. Retry without timeout is the worse of the two orderings: a slow dependency stops being one hung request and becomes several.</p>
<p><strong>An output validator wired to nothing.</strong> The sanitiser module exports two functions: one cleans user text before it goes into a prompt and is wired into the chat endpoint, the other validates what the model sends back and is wired into nothing at all. Input to the model is sanitised. Output from the model is trusted.</p>
<p><strong>A migration runner that only tests run.</strong> The module exists, it works, its tests are thorough, and its only inbound references come from the test file. Migrations are applied by hand and always have been. The runner reads as though that has been solved for a year.</p>
<p><strong>Eight API wrappers with no callers</strong>, plus a second database pool module referenced by nothing anywhere. An integration client that was built wide and wired narrow.</p>
<p>The count underneath those four was the part that stayed with me: 76 components in the repository are reachable only from test code. Coverage looks healthy. Some of that coverage is testing code that nothing runs.</p>
<h2>The defect is a missing edge</h2>
<p>Open the resilience module and read it. It is fine. It is better than fine, it is careful, it has documentation comments showing you how to use the breaker. Nothing in that file is wrong.</p>
<p>Grep for the breaker and every hit reassures you: the class, the two instances, the export line, the test that proves it opens after five failures. An agent reasoning correctly from all of that concludes the feature is there.</p>
<p>The bug is the import that was never written. An edge that does not exist appears in no file, so no amount of reading files will surface it. You cannot grep for an absence.</p>
<p>That is why the answer is a graph rather than a better prompt. Once the repository is a set of nodes and edges, “which exported functions have no inbound edge from non-test code” is a query, and it takes milliseconds. It is the same move <a href="/en/blog/measuring-ai-code-quality">fitness functions</a> make for boundaries, applied to reachability: stop asking people to notice, and compute it.</p>
<p>The same query shape answers the other question I actually care about before a refactor. The database pool has 149 dependants. The authentication check has 137. When an agent offers to “just clean up the auth helper”, that number is the reason the answer is no until I have read the diff twice.</p>
<h2>The confidence label is the point</h2>
<p>There was a fifth thing, and it is the reason I trust the other four.</p>
<p>The scan flagged a dependency cycle in the frontend auth service, at <code>certain</code> confidence: token acquisition schedules a proactive refresh, and the scheduled refresh calls token acquisition. Two functions calling each other, textbook cycle.</p>
<p>I opened it, and it is deliberate. The token refreshes itself at 75% of its lifetime, the previous timer is cleared before a new one is set, and logout cancels it. The cycle is the design.</p>
<p>A tool that reported that as a defect would have burned the credibility of the other four. This one reports what it can prove and labels the rest: <code>certain</code> for a resolved AST edge, <code>probable</code> for the dead-code candidates. The warning prints next to the result: reflection, dispatch tables and framework conventions can reference something without leaving a static edge. The four tickets survived because I checked them by hand and the evidence pointed at the exact line.</p>
<p>Confident and wrong is the failure mode of <a href="/en/blog/why-ai-petrifies-your-code">everything else in this stack</a>. A tool that hands an agent architecture facts is the one part that cannot afford it.</p>
<h2>Doing this yourself</h2>
<p>None of this needs my scanner. <a href="https://github.com/AraneaDev/Knossos-MCP">Knossos-MCP</a> is what I built because I wanted the answers available to the agent rather than to a CI report, and it is pre-release, so build it from source or do not bother with it at all. The underlying move is older than any of this and available in whatever you already run.</p>
<p>Ask your codebase three questions it cannot answer by being read:</p>
<ul>
<li><strong>What is exported and never imported outside its own tests?</strong> knip answers it as asked: <code>knip --production</code> drops test files from the graph, so an export only its own test reaches surfaces instead of hiding behind a green suite. dependency-cruiser needs its <code>reachable</code> rule pointed at your production entry point plus a <code>pathNot</code> on your test glob, and the entry point is the half that does the work, so a <code>pathNot</code> alone reads as a clean bill of health. <code>no-orphans</code> asks a stricter module-level question and would have missed the module in this post. Vulture finds dead Python code but does not draw the test-only line, so on that stack the distinction is yours to build.</li>
<li><strong>What has the most dependants?</strong> Not the biggest file. The most-depended-on symbol, which is rarely the same thing, and is the number that should govern how carefully a change to it gets reviewed.</li>
<li><strong>What does this diff actually touch?</strong> The transitive set reached from the files it edits, so a review knows where to look.</li>
</ul>
<p>Run the first one today on something an agent has been working in for a while. The four things I found had been sitting there long before any agent touched the repository, and the agents did not put them there. They just could not see them either, and now there are more files, faster, for a reader that only ever holds a few of them at a time.</p>
<p>The safety net was written, tested and never plugged in. Everyone read that file. Nobody read the space around it.</p>
]]></content:encoded>
</item>
<item>
<title>Who wrote this commit? Git attribution when an agent does the work</title>
<link>https://tim-schipper.nl/en/blog/git-attribution-coding-agents</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/git-attribution-coding-agents</guid>
<pubDate>Tue, 08 Sep 2026 18:40:00 GMT</pubDate>
<description>I went looking for the coding agent in 1,644 commits across ten of my public repositories. It left no trace at all. The thing that had quietly rewritten the authorship record was the merge button.</description>
<content:encoded><![CDATA[<p>I went looking for the agent in my own git history.</p>
<p>Ten public repositories, 1,644 commits on <code>main</code> on the afternoon I counted them. Some of that work involved a coding agent and some of it did not, and I want to be exact about the next part: I cannot tell you which. Nothing in the history separates them. Not one attribution trailer, on any branch, in any of the ten.</p>
<p>That is the first finding, and it is worth sitting with before the rest. The record I would reach for to answer “did a machine help with this one” does not contain the answer, and I am the person who wrote the repositories.</p>
<p>The second finding is that the record had been rewritten anyway, by something nobody argues about.</p>
<h2>The trailer everyone is arguing about</h2>
<p>Claude Code ships a commit trailer. By default it appends <code>Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;</code> to commits and a “Generated with Claude Code” line to pull request bodies. This has been contentious enough to produce bug reports, one of which argues the default is simply inaccurate.</p>
<p>That report has a point worth reading properly. The US Copyright Office’s registration guidance says, verbatim: “Applicants should not list an AI technology or the company that provided it as an author or co-author simply because they used it when creating their work.” The same guidance holds that “to qualify as a work of ‘authorship’ a work must be created by a human being”. Git tooling treats every <code>Co-authored-by</code> name as an author of the commit, so the trailer makes a claim about authorship.</p>
<p>The setting that controls it has moved. <code>includeCoAuthoredBy</code> is deprecated in favour of an <code>attribution</code> object that handles the commit trailer and the pull request body separately. Both are still read. In version 2.1.263 the resolution runs in three steps: use <code>attribution</code> if it is set and valid, otherwise emit nothing if <code>includeCoAuthoredBy</code> is exactly <code>false</code>, otherwise emit the trailer.</p>
<p>Two things follow from that order. One is documented somewhere you will never look, and one is not documented at all.</p>
<p>The first is a genuine footgun: the <code>attribution</code> branch fills each field with <code>??</code>, so a missing key falls back to the built-in default rather than to empty. Set <code>attribution: {&quot;pr&quot;: &quot;&quot;}</code> because the pull request footer annoyed you, and you have silently kept the commit trailer you thought you were configuring away. An empty string suppresses. An absent key restores.</p>
<p>Anthropic does state this, in the settings schema that ships inside the same binary: “Each field defaults to the standard Claude Code attribution if not set”, and “Empty string hides attribution.” It is exact, it is correct, and it lives in a JSON schema rather than in the page you read when you went looking for how to turn the trailer off.</p>
<p>The second is a small piece of archaeology: taking the deprecated path fires a telemetry counter named <code>tengu_dead_probe_include_coauthored_by</code>, guarded so it reports once per site. A “dead probe” is what you instrument when you want to know whether anyone still uses a code path before you delete it. Setting the modern key means the probe never fires, because the first branch returns before it. So the population being counted is precisely the people who set only the old key, and set it to <code>false</code>.</p>
<p>All of which took one line in my settings to resolve. <code>attribution</code> with empty strings, globally and in the project. That is why the trailer count across ten repositories is zero, and why the history cannot answer the question I opened with.</p>
<p>I am not sure that is wrong. A trailer that marks a commit as machine-assisted sounds like transparency until you ask what a reader would do with it: skip the review, or review it harder? The code is either defensible or it is not, and the trailer does not tell you which. What I am sure of is that turning it off was my decision, and it is why the history of that work will stay silent on the subject.</p>
<p>I had assumed that was the end of the story.</p>
<h2>Then I counted the authors</h2>
<p><img src="/images/illustrations/git-attribution-agents.svg" alt="Bar chart of who authored 1,644 commits: git config 1289, GitHub 355, the agent 0."></p>
<p>Ten distinct author identities across 1,644 commits, for a history written by one person and two bots.</p>
<table>
<thead>
<tr>
<th style="text-align:left">share</th>
<th style="text-align:left">who</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">29.0%</td>
<td style="text-align:left">my GitHub noreply address, which is the one every repository is configured to use</td>
</tr>
<tr>
<td style="text-align:left">27.8%</td>
<td style="text-align:left">my name against a different address entirely</td>
</tr>
<tr>
<td style="text-align:left">22.3%</td>
<td style="text-align:left">a work address from a former arrangement</td>
</tr>
<tr>
<td style="text-align:left">14.0%</td>
<td style="text-align:left">five more spellings of me, in various combinations of name and address</td>
</tr>
<tr>
<td style="text-align:left">6.8%</td>
<td style="text-align:left"><code>github-actions[bot]</code></td>
</tr>
<tr>
<td style="text-align:left">0.1%</td>
<td style="text-align:left">a review bot</td>
</tr>
</tbody>
</table>
<p>Every one of those ten repositories has <code>user.name</code> and <code>user.email</code> pinned locally to a single identity. I check it. There is a rule about it. And it accounts for well under a third of the history.</p>
<p>The reason is dull and it is the whole point: <strong><code>gh pr merge --squash</code> merges on the server.</strong> Your local git config is never consulted, because your machine is not the thing making the commit. GitHub builds it, and attributes it to your account profile and its commit email setting. Merge forty-five pull requests in a day, as I did the day I measured this, and those commits land under whichever identity your account happens to advertise, regardless of what every clone on your disk says.</p>
<p>Across the whole history, 355 commits were made by GitHub’s servers rather than by any machine of mine.</p>
<p>The same merge writes the <code>Co-authored-by</code> trailers. All 161 of them in my history are GitHub’s, added for the authors of the commits being squashed. GitHub documents the trailer itself, and documents that a co-author’s address should be one attached to their account. It does not document that the merge button will write these for you.</p>
<p>So the record contains, on the same line, an author I did not choose and co-authors I did not add.</p>
<h2>The bot in that table is a token, not a fact of life</h2>
<p>I filed that 6.8% under things done to me, next to the merge button. That was wrong, and correcting it took one line of YAML per repository.</p>
<p>A workflow that commits with the default <code>GITHUB_TOKEN</code> gets <code>github-actions[bot]</code> in the author field, because that token belongs to the bot rather than to me. Release automation is the usual case. <code>googleapis/release-please-action</code> takes a <code>token</code> input and falls back to <span v-pre><code>${{ github.token }}</code></span> when you leave it out, so the version bump, the changelog commit and the release pull request all land under the bot.</p>
<pre><code class="language-yaml">- uses: googleapis/release-please-action@v4
  with:
    token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
    release-type: node
</code></pre>
<p>Hand it a personal access token and the same commits carry the account that token belongs to. A GitHub App token is the third option, and it signs as <code>your-app[bot]</code>, which is at least a name you picked.</p>
<p>That input has a second effect, better documented than the first: events created with <code>GITHUB_TOKEN</code> do not trigger further workflow runs, so the checks you expect on a release pull request never start. Most people find the token through that symptom, and never notice it moved the author field as well.</p>
<p>I have since set it in all twelve of my public repositories, two more than the ten I counted that afternoon. It does not touch the bot commits already in the history, and I would not want it to. It does mean the next release commit says who asked for it.</p>
<h2>What the record is for</h2>
<p>This matters because of what people want <code>git log</code> to do. The argument that AI-generated code needs an audit trail usually lands on the commit, because the commit is the thing that persists. <a href="/en/blog/who-is-responsible-for-ai-generated-code">Who is responsible for AI-generated code</a> is a question people expect the history to help answer.</p>
<p>It cannot, and the agent is not why. Before a single line about AI enters the discussion, the commit’s own author field is set by whoever pressed a button in a web interface, and the trailers underneath it are generated by a merge strategy. A trailer naming the model would sit inside a record that is already three parties’ work: your git config for local commits, GitHub’s account settings for merged ones, and the merge strategy for everything below the subject line.</p>
<p>Add a model’s name to that and you have not created accountability. You have added a fourth writer to a document nobody was reading carefully.</p>
<p>The honest version is that the commit is a poor place for this. The pull request is where the reasoning lives: what was asked for, what the <a href="/en/blog/reviewing-ai-generated-pull-requests">review</a> caught, what the checks said, who approved it. That record is richer, it is harder to rewrite by accident, and it survives a squash. The commit that lands is a summary of it, written by a machine, attributed by a web server.</p>
<h2>What I would actually do</h2>
<p>Decide what you want the history to answer, then configure for that answer.</p>
<ul>
<li><strong>Set <code>attribution</code> explicitly</strong>, with every key you care about present. Empty strings suppress. Omitted keys restore the default, quietly.</li>
<li><strong>Set your account’s commit email</strong> as well as the one in your local config. The local one governs commits you make. The account one governs every commit GitHub makes on your behalf, which on a squash-merge workflow is most of them.</li>
<li><strong>Pass a token to every workflow that commits.</strong> The default <code>GITHUB_TOKEN</code> signs as <code>github-actions[bot]</code>. A personal access token or a GitHub App token puts a name in the author field that you chose, and it is the same setting that decides whether your checks run.</li>
<li><strong>Check what your history actually says</strong> before trusting it. <code>git log --format='%an &lt;%ae&gt;' | sort | uniq -c | sort -rn</code> takes a second and told me something I would have sworn was not true.</li>
<li><strong>Put the accountability in the pull request</strong>, where it has room to be specific, and let the commit be a summary.</li>
</ul>
<p>The thing I keep coming back to is how little the agent had to do with any of it. I went looking for its fingerprints and found a clean absence, because of one line I had put in my own settings. Everything actually wrong with the record was made by a merge strategy I chose years ago, a profile setting I have never once looked at, and a workflow input I left empty.</p>
<p>We have spent the better part of two years arguing about whether a model belongs in the author field. Meanwhile the author field was being filled in by a web server.</p>
]]></content:encoded>
</item>
<item>
<title>MCP server security scores are review prompts, not verdicts</title>
<link>https://tim-schipper.nl/en/blog/mcp-server-security-scores</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/mcp-server-security-scores</guid>
<pubDate>Sun, 06 Sep 2026 10:52:00 GMT</pubDate>
<description>An MCP server security score looks like a verdict. Measured across 74,101 servers, most of what a scanner tells you is a heuristic, and the state it almost never shows you is that nobody read the source.</description>
<content:encoded><![CDATA[<p>Two numbers about the same ecosystem, both from this year.</p>
<p>A study published in July found that existing MCP security scanners report 96.89% of servers as risky. I run a scanner over the same ecosystem, and it grades 63% of servers at its lowest risk band.</p>
<p>Both of those are honest. Neither is a lie about the data. The gap between them is the whole subject of this post, because what the two scanners disagree about is what a scanner is entitled to claim.</p>
<h2>What the scanner study actually found</h2>
<p>The paper is <a href="https://arxiv.org/abs/2607.11086">Rethinking MCP Security: A Large-Scale Study of Runtime MCP Servers and Security Scanner Reliability</a>, submitted 13 July 2026. The authors built MCPZoo, 64,611 unique servers with more than 37,288 of them running well enough for dynamic analysis, then pointed the existing scanners at it.</p>
<p>Their finding, in their own words:</p>
<blockquote>
<p>While existing scanners report that 96.89% of servers are risky, we find that these signals are unreliable. In particular, manual validation shows that less than 50% of sampled alerts are true positives, and scanner outputs exhibit clear inconsistency across scanners.</p>
</blockquote>
<p>Fewer than half the alerts survive a human looking at them. And the scanners do not agree with each other, which means at most one of them can be right about any server they disagree on.</p>
<p>Scanners are still worth running. The problem is the one that hands you a grade and stops there, because you cannot tell which half of its output you are holding.</p>
<p>The other ecosystem-scale measurement, <a href="https://arxiv.org/abs/2510.16558">A First Look at the Security Issues in the Model Context Protocol Ecosystem</a> (revised April 2026, 67,057 servers across six public registries), lands on the entry point rather than the scanner: “At the registry-level, weak vetting and ownership checks allow adversarial or hijacked servers to enter hosts.” Their own tool flagged 833 vulnerable servers and 18 with suspicious descriptions out of those 67,057.</p>
<p>Two studies, two very different alert counts, same ecosystem. That is the pattern.</p>
<h2>Three things a signal can be</h2>
<p><img src="/images/illustrations/mcp-server-security-scores-provenance.svg" alt="74,101 servers indexed, 5,143 published to npm or PyPI, and only 2,122 carrying a build attestation"></p>
<p>I built <a href="https://mcpobservatory.com">MCP Observatory</a> partly to have somewhere to put this problem. At 09:31 UTC on 6 September 2026, the moment the snapshot behind every figure in this post was taken, it held 74,101 servers and had read the source of 66,212 of them. Treat those two numbers as a photograph. The section below says how fast the subject is moving. Every signal it shows carries a tag for what backs it, and there are exactly three:</p>
<ul>
<li><strong>attested.</strong> Backed by a verifiable record: a CVE or OSV entry, a cryptographic build attestation, or the published artifact itself.</li>
<li><strong>reported.</strong> Asserted by a third party, a registry or a directory, and relayed. True that <em>they</em> said so, not independently confirmed.</li>
<li><strong>inferred.</strong> A heuristic. A keyword match, a pattern, a score. A “review this” prompt, never a verdict.</li>
</ul>
<p>Sorting the corpus into those three buckets is bleaker than it sounds.</p>
<p><strong>Attested is rare.</strong> A build attestation needs a packaging step to attach to, and only 5,143 of the 74,101 servers publish to npm or PyPI at all. Of those, 2,122 carry one. The remaining 68,958 are repositories you clone and run, with nothing cryptographic to check.</p>
<p><strong>Reported is most of what looks like verification.</strong> 18,946 servers carry an official-registry signal and 51 carry a directory listing. That is a registry saying the server is what it claims to be. Worth having, and a long way from anyone having checked.</p>
<p><strong>Everything else is inferred</strong>, and inferred is where the volume is. 45,525 safety findings across 17,852 servers. 26,821 of them come from a generic static-analysis pass rather than anything MCP-specific, and 24,373 of those are a single lowest-severity code pattern.</p>
<p>The permission flags tell the same story from the other side. Across the 33,222 servers whose tools have been captured, 496,883 tools in total, the detector raises no permission flag at all on 27,352 of them. Eighty-two percent of servers light up nothing. Read that as a keyword matcher finding no keywords, which is some distance from 82% of servers being safe.</p>
<h2>The state nobody displays</h2>
<p>Here is the part I think the whole category gets wrong, and it took building the thing to see it.</p>
<p>A scanner has three possible answers about a server. It can have looked and found something. It can have looked and found nothing. And it can have never looked.</p>
<p>The second and third are indistinguishable in every dashboard I have seen. Both render green.</p>
<p>So the observatory refuses to show a reassuring grade over source it never read. If the source was never analysed and the grade came out at A or B, the label is “insufficient evidence” instead, because “we found nothing” and “we never looked” produce the same colour and mean opposite things. Right now that applies to 1,944 servers. Small against 74,101, and every one of them would read as clean anywhere else.</p>
<p>The inverse case gets its own label. 101 servers carry a concerning grade with unread source, and there the grade is real but it is a floor: reading the code could only push it up.</p>
<h2>The ground moves while you read</h2>
<p><img src="/images/illustrations/mcp-server-security-scores-growth.svg" alt="New repositories carrying an MCP server per month, rising from 2,539 in February 2026 to 12,621 in August"></p>
<p>Every figure above is a photograph. Here is the shutter speed.</p>
<p>Over the thirty days to 6 September, the servers in this corpus shipped an average of 3,657 releases a day, with daily counts running between 2,913 and 4,519. In the same window, 418 new repositories carrying an MCP server were created per day.</p>
<p>That second number is accelerating. New repositories per month: 2,539 in February, 5,120 in April, 9,441 in June, 12,621 in August.</p>
<p>Two caveats, and they push the same way. A creation date is known for 63,581 of the 74,101 servers, so these are lower bounds. And the newest repositories are exactly the ones a crawler is least likely to have found yet, so the recent months are understated rather than inflated. The growth is at least this steep.</p>
<p>Which is the real reason I date every number I publish. 74,101 was true at 09:31 UTC on 6 September 2026, and it was wrong by lunchtime. A count in this field has a half-life measured in hours, and that belongs next to the count rather than in a footnote under it.</p>
<p>It also sets up the next problem. An ecosystem shipping 3,657 releases a day is an ecosystem that outruns whoever is scanning it.</p>
<h2>A grade has a shelf life</h2>
<p><img src="/images/illustrations/mcp-server-security-scores-freshness.svg" alt="What each grade was computed against: 43,583 current, 20,417 stale, 8,010 analyzer-pending, 2,045 unread"></p>
<p>The scan happened on a particular day, against a particular commit. The server kept moving.</p>
<p>Of the servers currently carrying a grade, 20,417 are stale in the strict sense: a newer package version shipped, or the repository was pushed to, after the analysis ran. That is 28% of the graded corpus describing code that is no longer the code you would install.</p>
<p>Nobody’s dashboard says this out loud, mine included until recently. A grade with no date on it is a claim about a snapshot presented as a claim about a server.</p>
<h2>The instrument moves too</h2>
<p><img src="/images/illustrations/mcp-server-security-scores-split.svg" alt="Of 63,184 changed tool definitions, 61,155 changed the description and 1,952 only the inferred permission mask"></p>
<p>This one caught me out, and it is the reason I no longer trust any drift number at face value.</p>
<p>The observatory keeps a history of every tool definition it has seen: name, description, input schema, and an inferred permission mask. Across 525,435 distinct tools, 63,184 have more than one recorded definition. My first instinct was to read that as the ecosystem changing under its users.</p>
<p>Then I checked which part changed. 1,952 of those tools had their permission mask move while the description and the input schema stayed byte-identical.</p>
<p>Nothing about those servers changed. The classifier changed. I had improved the permission detector, and a set of tools that ships only minified code stopped matching keywords it used to match. The measurement moved and the world did not.</p>
<p>That is the same trap as reading a duplication score on AI-written code and concluding the code got cleaner, when <a href="/en/blog/measuring-ai-code-quality">what actually moved was the detector</a>. An inferred signal is a function of two things, and only one of them is the server.</p>
<h2>What a changed description is worth</h2>
<p>The genuinely interesting number in that history is the descriptions.</p>
<p>Of the 63,184 tools with more than one definition, 61,155 changed their description. Only 280 changed their input schema. 60,952 changed the text while the machine-readable contract stayed exactly the same.</p>
<p>The description is the part the model reads and acts on. The schema is the part your code validates against. Sixty thousand tools rewrote the instruction to the agent without touching the interface to the program.</p>
<p>That is the exact shape of a tool-poisoning rug pull, where a server ships clean, gets installed, and then edits what its tool tells the model to do. It is also the exact shape of somebody fixing a typo in their docs.</p>
<p>I cannot tell those apart from the outside, and neither can anyone else. The dedicated rug-pull rule fires 81 findings across 51 servers, and I would not defend that number as complete. So the description-change signal ships as what it is: a list of servers worth re-reading before you trust them again, tagged inferred, with no accusation attached. <a href="/en/blog/they-moved-your-version">Moving a version out from under an installed dependency</a> is a known move in this ecosystem, and the tool description is a softer target than the version number.</p>
<h2>What to actually read before you install</h2>
<p>The <a href="/en/blog/how-to-vet-an-mcp-server">vetting checklist</a> still holds and this does not replace it. What the data adds is an ordering, by how much the signal is worth:</p>
<ul>
<li><strong>Is there an attestation?</strong> Rare, binary, and the only thing on this list that is cryptographic. Most servers cannot have one, which is itself worth knowing.</li>
<li><strong>Was the source actually read, and when?</strong> A grade over unread or six-month-old source is a different object from a grade over the commit you are about to install.</li>
<li><strong>Has the tool description changed since it was listed?</strong> Not damning on its own. Worth ten seconds of your reading time, because it is the field that steers the model.</li>
<li><strong>What does the permission surface claim?</strong> 658 servers have one tool that reaches the network and another that touches credentials, which is the pair an injected instruction chains. Treat a quiet result as “the matcher found nothing”, not as a clean bill.</li>
<li><strong>Who says it is fine, and are they the same party who published it?</strong> Most verification in this ecosystem is a registry relaying the publisher’s own claim.</li>
</ul>
<p>Five questions, and each one has its own answer for how much you should believe it.</p>
<p>The thing I keep coming back to is that the <a href="/en/blog/the-mcp-supply-chain-is-the-new-npm">MCP supply chain</a> inherited npm’s distribution model without inheriting even npm’s modest provenance story. What it did inherit is the appetite for a single letter grade that makes the decision for you.</p>
<p>Every grade and permission flag on the observatory is an inferred review prompt rather than a verdict, and each one is tagged by what backs it: attested, reported, or inferred. That is a worse product. Nobody wants five hedged answers when a competitor offers one confident letter.</p>
<p>It is the only version I can defend when fewer than half the alerts in this field survive somebody checking them.</p>
]]></content:encoded>
</item>
<item>
<title>MCP vs CLI for coding agents: what your tools actually cost</title>
<link>https://tim-schipper.nl/en/blog/mcp-vs-cli-agent-tools</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/mcp-vs-cli-agent-tools</guid>
<pubDate>Fri, 04 Sep 2026 11:53:13 GMT</pubDate>
<description>Every comparison quotes a multiplier. Mine came out at 88.9x, and my client had already answered the question that number was asking. What a tool surface costs now, and what still separates a schema from a command.</description>
<content:encoded><![CDATA[<p>Search for “MCP vs CLI” and you get a wall of multipliers. CLI is 4x cheaper. 10x. 32x. 137x. Every post has a number, every number comes from one benchmark, and none of the benchmarks agree.</p>
<p>So I measured my own. I spoke MCP directly to every server configured on this machine, asked each one for its tool list, and counted the bytes.</p>
<p>Forty-nine tools. 72,612 characters of tool definitions, against 817 characters if you list nothing but the names. That is 88.9x, and it sits right inside the range everyone is quoting.</p>
<p>The shape is real. The number is answering last year’s question.</p>
<p><img src="/images/illustrations/mcp-vs-cli-agent-tools.svg" alt="Two prompts side by side: 49 tool schemas in one, against tool_search and the four schemas it pulls on demand."></p>
<h2>What the bytes actually say</h2>
<p>Here is the measurement, with the servers anonymised because they are mine and their names are not the point.</p>
<table>
<thead>
<tr>
<th style="text-align:left">Server</th>
<th style="text-align:right">Tools</th>
<th style="text-align:right">Chars</th>
<th style="text-align:right">~Tokens</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">docs lookup</td>
<td style="text-align:right">2</td>
<td style="text-align:right">4,585</td>
<td style="text-align:right">1,100</td>
</tr>
<tr>
<td style="text-align:left">resilience audit</td>
<td style="text-align:right">3</td>
<td style="text-align:right">16,321</td>
<td style="text-align:right">4,000</td>
</tr>
<tr>
<td style="text-align:left">architecture</td>
<td style="text-align:right">33</td>
<td style="text-align:right">44,857</td>
<td style="text-align:right">10,900</td>
</tr>
<tr>
<td style="text-align:left">test fidelity</td>
<td style="text-align:right">11</td>
<td style="text-align:right">6,849</td>
<td style="text-align:right">1,700</td>
</tr>
<tr>
<td style="text-align:left"><strong>Total</strong></td>
<td style="text-align:right"><strong>49</strong></td>
<td style="text-align:right"><strong>72,612</strong></td>
<td style="text-align:right"><strong>~17,700</strong></td>
</tr>
<tr>
<td style="text-align:left">Names only</td>
<td style="text-align:right"></td>
<td style="text-align:right">817</td>
<td style="text-align:right">~200</td>
</tr>
</tbody>
</table>
<p>Characters are exact. Tokens are an estimate at roughly four characters per token, which I calibrated rather than guessed: MCP issue <a href="https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2808">#2808</a> measured eleven tools of the <code>context-mode</code> plugin through Anthropic’s token-counting API in May 2026, and re-measuring those same eleven tools here puts the ratio at 4.11. Treat the token column as a rounded figure with a real method behind it.</p>
<p>Two things fall out that the multiplier posts skip.</p>
<p><strong>The cost is the schema.</strong> Of those 72,612 characters, 11,911 are the human-readable tool descriptions. Sixteen per cent. The rest is JSON Schema: parameter names, nested types, enums, constraint keywords. So the standard advice to write terser tool descriptions is aimed at a sixth of the problem. Issue #2808 found the same thing from the other end, measuring an almost tenfold spread between the cheapest and most expensive tool in a single server and concluding that “the 10x delta is entirely in JSON Schema size”.</p>
<p><strong>Tool count is the wrong unit.</strong> My three-tool server costs 5,440 characters per tool. My 33-tool server costs 1,359. A server with a tenth of the tools can cost four times as much per tool, because one of them takes a deeply nested object and the others take a string. Counting tools tells you almost nothing about what a server costs you.</p>
<h2>The client already moved</h2>
<p>The whole MCP-is-expensive argument rests on a premise: every schema loads into the prompt before you type anything. That was true when the benchmarks were run, and I have <a href="/en/blog/the-token-saver-tax">done that arithmetic here before</a>, back when the honest total for a stacked install was around eleven thousand tokens of pre-work overhead.</p>
<p>Anthropic published the fix in November 2025. The <a href="https://www.anthropic.com/engineering/code-execution-with-mcp">code execution post</a> on 4 November made the case that an agent should read tool definitions the way it reads files, on demand, and put the saving at 98.7%, from 150,000 tokens down to 2,000. The <a href="https://www.anthropic.com/engineering/advanced-tool-use">advanced tool use post</a> on 24 November shipped the mechanism as the Tool Search Tool, and it is worth reading their own numbers on their own setup: “That’s 58 tools consuming approximately 55K tokens before the conversation even starts”, and, more bluntly, “At Anthropic, we’ve seen tool definitions consume 134K tokens before optimization.”</p>
<p>It stopped being an experiment on 17 February 2026, when the release notes recorded that the code execution tool, web fetch tool, tool search tool, tool use examples and memory tool “no longer require a beta header”. Ten months after the announcement it is the default in the client I am typing this in. Claude Code 2.1.260 carries a per-server <code>alwaysLoad</code> setting, described in the binary as:</p>
<pre><code class="language-text">When true, all tools from this server are always included in the prompt and never
deferred behind tool search. Equivalent to setting defer_loading: false on the API.
Default: tools are deferred when tool search is enabled.
</code></pre>
<p>Read the default clause twice. Deferral is what you get, and <code>alwaysLoad</code> is how you switch it off, per server, when you want a server’s tools present from turn one.</p>
<p>In this session, all 49 of those tools arrived as bare names. Getting at any one of them cost me a search call and a few thousand tokens for the three or four schemas I actually wanted. The 88.9x I measured is the gap between what a server publishes and what a client chooses to send, and my client is choosing to send the names.</p>
<h2>What deferral costs instead</h2>
<p>The tokens did not evaporate. They turned into round trips.</p>
<p>An always-loaded tool is one call. A deferred tool is a search, then a call, and the search can miss: it matches on names and descriptions, which is exactly the sixteen per cent I said was not the cost. You are now paying attention and latency where you used to pay context.</p>
<p>There is a third cost, and I only found it because the people arguing about the protocol got there first. On that same MCP discussion, a collaborator pointed out on 26 August that deferral “doesn’t deal with prompt cache invalidation, where even models that support lazy loading of tools bust the prompt cache if” the list of tool definitions changes, “even if defer loading is true on them all”. Your schemas can be deferred and your cache can still be cold, because any change to the tool set invalidates it, at any size. Start a server mid-session and you have paid for that.</p>
<p>The same binary is honest about the trade in the other direction. That <code>alwaysLoad</code> description continues: setting it “also blocks startup until the server is connected (capped at the standard 5s connect timeout) even though MCP startup is otherwise non-blocking by default, since the tools must be present when the turn-1 prompt is built”. Token cost and startup latency are the same dial. Pin a server’s tools into the prompt and you have also decided to wait for that server before your first message goes anywhere.</p>
<h2>The tools that quietly are not there</h2>
<p>Here is the part I did not expect to find, and the reason this post is not just about tokens.</p>
<p>Claude Code drops MCP tools it cannot use, and the binary builds two messages for it. One names the casualty: <code>&quot;toolName&quot; (MCP server &quot;X&quot;): &quot;reason&quot;</code>. The other is a summary that reads <code>MCP server &quot;X&quot;: 12 tools excluded (invalid input schema)</code>, and the switch between them is a constant set to 30. Under thirty dropped tools from one server you are told which ones and why. Over thirty you get a count.</p>
<p>A server can advertise a tool your client will never offer the model, because the schema uses something the client will not accept. Your tool list and the server’s tool list are different lists, and the gap is reported at a level of detail that decreases exactly as the problem gets worse.</p>
<p>That is a reliability property, and it is the one thing a CLI genuinely does not have. <code>git commit</code> does not fail to appear because its argument parser was too baroque.</p>
<h2>A CLI is not free either</h2>
<p>The case for the CLI is usually stated as though commands cost nothing. Discovery is where they charge you.</p>
<p>An agent that has never used your CLI has to learn it, and it learns it by running <code>--help</code> and reading the output into context. On this machine <code>gh --help</code> is 2,007 characters and <code>gh pr --help</code> is another 1,433. Two help calls to do one thing is roughly a thousand tokens, paid in the session where you use it, and paid again in the next session unless something wrote it down.</p>
<p>The real advantage of a CLI is that the model already knows <code>git</code>, <code>gh</code>, <code>docker</code> and <code>psql</code> from pretraining, so the discovery cost is zero for the tools everyone uses and full price for yours. A CLI you wrote last month is in exactly the same position as an MCP server: undocumented interface, discovered at runtime, and now discovered through a channel with no schema, which means the agent can get the flags wrong and find out from a non-zero exit code.</p>
<p>That is the trade, stated properly. MCP buys you an exact, machine-checked interface and charges you for it in the prompt, or in a search round trip. A CLI buys you whatever the model already knows and charges you for anything it does not.</p>
<h2>So which one</h2>
<p>The useful question is what a tool costs when the agent ignores it, and what it costs when the agent reaches for it.</p>
<ul>
<li><strong>The model already knows the interface.</strong> Use the CLI. <code>gh</code>, <code>git</code>, <code>kubectl</code>, <code>psql</code>. Wrapping these in an MCP server buys you a schema for something that needed no schema.</li>
<li><strong>You need auth, a remote service, or an audit trail.</strong> Use MCP. OAuth, per-tool permission policy and a server you can put a boundary around are not things a shell command gives you, and none of that is a token argument.</li>
<li><strong>You use it every session.</strong> Set <code>alwaysLoad</code> on that one server and accept the startup wait. Everything else stays deferred.</li>
<li><strong>You are about to build one.</strong> <a href="/en/blog/build-an-mcp-server-then-ask-if-it-should-exist">The test has not changed</a>: does this need to exist. Deferral only lowers the price of keeping an unnecessary one. It still runs, it still has a supply chain, and <a href="/en/blog/how-to-vet-an-mcp-server">it still needs vetting</a> whether or not its schema is in your prompt today.</li>
</ul>
<p>And measure your own, because your servers are not mine. Speak <code>tools/list</code> to each one, count the characters, and look at the per-tool spread rather than the tool count.</p>
<h2>The protocol is only now catching up</h2>
<p>Every fix described above is a client feature. The protocol still hands over the full schema for everything: when I asked those four servers for their tool lists, I got 72,612 characters, because that is the only thing <code>tools/list</code> returns.</p>
<p>That gap is known and it has been argued over for a while: issue #2808 was filed on 28 May 2026 and converted to a discussion by a maintainer the next day. Discussion <a href="https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1923">#1923</a>, “Progressive Tool Discovery for Token Efficiency”, has been open since 3 December 2025 and was still collecting comments a week before this post went out. <a href="https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2636">SEP-2636, “Progressive Tool Disclosure”</a>, has been open since 23 April 2026 and was last touched on 3 September, the day before I published this. The spec is still at revision 2026-07-28, and none of it is in there.</p>
<p>What did change is the intent. The <a href="https://modelcontextprotocol.io/development/roadmap">roadmap published on 22 August</a> makes this a funded work item rather than a thread: a Core Primitives working group on <strong>progressive discovery</strong>, so that “clients learn a server’s tools and resources as they need them instead of ingesting the full catalog up front”. That is the client-side trick this whole post is about, written down as something the protocol should do itself.</p>
<p>So the saving you are enjoying today belongs to your client. You can lose it by switching clients, by pinning a server, or by an update that retunes a threshold you never knew was there, and in a release or two you may stop owning it at all, because the protocol will. That is worth knowing before you rewrite a working MCP server as a CLI on the strength of a multiplier someone measured on a different client, in a different month, and rounded in their own favour.</p>
<p>The tools are cheaper than the benchmarks say. They are also less permanent than they look, and <a href="/en/blog/claude-code-context-management">the context they sit in</a> is still the budget everything else has to fit inside.</p>
]]></content:encoded>
</item>
<item>
<title>Slopsquatting: checking that the package exists is not a defence</title>
<link>https://tim-schipper.nl/en/blog/slopsquatting-ai-hallucinated-packages</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/slopsquatting-ai-hallucinated-packages</guid>
<pubDate>Wed, 02 Sep 2026 21:05:25 GMT</pubDate>
<description>The standard advice against AI-hallucinated packages is to verify the package exists before installing. I checked four names from the latest research against PyPI. All four resolve. The registry answers the wrong question, in both directions.</description>
<content:encoded><![CDATA[<p>Ask a model for a Python PDF table extractor and it may tell you to <code>pip install tabula</code>. That package exists. It resolves right now, version 1.0.5, and it is an ASCII table formatter by an author with no connection to PDF extraction whatsoever. Its last release was in November 2012.</p>
<p>The one you wanted is <code>tabula-py</code>.</p>
<p>I checked four names this afternoon, on 2 September 2026. They come from a paper published on 24 August, as examples of package names models invent. Every single one returns HTTP 200 from PyPI.</p>
<table>
<thead>
<tr>
<th style="text-align:left">The model says</th>
<th style="text-align:left">PyPI answer</th>
<th style="text-align:left">Last release</th>
<th style="text-align:left">What you actually wanted</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left"><code>pip install pil</code></td>
<td style="text-align:left">200, v1.1.6</td>
<td style="text-align:left">6 releases, long dormant</td>
<td style="text-align:left"><code>pillow</code>, last release 1 July 2026</td>
</tr>
<tr>
<td style="text-align:left"><code>pip install faiss</code></td>
<td style="text-align:left">200, v1.5.3</td>
<td style="text-align:left">16 April 2019, one release</td>
<td style="text-align:left"><code>faiss-cpu</code>, last release 3 Aug 2026</td>
</tr>
<tr>
<td style="text-align:left"><code>pip install tabula</code></td>
<td style="text-align:left">200, v1.0.5</td>
<td style="text-align:left">1 November 2012</td>
<td style="text-align:left"><code>tabula-py</code></td>
</tr>
<tr>
<td style="text-align:left"><code>pip install haystack</code></td>
<td style="text-align:left">200, v0.42</td>
<td style="text-align:left">3 July 2017</td>
<td style="text-align:left"><code>haystack-ai</code>, v3.1.0 on 24 August 2026</td>
</tr>
</tbody>
</table>
<p><code>haystack</code> on PyPI is a tool for searching C structures in process memory. The <code>haystack</code> your agent means is a framework for building LLM applications. They share a name and nothing else.</p>
<p>So here is the problem with the advice everyone gives.</p>
<p><img src="/images/illustrations/slopsquatting-hallucinated-packages.svg" alt="Four package names the registry approves but you must never install, against the two it wrongly flags as fabricated."></p>
<h2>The check everyone recommends misses half the attack</h2>
<p>Slopsquatting is the attack where a model invents a package name consistently enough that somebody can register it first and wait. The defence in every write-up, including the sensible ones, comes down to the same instruction: before you install what the agent suggested, check that the package is real.</p>
<p>That advice has a specific mechanical meaning. Ask the registry. If you get a 404, the model made it up. If you get a 200, the name is fine.</p>
<p>The 24 August paper puts the flaw in one sentence:</p>
<blockquote>
<p>An attacker who has already registered the hallucinated name trivially bypasses a pure 404 check.</p>
</blockquote>
<p>Its authors, Akash Raj and Sargam Sahu, built a two-layer detector precisely because one layer is not enough. Their first finding is the one that should end the existence check as a defence: <strong>half of the hallucinations their pipeline flagged were packages already registered on PyPI</strong>, sitting there as low-quality lookalikes of well-known projects. The deterministic layer missed all of them. A classifier trained on name and metadata caught them.</p>
<p><code>pil</code>, <code>faiss</code>, <code>tabula</code> and <code>haystack</code> are their examples. That table above is what a 404 check tells you about all four: nothing.</p>
<h2>The check fails in the other direction too</h2>
<p>If it only failed one way you could treat it as a floor and move on. It fails both ways, and the second direction is why teams that try to automate this end up ignoring their own tooling.</p>
<p>A registry 404 does not mean the model hallucinated. Frameworks ship virtual modules, bundled subpackages and import aliases that are valid in source and have no standalone registry entry.</p>
<p>The clearest example came out of npm. When the researcher behind a large package-hallucination study could not get his npm findings through the registry’s own security channels, he took them to Socket at the end of April. Socket reviewed all 18 npm candidates and published the result in July.</p>
<p>The four highest-volume names among them were <code>@ember/service</code>, <code>@ember/object</code>, <code>@ember/routing</code> and <code>@ember/controller</code>. All four are valid Ember imports that resolve through <code>ember-source</code>. A model emitting <code>@ember/service</code> is writing correct Ember. A registry lookup for that name returns 404.</p>
<p>Socket also found <code>ssh-keys</code> was a real package and <code>metro-evaluator</code> was already security-held, and those six names came out of the exploitable set. The researcher folded the finding into version 2 of the paper as a false-positive class.</p>
<p>I ran the same check against my own machine and got the same result at small scale.</p>
<p>I pulled every package install command my agents proposed in August 2026 out of the local transcripts: 1,100 transcript files, 135 sessions, 121 install invocations, 34 distinct package names. Then I asked npm and PyPI about each one.</p>
<p>Exactly one name 404’d: <code>argos-mcp</code>. It is my own MCP server, linked locally with <code>npm link</code>. Never published. The single registry miss in a month of agent output was a false positive, and it was mine.</p>
<p>The interesting part was the noise. The script I wrote to mine the transcripts was crude in its first version, and it pulled three ordinary words out of the agent’s prose that happened to sit next to <code>install</code>. The registry approved all three.</p>
<p><code>installation</code> is a real package, 23 versions, last touched in 2022. <code>both</code> is a real package, one version, <code>0.0.0</code>, whose description is the literal string <code>ERROR: No README.md file found!</code>. And <code>binaries</code> returns 200 with <strong>zero versions published and no maintainer listed</strong>, a name registered in 2018 and holding nothing at all.</p>
<p>Three words that were never package names, waved through by the check that was supposed to catch fabricated package names. One real local package, flagged.</p>
<p>That is a 34-name sample on one developer’s laptop, so treat it as an illustration rather than a measurement. It reproduced both error directions anyway.</p>
<h2>Switching models does not help</h2>
<p>The reflex fix is to have a second model check the first one’s suggestion. The research is unusually direct about why that underperforms.</p>
<p>The study Socket reviewed is a replication of the USENIX Security '25 work on package hallucination, run against five current models: Claude Sonnet 4.6, Claude Haiku 4.5, GPT-5.4-mini, Gemini 2.5 Pro and DeepSeek V3.2. Nearly 200,000 prompts. Beyond the per-model rates, it looked for names that <strong>every</strong> model invents, and found 127 of them.</p>
<p>PyPI Security’s prohibited-name list and normalisation defences left 41 of the 109 Python names registrable, and Socket’s review took the npm side from 18 down to 12. That is 53 names which all five models produce and an attacker could still register, as of the April 2026 disclosure.</p>
<p>One registration. Four vendors’ users.</p>
<p>Three of those names are public in the write-up: <code>css-color-stop</code>, <code>dns-sd</code> and <code>dom-ains</code>. I looked all three up while writing this. Two of them are still free.</p>
<p><code>css-color-stop</code> was registered on 28 July 2026, six days after Socket published the article that named it. What sits there now is version <code>0.0.0</code>: one file, 53 bytes unpacked, no dependencies, no install script, no README, no description.</p>
<p>An empty package with nothing that executes is what a defensive registration looks like, and that is the likeliest reading. Somebody saw the disclosure and took the name so that nobody else could. It is worth sitting with anyway, because the mechanism is identical either way. A name travelled from a published security write-up to a claimed npm package in six days, and the only difference between the harmless version of that and the other one is what the publisher decided to put in the tarball.</p>
<p>That 53 is the number that matters, and no single-model study could have found it. Asking Gemini to check Claude’s homework does not help when both invent the same name. The 24 August paper measures the family-level version of this: when the primary model and its fallback come from the same family, roughly 84% of the primary’s failures recur on the fallback.</p>
<h2>The number everyone quotes has expired</h2>
<p>While I was reading round this, nearly every blog post and vendor page cited the same two figures: 19.7% of generated packages do not exist, and 43% of hallucinated names recur across all ten reruns. Those come from Spracklen et al., which circulated as a 2024 preprint and was published at USENIX Security '25.</p>
<p>The replication measured current frontier models at <strong>4.62% to 6.10%</strong>. The spread between best and worst model collapsed from 16.5 percentage points to 1.48.</p>
<p>Quoting 19.7% in September 2026 describes a model generation that has been superseded. That cuts both ways, though. The rate fell and the shape of the risk got worse, because the models converged. When every vendor’s model invents the same 127 names, the attacker’s job stopped being probabilistic.</p>
<p>Read the caveats too, because the same study carries real ones. GPT-5.4-mini was tested at minimal reasoning effort and refused 32% of prompts, so its rate is computed over the compliant remainder. The measurements are one week in April against specific model snapshots. And agents that query the registry before suggesting a dependency behave differently from a single model answering a single prompt.</p>
<p>This is a preprint with a vendor correction folded into it.</p>
<h2>What to actually check</h2>
<p>Existence is the wrong question. These are the ones that separate the four names in my opening table from their real counterparts:</p>
<ul>
<li><strong>When was the last release?</strong> Every impostor above is dormant: 2012, 2017, 2019. Every real one shipped in the last two years. Staleness plus a name your agent just suggested is the strongest cheap signal there is.</li>
<li><strong>How many releases, and how many maintainers?</strong> One release and no maintainer is not a project. <code>binaries</code> has zero versions and returns 200.</li>
<li><strong>Does the description match what you asked for?</strong> <code>haystack</code> says it searches C structures in process memory. You asked for an LLM framework. The registry told you the truth and the check threw it away by only reading the status code.</li>
<li><strong>Does the import name match the install name?</strong> <code>import cv2</code> installs <code>opencv-python</code>. This mismatch is normal in Python, which is exactly why a lookalike hides in it comfortably.</li>
<li><strong>Commit the lockfile, and install from it in CI.</strong> This is the same argument as <a href="/en/blog/they-moved-your-version">pinning a version not being the same as pinning a commit</a>. A lockfile stops drift on packages you already vetted. It does nothing about the first install, which is the moment slopsquatting attacks.</li>
<li><strong>Treat an agent-proposed dependency like a pull request from a stranger.</strong> The <a href="/en/blog/how-to-vet-an-mcp-server">pre-install checklist for MCP servers</a> is the same ten minutes of work, aimed at a different registry.</li>
</ul>
<p>None of that is new. It is the vetting that <a href="/en/blog/the-mcp-supply-chain-is-the-new-npm">the npm playbook already taught us</a> and that we skip when the suggestion arrives from something that sounds confident.</p>
<p>Which is the actual mechanism here. The model was never guessing at a package name. It produced the most plausible continuation, and a plausible package name looks exactly like a real one. That is the whole attack surface.</p>
<p><code>tabula</code> is a better name than <code>tabula-py</code>. That is why the model reaches for it, and why somebody registered it in 2012 for something else entirely.</p>
<p>The registry will confirm it exists. It will not tell you the model was wrong.</p>
]]></content:encoded>
</item>
<item>
<title>Software estimation with AI: the typing was never the estimate</title>
<link>https://tim-schipper.nl/en/blog/software-estimation-with-ai</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/software-estimation-with-ai</guid>
<pubDate>Tue, 01 Sep 2026 21:33:36 GMT</pubDate>
<description>Software estimation with AI keeps missing. One METR survey asked 349 technical workers how much faster they had become and how much more value they produced, and got 3x and 1.4 to 2x. The gap between those two answers is the estimate you are actually making.</description>
<content:encoded><![CDATA[<p>Ask a developer how much faster AI made them and you get a multiple. Ask the same developer how much more value they produce and the number falls, usually by about half.</p>
<p>That gap comes from one survey.</p>
<p>METR ran it between February and April 2026 across 349 technical workers: 87 software engineers, 71 researchers, 129 academics and PhD students, 48 founders and managers. <a href="https://metr.org/blog/2026-05-11-ai-usage-survey/">The result</a> is a median self-reported speed change of 3x, and a median self-reported change in the <em>value</em> of the work of 1.4 to 2x.</p>
<p>Same people. Same tools. Same week. One question about speed, one about value, and a factor of two sitting between the answers.</p>
<p>If you estimate software for a living, that gap is the whole problem.</p>
<h2>The number everyone quotes is the loosest one</h2>
<p>Three days earlier, the same organisation published the piece that explains the gap. <a href="https://metr.org/blog/2026-05-08-task-substitution-and-uplift/">Task substitution and uplift</a>, by Tom Cunningham and Parker Whitfill, argues that there is no single quantity called “how much AI sped me up”. There are three, and they line up in an order:</p>
<blockquote>
<p>uplift on old tasks ≤ uplift in value ≤ uplift on new tasks</p>
</blockquote>
<p>Old tasks are the work you would have done in 2021. New tasks are the work you choose now, knowing an agent is available. Value is what you actually got.</p>
<p>Their worked example is deliberately small. An engineer splits an eight-hour day between writing documents and writing pull requests, an hour each. AI halves the cost of a PR and does nothing for documents. Measured on the old task list, that is a 33% uplift. Measured on the new one, where the engineer now writes eight PRs instead of four, it is 50%. Value lands somewhere between the two.</p>
<p>Widen the gap between the two activities and the bounds fly apart. In their more extreme case, where a PR goes from five hours to one, the three measures span +67%, +124% and +200%.</p>
<p>Three numbers, one engineer, one afternoon. All of them honest.</p>
<p>Here is what that does to an estimate. When somebody tells you their team is 3x faster, they are almost certainly reporting uplift on new tasks, because that is the number a human can feel. It is also the loosest of the three, which is the authors’ whole point.</p>
<p>They hedge it properly, and so should you: the ordering holds under simplifying assumptions, and their own footnote allows that it can reverse in practice. What it reliably tells you is which of the three questions you are being handed an answer to. The survey respondents asked about value instead of speed landed at 1.4 to 2x, and they were the same people.</p>
<p>Every respondent answered honestly. They were just answering a different question from the one you need answered when you commit to a date.</p>
<h2>You are not doing the old work faster</h2>
<p>This is the part that breaks estimation specifically, and it is worse than a measurement error.</p>
<p>An estimate is a comparison. You are saying this new thing resembles that old thing, and that old thing took a fortnight. The whole method depends on the two being commensurable.</p>
<p>Task substitution removes that. Whatever multiple you apply, you are applying it to a task list you would never have written down in 2024, because half of it was uneconomic.</p>
<p>The dashboard nobody asked for. The refactor across forty files. The second implementation you built to compare against the first. The prototype you threw away on Thursday, which cost four hours and would have cost four days.</p>
<p>Some of that is real value and some of it is motion. The point is that your denominator is gone. The old ticket and the new ticket share a title and nothing else.</p>
<p>I have written before that <a href="/en/blog/when-not-to-use-ai-coding">the aggregate speed number stopped meaning anything</a> once developers started choosing, per task, where they wanted the help. This is the same finding one level up. Estimation broke for the same reason <a href="https://metr.org/blog/2026-02-24-uplift-update/">METR’s follow-up study broke</a>: the population being measured kept quietly changing what it submitted.</p>
<h2>Where the afternoon actually went</h2>
<p>Take a real-shaped ticket. Add a filtered export endpoint to a reporting service that has been in production for three years. Estimated at two days before agents, and honestly, two days was about right.</p>
<p>Here is the same ticket, split into what the work is actually made of:</p>
<table>
<thead>
<tr>
<th style="text-align:left">Phase</th>
<th style="text-align:left">Before</th>
<th style="text-align:left">Now</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Understanding the problem and the code around it</td>
<td style="text-align:left">4h</td>
<td style="text-align:left">4h</td>
</tr>
<tr>
<td style="text-align:left">Writing it</td>
<td style="text-align:left">6h</td>
<td style="text-align:left">1h</td>
</tr>
<tr>
<td style="text-align:left">Reviewing it</td>
<td style="text-align:left">1h</td>
<td style="text-align:left">4h</td>
</tr>
<tr>
<td style="text-align:left">Integration: the migration, the config, three call sites</td>
<td style="text-align:left">3h</td>
<td style="text-align:left">3h</td>
</tr>
<tr>
<td style="text-align:left">Consequences: the bug that surfaces in week two</td>
<td style="text-align:left">2h</td>
<td style="text-align:left">3h</td>
</tr>
<tr>
<td style="text-align:left"><strong>Total</strong></td>
<td style="text-align:left">16h</td>
<td style="text-align:left">15h</td>
</tr>
</tbody>
</table>
<p>Writing fell by five sixths. The total fell by an hour.</p>
<p><img src="/images/illustrations/software-estimation-with-ai.svg" alt="The same ticket in hours, then and now: writing fell from six to one, the total from sixteen to fifteen"></p>
<p>Three of those five rows did not move at all, because none of them are typing. Understanding a codebase is reading and holding, and an agent that reads it for you leaves you holding nothing. Integration is negotiating with systems that already exist and already have opinions. Consequences arrive on their own schedule.</p>
<p>The row that moved in the wrong direction is review, and it moved because the thing being reviewed changed. Reviewing code you wrote is remembering. Reviewing code you did not write is reconstruction, and it is <a href="/en/blog/speed-got-cheap-judgement-didnt">the expensive half of the job now</a>. Four hours to review one hour of generated code is the actual price rather than a failure of discipline, and the reason so many teams post an improved cycle time is that they are not paying it.</p>
<p>Your rows will differ. Mine differ by codebase, and the ratio moves a long way depending on whether I know the subsystem. What holds across all of them is the shape: the one row that collapsed is the row that was never the majority of the estimate.</p>
<h2>What I estimate now</h2>
<p><strong>I estimate the verification, not the generation.</strong> The question I ask about a ticket is no longer “how long to build this” but “how long until I can defend it”. Those were nearly the same number when I typed everything. They are not close any more, and the second one is the one that ships.</p>
<p><strong>I estimate in unknowns, not in hours.</strong> How many things do I not know yet? Which of them can only be answered by touching production data, or by a conversation with someone who is on holiday? Generation did nothing to that list. It is the same list it was in 2019, and it still explains most of the variance in how long anything takes.</p>
<p><strong>I do not re-estimate when the diff appears.</strong> This is the seductive one and the reason so many two-day tickets ship on day three. A complete-looking diff at 11am feels like evidence that the work is nearly done. It is evidence that one row of the table is nearly done. The unread diff is inventory rather than progress you can bank, and reviewing it is the part you had not started.</p>
<p><strong>I record what I did, not what I would have done.</strong> Counterfactual self-report is the least reliable instrument in this whole discussion, and the survey says as much about itself. It puts the average overestimate of AI’s effect on time at 40 percentage points, drawing on METR’s 2025 trial, and warns that its own respondents are probably making the same error. That trial’s headline number is now flagged as out of date. The perception gap is the part that survived.</p>
<p>So I stopped asking myself how much faster I was. I write down what the ticket took, and I compare it to the estimate I made before I opened the editor. That number is boring and it is measured, which is precisely <a href="/en/blog/tokenmaxxing-measuring-the-wrong-thing">what makes it worth having</a>.</p>
<p>None of this makes estimates good. Estimates were never good. What changed is that the one part of the job that used to be predictable, the sitting-down-and-typing part, stopped being the part you were estimating, and a lot of teams updated their commitments as though it still was.</p>
<p>So when someone tells you their team got 3x faster, believe them. They are reporting uplift on new tasks, accurately, about a task list that no longer resembles the one your roadmap was built on.</p>
<p>Then ask what shipped.</p>
]]></content:encoded>
</item>
<item>
<title>Quality ratchets for AI code: baselines that only tighten</title>
<link>https://tim-schipper.nl/en/blog/quality-ratchets-for-ai-code</link>
<guid isPermaLink="true">https://tim-schipper.nl/en/blog/quality-ratchets-for-ai-code</guid>
<pubDate>Mon, 31 Aug 2026 06:40:00 GMT</pubDate>
<description>A lint baseline lets you enforce a standard you cannot fix today. Existing violations are grandfathered, new ones fail the build, and the file can only shrink. How PHPStan, ESLint, detekt and Sonar do it, and the four ways a ratchet quietly stops working.</description>
<content:encoded><![CDATA[<p>I have argued before that the fix for an agent ignoring your conventions is to <a href="/en/blog/stop-asking-your-agent-nicely">stop asking it nicely</a> and encode the convention somewhere it has to look. That post ends on a rule: the second time you correct the same thing, write it as an ADR or a lint rule.</p>
<p>Then you try it on a real repository and hit the wall immediately.</p>
<p>The convention you correct most often is usually the one your codebase already breaks in three hundred places. You cannot switch that rule on as an error, because the build goes red on code nobody is touching today.</p>
<p>So you set it to <code>warn</code>, and a warning is where a convention goes to die. Nobody reads a stream of three hundred warnings. Your agent reads it as evidence that this codebase does the thing.</p>
<p>There is a second kind of guard for exactly this case, and almost nobody wires it up deliberately.</p>
<p><img src="/images/illustrations/quality-ratchets.svg" alt="A baseline that only steps down per commit, with one new violation above it failing the build"></p>
<h2>The rule you cannot switch on</h2>
<p>A categorical rule says <em>never</em>. A ratchet says <em>never worse than yesterday</em>.</p>
<p>The mechanics are always the same. You run the check once, write the result into a file, and commit that file. From then on the check compares against the stored result and fails only on the difference.</p>
<p>What was already there is grandfathered. What the agent just added is not, and the number is allowed to move in one direction only.</p>
<p>PHPStan shipped this in October 2019 and described it in one sentence that is still the clearest statement of the idea: the baseline “allows you to be interested in violations only in new and changed code”. The <a href="https://phpstan.org/user-guide/baseline">documentation</a> is blunt about the trade too. It works for a few dozen to a few hundred errors, and if you have fifteen thousand you should be fixing your configuration instead.</p>
<h2>Three shapes</h2>
<p><strong>The frozen list.</strong> Every violation that exists right now, recorded by rule and file. PHPStan writes <code>phpstan-baseline.neon</code>, detekt writes a <code>baseline.xml</code>, and ESLint has shipped the same thing since v9.24.0 in April 2025:</p>
<pre><code class="language-bash"># fix what can be fixed, freeze the rest
eslint --fix --suppress-all
</code></pre>
<p>That writes <code>eslint-suppressions.json</code>, and the docs tell you to commit it “so that the suppressions are shared with all the developers”. Two details matter more than the flag. Only rules configured as <code>&quot;error&quot;</code> get suppressed, so this is a mechanism for standards you actually mean. And detekt splits its file into <code>CurrentIssues</code> and <code>ManuallySuppressedIssues</code>, which is the distinction most teams lose: what is here because history put it here, versus what is here because someone decided it should be.</p>
<p><strong>The single number.</strong> A per-file coverage floor, a bundle budget, a surviving-mutant count, <code>type-coverage --at-least 98</code>. One value, checked in, and a build that fails when it moves the wrong way. <code>type-coverage</code> even ships <code>--update-if-higher</code>, which tightens the stored threshold automatically whenever a run beats it.</p>
<p><strong>The diff gate.</strong> Sonar does not store a list at all. It computes what counts as new code, and in a pull request analysis, measured against the target branch, “Only issues on new code are reported.” Nothing is grandfathered explicitly, because the diff decides what is yours.</p>
<table>
<thead>
<tr>
<th style="text-align:left">Shape</th>
<th style="text-align:left">Where the memory lives</th>
<th style="text-align:left">Fails when</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">Frozen list</td>
<td style="text-align:left">A committed baseline file</td>
<td style="text-align:left">A violation appears outside it</td>
</tr>
<tr>
<td style="text-align:left">Single number</td>
<td style="text-align:left">A threshold in config or JSON</td>
<td style="text-align:left">The number moves the wrong way</td>
</tr>
<tr>
<td style="text-align:left">Diff gate</td>
<td style="text-align:left">Git history</td>
<td style="text-align:left">The changed lines break the rule</td>
</tr>
</tbody>
</table>
<h2>Why this lands on an agent</h2>
<p>An agent already self-heals against deterministic feedback. Give it a failing check with a clear message and it will fix the violation, then generate the next file closer to the pattern. That is the whole argument for custom lint rules.</p>
<p>A ratchet sharpens that message. The failure names the file the agent wrote thirty seconds ago and nothing else. There is no wading through inherited noise, no judgement call about which of the three hundred warnings are its problem, and no plausible reading in which the codebase’s own habits excuse it. The baseline is the codebase’s habits, in a file, already accounted for.</p>
<p>Wire it into the agent’s own loop. A check that runs after the turn ends gets fixed by the agent. A check that runs in CI ten minutes later gets fixed by you.</p>
<h2>Four ways a ratchet rots</h2>
<p><strong>Regenerating the baseline.</strong> This is the one that kills the whole idea, and PHPStan’s own docs put it best: “The life goal of a baseline file is to not exist.” Regenerating is one command, it makes the build green, and it looks like maintenance in a diff. Treat a growing baseline as a reviewable event with a reason attached, the same way you would treat a skipped test.</p>
<p>ESLint handles half of this for you. When a suppressed violation no longer occurs, the run fails with <code>There are suppressions left that do not occur anymore</code>, so the file cannot quietly hold space for problems you already fixed. The other half, the file growing, is still a human judgement.</p>
<p><strong>The agent editing the file.</strong> It will. A baseline is a text file in the repo, the failing check names it, and making it bigger is the shortest path to green. Put the baseline behind whatever your harness uses to make a path off limits.</p>
<p>In this repository that job belongs to a <code>protect-paths.sh</code> hook, which blocks writes to a fixed list of paths and hands the reason back to the agent. Until I wrote this post, the recorded audit runs under <code>docs/standards/</code> were not on that list. They carried an instruction inside the file saying never to edit them, which is a convention, and a convention holds exactly as well as a convention holds. Checking my own claim is what moved them onto the block list, which is roughly how every rule in that hook got there.</p>
<p><strong>Ratcheting the metric instead of the property.</strong> <a href="https://arxiv.org/abs/2605.21384">SpecBench</a>, published in May 2026, opens on the reason this matters: “As long-horizon coding agents produce more code than any developer can review, oversight collapses onto a single surface: the automated test suite.” Every frontier agent in their runs saturates the visible tests, the gap to held-out tests grows by 28 percentage points for every tenfold increase in code size, and one failure mode was a 2,900-line hash table that memorised test inputs.</p>
<p>So a coverage percentage is the worst thing you can ratchet, because the cheapest way to raise it is to write tests that assert nothing. Ratchet something with no cheap fake: <a href="/en/blog/ai-generated-tests-mutation-testing">surviving mutants</a>, <a href="/en/blog/ai-code-empty-catch-blocks">a count of one specific violation</a>, a bundle size in bytes.</p>
<p><strong>Nobody lowers it.</strong> A baseline that only ever gets regenerated is a budget with better manners. The fix is boring: date each run, keep it, and compare across quarters instead of across commits. The dated JSON files under <code>docs/standards/</code> in this repo exist for that, one per run, never edited, so the number worth watching is what the score still says three months after the day everything got fixed.</p>
<h2>What it buys upstream</h2>
<p>Faros AI’s <a href="https://pages.faros.ai/hubfs/AI_Engineering_Report_2026_The_Acceleration_Whiplash_Faros.pdf">AI Engineering Report 2026</a> reached the conclusion this post is built on: “the code arriving for review is often not review-ready. Reviewers are not just assessing more code, they appear to be working harder to bring that code up to a standard it should have met before the PR was opened.”</p>
<p>A ratchet removes one class of comment from that queue permanently. It makes the standard a precondition of the pull request existing, so the reviewer never has to ask for it.</p>
<p>It only covers the half a machine can see, which is the same limit every instrument on this blog has. A green board says the checkable half is in order. It says nothing about whether the code does what the ticket asked, and <a href="/en/blog/measuring-ai-code-quality">that half still needs your eyes</a>.</p>
<p>One last honest note. Betterer, the tool that generalised this pattern for JavaScript with a <code>.betterer.results</code> file and a <code>smaller</code> constraint, has not had a stable release since v5.4.0 in August 2022, and nothing at all has been published since an alpha in December 2024. The idea outlived the tooling. Learn the pattern and build it on whatever checker your stack already runs.</p>
<p>Start with one convention. Pick the one you corrected twice this week and cannot fix repo-wide, enable it as an error, generate the baseline today, commit it, and run the check where the agent will see it fail. The <a href="/en/blog/code-churn-is-the-new-lava-layer">churn</a> you are trying to avoid is mostly code written against a standard nobody enforced at the moment it was typed.</p>
<p>Yesterday’s code is a fact you have to live with. The next two hundred lines are still a choice.</p>
]]></content:encoded>
</item>
</channel>
</rss>
