<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hyuga</title>
    <description>The latest articles on DEV Community by hyuga (@hyuga611).</description>
    <link>https://dev.to/hyuga611</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4069679%2F58392e9f-7cbb-4566-8fed-b1de5f5d09f3.png</url>
      <title>DEV Community: hyuga</title>
      <link>https://dev.to/hyuga611</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hyuga611"/>
    <language>en</language>
    <item>
      <title>You are in the cockpit now. Nobody gave you instruments.</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Sun, 30 Aug 2026 11:36:38 +0000</pubDate>
      <link>https://dev.to/hyuga611/you-are-in-the-cockpit-now-nobody-gave-you-instruments-3315</link>
      <guid>https://dev.to/hyuga611/you-are-in-the-cockpit-now-nobody-gave-you-instruments-3315</guid>
      <description>&lt;p&gt;I noticed recently that I have stopped typing.&lt;/p&gt;

&lt;p&gt;The agent writes the code. What I do is look at what comes back and press &lt;code&gt;y&lt;/code&gt;. Dozens of times a day.&lt;/p&gt;

&lt;p&gt;And — you probably recognize this — &lt;strong&gt;somewhere around the tenth one, I stopped reading.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Same as the last one." It usually is. But if one of those fifty had touched production, would you have caught it? I wouldn't have.&lt;/p&gt;

&lt;p&gt;That is not supervision. That is a rubber stamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Am I flying this, or am I a passenger?
&lt;/h2&gt;

&lt;p&gt;Here is the thing I keep coming back to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pilot does not see the outside world directly.&lt;/strong&gt; They see instruments: fuel, damage, remaining ordnance, whether the limiter is engaged. Reaction time matters, but knowing &lt;em&gt;what is happening right now&lt;/em&gt; comes first.&lt;/p&gt;

&lt;p&gt;An aircraft with broken instruments is not the same aircraft with a small problem. It is a different aircraft.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;everyone running an AI agent is already sitting in a cockpit.&lt;/strong&gt; You don't move the controls yourself; the machine acts and you decide at the moments that matter. That is a pilot's job description.&lt;/p&gt;

&lt;p&gt;You are just sitting there &lt;strong&gt;without an instrument panel.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What you can see is one approval dialog at a time. What you cannot see: what this session has done in total, how far into dangerous territory it has gone, what it got wrong last time.&lt;/p&gt;

&lt;p&gt;I wanted that panel, so I built it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @hyuga/airframe &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The panel
&lt;/h2&gt;

&lt;p&gt;It says nothing most of the time. Silence is the correct state. You look when you want to look.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ airframe status

sortie   2026-08-30T07-07-43-412Z-18aa80
form     strike / fire
limiter  0
wingmen  0
fuel     0 (no budget set)
mounted
  + airframe      the vessel
  + redline       limiter — counts the sortie, not the call
  + habit         learns from the corrections you make by hand
  + carbon        keeps the draft about to be written over — cruise only
  + groundtruth   completion gate — called from your code, not from a hook
  + llm-safe-sql  runs the write, measures it, rolls back — from your code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vocabulary is aviation, partly because I like it and partly because it turned out to be the right shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;sortie&lt;/strong&gt; — one run of work. What the last one left unfinished survives into the next&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;limiter&lt;/strong&gt; — how much irreversible ground this sortie has covered&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wingmen&lt;/strong&gt; — how many subagents were launched&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fuel&lt;/strong&gt; — how many moves are left&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;limiter 0&lt;/code&gt; means nothing dangerous has happened yet. When it climbs, the machine is pushing into something.&lt;/p&gt;

&lt;h2&gt;
  
  
  A limiter is not there to slow you down
&lt;/h2&gt;

&lt;p&gt;This is the part I most want to land.&lt;/p&gt;

&lt;p&gt;"Guardrails" sounds like friction. It is the opposite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The limiter is what lets you open the throttle.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without instruments there is exactly one safe way to operate: go slow, always. And that is what everyone does — confirm every step, never look away. Sitting in a fast machine, driving at walking pace forever.&lt;/p&gt;

&lt;p&gt;With a number on the panel, &lt;strong&gt;"safe to let it run" and "hold the reins" become different situations.&lt;/strong&gt; Zero? Leave it alone. Climbing? Pay attention. It is a part that makes you faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own instrument lied to me
&lt;/h2&gt;

&lt;p&gt;Then the instrument lied to me.&lt;/p&gt;

&lt;p&gt;While publishing this repository, the limiter — threshold 3 — reached &lt;strong&gt;20&lt;/strong&gt;. More than six times over.&lt;/p&gt;

&lt;p&gt;I looked at what it had charged for. &lt;strong&gt;Every single one was a search.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"npm publish"&lt;/span&gt; .github/workflows/release.yml    &lt;span class="c"&gt;# +3&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"rm -rf"&lt;/span&gt; packages/                             &lt;span class="c"&gt;# +3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Looking for the string&lt;/strong&gt; &lt;code&gt;npm publish&lt;/code&gt; scored the same as actually publishing. The limiter was matching a regex against the whole command as one blob of text.&lt;/p&gt;

&lt;p&gt;The problem is not that it was too strict. The problem is that &lt;strong&gt;once you learn 19 of 20 charges are noise, you skip the 20th too.&lt;/strong&gt; And the one you skip is the one that mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A broken instrument is worse than no instrument&lt;/strong&gt;, because it lets you feel watched while nothing is watching.&lt;/p&gt;

&lt;p&gt;Fixed the same day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grep "npm publish" README.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grep "npm publish" README.md &amp;amp;&amp;amp; npm publish&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm publish&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The arguments to &lt;code&gt;grep&lt;/code&gt; are text, not actions. But &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;xargs&lt;/code&gt; and &lt;code&gt;node -e&lt;/code&gt; are still charged on what they contain — their arguments &lt;em&gt;are&lt;/em&gt; commands, and nothing here can tell whether that string is about to run or about to be printed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Charging when the answer is genuinely unknown is design. Charging when the answer was obvious was the bug.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Past the limit, it does not stop you
&lt;/h2&gt;

&lt;p&gt;When the score goes past the threshold, the machine &lt;strong&gt;does not stop.&lt;/strong&gt; It says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This sortie has spent 20 against a limit of 3. You are flying it, so it is your call — but say out loud that you are past the edge before going on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does not stop because &lt;strong&gt;there is no guarantee the machine is more right than the person in the seat.&lt;/strong&gt; The call belongs to the pilot.&lt;/p&gt;

&lt;p&gt;One exception: set &lt;code&gt;AIRFRAME_AUTONOMY&lt;/code&gt; to the reason you are running unattended — a loop, a timer — and then it &lt;em&gt;will&lt;/em&gt; halt. With nobody in the seat there is nobody to advise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advise when someone is aboard. Halt when nobody is.&lt;/strong&gt; The condition for releasing the limiter is decided by the seat, not by the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Converging and diverging are opposite jobs
&lt;/h2&gt;

&lt;p&gt;One more thing you switch by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;airframe mode strike   &lt;span class="c"&gt;# implement it, fix it, ship it&lt;/span&gt;
airframe mode cruise   &lt;span class="c"&gt;# draft, design, decide what to build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While you are implementing, you want the gate: &lt;em&gt;did those rows actually land?&lt;/em&gt; Stop me if not.&lt;/p&gt;

&lt;p&gt;While you are &lt;strong&gt;drafting&lt;/strong&gt;, the same gate is a wrist being grabbed mid-sentence. An idea that has not taken shape yet gets reported as "unmet." The better the idea, the earlier it closes.&lt;/p&gt;

&lt;p&gt;Opposite jobs. Tune for one and you break the other.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;the machine never guesses which one you are in.&lt;/strong&gt; Guessing wrong is the same bug as not having the two. You type it. It takes a second.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;cruise&lt;/code&gt;, the draft keeper runs: when the agent is about to overwrite a file git does not have, the previous version is kept.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ carbon list
2026-08-30T07-32-32-255Z-0bef6e1d.md    1832 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files git already tracks are never copied — &lt;code&gt;git show&lt;/code&gt; gets those back. &lt;strong&gt;Only what git is not keeping.&lt;/strong&gt; For anyone who has had an agent overwrite a draft and found &lt;code&gt;git diff&lt;/code&gt; empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three CI files that had never run once
&lt;/h2&gt;

&lt;p&gt;One more thing that actually happened.&lt;/p&gt;

&lt;p&gt;When I merged the parts into one repository, I moved each part's &lt;code&gt;.github/workflows/&lt;/code&gt; along with it, into the package folders. GitHub only reads &lt;code&gt;.github/&lt;/code&gt; at the repository root.&lt;/p&gt;

&lt;p&gt;Three CI files. Three release files. After the merge, &lt;strong&gt;not one of them had run.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And nothing says so. Nothing is failing. On the GitHub UI, &lt;strong&gt;"zero failures" and "zero runs" look exactly the same.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which is the general form of what the completion gate does: at the moment something claims "done", go re-fetch the actual state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;groundtruth verify &lt;span class="nt"&gt;--probe&lt;/span&gt; &lt;span class="s2"&gt;"psql -tAc 'select count(*) from t where batch=123'"&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Told 45 rows landed? Go count them. Zero? Stop there. No LLM, no API key — the raw output of the command you gave it, shown as evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually pays off
&lt;/h2&gt;

&lt;p&gt;Honestly: this thing helps in a narrow set of situations, and it is worth naming them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Late in a long day.&lt;/strong&gt; Work with an agent from morning to evening and your judgment degrades. You cannot feel it happen. The approval dialog asks you from scratch every time, so the tenth one looks exactly like the first. The limiter is the opposite: &lt;strong&gt;a number that does not go down.&lt;/strong&gt; Name your production paths and the day's total is right there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/var/www/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/srv/client-sites/"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When it runs unattended.&lt;/strong&gt; Set &lt;code&gt;AIRFRAME_AUTONOMY&lt;/code&gt; to the reason — a loop, a timer — and the machine really does halt. With nobody in the seat, "advise" means nothing. I don't know of another brake that works for unattended runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you are writing prose with an agent.&lt;/strong&gt; Articles, proposals, design notes. Overwriting a file git does not track, in &lt;code&gt;cruise&lt;/code&gt;, keeps the previous version. And the completion gate goes quiet, so a draft never gets reported as "unmet."&lt;/p&gt;

&lt;p&gt;Just as clearly, &lt;strong&gt;where it does not help:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short one-off tasks&lt;/strong&gt; — it's over before you'd look at the panel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local, reversible experiments&lt;/strong&gt; — nothing to count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overwrites via &lt;code&gt;sed -i&lt;/code&gt; or a formatter&lt;/strong&gt; — those never pass through a hook, so the draft keeper cannot see them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judgment calls&lt;/strong&gt; — "is this design good" has no probe, so the gate has nothing to check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Long sessions, a lot delegated, hard to undo.&lt;/strong&gt; The more of those three you have, the more this earns its place. None of them? Don't install it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I think this goes
&lt;/h2&gt;

&lt;p&gt;Soon enough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;agents will run &lt;strong&gt;while you sleep&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;you will fly &lt;strong&gt;several at once&lt;/strong&gt;, not one&lt;/li&gt;
&lt;li&gt;a single run will last &lt;strong&gt;hours&lt;/strong&gt;, not minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When that happens, I don't think the bottleneck is model quality. It is &lt;strong&gt;whether a human can stay in the loop at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You wake up to eight agents' worth of work. Can you reconstruct what happened? Which of them touched production? Did any of them quietly revert the fix you made by hand yesterday?&lt;/p&gt;

&lt;p&gt;That is why &lt;code&gt;status&lt;/code&gt; has a &lt;code&gt;wingmen&lt;/code&gt; row. How many were launched is &lt;strong&gt;a number a pilot should be able to see afterwards&lt;/strong&gt;, and there is no other way to know it.&lt;/p&gt;

&lt;p&gt;Whether "AI does the work" means &lt;em&gt;you are in command&lt;/em&gt; or &lt;em&gt;you are being carried&lt;/em&gt; is decided, I think, by the cockpit rather than by the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of this buys
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It does not make the agent correct.&lt;/strong&gt; None of these parts read your code. They watch what happens around the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The limiter is a gauge, not a guard.&lt;/strong&gt; Its number is summed out of a ledger the agent can write to. It is built for the agent that has &lt;strong&gt;lost count&lt;/strong&gt;, not the one trying to get past you — anything that can run commands on your behalf is already inside the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The draft keeper matches names, not contents.&lt;/strong&gt; &lt;code&gt;.env&lt;/code&gt; is never copied; a draft with a key pasted into the third paragraph is copied like any other draft. A part that opens every draft to judge it is a part that opens every draft. I liked that less.&lt;/p&gt;






&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @hyuga/airframe &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 18+. No dependencies, no daemon, no network, no LLM. MIT.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/hyuga611/airframe" rel="noopener noreferrer"&gt;github.com/hyuga611/airframe&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are sitting in the seat, look at the instruments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>productivity</category>
      <category>node</category>
    </item>
    <item>
      <title>cd, type go, and your workspace builds itself — tsumugi 0.3</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:24:11 +0000</pubDate>
      <link>https://dev.to/hyuga611/cd-type-go-and-your-workspace-builds-itself-tsumugi-03-46g2</link>
      <guid>https://dev.to/hyuga611/cd-type-go-and-your-workspace-builds-itself-tsumugi-03-46g2</guid>
      <description>&lt;p&gt;A while back I wrote about &lt;a href="https://dev.to/hyuga611/i-built-a-terminal-because-i-could-never-remember-the-keybindings-13k1"&gt;building a terminal because I could never remember the keybindings&lt;/a&gt;. That terminal is &lt;strong&gt;tsumugi&lt;/strong&gt;, and I just shipped 0.3. This post is about the three things that went in — a workspace command, screenshot paste, and self-update — and why each one is shaped the way it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;github.com/hyuga611/tsumugi&lt;/strong&gt; — Rust, MIT OR Apache-2.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  The premise
&lt;/h2&gt;

&lt;p&gt;Short version, since the first post covered it. tsumugi is built on one claim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The grid is a document. The process is appending to its end.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your scrollback is not a log, it is &lt;em&gt;what you just did&lt;/em&gt;, so it should be readable, selectable and reusable. Vim motions over scrollback, &lt;code&gt;ac&lt;/code&gt; to select a command with its output, &lt;code&gt;:e&lt;/code&gt; to turn the pane into an editor — they all fall out of that one line.&lt;/p&gt;

&lt;p&gt;And one rule on top: &lt;strong&gt;every command has a mouse path, enforced in CI.&lt;/strong&gt; A test walks the command registry and fails if any command lacks one.&lt;/p&gt;

&lt;p&gt;Those two decided the shape of almost everything below.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;go&lt;/code&gt; builds a workspace in one message
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/dev/tsumugi
go
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pane you are in becomes the middle one, a &lt;strong&gt;directory tree opens on the left and an AI agent on the right&lt;/strong&gt;, and &lt;strong&gt;the tab is renamed after the directory&lt;/strong&gt;. 20 / 50 / 30. One tab per repository, and the tab names tell you which is which.&lt;/p&gt;

&lt;h3&gt;
  
  
  One message, not four
&lt;/h3&gt;

&lt;p&gt;Sending "split", "open the tree", "start the agent", "rename the tab" separately costs you twice: intermediate layouts get broadcast and &lt;strong&gt;the screen jumps&lt;/strong&gt;, and you end up needing "the id of the pane that was just created" in the next message — an async hole. So it is a single &lt;code&gt;ClientMsg::Workspace&lt;/code&gt;. The server assembles it and broadcasts once.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tree has no motions of its own
&lt;/h3&gt;

&lt;p&gt;The tree implements &lt;code&gt;Buffer&lt;/code&gt;. That is the whole trick: &lt;code&gt;j&lt;/code&gt; &lt;code&gt;k&lt;/code&gt; &lt;code&gt;gg&lt;/code&gt; &lt;code&gt;G&lt;/code&gt; &lt;code&gt;/&lt;/code&gt;, visual selection and the mouse &lt;strong&gt;already work&lt;/strong&gt;, because they are the same ones you use on the terminal. I did not write a single tree-specific motion.&lt;/p&gt;

&lt;p&gt;Only a handful of keys are added inside the tree: Enter opens, &lt;code&gt;l&lt;/code&gt; / &lt;code&gt;h&lt;/code&gt; fold a branch, &lt;code&gt;a&lt;/code&gt; / &lt;code&gt;A&lt;/code&gt; create, &lt;code&gt;r&lt;/code&gt; renames, &lt;code&gt;R&lt;/code&gt; reloads. They are &lt;strong&gt;not in the default keymap&lt;/strong&gt; — &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;r&lt;/code&gt; meaning something else in a terminal would be wrong. Double-click opens, drag and drop moves. &lt;code&gt;:q&lt;/code&gt; closes the tree and the pane goes back to being a shell, with the shell underneath still running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no delete.&lt;/strong&gt; An operation you cannot undo does not belong on a list your finger slides across. Delete from the shell in the middle pane.&lt;/p&gt;

&lt;h3&gt;
  
  
  The listing is read server-side
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;[domains]&lt;/code&gt;, the files are on the far machine, so the tree is read there. Only branches you have opened are walked, so a directory with &lt;code&gt;node_modules&lt;/code&gt; or &lt;code&gt;target&lt;/code&gt; in it stays fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  It coexists with the Go toolchain
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;go&lt;/code&gt; is a function the shell integration installs; it calls &lt;code&gt;tsg --workspace&lt;/code&gt;. &lt;strong&gt;Anything with arguments is forwarded to the real &lt;code&gt;go&lt;/code&gt;&lt;/strong&gt;, so &lt;code&gt;go build&lt;/code&gt; still works, and a bare &lt;code&gt;go&lt;/code&gt; outside tsumugi still runs Go. &lt;code&gt;TSUMUGI_NO_GO=1&lt;/code&gt; gives the name back. From the keyboard it is Space then &lt;code&gt;w&lt;/code&gt;; from the palette, &lt;code&gt;:go&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Paste a screenshot into what you are telling the agent
&lt;/h2&gt;

&lt;p&gt;Ctrl+Shift+V writes the clipboard image out as a PNG and &lt;strong&gt;types its path into the prompt&lt;/strong&gt; — it does not press Enter, so you can keep writing before you send. Dropping an image file on the window lands in the same place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No new command was added.&lt;/strong&gt; Paste is one verb that does the closest thing to what it is holding: text if text, a path if a picture. Adding verbs adds things to remember.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;server&lt;/strong&gt; writes the file, into a per-session temp area — so when you are connected to a &lt;code&gt;[domains]&lt;/code&gt; host, the file appears &lt;strong&gt;where the agent reading it lives&lt;/strong&gt;. The last 32 are kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;tsg update&lt;/code&gt; starts no external process
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tsg update&lt;/code&gt; fetches the latest release and swaps it in. It downloads nothing when you are already on the latest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the download-and-run shape had to go
&lt;/h3&gt;

&lt;p&gt;The first version ran &lt;code&gt;powershell -Command "irm &amp;lt;URL&amp;gt; | iex"&lt;/code&gt; inside itself, to keep exactly one way of installing. But &lt;strong&gt;that shape is indistinguishable from malware that downloads and runs code.&lt;/strong&gt; Windows Defender on a corporate machine blocked it — the process was denied (&lt;code&gt;os error 5&lt;/code&gt;) and the installed binary was removed along with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The machines that need it most are the strictest, so that is exactly where it broke.&lt;/strong&gt; It now pulls the release over HTTPS itself (&lt;code&gt;ureq&lt;/code&gt; / rustls) and replaces the file. No child process at all. The first-time &lt;code&gt;install.ps1&lt;/code&gt; / &lt;code&gt;install.sh&lt;/code&gt; are unchanged — there is no &lt;code&gt;tsg&lt;/code&gt; yet, so that one has to start from outside.&lt;/p&gt;

&lt;h3&gt;
  
  
  After the swap, no reopening and no &lt;code&gt;--kill&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tsg update&lt;/code&gt; &lt;strong&gt;reopens the window on the new build.&lt;/strong&gt; The multiplexer is a separate process, so replacing only the window reconnects you &lt;strong&gt;with your shells and agents still alive&lt;/strong&gt;. I watched the window process change while the multiplexer's PID stayed the same.&lt;/p&gt;

&lt;p&gt;It checks before it commits: it asks the new binary for its protocol version (&lt;code&gt;--protocol&lt;/code&gt;) and reopens only if it matches the running server. Only when they differ does it become a conversation about stopping sessions — and it does not stop them for you, because stopping one ends the shells and agents inside it.&lt;/p&gt;

&lt;p&gt;Related decision: &lt;strong&gt;the protocol version is not bumped for additive changes.&lt;/strong&gt; Bumping it means a running server stops accepting new windows, which means killing the shells inside. It moves only when the meaning of an existing message changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;No admin rights.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows (PowerShell)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;irm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://raw.githubusercontent.com/hyuga611/tsumugi/main/install.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line &lt;strong&gt;leaves you with a working setup&lt;/strong&gt;: the binary in &lt;code&gt;%USERPROFILE%\bin&lt;/code&gt;, Start Menu / PATH / folder context menu, &lt;strong&gt;the shell integration&lt;/strong&gt;, and the Claude Code and Codex hooks when they are present.&lt;/p&gt;

&lt;p&gt;It used to be three steps, and skipping the last two left you with a terminal where the gutter, &lt;code&gt;[[&lt;/code&gt;, &lt;code&gt;ac&lt;/code&gt; and &lt;code&gt;go&lt;/code&gt; all did nothing — &lt;strong&gt;installed but not working&lt;/strong&gt;. It was one errand for the person typing, so it is one command now. Every change is printed, and &lt;code&gt;tsg --uninstall&lt;/code&gt; takes all of it back out.&lt;/p&gt;

&lt;p&gt;The CRT is linked statically, so it runs on machines &lt;strong&gt;without the VC++ redistributable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;macOS / Linux (experimental)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/hyuga611/tsumugi/main/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Untested.&lt;/strong&gt; CI builds and tests on three OSes, but the window (winit / wgpu), the IME and &lt;code&gt;--install&lt;/code&gt; have parts written against Windows APIs, and nobody has run them yet. Shipping the binaries is to make trying it easy, not a promise that it works. Reports very welcome.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Check it took&lt;/strong&gt; — a shell reads its config at startup, so the window you typed in does not have it yet. Open a new one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;tsg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-V&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="c"&gt;# prints the version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;go&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="c"&gt;# Function means the shell integration took&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;You need no configuration to use an agent.&lt;/strong&gt; The waiting mark on the tab and the notifications come from the hooks, so typing &lt;code&gt;claude&lt;/code&gt; or &lt;code&gt;codex&lt;/code&gt; in a pane just works. Configuration only decides what &lt;code&gt;go&lt;/code&gt; starts on the right.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[shell]&lt;/span&gt;
&lt;span class="py"&gt;program&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"pwsh"&lt;/span&gt;

&lt;span class="nn"&gt;[workspace]&lt;/span&gt;
&lt;span class="py"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"claude"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Updating&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;tsg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows SmartScreen will warn on first launch — the binary is unsigned. More info, then Run anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also fixed in 0.3.x
&lt;/h2&gt;

&lt;p&gt;Twelve patch releases, almost all of them from running it on a &lt;strong&gt;different&lt;/strong&gt; machine — a locked-down corporate laptop. A sample:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Windows PowerShell 5.1 and PowerShell 7 read &lt;strong&gt;different&lt;/strong&gt; profiles; the installer wrote to whichever it found first. Now it writes to all of them.&lt;/li&gt;
&lt;li&gt;It guessed where &lt;code&gt;$PROFILE&lt;/code&gt; was. If you have never created one, neither directory exists and it gave up. Now it &lt;strong&gt;asks PowerShell&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The default shell was &lt;code&gt;cmd.exe&lt;/code&gt; (from &lt;code&gt;COMSPEC&lt;/code&gt;) — and cmd.exe has no way to emit OSC 133, which everything in tsumugi is built on. Everyone who installed it started from a state where nothing worked.&lt;/li&gt;
&lt;li&gt;Agent state was landing on the wrong pane: the hook ran &lt;code&gt;tsg --agent-state&lt;/code&gt; without naming a pane, so it attached to whichever was selected. Two agents, one badge that never clears. It reads &lt;code&gt;TSUMUGI_PANE&lt;/code&gt; now.&lt;/li&gt;
&lt;li&gt;Three bugs on top of Claude Code, all one root cause: ownership was decided by &lt;strong&gt;alt screen alone&lt;/strong&gt;, and some tools paint the whole screen without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And 0.3.13 is &lt;strong&gt;the four I hit while taking the screenshot for this post.&lt;/strong&gt; Worst first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[workspace] agent = "claude"&lt;/code&gt; built no workspace at all&lt;/strong&gt; — the headline feature of this post, with the configuration this post recommends. npm puts both &lt;code&gt;claude&lt;/code&gt; (an extensionless shell script) and &lt;code&gt;claude.cmd&lt;/code&gt; on Windows; &lt;code&gt;CreateProcessW&lt;/code&gt; does not consult PATHEXT, so a bare name hits the former and dies with &lt;code&gt;os error 193&lt;/code&gt;. A bare name is now resolved against the extensions &lt;code&gt;CreateProcessW&lt;/code&gt; can actually start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That reason never reached the person who typed it.&lt;/strong&gt; &lt;code&gt;tsg --workspace&lt;/code&gt; sent the request, slept 200ms and detached without reading the reply, so the server's error went nowhere. &lt;code&gt;go&lt;/code&gt; calls exactly that, so all you saw was nothing happening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed &lt;code&gt;go&lt;/code&gt; leaked a pane.&lt;/strong&gt; The tree spawns before the agent; when the agent failed, the tree stayed alive and attached to no tab. One more every time you retried.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shell integration could be dead in every pane (PowerShell only).&lt;/strong&gt; The guard against double-sourcing was an environment variable — and environment variables are inherited by children. Start the mux from a shell that already sourced it (that is: type &lt;code&gt;tsg&lt;/code&gt; in your terminal) and every pane's shell decides it has already run. Gutter, &lt;code&gt;[[ ]]&lt;/code&gt;, &lt;code&gt;ac&lt;/code&gt; / &lt;code&gt;io&lt;/code&gt; and &lt;code&gt;go&lt;/code&gt; all go dark. Launch from the Start Menu and you never see it; &lt;strong&gt;type &lt;code&gt;tsg&lt;/code&gt; in a terminal and you always do.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both halves of that list say the same thing. &lt;strong&gt;"Installed but not working" never reproduces on the machine of the person who installed it.&lt;/strong&gt; Every test passed here.&lt;/p&gt;

&lt;p&gt;Full list in the CHANGELOG.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/hyuga611/tsumugi" rel="noopener noreferrer"&gt;https://github.com/hyuga611/tsumugi&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>terminal</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built a terminal because I could never remember the keybindings</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:07:40 +0000</pubDate>
      <link>https://dev.to/hyuga611/i-built-a-terminal-because-i-could-never-remember-the-keybindings-13k1</link>
      <guid>https://dev.to/hyuga611/i-built-a-terminal-because-i-could-never-remember-the-keybindings-13k1</guid>
      <description>&lt;p&gt;I could never remember the keybindings.&lt;/p&gt;

&lt;p&gt;I know WezTerm and NeoVim are good. I installed both more than once, and backed&lt;br&gt;
out both more than once, always at the same place. &lt;strong&gt;It isn't that I refused to&lt;br&gt;
learn them — I stalled before I got far enough to learn anything.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I built the terminal I wanted instead: one where you can do everything&lt;br&gt;
without knowing a single key, and where knowing the keys only makes you faster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/hyuga611/tsumugi" rel="noopener noreferrer"&gt;https://github.com/hyuga611/tsumugi&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rust, MIT / Apache-2.0. Developed on Windows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yavncx00wgwbvtftpx5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yavncx00wgwbvtftpx5.png" alt="editor" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  One idea: the screen is a document
&lt;/h2&gt;

&lt;p&gt;In a normal terminal, past output is over. You can scroll back and look at it,&lt;br&gt;
but you can't really &lt;em&gt;use&lt;/em&gt; it — you end up dragging with the mouse and hoping&lt;br&gt;
you grabbed the right lines.&lt;/p&gt;

&lt;p&gt;But scrollback isn't a log. It's &lt;em&gt;what you just did&lt;/em&gt;, and it's meant to be read&lt;br&gt;
back and reused. So tsumugi treats it as a document:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;walk past output with vim motions (&lt;code&gt;j&lt;/code&gt; &lt;code&gt;k&lt;/code&gt; &lt;code&gt;w&lt;/code&gt; &lt;code&gt;[[&lt;/code&gt; &lt;code&gt;]]&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;select a command &lt;strong&gt;and its output&lt;/strong&gt; as one thing (&lt;code&gt;ac&lt;/code&gt; / &lt;code&gt;io&lt;/code&gt; text objects)&lt;/li&gt;
&lt;li&gt;open a file in the same pane (&lt;code&gt;:e&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The screen and a file are both "documents", so &lt;strong&gt;the same keys work on both.&lt;/strong&gt;&lt;br&gt;
There is no separate editor to launch.&lt;/p&gt;
&lt;h2&gt;
  
  
  Selecting "a command and its output" honestly
&lt;/h2&gt;

&lt;p&gt;Guessing at this always breaks. Decide that "lines starting with &lt;code&gt;$&lt;/code&gt; are&lt;br&gt;
prompts" and the first &lt;code&gt;$&lt;/code&gt; inside some output ruins it.&lt;/p&gt;

&lt;p&gt;So tsumugi uses &lt;strong&gt;OSC 133&lt;/strong&gt; — shell integration. The shell tells the terminal&lt;br&gt;
where the prompt starts, where the command starts, where the output starts, and&lt;br&gt;
what the exit code was.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tsg --install-shell-integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that you get ✓ / ✗ marks in the left gutter, &lt;code&gt;[[&lt;/code&gt; &lt;code&gt;]]&lt;/code&gt; to jump between&lt;br&gt;
prompts, and &lt;code&gt;]e&lt;/code&gt; to walk only the failed commands. &lt;strong&gt;Nothing is matched with a&lt;br&gt;
regex against your output, so no mark is ever a lie.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Every command has a mouse path — enforced by a test
&lt;/h2&gt;

&lt;p&gt;Since the whole point is "you don't have to know the keys", this isn't left to&lt;br&gt;
good intentions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[test]&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;every_command_is_mouse_reachable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a command without a mouse path and CI fails. There are 78 of them right now.&lt;br&gt;
Right-click gives you "what you can do here". &lt;code&gt;Space t&lt;/code&gt; labels every path and URL&lt;br&gt;
on screen with one or two letters, and that letter opens it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Running several AI agents turned me into a search function
&lt;/h2&gt;

&lt;p&gt;This part came out of using it, not planning it.&lt;/p&gt;

&lt;p&gt;I had three Claude Code sessions going on different jobs, and noticed that most&lt;br&gt;
of my actual work had become &lt;strong&gt;hunting for which one was waiting on me.&lt;/strong&gt; Look&lt;br&gt;
at one pane, look at the next, find the stalled one, answer it, start over.&lt;/p&gt;

&lt;p&gt;So I made the agents say so themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tsg --install-agent-hooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That wires one line into Claude Code's and Codex's hooks. Tabs get a ● mark, the&lt;br&gt;
status bar counts how many are waiting, and &lt;code&gt;Space a&lt;/code&gt; jumps to the next one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhj9kxg39niwnpj5lyham.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhj9kxg39niwnpj5lyham.png" alt="agents" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the window isn't focused, the taskbar flashes. Anything running inside can&lt;br&gt;
also call for you with OSC 9 when a long job finishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Losing the machine doesn't lose the session
&lt;/h2&gt;

&lt;p&gt;Closing the window was already fine — the multiplexer is a separate process, so&lt;br&gt;
shells and agents keep running. &lt;strong&gt;Losing power was not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now the layout and each pane's directory come back on the next launch. But&lt;br&gt;
&lt;strong&gt;the screen contents are never written to disk.&lt;/strong&gt; &lt;code&gt;SECURITY.md&lt;/code&gt; promises that&lt;br&gt;
scrollback never reaches the filesystem, and I didn't want to quietly break it&lt;br&gt;
for a convenience feature.&lt;/p&gt;

&lt;p&gt;So what comes back is the shape, the directories, and the argv. And the&lt;br&gt;
conversation? &lt;strong&gt;The agent owns its own transcript&lt;/strong&gt;, so tsumugi only needs to&lt;br&gt;
know how to say "continue". A restored pane gets &lt;code&gt;claude --continue&lt;/code&gt; &lt;em&gt;typed at&lt;br&gt;
the prompt but not run&lt;/em&gt; — pressing it is your call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reviewing what the agent wrote, where you are
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Space g&lt;/code&gt; opens &lt;code&gt;git diff&lt;/code&gt; in colour. &lt;code&gt;Space G&lt;/code&gt; stages the hunk under the&lt;br&gt;
cursor, &lt;code&gt;Space R&lt;/code&gt; reverts it (twice — it destroys work).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfl34zrzd80qw4cugkn4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfl34zrzd80qw4cugkn4.png" alt="diff" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A diff you only look at means making the same decision again somewhere else&lt;br&gt;
later. And if an agent rewrites a file you have open, tsumugi reloads it (unless&lt;br&gt;
you have unsaved edits, in which case it tells you instead). Without that you&lt;br&gt;
read stale text, edit it, and delete the agent's work the moment you save.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also in the box
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;language servers: squiggles for errors, &lt;code&gt;gd&lt;/code&gt; to definition, Ctrl+Space to complete&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;:grep&lt;/code&gt; searches the whole project and opens straight from the results&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;:123&lt;/code&gt;, &lt;code&gt;s/old/new/&lt;/code&gt;, &lt;code&gt;g/&lt;/code&gt; for a regular expression&lt;/li&gt;
&lt;li&gt;SSH domains — the far side keeps running when the link drops&lt;/li&gt;
&lt;li&gt;Markdown rendered in place, images (Kitty graphics / Sixel)&lt;/li&gt;
&lt;li&gt;narrowing the window &lt;strong&gt;re-wraps&lt;/strong&gt; scrollback instead of truncating it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What building it actually taught me
&lt;/h2&gt;

&lt;p&gt;The lesson that stuck: &lt;strong&gt;you cannot test your own work with your own habits.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Seven keys listed in the command palette did nothing when pressed — &lt;code&gt;Space g&lt;/code&gt;&lt;br&gt;
for the diff, &lt;code&gt;Space a&lt;/code&gt; to jump to a waiting agent, and five more. I had been&lt;br&gt;
verifying everything through the CLI (&lt;code&gt;tsg --run &amp;lt;id&amp;gt;&lt;/code&gt;) and had never once gone&lt;br&gt;
through the key path. A test that mechanically cross-checks the list against the&lt;br&gt;
implementation found all seven in one run.&lt;/p&gt;

&lt;p&gt;Opening a tab-indented file &lt;strong&gt;deleted every tab&lt;/strong&gt; — the worst kind, because&lt;br&gt;
saving then destroys someone else's indentation. That fell out of writing a test&lt;br&gt;
for auto-indent, not from using the editor.&lt;/p&gt;

&lt;p&gt;Wiring up LSP, I sent &lt;code&gt;initialized&lt;/code&gt; without waiting for the &lt;code&gt;initialize&lt;/code&gt;&lt;br&gt;
response (a spec violation) and rust-analyzer simply went quiet: running fine,&lt;br&gt;
zero diagnostics. Then a second one — language servers return &lt;code&gt;file:///c:/...&lt;/code&gt;&lt;br&gt;
with a &lt;strong&gt;lowercase drive letter&lt;/strong&gt;, and I was comparing paths as strings, so every&lt;br&gt;
diagnostic was arriving and being thrown away.&lt;/p&gt;

&lt;p&gt;All three looked like they were working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being straight about the state
&lt;/h2&gt;

&lt;p&gt;Developed on Windows. macOS and Linux build and pass CI, but &lt;strong&gt;nobody has run&lt;br&gt;
the window layer&lt;/strong&gt; (decorations, IME, the installer). The README says untested&lt;br&gt;
rather than supported, and I'd rather keep it that way until someone has.&lt;/p&gt;

&lt;p&gt;It isn't finished so much as &lt;em&gt;mine&lt;/em&gt; — I use it daily and can fix it myself. I&lt;br&gt;
started out unable to remember keybindings and now drive about half of it from&lt;br&gt;
the keyboard. Not because I finally memorised them, but because I could keep&lt;br&gt;
using it while I didn't.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>terminal</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your AGENTS.md is lying to your agent. So was my linter.</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Sun, 23 Aug 2026 01:39:00 +0000</pubDate>
      <link>https://dev.to/hyuga611/your-agentsmd-is-lying-to-your-agent-so-was-my-linter-oc1</link>
      <guid>https://dev.to/hyuga611/your-agentsmd-is-lying-to-your-agent-so-was-my-linter-oc1</guid>
      <description>&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt; rot silently. You write "setup is &lt;code&gt;npm run build&lt;/code&gt;", "the entry point is &lt;code&gt;src/index.ts&lt;/code&gt;", someone renames a script, and &lt;strong&gt;the instruction quietly becomes a lie.&lt;/strong&gt; The agent believes it and breaks things.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/hyuga611/reflint" rel="noopener noreferrer"&gt;reflint&lt;/a&gt;: a linter that ignores wording and style entirely and checks one thing — do the references still resolve? Back-quoted paths against the disk, markdown link targets against the repo, &lt;code&gt;npm run &amp;lt;script&amp;gt;&lt;/code&gt; against &lt;code&gt;package.json&lt;/code&gt;. Zero dependencies, language-agnostic, exits 1 in CI.&lt;/p&gt;

&lt;p&gt;That is the launch paragraph. The rest of this post is what happened after I published it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I ran reflint against 118 public repositories that have an &lt;code&gt;AGENTS.md&lt;/code&gt; or &lt;code&gt;CLAUDE.md&lt;/code&gt;, in both the before and after versions.&lt;/strong&gt; What it found was mostly my own breakage.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A flag I had advertised in a blog post had never done anything
&lt;/h2&gt;

&lt;p&gt;reflint has &lt;code&gt;--code-blocks&lt;/code&gt;. The help text says it makes the tool "also check paths inside fenced code blocks", which implies the default skips them. Months earlier I had written a post saying, in so many words: turn this on and it checks bare paths inside code blocks.&lt;/p&gt;

&lt;p&gt;Across 118 repositories, &lt;strong&gt;the number where the flag changed the result was zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cause was wiring. Only the fourth of four scanners consulted the in-a-fence state. The &lt;code&gt;npm run&lt;/code&gt; scanner, the back-quoted reference scanner and the markdown link scanner never looked at it — they read fenced content by default, so turning the flag on could not add anything. The flag was dead.&lt;/p&gt;

&lt;p&gt;I moved all three inside the fence check. On the same 118 repositories, &lt;strong&gt;10 now change their result depending on the flag.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;pnpm -r build&lt;/code&gt; was reported as "no script named -r"
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AGENTS.md:3   `pnpm -r` — no script "-r" in package.json
AGENTS.md:4   `pnpm --filter` — no script "--filter" in package.json
AGENTS.md:5   `pnpm -w` — no script "-w" in package.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The character class in the script-name regex included &lt;code&gt;-&lt;/code&gt;, so the flag right after &lt;code&gt;pnpm&lt;/code&gt; was read as the script name. &lt;strong&gt;16 of the 39 script-kind findings were this.&lt;/strong&gt; In any repo using pnpm workspaces, correct documentation turned red.&lt;/p&gt;

&lt;p&gt;Those forms run per-workspace-package, so the root &lt;code&gt;package.json&lt;/code&gt; is not the right thing to check them against. reflint now skips the whole invocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. One misplaced fence and it checked nothing, then said everything was fine
&lt;/h2&gt;

&lt;p&gt;Fence state was tracked with the obvious &lt;code&gt;inFence = !inFence&lt;/code&gt; toggle. That breaks on any document that demonstrates a fence by wrapping it in a longer fence: the marker count goes odd and &lt;strong&gt;everything below sticks to "inside a fence" forever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One of the 118 — a 3,461-line &lt;code&gt;AGENTS.md&lt;/code&gt; — did exactly this. Headings and prose were being treated as fenced content.&lt;/p&gt;

&lt;p&gt;Combined with fix #1, this is the dangerous one. Fenced content is skipped by default now, so from the misplaced marker down, &lt;strong&gt;reflint inspects nothing, exits 0, and prints &lt;code&gt;reflint: all references resolve&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a linter that is the worst possible failure, and I had already been here once. In 0.9.2, the CLI installed via &lt;code&gt;npm i -g&lt;/code&gt; or &lt;code&gt;npx&lt;/code&gt; exited 0 without running at all, because the entry-point check compared &lt;code&gt;process.argv[1]&lt;/code&gt; against &lt;code&gt;import.meta.url&lt;/code&gt; and those two never match through a symlink. &lt;strong&gt;"Found no problems" and "never ran" are indistinguishable — to the user and to CI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Closing is now CommonMark's rule: same character, at least as long as the opener, no info string.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you go quiet, say why you went quiet
&lt;/h2&gt;

&lt;p&gt;Every one of those fixes creates a region that is not checked by default. If the count drops, the user cannot tell whether things got fixed or whether the tool stopped looking. That is the same failure again, one level up.&lt;/p&gt;

&lt;p&gt;So reflint now always reports what it held back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reflint: 2 broken references (3 inside code blocks, not checked — run with --code-blocks)
reflint: all references resolve (1 inside code blocks, not checked — run with --code-blocks)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON output gained a &lt;code&gt;skipped&lt;/code&gt; field. HTML comments are not counted — they are disabled text, not content that &lt;code&gt;--code-blocks&lt;/code&gt; would bring back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Across the 118 repositories: &lt;strong&gt;208 findings became 185.&lt;/strong&gt; All 23 that disappeared were noise, and &lt;strong&gt;zero new findings appeared.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For honesty's sake, two other fixes in the same release — HTML comments and indented code blocks — had &lt;strong&gt;zero occurrences in this corpus.&lt;/strong&gt; They are reproducible and I closed them, but they moved no numbers. The three above did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A different shape, same lesson: only the people who wrote it correctly got warned
&lt;/h2&gt;

&lt;p&gt;The release after the audit fixed something else. Agent instruction files are full of prohibitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Never run npm run release; releases are performed by a human.
  → `npm run release` — no script "release" in package.json

Never read or execute `scripts/deprecated.sh`; it was removed after the migration.
  → reference `scripts/deprecated.sh` does not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those are &lt;strong&gt;correctly written documents.&lt;/strong&gt; If you explicitly tell the agent not to use something that was removed, then of course it does not exist — that is the entire point of the sentence. And yet the person who documented it was the only one getting flagged.&lt;/p&gt;

&lt;p&gt;Prohibition lines are no longer scanned as references, and that test runs outside fences only, so a &lt;code&gt;# never edit this&lt;/code&gt; comment inside a block does not silently disqualify the real references under it.&lt;/p&gt;

&lt;p&gt;I have now hit this exact shape in three of my linters. Text rules see &lt;em&gt;mention&lt;/em&gt;, not &lt;em&gt;execution&lt;/em&gt;. The more dangerous a thing is, the more likely a careful author is to name it in order to declare, forbid, or scope it — so &lt;strong&gt;false positives concentrate on the people writing the best documentation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I actually want to leave here
&lt;/h2&gt;

&lt;p&gt;Through all of it, &lt;strong&gt;the test suite was green before any of these fixes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There were tests for &lt;code&gt;--code-blocks&lt;/code&gt;. They asserted that enabling it made the fourth scanner check fenced paths. They did not assert that the other three were skipping them. There were tests for fence tracking. None of them fed in a document with an odd marker count.&lt;/p&gt;

&lt;p&gt;The tests I write only cover the ways I imagined it breaking. 118 repositories of other people's &lt;code&gt;AGENTS.md&lt;/code&gt; cover the ways I did not. Until I ran it against them, I believed my tool worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @hyuga/reflint                 &lt;span class="c"&gt;# auto-detects AGENTS.md / llms.txt / CLAUDE.md&lt;/span&gt;
npx @hyuga/reflint &lt;span class="nt"&gt;--code-blocks&lt;/span&gt;   &lt;span class="c"&gt;# also check inside fenced blocks (this works now)&lt;/span&gt;
npx @hyuga/reflint &lt;span class="nt"&gt;--since&lt;/span&gt; main    &lt;span class="c"&gt;# only files changed against a git ref&lt;/span&gt;
npx @hyuga/reflint &lt;span class="nt"&gt;--format&lt;/span&gt; json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reflint&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;reflint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hyuga611/reflint@v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Findings show up as inline PR annotations. One correction I had to make to my own README: GitHub does not block a merge on a failing check until you mark that check required. Until then it is a red X that anyone can click past.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/hyuga611/reflint" rel="noopener noreferrer"&gt;https://github.com/hyuga611/reflint&lt;/a&gt; — the changelog is written up per release, including the ones where I was wrong.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>devtools</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>I asked one question. All four of my linters failed it.</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Fri, 21 Aug 2026 01:23:33 +0000</pubDate>
      <link>https://dev.to/hyuga611/i-asked-one-question-all-four-of-my-linters-failed-it-1mmm</link>
      <guid>https://dev.to/hyuga611/i-asked-one-question-all-four-of-my-linters-failed-it-1mmm</guid>
      <description>&lt;p&gt;Three separate times, in one of my linters, the same bug came back wearing a different hat: &lt;strong&gt;a text rule was looking at mentions and treating them as execution.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That matters more than it sounds, because of who writes mentions. If you never think about &lt;code&gt;scripts/deprecated.sh&lt;/code&gt;, you never type it. The person who writes &lt;code&gt;Never run scripts/deprecated.sh — it was removed in the migration&lt;/code&gt; is the person being careful. My rules were reading that line as a step and reporting it. &lt;strong&gt;The tool punished exactly the people who did it right.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After the third occurrence I stopped fixing instances and asked whether the shape was in my other tools. It was. In all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;I did not ask for a code review. Reviews come back with style notes. I asked a single adversarial question and gave it the source:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Construct an input that punishes the person who wrote it carefully.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four tools, four hits.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;What the careful writer got&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;skills-lint&lt;/td&gt;
&lt;td&gt;0.9.0&lt;/td&gt;
&lt;td&gt;A prohibition &lt;strong&gt;silenced a real broken reference&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tracklint&lt;/td&gt;
&lt;td&gt;0.7.0&lt;/td&gt;
&lt;td&gt;Comments counted as code — &lt;strong&gt;in both directions&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokenlint&lt;/td&gt;
&lt;td&gt;0.5.0&lt;/td&gt;
&lt;td&gt;React inline styles missed entirely, inflating the headline number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reflint&lt;/td&gt;
&lt;td&gt;0.12.0&lt;/td&gt;
&lt;td&gt;Commands and paths inside prohibition sentences counted as steps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The worst one was not a false positive
&lt;/h2&gt;

&lt;p&gt;skills-lint checks whether a &lt;code&gt;SKILL.md&lt;/code&gt; references files that do not exist. It has a sensible exemption: if the document says the skill &lt;em&gt;produces&lt;/em&gt; a file, that file is not expected to be there yet. &lt;code&gt;PRODUCES&lt;/code&gt; matched on stems like &lt;code&gt;generat&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So this happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Never generate &lt;span class="sb"&gt;`scripts/missing.py`&lt;/span&gt;.
When asked to deploy, execute &lt;span class="sb"&gt;`scripts/missing.py`&lt;/span&gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line 1 registered &lt;code&gt;scripts/missing.py&lt;/code&gt; as an artifact this skill produces. Once a name is registered, references to it are exempt &lt;strong&gt;anywhere in the document&lt;/strong&gt; — including line 2, which is a genuinely broken reference to a file that does not exist.&lt;/p&gt;

&lt;p&gt;The tool printed &lt;code&gt;✓ all clean&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the part I keep thinking about. A false positive is loud: someone opens an issue, you fix it. &lt;strong&gt;A warning that never fires is invisible.&lt;/strong&gt; If a user had hit this, they would have concluded their skill was fine. There is no complaint to receive. The only reason I found it is that I went looking for the input that would cause it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broken in both directions at once
&lt;/h2&gt;

&lt;p&gt;tracklint checks conversion tracking on web forms, and it was matching regexes against raw text. Comments are raw text.&lt;/p&gt;

&lt;p&gt;The direction that punishes the honest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- This site intentionally does not call gtag() or load Google Analytics. --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line convinced the tool the project &lt;em&gt;has&lt;/em&gt; analytics, which switched on every wiring rule. An identical form went from &lt;code&gt;clean&lt;/code&gt; to &lt;code&gt;1 error&lt;/code&gt; because someone documented that they don't track.&lt;/p&gt;

&lt;p&gt;The direction that hides the real problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// gtag must never be called here; conversion tracking is not implemented yet.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Counted as a real call, so an AJAX form with genuinely no success-time tracking stopped reporting &lt;code&gt;ajax-no-conversion&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was a &lt;code&gt;stripComments()&lt;/code&gt; that blanks comments while preserving length and newlines, so line numbers don't shift. I deliberately made it &lt;strong&gt;conservative&lt;/strong&gt; — &lt;code&gt;https://&lt;/code&gt; and &lt;code&gt;src="//cdn..."&lt;/code&gt; are not comments, and over-stripping would make the tool miss the analytics install and go quiet. Between a fix that over-reports and a fix that under-reports, I took over-reporting, for the same reason as above: noise is visible, silence isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a miss flatters your own headline number
&lt;/h2&gt;

&lt;p&gt;tokenlint reports a design-token coverage percentage: &lt;code&gt;tokenized / (tokenized + hardcoded)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its markup scanner only understood quoted HTML attributes, &lt;code&gt;style="..."&lt;/code&gt;. It had never once matched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ff0000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the ordinary way to hardcode a color in React. Files consisting entirely of hardcoded colors reported &lt;strong&gt;zero hardcoded values&lt;/strong&gt; — and because the miss lands in the denominator, every miss pushes the coverage number &lt;em&gt;up&lt;/em&gt;. My tool's own advertised metric was wrong in the flattering direction.&lt;/p&gt;

&lt;p&gt;Worth separating two things: this was a miss, not a judgment call. A &lt;code&gt;//&lt;/code&gt; line comment containing a Tailwind class is still not scanned, and that one stays — &lt;code&gt;//&lt;/code&gt; cannot be told apart from &lt;code&gt;https://&lt;/code&gt; without hiding whole URL lines, which trades a false positive for a miss. That trade I'm making on purpose. The React one I just never checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  One caveat about how this was found
&lt;/h2&gt;

&lt;p&gt;The model I asked could not run &lt;code&gt;node&lt;/code&gt; in its sandbox, so it validated by calling internal functions and reasoning about them. That is not evidence. &lt;strong&gt;Every claim it made, I re-ran through the real CLI myself&lt;/strong&gt; before writing a fix. After publishing, I installed each package fresh from npm and re-ran the failing path against the released build.&lt;/p&gt;

&lt;p&gt;An adversarial reviewer is very good at generating the input you would not have thought of. It is not a substitute for executing that input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two questions worth asking your own tool
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Construct an input that punishes the person who wrote it carefully."&lt;/strong&gt; Better than "review this." It forces a concrete artifact you can run instead of a list of concerns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"What does this change stop reporting?"&lt;/strong&gt; Every one of these bugs was easy to see once framed as a miss, and invisible while framed as a fix. If your tool has a coverage percentage or a clean/dirty verdict, ask which direction a bug in it would move that number.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sources, if you want the actual diffs. The changelogs are written in Japanese, but each entry contains the literal failing input, and that part reads fine in any language:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/hyuga611/skills-lint" rel="noopener noreferrer"&gt;skills-lint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/hyuga611/tracklint" rel="noopener noreferrer"&gt;tracklint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/hyuga611/tokenlint" rel="noopener noreferrer"&gt;tokenlint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/hyuga611/reflint" rel="noopener noreferrer"&gt;reflint&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four were green on their own test suites the entire time. That's the other thing worth saying out loud: a test suite you wrote is a picture of what you already thought of.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I audited 2,465 published agent skills. 192 of them cannot be selected the way the spec says</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Tue, 18 Aug 2026 01:03:53 +0000</pubDate>
      <link>https://dev.to/hyuga611/i-audited-2465-published-agent-skills-192-of-them-cannot-be-selected-the-way-the-spec-says-4k70</link>
      <guid>https://dev.to/hyuga611/i-audited-2465-published-agent-skills-192-of-them-cannot-be-selected-the-way-the-spec-says-4k70</guid>
      <description>&lt;h2&gt;
  
  
  192 skills have no frontmatter at all
&lt;/h2&gt;

&lt;p&gt;An agent picks a skill by reading its &lt;code&gt;description&lt;/code&gt; and deciding whether the request matches. No &lt;code&gt;description&lt;/code&gt;, no selection — whatever else is in the file.&lt;/p&gt;

&lt;p&gt;I drew 2,465 skills at random from a public registry and ran a reference-integrity and portability check over them. &lt;strong&gt;192 of them (7.8%) ship without a single line of YAML frontmatter.&lt;/strong&gt; No &lt;code&gt;name&lt;/code&gt;, no &lt;code&gt;description&lt;/code&gt;. They are published, downloadable, and cannot be chosen the way the specification says skills get chosen.&lt;/p&gt;

&lt;p&gt;I should be careful about what that does and doesn't say. It is a statement about the files, not about any particular runtime — what a given agent does when the string is missing is something I did not measure, and inferring runtime behaviour from files is a mistake I made elsewhere in this same audit (below). What the count says is that in 192 published skills, the input that selection is supposed to run on is not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the breakdown
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1,424 of 2,465 skills (57.8%) have at least one error-level finding.&lt;/strong&gt; Percentages are the share of sampled skills with at least one finding of that kind, not the share of findings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;skills&lt;/th&gt;
&lt;th&gt;of sample&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;frontmatter &lt;code&gt;name&lt;/code&gt; differs from the registry slug&lt;/td&gt;
&lt;td&gt;720&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a back-quoted path or link that does not resolve in the package&lt;/td&gt;
&lt;td&gt;445&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ships with no YAML frontmatter at all&lt;/td&gt;
&lt;td&gt;192&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.8%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;an absolute path that resolves only on the author's machine&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.8%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;malformed &lt;code&gt;allowed-tools&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a provider API key read straight from the environment&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;an external CLI the skill never declares&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;1.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One caveat I want to put next to the top row rather than in a footnote: that 29.2% is not 29.2% broken. The registry deliberately keeps the routable slug separate from the stored display name, and says so in its own docs, so a real share of those are working as intended. What remains worth knowing is that on nearly a third of published skills, the name the agent registers under is not the name the registry shows you.&lt;/p&gt;

&lt;p&gt;The sample is 2,465 drawn uniformly from 69,265 enumerated, seed &lt;code&gt;20260804&lt;/code&gt;. Those 69,265 are the most-recently-updated slice of the registry, not all of it, so every rate above is a rate inside that frame. The harness is published and the seed is fixed; re-run it and you should get the same draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that mattered was my own
&lt;/h2&gt;

&lt;p&gt;Separately from the registry sample, I audited all 46 skills bundled inside &lt;a href="https://github.com/openclaw/openclaw" rel="noopener noreferrer"&gt;openclaw/openclaw&lt;/a&gt; — a repository with 385,000 stars — at a pinned commit.&lt;/p&gt;

&lt;p&gt;The first run produced &lt;strong&gt;219 findings&lt;/strong&gt;. After six precision fixes to my own tools, the same 46 skills produced &lt;strong&gt;59&lt;/strong&gt;. Every genuine defect appears in both runs. So the 160 that vanished were all mine.&lt;/p&gt;

&lt;p&gt;Publishing before that work would have made the census wrong by nearly a factor of four.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had wrong
&lt;/h2&gt;

&lt;p&gt;The big one, 139 of 197 path findings: I resolved references against the skill's own folder instead of the repository it lives in. A skill sitting inside a repo and pointing at &lt;code&gt;packages/&lt;/code&gt; or &lt;code&gt;docs/&lt;/code&gt; got reported as pointing at nothing.&lt;/p&gt;

&lt;p&gt;The rest, listed plainly because the list is the useful part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;openai/gpt-5.4&lt;/code&gt; read as a file path, because &lt;code&gt;.4&lt;/code&gt; looks like an extension.&lt;/li&gt;
&lt;li&gt;An artifact excused on the line that creates it, but reported as a missing reference on the line that reads it back — 16 of 80.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/home/YOUR_USER/…&lt;/code&gt; treated as an author's real home directory.&lt;/li&gt;
&lt;li&gt;Indented frontmatter parsing to nothing, so a skill with both required keys was reported as having neither.&lt;/li&gt;
&lt;li&gt;Sample output inside a fenced code block checked as if it were a live reference — 10.8% of path findings.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Number 5 I found by dogfooding: a skill written to &lt;em&gt;document&lt;/em&gt; these linters could not pass them, because it quoted their own example output.&lt;/p&gt;

&lt;p&gt;Number 1 has a detail I keep thinking about. The guard that was supposed to stop model identifiers being read as paths already existed, with a comment explaining exactly why it existed. The example in that comment ended in a letter. Anything ending in a version number sailed straight through. The comment was right and the code under it was not, and the comment is what stopped me looking.&lt;/p&gt;

&lt;p&gt;A linter dies of false positives, so I treated the false positives as the bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding that looks textbook and isn't
&lt;/h2&gt;

&lt;p&gt;One bundled skill hardcodes &lt;code&gt;/Users/steipete/openclaw&lt;/code&gt; — a maintainer's home directory, in a file shipped to everybody. That is the textbook portability finding, and it is &lt;strong&gt;not a bug&lt;/strong&gt;. That skill is an explicit single-machine runbook for one canonical checkout, and the path is correct there. It was deliberately left out of the upstream report.&lt;/p&gt;

&lt;p&gt;This is the part a percentage cannot carry. A linter produces candidates. Whether a candidate is a defect depends on what the document is &lt;em&gt;for&lt;/em&gt;, and that judgement did not survive automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  One report was closed as &lt;code&gt;not planned&lt;/code&gt;, and closing it was right
&lt;/h2&gt;

&lt;p&gt;I filed two issues upstream. One was rejected, and I agree with the rejection.&lt;/p&gt;

&lt;p&gt;I had argued that a skill could not read the files bundled alongside it. It can: the prompt that hands the skill list to the model spells out the relative-path resolution rule. I had read one file, found no rule in it, and stopped.&lt;/p&gt;

&lt;p&gt;That is worth more than the finding would have been. &lt;strong&gt;Choosing a resolution rule is choosing a definition of "broken," and that definition has to match runtime behaviour, not the filesystem.&lt;/strong&gt; Mine was tuned to the permissive side afterwards, which is why every number above is a floor rather than a ceiling.&lt;/p&gt;

&lt;p&gt;The other report held up: two bundled skills pointed at files deleted in an earlier refactor, so the reads return nothing and the work proceeds without the rules it was supposed to apply — silently, as usual. The fix is merged and the issue is closed as completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not publish, and why
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The corpus.&lt;/strong&gt; 2,465 third-party skill files are other people's work. Re-fetch them with the harness instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 94 author paths.&lt;/strong&gt; Reported as a count, never as a list, with home-directory segments redacted in the example output. Nobody was contacted. Mass-filing issues against individuals, on the strength of a rule with a known false-positive history, would be the wrong move — see the entire middle of this post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproducing
&lt;/h2&gt;

&lt;p&gt;The harness, the seed, and the results are in &lt;a href="https://github.com/hyuga611/agent-skill-audit" rel="noopener noreferrer"&gt;agent-skill-audit&lt;/a&gt;. Data is CC0, harness is MIT.&lt;/p&gt;

&lt;p&gt;The rates above are tied to the tool versions used on the run: tenken 0.2.0, skills-lint 0.7.1, carrylint 0.2.2, reflint 0.8.3. All of them have moved since — precision fixes in some places, new rules in others — so a re-run today would not reproduce the table exactly. That is the reason to publish the harness and not only the numbers.&lt;/p&gt;

&lt;p&gt;Corrections to the numbers themselves are welcome, and I would rather have them in public than not.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>devtools</category>
      <category>testing</category>
    </item>
    <item>
      <title>My linter kept warning the people who did it right. Three times, in the same direction</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:53:09 +0000</pubDate>
      <link>https://dev.to/hyuga611/my-linter-kept-warning-the-people-who-did-it-right-three-times-in-the-same-direction-2j45</link>
      <guid>https://dev.to/hyuga611/my-linter-kept-warning-the-people-who-did-it-right-three-times-in-the-same-direction-2j45</guid>
      <description>&lt;h2&gt;
  
  
  The warning landed on the only people who had done it properly
&lt;/h2&gt;

&lt;p&gt;I maintain a linter that reads agent config files — &lt;code&gt;SKILL.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt; — and fails CI when they bake in something that only works on the author's machine. One of its rules says: if you call an external CLI, declare it, or the next person won't have it.&lt;/p&gt;

&lt;p&gt;Declaring it means naming it in frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;codex&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except that anyone with more than one dependency writes the list form, because that's what YAML is for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;codex&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gemini&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My implementation only read the first shape. So the block list — the normal way, the way you write it the moment you have two of anything — was invisible to the linter, and it warned you for an undeclared CLI that you had, in fact, declared.&lt;/p&gt;

&lt;p&gt;Read that back slowly. Authors who ignored the dependency question entirely were never flagged, because they never wrote a &lt;code&gt;requires:&lt;/code&gt; key at all. Authors who sat down and wrote the contract properly got a warning telling them they hadn't. &lt;strong&gt;The rule was inverted with respect to the thing it was trying to encourage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I shipped that. It went out in a patch release, and I only found it because a commenter used the phrase "dependency contract" and I went to re-read my own implementation of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it happened again. Twice, in one release
&lt;/h2&gt;

&lt;p&gt;Two comments on a post of mine turned into new rules. One of them, &lt;code&gt;unverified-write&lt;/code&gt;, reports a file that changes external state — &lt;code&gt;git push&lt;/code&gt;, &lt;code&gt;npm publish&lt;/code&gt;, an &lt;code&gt;INSERT&lt;/code&gt; — and never reads that state back anywhere.&lt;/p&gt;

&lt;p&gt;Before publishing, I ran it over 586 real skill files pulled from a public registry, found two false-positive shapes in the data, fixed both, and re-measured. Fire rate 0.7%, and every hit I could check by hand was genuine. I felt good about it.&lt;/p&gt;

&lt;p&gt;Then I handed the diff to a different model for a pre-publish read, and it produced this input in about a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Never run &lt;span class="sb"&gt;`git push --force`&lt;/span&gt; from this skill.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a &lt;code&gt;git push&lt;/code&gt; in a code span, in a file with no read-back anywhere. My rule flagged it as an unverified write.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt; are &lt;em&gt;full&lt;/em&gt; of that sentence. Writing down "don't push without asking" is the single most common act of care in that genre of file. I had built a rule that warns you for pushing, specifically because you wrote down that you must not push.&lt;/p&gt;

&lt;p&gt;I fixed it, published, and then noticed the same shape one layer further out. Prohibitions were now excluded, but permissions were not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`git push`&lt;/span&gt; は明示の指示があるときだけ。
&lt;span class="p"&gt;-&lt;/span&gt; Only run &lt;span class="sb"&gt;`git push`&lt;/span&gt; when the user asks.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`npm publish`&lt;/span&gt; requires approval from a maintainer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody who writes those sentences has an unverified write. They have a policy. Three releases, three variations, all pointing the same way: &lt;strong&gt;the warning finds the author who wrote the rule down and misses the one who never mentioned it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it points that way
&lt;/h2&gt;

&lt;p&gt;A text-matching rule cannot see actions. It sees &lt;em&gt;mentions&lt;/em&gt;. And mentions of a dangerous operation are not distributed randomly across authors — they concentrate in the files of people who thought about it.&lt;/p&gt;

&lt;p&gt;The careless author's &lt;code&gt;AGENTS.md&lt;/code&gt; doesn't say &lt;code&gt;git push&lt;/code&gt; anywhere. There is nothing for the rule to catch. The careful author's file says it three times: once to declare when it's allowed, once to forbid the force variant, once in the actual deploy step. Two of those three are not the thing you're detecting, and both of them are evidence of care.&lt;/p&gt;

&lt;p&gt;So the base rate is against you. Among all the files containing the string you match on, the share written by conscientious authors is much higher than in the population — and every false positive you have is drawn from that pool. The people most likely to read your warning carefully, and most likely to uninstall you over it, are the people you are most likely to be wrong about.&lt;/p&gt;

&lt;p&gt;Static analysis has a name for the underlying distinction — use versus mention — and my older rule already knew it. Its CLI check ignores a bare &lt;code&gt;`codex`&lt;/code&gt; in prose and only fires on &lt;code&gt;codex exec build&lt;/code&gt;, an actual invocation with an argument. I wrote that exclusion two releases earlier, in response to the same class of complaint, and then built a new rule without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit that found nothing here
&lt;/h2&gt;

&lt;p&gt;The part I want to be honest about: running against 586 real files did not catch any of this.&lt;/p&gt;

&lt;p&gt;It couldn't. Published, downloadable skills are written to be &lt;em&gt;used&lt;/em&gt;; they say "run this" far more often than "never run this." The prohibition shape lives in team-internal &lt;code&gt;AGENTS.md&lt;/code&gt; files that nobody uploads to a registry. My corpus was real data, and it was the wrong real data — biased, in exactly the direction that hid the failure.&lt;/p&gt;

&lt;p&gt;That's worth separating out, because "test against real data" is advice I've given in writing and still believe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real-data audit tells you what your rule does to &lt;strong&gt;the corpus you can reach&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;An adversarial read tells you what your rule does to &lt;strong&gt;the input someone constructs on purpose&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one found in one pass what 586 files had not. It cost one prompt. If your detector will run on files you can't see — and a linter always does — you need both, and you should assume the corpus is the more comfortable of the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixed it
&lt;/h2&gt;

&lt;p&gt;Not more keywords. The fix was making the use/mention distinction structural, then checking the price:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A write signal only counts &lt;strong&gt;in a code context&lt;/strong&gt; — inside a fence, or inside a backtick span. Prose saying "after that we push to git" is not a step.&lt;/li&gt;
&lt;li&gt;A line carrying a prohibition (&lt;code&gt;never&lt;/code&gt;, &lt;code&gt;do not&lt;/code&gt;, 禁止) isn't a step.&lt;/li&gt;
&lt;li&gt;A line carrying a condition or a permission (&lt;code&gt;only … when&lt;/code&gt;, &lt;code&gt;requires approval&lt;/code&gt;, 〜のときだけ) isn't a step &lt;strong&gt;outside a fence&lt;/strong&gt;. Inside one, the lines are commands, and excluding them there would drop a real finding over an incidental comment like &lt;code&gt;git push origin main   # main only&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the part that isn't optional: re-run the corpus and prove the exclusions didn't eat the signal. Four true positives before, the same four after, fire rate unchanged at 0.7%. An exclusion you didn't measure is just a rule you deleted with extra steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing, since your linter runs on other people's files
&lt;/h2&gt;

&lt;p&gt;The same review turned up something unrelated but worse. My check for a remote copy looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\b(?:scp|rsync)\b[^\n]*\s\S+@\S+:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Greedy fill, then a search for &lt;code&gt;something@something:&lt;/code&gt;. On a long line containing many &lt;code&gt;@&lt;/code&gt; and no colon, it backtracks quadratically: 77ms at 20k characters, 312ms at 40k, and it keeps going up from there. A minified blob or a base64 payload on one line of somebody's repo is enough.&lt;/p&gt;

&lt;p&gt;I had been thinking of the input as "config files people wrote." It isn't. It's &lt;strong&gt;arbitrary text from strangers&lt;/strong&gt;, and a linter that hangs is a linter that stops a stranger's CI. The fix was to stop the filler crossing an &lt;code&gt;@&lt;/code&gt; so there's nothing to backtrack over; an 80,000-character line is now a test case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule of thumb I'm keeping
&lt;/h2&gt;

&lt;p&gt;When a detector matches on text, ask who says that text most often. If the answer is "the people being careful about it," your false positives are not evenly distributed — they're aimed. And the only reliable way to see it is to hand the rule to something that is actively trying to embarrass you, because your own corpus is made of the cases you already knew about.&lt;/p&gt;

&lt;p&gt;The tools: &lt;a href="https://github.com/hyuga611/carrylint" rel="noopener noreferrer"&gt;carrylint&lt;/a&gt; is the linter above; the read-back half it deliberately doesn't attempt is &lt;a href="https://github.com/hyuga611/genchi" rel="noopener noreferrer"&gt;genchi&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devtools</category>
      <category>ai</category>
      <category>opensource</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>That amazing skill you shared only runs on your machine. I built a linter for it — then found I was 85% wrong</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Mon, 17 Aug 2026 02:09:29 +0000</pubDate>
      <link>https://dev.to/hyuga611/that-amazing-skill-you-shared-only-runs-on-your-machine-i-built-a-linter-for-it-then-found-i-was-2dc2</link>
      <guid>https://dev.to/hyuga611/that-amazing-skill-you-shared-only-runs-on-your-machine-i-built-a-linter-for-it-then-found-i-was-2dc2</guid>
      <description>&lt;h2&gt;
  
  
  I mass-produced skills by saying "turn this into a skill." Then I shared them
&lt;/h2&gt;

&lt;p&gt;With Claude Code or Codex, any procedure that worked can become a reusable &lt;code&gt;SKILL.md&lt;/code&gt; just by asking "turn what we just did into a skill." I did this for image generation, deploy steps, everything. It was great.&lt;/p&gt;

&lt;p&gt;Where it fell apart was handing those skills to teammates. They ran on my machine and silently died on theirs. Every time I dug in, it was the same shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Output path hardcoded to &lt;code&gt;C:\Users\atlan\Downloads\out.png&lt;/code&gt; (my home directory)&lt;/li&gt;
&lt;li&gt;The body calls &lt;code&gt;codex exec ...&lt;/code&gt; with no instruction anywhere for installing that CLI&lt;/li&gt;
&lt;li&gt;Assumes &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; is already set, with no guidance when it isn't&lt;/li&gt;
&lt;li&gt;A model id like &lt;code&gt;gpt-image-2&lt;/code&gt; written inline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them are the author's environment baked in. None of them raise an error — they just fail quietly in the next person's hands, which is the worst kind.&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;carrylint&lt;/code&gt;, a linter that fails CI on exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard made the &lt;em&gt;format&lt;/em&gt; portable. Whether the contents run is a separate question
&lt;/h2&gt;

&lt;p&gt;In December 2025, Anthropic made Agent Skills an open standard. One &lt;code&gt;SKILL.md&lt;/code&gt; now runs across Claude Code, Codex, Gemini CLI, Cursor, Copilot, and 20-odd others. &lt;strong&gt;Format&lt;/strong&gt; portability is solved.&lt;/p&gt;

&lt;p&gt;But a standard only guarantees the shape of the container. If the inside holds an absolute path or an undeclared CLI, the container is valid and the contents still don't run for anyone else. I couldn't find a tool looking at that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;reflint&lt;/strong&gt; (also mine) asks: do the references exist?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;skills-lint&lt;/strong&gt; (also mine) asks: do skills collide, is the frontmatter valid?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;carrylint&lt;/strong&gt; asks: do the references resolve &lt;strong&gt;in a different environment, under a different model?&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure class is inverted. The others check "is this correct as specified." carrylint checks "will the next person install this and have it actually run."&lt;/p&gt;

&lt;h2&gt;
  
  
  What it catches
&lt;/h2&gt;

&lt;p&gt;Run it against a deliberately non-portable sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ examples/bad/leaky-image-gen/SKILL.md — 3 errors / 3 warnings
  ✗ :16  [abs-path] machine-specific absolute path `C:\Users\alice\Downloads\out.png`
         — will not resolve on anyone else's machine (use a relative path or {baseDir})
  • :16  [undeclared-cli] calls `codex` but never declares or installs it
  ✗ :22  [abs-path] machine-specific absolute path `C:\Users\alice\Downloads\out.png`
  • :25  [provider-env] assumes `OPENAI_API_KEY` is set
  ✗ :27  [placeholder] unresolved placeholder `&amp;lt;FILL_ME&amp;gt;` left in a shipped file
  • :29  [todo] TODO/FIXME marker left in a shipped file

carrylint: 3 errors / 3 warnings
exit code: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules are split by severity. False positives are what get a linter uninstalled, so &lt;strong&gt;only things the next person will hit, with no room for interpretation&lt;/strong&gt;, are &lt;code&gt;error&lt;/code&gt; (which fails the PR). I moved that line a lot after shipping — see below.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;error&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Author-environment absolute paths (&lt;code&gt;C:\…&lt;/code&gt;, &lt;code&gt;/Users/&amp;lt;realname&amp;gt;/&lt;/code&gt;); unfinished markers (&lt;code&gt;&amp;lt;FILL_ME&amp;gt;&lt;/code&gt;, &lt;code&gt;REPLACE_ME&lt;/code&gt;). &lt;code&gt;$HOME&lt;/code&gt;, &lt;code&gt;~&lt;/code&gt;, &lt;code&gt;YOUR_API_KEY&lt;/code&gt;, &lt;code&gt;/path/to/&lt;/code&gt; are &lt;strong&gt;excluded&lt;/strong&gt; — they're portable or a documentation convention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;warn&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Undeclared external CLIs (host commands like &lt;code&gt;claude mcp add&lt;/code&gt; excluded); raw provider-specific env references; leftover &lt;code&gt;TODO:&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;opt-in&lt;/td&gt;
&lt;td&gt;Hardcoded model ids (&lt;code&gt;claude-*&lt;/code&gt;, &lt;code&gt;gpt-*&lt;/code&gt;) — deliberate pinning is legitimate, so off by default&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No LLM and no API key at runtime — pure static analysis. Since the criterion &lt;em&gt;is&lt;/em&gt; "are you locked into one environment or model", it works identically whether the skill was written by Claude or Codex. The tool embodies the thing it checks for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I ran it against 230 real skills and learned I was 85% wrong
&lt;/h2&gt;

&lt;p&gt;This is the part I most want to be honest about. I had shipped it, but something nagged before I promoted it: &lt;strong&gt;my own examples and my own tests passing proves nothing.&lt;/strong&gt; So I collected &lt;strong&gt;230 real &lt;code&gt;SKILL.md&lt;/code&gt; / &lt;code&gt;AGENTS.md&lt;/code&gt; files&lt;/strong&gt; from public GitHub repos and ran carrylint against them untouched.&lt;/p&gt;

&lt;p&gt;The good half. &lt;strong&gt;Skills that only run for their author are genuinely out there.&lt;/strong&gt; One PPT-generation skill hardcoded &lt;code&gt;/Users/guohao/Documents/...&lt;/code&gt; — the author's Mac path — into the body. Another used &lt;code&gt;C:/Users/vudrk/Desktop/AI Projects/&lt;/code&gt; as the base for every script. None of these error out; they die quietly for the next person. carrylint's reason to exist was sitting in the real data.&lt;/p&gt;

&lt;p&gt;The bad half. &lt;strong&gt;About 85% of my errors were false positives.&lt;/strong&gt; I was flagging &lt;code&gt;Bearer YOUR_API_KEY&lt;/code&gt; (the standard API-docs convention for "put your key here") as unfinished. I was flagging &lt;code&gt;claude mcp add&lt;/code&gt; — the host itself — as an undeclared CLI. I was flagging &lt;code&gt;$HOME/...&lt;/code&gt;, which resolves per user and is the &lt;em&gt;portable&lt;/em&gt; way to write it, as a machine-specific absolute path. I'd written that false positives are a linter's only cause of death, and then nearly shipped promotion on top of an 85% false-positive rate.&lt;/p&gt;

&lt;p&gt;Luckily the real data named exactly what to fix. &lt;strong&gt;v0.1.1&lt;/strong&gt; corrected four things: &lt;code&gt;$HOME&lt;/code&gt;/&lt;code&gt;~&lt;/code&gt;/generic names are portable; placeholders narrowed to genuine fill-me markers; host CLI setup commands (&lt;code&gt;claude mcp add&lt;/code&gt; etc.) excluded; the home-relative path rule dropped entirely. Re-run against the same 230: &lt;strong&gt;error false positives went from ~85% to near zero, and every true positive survived.&lt;/strong&gt; I then ran it against 70 repos it had never seen to check I hadn't overfit (3% fired, all genuine). The real examples I found are bundled in the repo as regression tests.&lt;/p&gt;

&lt;p&gt;One lesson: &lt;strong&gt;your own tests passing is not evidence of correctness. You find out by running against real data — and by doing it before you promote, not after.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Honestly: this niche is already crowded
&lt;/h2&gt;

&lt;p&gt;One more admission. Partway through designing this I went looking, and there were already 7+ linters for &lt;code&gt;SKILL.md&lt;/code&gt; as of 2026. My own skills-lint is one of them. This was not an empty lot.&lt;/p&gt;

&lt;p&gt;But I read the ones I could find, and &lt;strong&gt;none of them looked at whether the contents actually run somewhere else.&lt;/strong&gt; They stop at spec compliance and frontmatter. So carrylint does only that. Teams that mix Claude and Codex &lt;em&gt;and&lt;/em&gt; distribute skills to each other are honestly still rare — the demand may be slightly ahead of its time. Still, if "I shared it and it only ran for me" has ever stung you, the low-noise error rules (sharpened by that audit) should earn their place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The standard made &lt;code&gt;SKILL.md&lt;/code&gt; portable as a format. Whether it runs is a different question. carrylint fails CI on that difference.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hyuga611/carrylint@v0&lt;/span&gt;     &lt;span class="c1"&gt;# in CI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @hyuga/carrylint              &lt;span class="c"&gt;# right now, locally&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/hyuga611/carrylint" rel="noopener noreferrer"&gt;https://github.com/hyuga611/carrylint&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>githubactions</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Don't trust "Done." — forcing AI agents to re-fetch reality before they report completion</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Sun, 16 Aug 2026 02:44:59 +0000</pubDate>
      <link>https://dev.to/hyuga611/dont-trust-done-forcing-ai-agents-to-re-fetch-reality-before-they-report-completion-3hk9</link>
      <guid>https://dev.to/hyuga611/dont-trust-done-forcing-ai-agents-to-re-fetch-reality-before-they-report-completion-3hk9</guid>
      <description>&lt;h2&gt;
  
  
  "Inserted the rows. Done." — except not a single row had landed
&lt;/h2&gt;

&lt;p&gt;I hand a lot of my client work to AI agents. Production deploys, report generation, bulk data inserts. Every procedure that works gets turned into a skill, and by now a few dozen skills run my day-to-day.&lt;/p&gt;

&lt;p&gt;The one that broke me was a bulk insert. At the end, the agent said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Inserted N rows. Done."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A completion report like any other. I almost took it. I opened the admin panel just in case — &lt;strong&gt;not one row had been added.&lt;/strong&gt; The insert command had failed partway through, the error had been swallowed, and the agent had confidently reported completion anyway.&lt;/p&gt;

&lt;p&gt;That was a cold moment. What if it had been a number in a report? A deliverable going to a client? &lt;strong&gt;How many "done" reports had I already waved through that were never actually done?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the nastiest species of hallucination. It isn't a plausible-sounding sentence. &lt;strong&gt;It's the fabrication of the fact that the work was completed at all.&lt;/strong&gt; And humans don't interrogate a confident "Done."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents lie (it isn't malice)
&lt;/h2&gt;

&lt;p&gt;Chase the cause and it isn't that the model is dishonest. It's that &lt;strong&gt;acting and checking are the same step.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One turn of an LLM agent looks roughly like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Call a tool (run the insert)&lt;/li&gt;
&lt;li&gt;Look at the return value, generate the next sentence&lt;/li&gt;
&lt;li&gt;Write "Done."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 is the problem. When the tool's return value is empty, ambiguous, or a timeout, the model writes &lt;strong&gt;the most likely continuation — "it probably worked."&lt;/strong&gt; In the training data, the most natural thing to follow "ran the insert" is "done", so of course that's what comes out.&lt;/p&gt;

&lt;p&gt;The agent is &lt;strong&gt;claiming completion without ever looking at the world it just changed.&lt;/strong&gt; Having never looked, it can't notice the failure either.&lt;/p&gt;

&lt;h2&gt;
  
  
  There's one fix: "done" may only be claimed from re-fetched real state
&lt;/h2&gt;

&lt;p&gt;The countermeasure is simple. &lt;strong&gt;An operation with side effects must re-read the state of the world afterward and confirm it, before anything is allowed to say "done."&lt;/strong&gt; I call this the &lt;em&gt;completion contract&lt;/em&gt; and made it a shared rule across all my skills. It's three lines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Anything with side effects — create, update, delete, upload, insert — re-fetches existence and state with a separate command before saying "done."&lt;/strong&gt; Inserted rows? Count them again. Uploaded a file? Hit the URL. Created a record? Look it up by id. &lt;strong&gt;Never use the return value of the same tool call as evidence&lt;/strong&gt; — that's exactly where "I think I did it" breeds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty output, errors, and timeouts are never filled in.&lt;/strong&gt; If a count, path, or id couldn't be read, don't guess it — report "empty" or "failed" as-is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers that go into a ledger or report must be values confirmed by a re-fetch.&lt;/strong&gt; Anything unconfirmed doesn't get written.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put differently: &lt;strong&gt;the burden of proof moves to the agent.&lt;/strong&gt; Not "it should have worked" but "I re-read it just now with my own eyes and it is there."&lt;/p&gt;

&lt;p&gt;Here's the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (I think I did it)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; Inserting the data
[tool] db_insert(...)   ← empty return value, but not conclusively an error
&amp;gt; Inserted N rows. Done.   ← claimed without looking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (completion contract)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; Inserting the data
[tool] db_insert(...)   ← empty return value
&amp;gt; The insert returned nothing. Verifying that the rows exist.
[tool] db_count(where=...)   ← re-fetch
&amp;gt; 0 matching rows. The insert did not land (failed).
  Want me to check the error log and retry?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second agent can't lie. &lt;strong&gt;The moment you insert a step that goes and looks at the world afterward, "I think I did it" stops being physically possible.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the existing guardrails don't cover this
&lt;/h2&gt;

&lt;p&gt;You might think a guardrail library already handles it. I looked — guardrails-ai, deepeval, promptfoo, a few others. They're good tools. &lt;strong&gt;They just guard a different place.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What they validate is essentially the &lt;strong&gt;text the LLM produced&lt;/strong&gt;: is the format right, is it harmful, is it factually consistent, is it close to the expected answer. They grade &lt;strong&gt;the content of the output.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But the "I think I did it" hallucination is &lt;em&gt;flawless&lt;/em&gt; as text. "Inserted N rows. Done." is grammatical and internally consistent. No amount of grading the text catches it — &lt;strong&gt;because what's wrong isn't the text, it's the state of the world after the action.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's a gap here. &lt;strong&gt;There are piles of tools that verify output, but as far as I could find, nothing that re-fetches the world after an action and reconciles the report against reality.&lt;/strong&gt; We've reached the point where agents actually rewrite the world, and verification stopped at "what did the agent say."&lt;/p&gt;

&lt;h2&gt;
  
  
  You can do this today, with no library
&lt;/h2&gt;

&lt;p&gt;The most important part first: &lt;strong&gt;this contract works right now, with no dependency.&lt;/strong&gt; Drop the three rules into your agent's system prompt, &lt;code&gt;CLAUDE.md&lt;/code&gt;, or &lt;code&gt;AGENTS.md&lt;/code&gt; as a single paragraph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Completion contract&lt;/span&gt;
An operation with side effects (create, update, delete, upload, insert) may not be
reported as complete until a separate command has re-fetched the resulting state and
the raw result has been shown. Empty output, errors, and timeouts are reported as
"empty" or "failed" as-is — never filled in with an imagined id, path, or count.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone visibly reduced false completions in my setup. Zero cost, zero dependencies. If you only try one thing, try this.&lt;/p&gt;

&lt;h2&gt;
  
  
  But prompts don't hold
&lt;/h2&gt;

&lt;p&gt;Here's what I learned running it: &lt;strong&gt;discipline written into a prompt gets quietly broken on a busy turn.&lt;/strong&gt; As context grows, the model steps over that paragraph "by accident" and starts saying "Done." again. Like a human promising to be careful, a declaration gets broken.&lt;/p&gt;

&lt;p&gt;At some point you want the discipline &lt;strong&gt;enforced, not just written&lt;/strong&gt; — the same way you replace a verbal note in code review with a linter that mechanically fails CI. I wanted "did you actually re-fetch before saying done?" backed by machinery instead of good intentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built the piece that enforces it — &lt;code&gt;genchi&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I published a small piece that backs the completion contract with machinery rather than goodwill: &lt;code&gt;genchi&lt;/code&gt; (現地現物 — &lt;em&gt;go and see the actual thing&lt;/em&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @hyuga/genchi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does exactly one thing: &lt;strong&gt;run a probe that re-fetches real state, and issue a verdict from nothing else.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@hyuga/genchi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// the side effect&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insert 45 rows&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="c1"&gt;// ← re-reads real state, not the action's return value&lt;/span&gt;
  &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// reaching this line means the 45 rows are really there. Otherwise it threw GenchiIncomplete.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The crux is that &lt;strong&gt;&lt;code&gt;verify&lt;/code&gt; / &lt;code&gt;gate&lt;/code&gt; accept nothing but a probe.&lt;/strong&gt; The evidence has to come from calling something at the moment completion is asserted, not from a value handed in beside the claim. Empty results, errors, and timeouts aren't swallowed; they're reported as failures rather than imagined into successes. A returned count of 0 (nothing landed) counts as incomplete too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing that requirement does not buy, and I claimed it did.&lt;/strong&gt; Until 0.3.0 the README said this made "I think I did it" &lt;em&gt;structurally unwritable&lt;/em&gt;. It is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;doTheInsert&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// suppose nothing landed&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insert 45 rows&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="na"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the action's own return value&lt;/span&gt;
               &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// → ok: true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A probe is a function, and nothing in JavaScript can force a function to do I/O. Worse, the CLI printed &lt;code&gt;re-fetched: 45&lt;/code&gt; for a &lt;code&gt;--probe "echo 45"&lt;/code&gt; that re-fetched nothing — a tool whose entire subject is "don't report what you didn't check", asserting in its own output a thing it had not checked. Both are corrected in 0.3.0: the wording is now &lt;em&gt;the probe returned&lt;/em&gt;, and &lt;code&gt;--help&lt;/code&gt; states the limit without being asked.&lt;/p&gt;

&lt;p&gt;What requiring a probe actually buys is a &lt;em&gt;place&lt;/em&gt; to put the re-read — an expression somebody wrote on purpose — plus refusals that don't get quietly swallowed. That is worth having. It is less than I wrote down, and the difference is the sort of thing you only find by attacking your own package instead of re-reading it.&lt;/p&gt;

&lt;p&gt;There's a CLI for agents that don't write JS. Hand it a re-fetch command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;genchi verify &lt;span class="nt"&gt;--probe&lt;/span&gt; &lt;span class="s2"&gt;"psql -tAc 'select count(*) from t where batch=123'"&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 45
&lt;span class="c"&gt;# exit 0=verified / 1=empty or mismatched / 3=probe failed. Raw probe output is always emitted as evidence.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Claude Code, a Stop hook can block a turn that still has unverified completion contracts on it (&lt;code&gt;adapters/claude-code&lt;/code&gt;). No LLM and no API key at runtime — it's a zero-dependency static piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honestly: this may be slightly ahead of demand
&lt;/h2&gt;

&lt;p&gt;Let me be straight. As far as I could find, "re-fetch the world after the action and verify" was a genuine gap. But I don't think that many people have yet been burned specifically by &lt;em&gt;fabricated completion&lt;/em&gt; while letting agents rewrite real things. The demand may be a little ahead of its time.&lt;/p&gt;

&lt;p&gt;I still built it &lt;strong&gt;framework-agnostic&lt;/strong&gt; on purpose — the Claude Code hook is pushed out to a thin adapter and the core works from any agent. I have a linter (carrylint) whose whole message is "don't bake your environment in", so it would be incoherent for my own tool to be Claude-Code-only. Unlike my static linters (reflint for reference integrity, skills-lint for skill collisions, carrylint for runtime portability), this is the first piece of mine that &lt;strong&gt;goes and looks at the world at runtime.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've ever had that cold moment over something that was reported done and wasn't, it should land.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The worst hallucination from an AI agent isn't prose — it's &lt;strong&gt;the fabrication of having completed the work.&lt;/strong&gt; The cause is that acting and checking are the same step.&lt;/li&gt;
&lt;li&gt;There's one fix: &lt;strong&gt;"done" may only be claimed from re-fetched real state.&lt;/strong&gt; Empty and failed get reported as empty and failed.&lt;/li&gt;
&lt;li&gt;Existing guardrails validate &lt;strong&gt;text output&lt;/strong&gt;. &lt;strong&gt;Re-fetching world state after an action and reconciling it&lt;/strong&gt; is the gap.&lt;/li&gt;
&lt;li&gt;The contract &lt;strong&gt;works today as a single prompt paragraph.&lt;/strong&gt; To enforce it in code: &lt;code&gt;npm i @hyuga/genchi&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Go doubt one more "Done."&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/hyuga611/genchi" rel="noopener noreferrer"&gt;https://github.com/hyuga611/genchi&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>llm</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I attacked my own npm package before launching it. It let the proposer approve their own writes</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Thu, 13 Aug 2026 10:13:32 +0000</pubDate>
      <link>https://dev.to/hyuga611/i-attacked-my-own-npm-package-before-launching-it-it-let-the-proposer-approve-their-own-writes-4mki</link>
      <guid>https://dev.to/hyuga611/i-attacked-my-own-npm-package-before-launching-it-it-let-the-proposer-approve-their-own-writes-4mki</guid>
      <description>&lt;p&gt;I maintain a library whose entire job is one sentence: &lt;em&gt;a person who did not write this statement looked at what it actually does, and said yes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An LLM proposes an &lt;code&gt;UPDATE&lt;/code&gt;. The library runs it for real inside a transaction, reads the before and after values back out of the database, rolls back, and shows a human the measurement. Not a summary the model wrote about its own SQL — the values the database produced when the statement ran.&lt;/p&gt;

&lt;p&gt;Last week I found out it was letting the proposer be the person who said yes. And writing "approved" into the audit trail when they did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I attacked it in the first place
&lt;/h2&gt;

&lt;p&gt;I saw someone get taken apart in a &lt;code&gt;shellcheck&lt;/code&gt; issue thread. They had posted an alternative tool, and the reply was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A cursory glance also tells you it's a vibecoded clone of shellcheck.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One glance. That's the bar now, and my GitHub profile fits the pattern that gets you that reply — eleven repositories published in a month. I do have tests. 386 of them, passing, including 161 against real MySQL 8.4 and PostgreSQL 16 in containers.&lt;/p&gt;

&lt;p&gt;But a passing test suite only contains the attacks you already thought of. So before launching I installed my own published package with &lt;code&gt;npx&lt;/code&gt;, as a stranger, and went looking for a way to break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first thing I tried worked
&lt;/h2&gt;

&lt;p&gt;I used the example from my own README — a privilege escalation riding along inside a quota change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;llm-safe-sql plan &lt;span class="s2"&gt;"UPDATE members SET quota = quota + 10,
&lt;/span&gt;&lt;span class="go"&gt;    profile = '{\"role\":\"admin\"}' WHERE id = 7" --as kenji

Measured by running the statement and rolling it back
  id = 7
&lt;/span&gt;&lt;span class="gp"&gt;      quota: 5 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;15
&lt;span class="gp"&gt;      profile: {"role":"user"} -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;:&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="go"&gt;
This needs a person. Neither the assistant nor this tool can approve it:
&lt;/span&gt;&lt;span class="gp"&gt;  llm-safe-sql approve &amp;lt;id&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--as&lt;/span&gt; you@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool says it out loud: &lt;em&gt;this needs a person, neither the assistant nor this tool can approve it.&lt;/em&gt; So I approved it as the same person who proposed it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;llm-safe-sql approve &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--as&lt;/span&gt; kenji
&lt;span class="go"&gt;Approved.

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;llm-safe-sql apply &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--as&lt;/span&gt; kenji
&lt;span class="go"&gt;Applied: UPDATE on members, 1 row(s).
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Committed. &lt;code&gt;quota=15&lt;/code&gt;, &lt;code&gt;role=admin&lt;/code&gt;, confirmed by querying the database directly rather than trusting the tool's own output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The commit wasn't the frightening part
&lt;/h2&gt;

&lt;p&gt;The audit table was.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;planned    actor=kenji&lt;/span&gt;
&lt;span class="py"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;approved   actor=kenji&lt;/span&gt;
&lt;span class="py"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;applied    actor=kenji&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's an &lt;code&gt;approved&lt;/code&gt; row. Anybody reading that trail later concludes a human reviewed this change. Nobody read anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing audit trail is better than that.&lt;/strong&gt; A missing one tells you it's missing. A complete one that describes a review which never happened is a record that decisions get built on top of. I had shipped a machine that stores the absence of review &lt;em&gt;as&lt;/em&gt; review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I can't excuse
&lt;/h2&gt;

&lt;p&gt;I had thought about this attack. Just not all of it.&lt;/p&gt;

&lt;p&gt;My spec document already said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;P5&lt;/strong&gt; — This library's own plan and audit tables are refused &lt;strong&gt;regardless of configuration&lt;/strong&gt;. &lt;em&gt;A model that can write the plan table can approve its own writes.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I knew self-approval was the thing to prevent. I closed the indirect path — tamper with the plan table to mark something approved — and left the front door open. You just run &lt;code&gt;approve&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The README's answer to "the model can't approve" was that &lt;code&gt;approve&lt;/code&gt; lives in a separate process the model has no path to. That's true of the deployment I recommend. It is not true of the one &lt;code&gt;npx&lt;/code&gt; gives you, and nobody starts at the recommended deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the check was the interesting part
&lt;/h2&gt;

&lt;p&gt;The comparison itself is three lines. The judgement calls were:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignore case and surrounding whitespace.&lt;/strong&gt; A check that &lt;code&gt;--as Kenji&lt;/code&gt; walks past is theatre. It refuses the honest caller and waves through anyone who hit shift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't get clever about matching.&lt;/strong&gt; &lt;code&gt;alice@example.com&lt;/code&gt; &lt;em&gt;can&lt;/em&gt; approve a plan proposed by &lt;code&gt;alice&lt;/code&gt;. Refusing it because it contains the string would lock out a legitimate second reviewer, and a security check that blocks honest use is a security check that gets switched off. I made it deliberately dumb.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship an escape hatch.&lt;/strong&gt; Some people really do hold both roles — a solo operator with nobody to hand the card to. &lt;code&gt;--allow-self-approve&lt;/code&gt; exists. It approves the plan and leaves &lt;em&gt;both&lt;/em&gt; acts under the one name in the audit trail. It buys you an apply. It does not buy you a tidier story about who reviewed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I nearly shipped something worse
&lt;/h2&gt;

&lt;p&gt;Fix written, tests green, about to publish. And I realised:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--as&lt;/code&gt; is self-asserted.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My new check compares two strings handed to the same process from the same untrusted place. It stops one identity running both halves — an agent and its operator sharing &lt;code&gt;$USER&lt;/code&gt;, which is exactly what a single terminal gives you and exactly how the plausible-looking audit trail gets manufactured by accident. It does nothing about a person who types a different name.&lt;/p&gt;

&lt;p&gt;And my own README says this, in a section about which guards are real:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Most of what this library does runs &lt;em&gt;inside this process&lt;/em&gt;, holding a credential that can write. That is worth saying out loud, because the alternative is an operator believing in a boundary that turns out to be &lt;strong&gt;one &lt;code&gt;if&lt;/code&gt; statement in a library they have never read.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had just written an &lt;code&gt;if&lt;/code&gt; statement. Shipping it quietly would have meant the release that adds the guard also commits the exact defect that paragraph exists to prevent.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;check&lt;/code&gt; now says it every run, in every configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;! `--as` is taken at its word: nothing here authenticates anybody.
  So the refusal that stops a proposer approving their own plan catches
  one identity running both halves — an agent and its operator sharing
  $USER, which is what a single terminal gives you — and does not catch
  a person who types a different name.
  Actor separation is a record, not a boundary.
  The boundary is applyConnection: a database account the proposing side
  has no password for.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State what the guard buys and what it doesn't, before anyone asks. What it buys is that a silent non-review becomes a refusal. What it does not buy is authorisation. The only identity here that means anything is a database account the proposing side has no password for. There's a test pinning that line in place, because if it silently disappeared nobody would notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing I deliberately did not fix
&lt;/h2&gt;

&lt;p&gt;The same audit turned up that anyone can cancel anyone else's plan.&lt;/p&gt;

&lt;p&gt;I left it. Cancelling only ever prevents an apply, the MCP surface doesn't expose it, and everyone who can reach it can already write to the plan table directly. Adding a name check there would put a second authorisation-shaped string comparison next to one that already needs a paragraph explaining it isn't authorisation.&lt;/p&gt;

&lt;p&gt;It's written into the spec under "out of scope" so it reads as a decision rather than a gap somebody closes by reflex.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run something like this
&lt;/h2&gt;

&lt;p&gt;Try approving a plan under the same identity that proposed it. If it goes through, your audit log is recording reviews that did not happen.&lt;/p&gt;

&lt;p&gt;If you were on 0.5.2 or earlier of mine, this finds the affected records — verified on MySQL 8.4, PostgreSQL 16 and SQLite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;proposed_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logged_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detail&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;llm_safe_sql_audit&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;llm_safe_sql_audit&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'planned'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logged_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rows that come back were committed on one person's word while the trail reads as though two people were involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human-in-the-loop only means anything if the loop checks that the approver is somebody else.&lt;/strong&gt; Mine didn't, from the first release through 0.5.2, while saying otherwise on the tin. The test count had nothing to say about it — the hole showed up the first time I installed my own package as a stranger and typed the laziest possible thing.&lt;/p&gt;

&lt;p&gt;Fixed in 0.6.0.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/hyuga611/llm-safe-sql" rel="noopener noreferrer"&gt;https://github.com/hyuga611/llm-safe-sql&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;code&gt;@hyuga/llm-safe-sql&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>security</category>
      <category>database</category>
    </item>
    <item>
      <title>Your agent can write to your database. Which layer is actually stopping it?</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:11:10 +0000</pubDate>
      <link>https://dev.to/hyuga611/your-agent-can-write-to-your-database-which-layer-is-actually-stopping-it-2pfm</link>
      <guid>https://dev.to/hyuga611/your-agent-can-write-to-your-database-which-layer-is-actually-stopping-it-2pfm</guid>
      <description>&lt;p&gt;In July 2025, &lt;a href="https://fortune.com/2025/07/23/ai-coding-tool-replit-wiped-database-called-it-a-catastrophic-failure" rel="noopener noreferrer"&gt;Replit's agent deleted a live production database&lt;/a&gt;&lt;br&gt;
during an explicit code and action freeze. Replit's CEO called it unacceptable&lt;br&gt;
and shipped dev/prod separation and better rollback in response.&lt;/p&gt;

&lt;p&gt;The freeze was real. It was written down, it was agreed, and the agent had been&lt;br&gt;
told about it — and none of that was in the execution path. The instruction lived&lt;br&gt;
in the model's context, and the model's context is not a place where rules are&lt;br&gt;
enforced.&lt;/p&gt;

&lt;p&gt;There is a second detail worth sitting with. Afterwards, the agent reported that&lt;br&gt;
rollback was impossible. That was wrong, and it delayed the recovery. So the&lt;br&gt;
system's own account of what it had done was not merely unhelpful; it was&lt;br&gt;
confidently false in the direction that mattered.&lt;/p&gt;

&lt;p&gt;That incident is the shortest version of the question this post is about. Every&lt;br&gt;
tool in this space has a guardrail. They differ enormously in &lt;strong&gt;which layer that&lt;br&gt;
guardrail lives at&lt;/strong&gt;, and almost none of them tell you.&lt;/p&gt;

&lt;p&gt;I went looking for a comparison organised that way and could not find one, so&lt;br&gt;
here is mine. I have a tool in this space myself; it is at the end, alternatives&lt;br&gt;
to it are named before it, and I have tried to describe everything else the way&lt;br&gt;
its maintainers would.&lt;/p&gt;

&lt;p&gt;An earlier version of this post was reviewed by people looking specifically for&lt;br&gt;
places where I had flattered my own tool or misdescribed someone else's. They&lt;br&gt;
found several. The corrections are in the text rather than in a footnote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder
&lt;/h2&gt;

&lt;p&gt;There are five places a rule about database writes can be enforced. They are not&lt;br&gt;
equally good, and the difference is not a matter of degree.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Enforced by&lt;/th&gt;
&lt;th&gt;Gets past it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Model context&lt;/td&gt;
&lt;td&gt;prompt text, tool descriptions&lt;/td&gt;
&lt;td&gt;the model, by ignoring it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Client&lt;/td&gt;
&lt;td&gt;the IDE or chat app's approval dialog&lt;/td&gt;
&lt;td&gt;the user, by clicking "always allow"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Server process&lt;/td&gt;
&lt;td&gt;the MCP server's own code&lt;/td&gt;
&lt;td&gt;anyone holding the credential it uses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Proxy / gateway&lt;/td&gt;
&lt;td&gt;a network hop&lt;/td&gt;
&lt;td&gt;connecting directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Database&lt;/td&gt;
&lt;td&gt;roles, privileges, engine settings&lt;/td&gt;
&lt;td&gt;nothing, short of another credential&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The useful property of this ladder: &lt;strong&gt;each rung survives the failure of every&lt;br&gt;
rung above it.&lt;/strong&gt; A database role with no write privileges does not care whether&lt;br&gt;
your allowlist has a parsing bug, whether the user clicked "always allow", or&lt;br&gt;
whether the model was talked into something by a support ticket it read.&lt;/p&gt;

&lt;p&gt;Most of the disagreement in this space turns out to be tools sitting at different&lt;br&gt;
rungs while using the same words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: the model's context
&lt;/h2&gt;

&lt;p&gt;This is tool descriptions, system prompts, and instructions returned inside tool&lt;br&gt;
results — "only use this for read queries", "ask the user before applying".&lt;/p&gt;

&lt;p&gt;It has &lt;strong&gt;no security value&lt;/strong&gt;. It is worth writing, because it improves ordinary&lt;br&gt;
behaviour and ordinary behaviour is most behaviour. It is not a control, and the&lt;br&gt;
July 2025 incident is what it looks like when someone believes otherwise.&lt;/p&gt;

&lt;p&gt;There is a specific version of this worth naming, because it is easy to mistake&lt;br&gt;
for layer 3. Some MCP servers implement a two-step write flow — prepare, then&lt;br&gt;
complete — where the separation between the steps is that the &lt;em&gt;tool description&lt;/em&gt;&lt;br&gt;
tells the model to verify in between. If nothing refuses the second call when the&lt;br&gt;
first was never really checked, that is layer 1 wearing layer 3's clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: the client
&lt;/h2&gt;

&lt;p&gt;Every serious MCP client shows a confirmation dialog before a tool call, and the&lt;br&gt;
agent frameworks have their own equivalents: LangChain's&lt;br&gt;
&lt;code&gt;HumanInTheLoopMiddleware&lt;/code&gt; and &lt;code&gt;interrupt&lt;/code&gt;, the OpenAI Agents SDK's&lt;br&gt;
&lt;code&gt;needsApproval&lt;/code&gt; with resumable &lt;code&gt;interruptions&lt;/code&gt;, Pydantic AI's &lt;code&gt;requires_approval&lt;/code&gt;&lt;br&gt;
deferred tools. These are real, well-built mechanisms, and if you are building an&lt;br&gt;
agent that writes to anything, you should use them.&lt;/p&gt;

&lt;p&gt;Two honest problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the human is shown is usually the model's own tool input.&lt;/strong&gt; For a database&lt;br&gt;
write that means a SQL string, rendered as JSON. Not the rows it will touch, not&lt;br&gt;
how many, not what they currently contain. The same clients that render a file&lt;br&gt;
edit as a colour diff render a database mutation as a blob of text. You are being&lt;br&gt;
asked to approve a statement, which means you are being asked to be a SQL&lt;br&gt;
interpreter, in your head, against data you cannot see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And these gates get turned off.&lt;/strong&gt; &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;, auto-run&lt;br&gt;
modes, allowlist creep. This is not user error; it is the predictable result of&lt;br&gt;
asking someone twenty times an hour whether they meant it. Any control whose cost&lt;br&gt;
is a click, paid repeatedly, converges on being disabled.&lt;/p&gt;

&lt;p&gt;A design that dodges both problems, and is under-credited because it is boring:&lt;br&gt;
&lt;strong&gt;have the agent produce an artefact that goes through review you already have.&lt;/strong&gt;&lt;br&gt;
A migration file in a pull request. A row in a staging table that a trusted job&lt;br&gt;
applies. The human then reviews in a tool built for reviewing, with history and&lt;br&gt;
blame and a second pair of eyes, instead of in a modal that interrupted them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: the server process
&lt;/h2&gt;

&lt;p&gt;This is where most database MCP servers put their guard, and it is where the&lt;br&gt;
interesting failures are, because a server here holds a credential that can write&lt;br&gt;
and is choosing not to use it.&lt;/p&gt;

&lt;p&gt;There are at least four distinct mechanisms, and they are not equally strong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapping the query in a read-only transaction.&lt;/strong&gt; The original MCP reference&lt;br&gt;
server for Postgres did this: &lt;code&gt;BEGIN TRANSACTION READ ONLY&lt;/code&gt;, run the SQL, roll&lt;br&gt;
back in a &lt;code&gt;finally&lt;/code&gt;. Datadog Security Labs&lt;br&gt;
&lt;a href="https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/" rel="noopener noreferrer"&gt;showed it was bypassable&lt;/a&gt;&lt;br&gt;
by statement stacking: the Postgres driver accepts multiple semicolon-separated&lt;br&gt;
statements in one call, so &lt;code&gt;COMMIT; DROP SCHEMA public CASCADE;&lt;/code&gt; ends the&lt;br&gt;
read-only transaction and everything after it runs with full privileges.&lt;/p&gt;

&lt;p&gt;Their recommended fix is the whole argument of this post, from a security team&lt;br&gt;
that had just finished breaking the application-layer version of it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A possible mitigation—one we recommend in any case—is using a Postgres user&lt;br&gt;
with restricted privileges. You should definitely do this.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Their conclusion is the unglamorous one, and worth repeating: "classic&lt;br&gt;
application security vulnerabilities are still very relevant to MCP servers and&lt;br&gt;
other AI tooling."&lt;/p&gt;

&lt;p&gt;The lesson generalises, in my words rather than theirs: a read-only transaction&lt;br&gt;
is not a security boundary when the protocol accepts semicolons.&lt;/p&gt;

&lt;p&gt;That server was deprecated in July 2025 and archived. It still had &lt;strong&gt;86,941 npm&lt;br&gt;
downloads&lt;/strong&gt; in the week of 2 August 2026, which I checked while writing this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocking keywords.&lt;/strong&gt; AWS's database MCP servers default to read-only and reject&lt;br&gt;
&lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, session-state statements and a list of dangerous&lt;br&gt;
functions. Their own README is unusually straight about what that buys you:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Treat this as a best-effort, defense-in-depth mechanism, not a security&lt;br&gt;
boundary. A blocklist cannot enumerate every dangerous construct, and a&lt;br&gt;
sufficiently creative query (obfuscation, quoted identifiers, new&lt;br&gt;
server/extension functions, etc.) may bypass it. Do not rely on it as your&lt;br&gt;
only control.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and then says the thing this entire post is about, better than I did:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Combining a minimal-privilege role (database-enforced) with the blocklist&lt;br&gt;
(application-enforced) gives you defense in depth.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Database-enforced and application-enforced. That is the distinction, in a&lt;br&gt;
vendor's own documentation, and it is the one almost nothing else makes explicit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parsing the SQL properly.&lt;/strong&gt; Better than keyword matching, and it moves the&lt;br&gt;
problem rather than removing it: you are now maintaining a SQL parser that must&lt;br&gt;
agree with the database's parser about every dialect quirk, forever. Disagreement&lt;br&gt;
between the two parsers is the whole bug class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not registering the write tools at all.&lt;/strong&gt; Neon's read-only mode restricts which&lt;br&gt;
tools exist. This is clean and easy to reason about — but it is still layer 3: the&lt;br&gt;
credential in the process can write, and anything that reaches it can too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trap worth naming&lt;/strong&gt;, because I nearly filed it under layer 5 myself:&lt;br&gt;
Postgres's &lt;code&gt;default_transaction_read_only&lt;/code&gt; looks like engine-level enforcement&lt;br&gt;
and is not. It is a &lt;code&gt;USERSET&lt;/code&gt; parameter — a default, not a privilege — so&lt;br&gt;
&lt;code&gt;SET default_transaction_read_only = off;&lt;/code&gt; or &lt;code&gt;BEGIN READ WRITE;&lt;/code&gt; restores writes&lt;br&gt;
from inside the very session it is supposed to constrain. The same semicolon that&lt;br&gt;
beats a read-only transaction beats this. It is only load-bearing when the role&lt;br&gt;
has no write grants, in which case the grants are the control and the setting is&lt;br&gt;
decoration.&lt;/p&gt;

&lt;p&gt;There is also a family of servers that deliberately hands the model transaction&lt;br&gt;
control — &lt;code&gt;begin transaction&lt;/code&gt;, &lt;code&gt;commit&lt;/code&gt;, &lt;code&gt;rollback&lt;/code&gt; as callable tools — or a&lt;br&gt;
general &lt;code&gt;execute_sql&lt;/code&gt; that accepts multiple statements. &lt;code&gt;mcp-node-mssql&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;mcp-sqlite-tools&lt;/code&gt; are examples I verified. This is a legitimate design for a&lt;br&gt;
trusted local development database. It is worth being clear-eyed about what it&lt;br&gt;
means in any other setting: &lt;strong&gt;the model holds the commit button.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The choice is not forced, and it is worth naming a server that goes the other&lt;br&gt;
way. &lt;code&gt;mssql-mcp-node&lt;/code&gt; is read-only by default, gates writes behind an&lt;br&gt;
&lt;code&gt;MSSQL_ENABLE_WRITES&lt;/code&gt; environment variable, and runs every read inside a&lt;br&gt;
transaction it always rolls back. Same protocol, same language, opposite default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: the proxy
&lt;/h2&gt;

&lt;p&gt;An MCP gateway that sits between client and server can gate tool calls centrally,&lt;br&gt;
log them in one place, and apply policy from an engine like OPA or Cedar. It is&lt;br&gt;
the right answer to "which of our forty agents may reach which of our servers",&lt;br&gt;
and it survives a compromised client.&lt;/p&gt;

&lt;p&gt;Its limit is structural: it protects the path through it. If the credential is&lt;br&gt;
also reachable directly, the proxy is a convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: the database
&lt;/h2&gt;

&lt;p&gt;This is the only rung where the enforcement outlives everything above it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A role without write privileges.&lt;/strong&gt; Supabase's MCP read-only mode runs queries
as a read-only Postgres user — enforcement at the role, not in the process. It
is opt-in rather than default, which is a real criticism, but the mechanism is
the strong one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engine-level read-only.&lt;/strong&gt; ClickHouse's &lt;code&gt;readonly=1&lt;/code&gt;, which also forbids
changing settings, and SQLite's read-only connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped write authority&lt;/strong&gt;, which barely appears in agent discussions and is
the mature answer to the actual question. Row-level security, column-level
grants, updatable views, &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions as the only write path.
"May write, but only these rows" is a solved problem with a decade of
production use, and it maps onto agents exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No raw SQL at all.&lt;/strong&gt; Typed, parameterised endpoints — PostgREST, Hasura,
stored procedures, an MCP server exposing verbs instead of a SQL box. The agent
picks an operation and fills typed slots; the SQL was written by a human in
advance. This is the most common answer in production systems and the least
discussed in agent writing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch is that layer 5 controls what &lt;em&gt;may&lt;/em&gt; happen and cannot tell you what&lt;br&gt;
&lt;em&gt;will&lt;/em&gt;. &lt;code&gt;UPDATE orders SET status='shipped' WHERE placed_on &amp;lt; '2026-08-01'&lt;/code&gt; is&lt;br&gt;
permitted by any sane privilege model. Whether it touches four rows or forty&lt;br&gt;
thousand is not a privilege question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things worth saying plainly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read-only is not the same as safe.&lt;/strong&gt; Removing writes does not remove risk: an&lt;br&gt;
unbounded read is a denial of service and a cost incident, and read access is the&lt;br&gt;
exfiltration half of every serious prompt-injection scenario. Several of the&lt;br&gt;
worst documented MCP incidents involved no write at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approval cannot be the whole answer,&lt;/strong&gt; because a growing share of agent runs&lt;br&gt;
have no human present — cron jobs, CI, batch runs. For those, the only workable&lt;br&gt;
combination is layer 5 plus caps plus reversibility plus audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reversibility deserves equal billing with prevention,&lt;/strong&gt; and gets almost none.&lt;br&gt;
Point-in-time recovery, time travel, instant branch restore, temporal tables,&lt;br&gt;
soft deletes. In practice this is what most teams actually rely on, and the&lt;br&gt;
questions that matter — how far back, does it cover DDL, can the agent itself&lt;br&gt;
call the restore, and would you even notice you needed it — are rarely asked&lt;br&gt;
before they are needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring before approving is not a new category
&lt;/h2&gt;

&lt;p&gt;I should name the alternatives here too, since this is the one part of the ladder&lt;br&gt;
where I sell something.&lt;/p&gt;

&lt;p&gt;Bytebase's SQL review computes affected rows and a risk level and routes the&lt;br&gt;
change to a human before rollout. Branch-based databases — Neon, PlanetScale —&lt;br&gt;
let you apply to a copy and inspect the result before promoting it, which answers&lt;br&gt;
the same question from a different direction and gives you a rollback story for&lt;br&gt;
free. And the cheapest version needs no tool at all: have the agent produce the&lt;br&gt;
matching &lt;code&gt;SELECT&lt;/code&gt;, or run the statement in an open transaction with &lt;code&gt;RETURNING&lt;/code&gt;&lt;br&gt;
and read it back before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my own tool sits, honestly
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/hyuga611/llm-safe-sql" rel="noopener noreferrer"&gt;llm-safe-sql&lt;/a&gt;, which&lt;br&gt;
attacks the layer-2 problem specifically: that the human is approving a &lt;em&gt;claim&lt;/em&gt;.&lt;br&gt;
It runs the proposed &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; inside a transaction, measures the real&lt;br&gt;
before/after values, always rolls back, and shows the human that measurement.&lt;br&gt;
Applying happens in a separate process, from a separate credential.&lt;/p&gt;

&lt;p&gt;Being honest about the ladder, and more honest than my first draft was. The&lt;br&gt;
measurement runs at &lt;strong&gt;layer 3&lt;/strong&gt;. Both the planning credential and the applying&lt;br&gt;
credential can write — a dry run cannot execute without write privileges — so the&lt;br&gt;
database is not enforcing the split between them. The library and the process&lt;br&gt;
boundary are. Calling the separate apply credential "layer 5", as I first wrote,&lt;br&gt;
was promoting my own tool one rung on my own ladder.&lt;/p&gt;

&lt;p&gt;The one genuinely layer-5 setting it has is a separate read-only role for the&lt;br&gt;
model's reads, which is also the cheapest thing an operator can do here. And its&lt;br&gt;
&lt;code&gt;check&lt;/code&gt; command now prints where each guard actually sits and names the ones that&lt;br&gt;
are fictional, because "apply uses the same credential as plan" is something an&lt;br&gt;
operator deserves to be told rather than to discover.&lt;/p&gt;

&lt;p&gt;And the honest limits: this is human-in-the-loop, so unattended agents are out of&lt;br&gt;
scope. It does not solve prompt injection — a measured diff tells you what will&lt;br&gt;
happen, not who chose it, though "what will happen" is a great deal more than the&lt;br&gt;
model's own description of it. And gating database access on a human decision is&lt;br&gt;
not new — privileged-access brokers such as StrongDM have checked commands&lt;br&gt;
against policy mid-session for years, long before anyone was pointing a language&lt;br&gt;
model at a production database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question to ask
&lt;/h2&gt;

&lt;p&gt;If you are evaluating anything in this space, the question that separates the&lt;br&gt;
options is not which features it has. It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When your guardrail refuses something, what is doing the refusing — and what&lt;br&gt;
would still refuse it if that component had a bug?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the answer is "the tool description", you have a convention. If it is "the&lt;br&gt;
approval dialog", you have a convention plus a habit. If it is "a role that&lt;br&gt;
cannot write", you have a control.&lt;/p&gt;

&lt;p&gt;Most tools are a mix, which is fine. Not knowing which is which is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;Everything named above was checked against a primary source while writing. Where&lt;br&gt;
a claim came from a vendor's comparison page about a competitor, I dropped it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/" rel="noopener noreferrer"&gt;MCP vulnerability case study: SQL injection in the Postgres MCP server&lt;/a&gt; — Datadog Security Labs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://fortune.com/2025/07/23/ai-coding-tool-replit-wiped-database-called-it-a-catastrophic-failure" rel="noopener noreferrer"&gt;Replit AI wiped a database and called it a catastrophic failure&lt;/a&gt; — Fortune, July 2025&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/awslabs/mcp/tree/main/src/postgres-mcp-server" rel="noopener noreferrer"&gt;awslabs/mcp — postgres-mcp-server&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/ai-tools/mcp" rel="noopener noreferrer"&gt;Supabase MCP server docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/neondatabase/mcp-server-neon" rel="noopener noreferrer"&gt;neondatabase/mcp-server-neon&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ClickHouse/mcp-clickhouse" rel="noopener noreferrer"&gt;ClickHouse MCP server&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/crystaldba/postgres-mcp" rel="noopener noreferrer"&gt;crystaldba/postgres-mcp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/bytebase/dbhub" rel="noopener noreferrer"&gt;bytebase/dbhub&lt;/a&gt; and &lt;a href="https://www.bytebase.com/docs/sql-review/overview/" rel="noopener noreferrer"&gt;Bytebase SQL review&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mihai-dulgheru/mssql-mcp-node" rel="noopener noreferrer"&gt;mihai-dulgheru/mssql-mcp-node&lt;/a&gt; — read-only by default&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-client.html" rel="noopener noreferrer"&gt;PostgreSQL: default_transaction_read_only&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.langchain.com/oss/python/langchain/middleware" rel="noopener noreferrer"&gt;LangChain human-in-the-loop middleware&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.github.io/openai-agents-js/guides/guardrails/" rel="noopener noreferrer"&gt;OpenAI Agents SDK — guardrails and human review&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.pydantic.dev/deferred-tools/" rel="noopener noreferrer"&gt;Pydantic AI — deferred tools and approval&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Download figure for &lt;code&gt;@modelcontextprotocol/server-postgres&lt;/code&gt; taken from the npm&lt;br&gt;
registry API for 2026-08-02 to 2026-08-08.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>security</category>
      <category>mcp</category>
    </item>
    <item>
      <title>The card said one column. The apply wrote two.</title>
      <dc:creator>hyuga</dc:creator>
      <pubDate>Mon, 10 Aug 2026 09:56:46 +0000</pubDate>
      <link>https://dev.to/hyuga611/the-card-said-one-column-the-apply-wrote-two-2pa0</link>
      <guid>https://dev.to/hyuga611/the-card-said-one-column-the-apply-wrote-two-2pa0</guid>
      <description>&lt;p&gt;I have been building a thing that lets a language model propose an &lt;code&gt;UPDATE&lt;/code&gt;, then&lt;br&gt;
executes it for real inside a transaction, measures the actual before and after&lt;br&gt;
values, and always rolls back. A human reads the measurement and decides. Only&lt;br&gt;
then does anything commit.&lt;/p&gt;

&lt;p&gt;The pitch is one sentence: &lt;strong&gt;what you approve is not the model's description of&lt;br&gt;
its SQL, it is what the database did when the SQL ran.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Last week I found that the thing showing you that measurement was showing you a&lt;br&gt;
subset of it, and had been since the first release.&lt;/p&gt;
&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;

&lt;p&gt;Real output, from &lt;code&gt;@hyuga/llm-safe-sql@0.4.0&lt;/code&gt; installed from npm. One row:&lt;br&gt;
&lt;code&gt;name = 'Tanaka'&lt;/code&gt;, &lt;code&gt;postcode = '00100'&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  UPDATE customers SET name='Sato', postcode='00100' WHERE id=1

What this touches
  customers — Customer records. The postcode is used for billing address and delivery.
  1 row would change, across 1 column: name

Measured by running the statement and rolling it back
  id = 1
      name: 'Tanaka' -&amp;gt; 'Sato'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row, one column. &lt;code&gt;postcode&lt;/code&gt; is not mentioned, and that is &lt;em&gt;correct&lt;/em&gt; — it is&lt;br&gt;
being assigned the value it already holds, so nothing about it changes. The card&lt;br&gt;
is describing the diff accurately.&lt;/p&gt;

&lt;p&gt;Approve it. Then, before it is applied, somebody else notices the postcode is&lt;br&gt;
wrong and fixes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;postcode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'90210'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now apply the approved plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Applied: UPDATE on customers, 1 row(s), at 2026-08-10T09:49:12.049Z.
DB now: [{"name":"Sato","postcode":"00100"}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is gone. Zero warnings. The word &lt;code&gt;postcode&lt;/code&gt; never appeared on the&lt;br&gt;
approval card, never appeared in the audit record, and never appeared in the&lt;br&gt;
comparison the tool makes before it commits.&lt;/p&gt;
&lt;h2&gt;
  
  
  One variable doing two jobs
&lt;/h2&gt;

&lt;p&gt;The diff was built like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;same&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// drop what did not move&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// drop what the DB maintains itself&lt;/span&gt;
  &lt;span class="nx"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a correct answer to "what should the card show". Showing &lt;code&gt;postcode:&lt;br&gt;
'00100' -&amp;gt; '00100'&lt;/code&gt; would be noise, and worse than noise — it would be the card&lt;br&gt;
claiming a change where there is none.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;changed&lt;/code&gt; was &lt;em&gt;also&lt;/em&gt; the list the apply iterated when it&lt;br&gt;
re-checked that nothing had moved since approval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;same&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApplyRefused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ROW_CHANGED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;postcode&lt;/code&gt; is not in &lt;code&gt;changed&lt;/code&gt;, so it is not checked. And the statement writes it&lt;br&gt;
on every execution. &lt;strong&gt;A column that is written and never verified.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"The set of columns that change" and "the set of columns the statement writes"&lt;br&gt;
are different sets, and I had used one name for both. &lt;code&gt;SET x = &amp;lt;the value it&lt;br&gt;
already has&amp;gt;&lt;/code&gt; is not exotic SQL — it is zero-padded codes, defensive &lt;code&gt;status&lt;/code&gt;&lt;br&gt;
assignments, &lt;code&gt;SET updated_by = 'batch'&lt;/code&gt;. Every one of those is this shape.&lt;/p&gt;
&lt;h2&gt;
  
  
  MySQL was catching it by accident
&lt;/h2&gt;

&lt;p&gt;There is a second version of the same hole, one level up.&lt;/p&gt;

&lt;p&gt;When a &lt;code&gt;WHERE&lt;/code&gt; matches several rows and one of them already holds the target&lt;br&gt;
value, the card says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  1 row would change, across 1 column: status
  (1 more match the condition but are already correct.)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That row's &lt;code&gt;changed&lt;/code&gt; is empty, so the verification loop runs zero times — before&lt;br&gt;
the write and after it. Nothing about that row's contents was ever checked.&lt;/p&gt;

&lt;p&gt;MySQL was saved by an accident of its protocol. It reports "rows matched" and&lt;br&gt;
"rows changed" separately, so a plan that measured one changed row and an apply&lt;br&gt;
that changed two is a detectable disagreement. PostgreSQL and SQLite rewrite a&lt;br&gt;
row even when the new values equal the old ones, so those two numbers are the&lt;br&gt;
same and the comparison says nothing. The reconciliation code knew this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowsChangedIsMeaningful&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowsChanged&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowsChanged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rowsChangedIsMeaningful&lt;/code&gt; is true on MySQL and false on the other two. &lt;strong&gt;A guard&lt;br&gt;
that worked on one of three supported engines was the only thing standing there.&lt;/strong&gt;&lt;br&gt;
Develop on MySQL, test on MySQL, and you never learn this.&lt;/p&gt;

&lt;p&gt;The fix was to stop conflating the two sets: &lt;code&gt;PlanRow&lt;/code&gt; now carries &lt;code&gt;covered&lt;/code&gt; —&lt;br&gt;
every column the statement assigns — snapshotted before and after even when the&lt;br&gt;
value does not move, included in the tamper digest, and checked at both ends of&lt;br&gt;
the apply. &lt;code&gt;changed&lt;/code&gt; still drives the display. Same sequence on 0.4.2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Refused (ROW_CHANGED): Row id=1 no longer holds the value you approved:
`postcode` was '00100' when the plan was made and is '90210' now.
Nothing was applied — make a new plan against the current values.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The audit that did not open the file
&lt;/h2&gt;

&lt;p&gt;I found this because of how the previous audit failed.&lt;/p&gt;

&lt;p&gt;I had run an adversarial review over the codebase — several independent passes by&lt;br&gt;
dimension, each finding verified by separate sceptics whose job was to refute it.&lt;br&gt;
Twenty-one findings survived. Adapters, engine, parser, policy. It felt like a&lt;br&gt;
good day's work.&lt;/p&gt;

&lt;p&gt;Then I asked a different question: not "what did you find" but &lt;strong&gt;"what did this&lt;br&gt;
review structurally not look at?"&lt;/strong&gt; The first line of the answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;src/apply.ts&lt;/code&gt; (494 lines at the time) produced zero findings. That should be read as&lt;br&gt;
"nobody opened it", not as "it was clean".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;apply.ts&lt;/code&gt; is the only code in the library that writes to production. Dry runs&lt;br&gt;
always roll back. Approval only writes a record. Committing happens in exactly&lt;br&gt;
one place, and the review had not been there.&lt;/p&gt;

&lt;p&gt;Scoped to that one file, the same process returned &lt;strong&gt;twenty-three&lt;/strong&gt; findings —&lt;br&gt;
more than the first pass found in the whole rest of the codebase. Everything&lt;br&gt;
above came out of it.&lt;/p&gt;

&lt;p&gt;The number 21 had felt like progress. It was a record of where I had looked.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then the examples found two more
&lt;/h2&gt;

&lt;p&gt;This week I wrote worked examples: the four database accounts the design assumes,&lt;br&gt;
with the exact grants, for MySQL and Postgres. I decided to run every line against&lt;br&gt;
a real server rather than write what I knew to be true.&lt;/p&gt;

&lt;p&gt;The server disagreed twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A database-wide grant cannot be narrowed.&lt;/strong&gt; I had written the obvious thing —&lt;br&gt;
grant DML on the whole schema, then take it back on the two tables that hold the&lt;br&gt;
approval records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR 1147 (42000): There is no such grant defined for user 'llm_plan'
                    on host '%' on table 'llm_safe_sql_plans'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL will not revoke a table-level subset of a database-level grant. So&lt;br&gt;
&lt;code&gt;GRANT ... ON shop.*&lt;/code&gt; hands the dry-run account write access to the table that&lt;br&gt;
records approvals, permanently. A dry run could forge its own approval. The&lt;br&gt;
examples name each table instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And &lt;code&gt;check&lt;/code&gt; did not check.&lt;/strong&gt; The tool has a command whose entire job is to say&lt;br&gt;
whether the environment will work. It verified four connections and never the two&lt;br&gt;
tables the whole approval record lives in. With them missing it reported every&lt;br&gt;
table as &lt;code&gt;ready&lt;/code&gt; and exited 0 — and the omission surfaced on the first &lt;code&gt;plan&lt;/code&gt;, as&lt;br&gt;
a driver error escaping as an unhandled rejection, after the dry run had already&lt;br&gt;
executed and rolled back.&lt;/p&gt;

&lt;p&gt;That is the likeliest mistake anyone makes on day one, and it was invisible to the&lt;br&gt;
command that exists to catch exactly that class of thing. Fixed in 0.4.2: it&lt;br&gt;
reports the missing table and exits non-zero, and a missing store table is a&lt;br&gt;
refusal on every path rather than a stack trace.&lt;/p&gt;

&lt;p&gt;There was a third, smaller one. &lt;code&gt;check&lt;/code&gt; now asks the catalogue whether the tables&lt;br&gt;
exist rather than issuing &lt;code&gt;SELECT 1 FROM audit WHERE 1 = 0&lt;/code&gt; — because the store&lt;br&gt;
account the examples recommend holds &lt;code&gt;INSERT&lt;/code&gt; on the audit table and nothing else,&lt;br&gt;
so the select probe reports the table missing &lt;em&gt;exactly when the credential is as&lt;br&gt;
narrow as it is supposed to be.&lt;/em&gt; Writing the recommended configuration is what&lt;br&gt;
made that visible.&lt;/p&gt;

&lt;p&gt;And then CI failed, because CI runs the README's own quick start verbatim, and my&lt;br&gt;
fix had made the documented order wrong. The instructions said &lt;code&gt;check&lt;/code&gt; then&lt;br&gt;
&lt;code&gt;migrate&lt;/code&gt;; &lt;code&gt;check&lt;/code&gt; now exits non-zero before &lt;code&gt;migrate&lt;/code&gt; has run. Something noticed&lt;br&gt;
before a reader did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Count what you have not looked at.&lt;/strong&gt; A list of findings looks like evidence of&lt;br&gt;
thoroughness and is evidence of coverage. The file that should obviously have been&lt;br&gt;
at the top of a risk-ordered list did not appear on the list at all, and absence&lt;br&gt;
looked exactly like safety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing the explanation is a test.&lt;/strong&gt; Twice now, the act of documenting this&lt;br&gt;
thing has found defects in it that reading the code did not. Not because prose is&lt;br&gt;
magic — because writing an example means running it, and running it means the&lt;br&gt;
server gets a vote. Two privilege lists that were obviously right were wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch for one name doing two jobs.&lt;/strong&gt; The bug here was not a missing check. It&lt;br&gt;
was a display set and a verification set sharing a variable, so narrowing the&lt;br&gt;
display for good reasons silently narrowed the check. If you have something that&lt;br&gt;
both shows a human what will happen and decides whether it may happen, those are&lt;br&gt;
two lists, and they should have two names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A guard that only works on one backend is not a guard.&lt;/strong&gt; It is a coincidence&lt;br&gt;
with good timing, and it will be silently absent on the day you switch.&lt;/p&gt;

&lt;p&gt;If you are building anything that measures before it asks a human to agree, the&lt;br&gt;
question I would now ask it is: &lt;em&gt;what is the interface not showing me, and how&lt;br&gt;
would I know?&lt;/em&gt; The answers worth having are specific — which columns does it&lt;br&gt;
compare, what does it do with a value too long to print, what does it do with two&lt;br&gt;
values that look the same and are not.&lt;/p&gt;

&lt;p&gt;None of the five layers people usually reach for would have caught this. Not a&lt;br&gt;
role without write privileges, not a proxy, not an approval dialog, not a stricter&lt;br&gt;
prompt. Every one of these was a legitimate, authorised &lt;code&gt;UPDATE&lt;/code&gt; on an allowlisted&lt;br&gt;
table by a credential entitled to run it. Nothing about &lt;em&gt;what was permitted&lt;/em&gt; was&lt;br&gt;
violated. What was wrong was what the human was told before they agreed.&lt;/p&gt;




&lt;p&gt;Code, and the full list of what changed in each version, at&lt;br&gt;
&lt;a href="https://github.com/hyuga611/llm-safe-sql" rel="noopener noreferrer"&gt;github.com/hyuga611/llm-safe-sql&lt;/a&gt;.&lt;br&gt;
The &lt;code&gt;examples/&lt;/code&gt; directory is the part I would read first — it is the only&lt;br&gt;
documentation I have written where every line was executed against a real server&lt;br&gt;
before it was committed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>security</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
