Essay · Agent Security

The Tool Was Read-Only. The Consequence Wasn't.

An AI coding agent shipped a git tool labeled read-only and wired it to auto-approve. A model-controlled --output= turned reading into arbitrary file write. A safety label is not a fact about a tool; it is a claim that needs an enforcer.

Published August 28, 2026 Topic Agent tool safety & effect enforcement

CVE-2026-75913 is a small bug with a clean lesson. CodeWhale, an AI coding agent, shipped a `git_show` tool that it advertised as read-only and wired to auto-approve. A model-controlled argument beginning with `--output=` got passed straight into git's command line, and git did what git does with `--output=`: it wrote the operation's result to whatever file you named, including `~/.ssh/authorized_keys`, `~/.bashrc`, or `~/.gitconfig`.

I don't care much about the individual bug. It was patched in 0.8.64, and the world moved on. What I care about is the assumption it exposes, because that assumption is load-bearing in almost every agent stack being built right now: that you can attach a security property to a tool by naming it.

The label was never a fact

"Read-only" was never a fact about git_show. It was a description of what the tool's author believed the operation did. Someone modeled git show in their head as an operation that reads a commit and prints it, decided that reading is safe, set a boolean, and moved on. That mental model was correct about the intended use and wrong about the reachable use. The gap between those two is where the entire failure lives.

git show is not a read primitive. It is a porcelain command over a general-purpose engine, and it inherits the engine's full option grammar. Among those options is --output=<file>, a diff-family flag that redirects the result to disk. I confirmed the behavior in about thirty seconds: git show --output=victim/target HEAD overwrites victim/target with the commit's formatted output. When you wrap one verb of a general-purpose binary and expose a string parameter to it, you do not inherit the slice of behavior you named. You inherit everything the binary will accept. The author thought they were exposing rev drawn from the set of valid git revisions. They were actually exposing rev drawn from a much larger set. Argument injection is just the name for the distance between those two sets.

Classification belongs to the consequence, not the verb

That distance is the real subject. A safety classification does not belong to the verb on a tool. It belongs to the reachable consequences of the operation, evaluated over every input the system will actually permit, in the environment where it actually runs. Two refinements matter here, because "read-only" quietly smuggles two assumptions past the reviewer.

First, a security property is only valid over the input domain it was checked against. "Read-only" was true for the revisions the author imagined and false for the flags they didn't. In an agent, the input domain is not the set of well-formed values. It is the set of strings the model is actually permitted to produce, which is far broader than the author's intended revision grammar and may be adversary-influenced.

Second, "read-only" is a property of a deployment, not a tool. Whether git_show can write depends on the binary, the argument construction, the input validation, the filesystem permissions, and the sandbox. CodeWhale's own sandbox already blocks writes outside the workspace in its normal mode and only opens up under a trust setting. Same tool, two different classifications, decided entirely by the substrate around it. The label was attached to the wrong object.

So the correct classification is not the intended consequence. It is the most severe effect reachable across all permitted inputs, in the deployed environment. The worst reachable effect, not the typical one.

It is a pattern, and it is not an AI problem

If this were one bug, it would be a footnote. It is a pattern, and the pattern is visible inside the same codebase. CodeWhale's git_blame tool has the identical flaw: an unvalidated rev, no end-of-options sentinel. There the useful injected flag is --contents=<path>, filed as CVE-2026-75912, which turns "read a file's blame" into a read of any file on disk, exfiltrated through the tool's output. One root cause, two tools, and the reachable consequence forked. git_show became a write primitive. git_blame became a disclosure primitive. Both tools are named for reading. A classifier that reasons about names clears both. A classifier that reasons about the reachable option set rejects both.

And it is not an AI problem. The exact git show plus --output= injection was filed against OpenProject, a Ruby web application with no model anywhere near it, as CVE-2026-24685. Same binary, same flag, same arbitrary file write, gated only by a repository-browse permission instead of an auto-approved tool. The failure is older than agents. Agents did not discover it. They removed the last thing that was containing it.

That last thing was the human. Argument injection is CWE-88, a category with gray hair. What is new is not the injection. It is the decision to let a nominal classification gate autonomous execution. A manual approval at least preserves an intervention point where the anomalous argument might be noticed, even if a reviewer rubber-stamps it or the interface never surfaces the dangerous flag. Auto-approval removes even that opportunity. It did not create the injection vulnerability. It turned an incorrect classification into autonomous consequence.

One honest limit, since I would rather state it than get corrected on it. The write is arbitrary in location, but the bytes are git show output, and that output opens with a commit <hash> header. That constrains clean exploitation of strict file formats, so a working authorized_keys line is not guaranteed by this primitive alone. The reliable, provable result is overwrite, corruption, and denial of service against integrity and availability, along with injection into text files that tolerate the header noise. That is still a serious event, and it is worth not overselling it into something cleaner than it is.

This is an unchecked effect annotation

Here is the framing I think actually explains the whole class. "Read-only" was an effect annotation with no effect checker behind it.

Programming-language people have a precise version of this. In a language with an enforced effect system, a function's allowed effects are part of what the checker verifies. A function classified as effect-free cannot quietly perform an untracked write and still satisfy that classification, because the code that tried would not type-check. Without that kind of enforcement, an effect annotation is a comment. It records intent and guarantees nothing.

The CodeWhale gate is an unchecked effect annotation wired straight to an authorization decision. The author wrote read-only the way a tired developer writes "does not mutate" above a function that mutates, and the auto-approval layer consumed that annotation as though a checker stood behind it. None did. The guarantee had the strength of a promise, and the model got to decide whether to keep it. The agent-tooling world is currently rediscovering, one CVE at a time, that effect annotations need enforcement, on a substrate whose schemas usually type the arguments but do not enforce externally observable effects.

What the fix ladder looks like

The reframe hands you the fix, because effect systems already solved it: move the effect into something that gets checked, and gate the boundary on the checked property, not the asserted one. Concretely, there is a ladder, and most teams stop on the first rung and call the class solved.

The bottom rung is input validation. CodeWhale 0.8.64 fixed this bug and its disclosed siblings by validating the rev parameter and rejecting option-shaped values. An end-of-options sentinel is another useful defense where the wrapped command supports it. This rung works, but it is per-instance: it fixes the tools you audited and does nothing structural to stop the next wrapped command from reintroducing the class.

The next rung is preferring plumbing to porcelain. git cat-file has a far narrower grammar than git show. Wrapping a small consequence space is cheaper than fencing a large one.

The rung above that is classifying by closure. The auto-approval gate should consume a consequence type computed from the tool's reachable effects under its validation, not a boolean the author asserts. If the closure cannot be bounded, the tool is not eligible for auto-approval. Unboundable means manual.

The top rung is substrate enforcement. Make read-only a property of the process, not the name. Run read-classified tools with no writable filesystem capability: a read-only mount namespace, a Landlock or AppArmor-style filesystem policy, or a kernel sandbox like Seatbelt or bubblewrap where it is actually active. CodeWhale already ships this: its sandbox exposes a read-only mode alongside workspace-write and full-access modes, enforced by Seatbelt on macOS and, opt-in, bubblewrap on Linux, with gaps on platforms where neither is active. Under a read-only process, an injected --output= fails at the kernel even when every layer above it is wrong. That is the effect checker made real. The label stops being a claim the system trusts and becomes a consequence of the box the tool runs in.

The cost, and the way around it

The strong form of this thesis has a cost, and it is worth naming before someone else does. The reachable consequence closure of a tool that wraps a general-purpose binary is often unbounded, and computing the worst case by static analysis is frequently undecidable. Read literally, the thesis auto-approves almost nothing and stalls the agent.

The way out is to treat undecidability as the instruction rather than the objection. When you cannot bound the closure by analysis, bound it by construction. Confinement shrinks the reachable set to something you can classify, which is why substrate enforcement is not merely the strongest fix but the one that makes the thesis usable. You are not obligated to prove a tool is read-only. You are obligated to make it read-only and let the proof fall out of the sandbox.

So the rule I would put in front of any team shipping agent tools is narrow and boring: a tool's safety class is the worst outcome reachable through any input it will accept, in the environment where it runs, and if you cannot bound that set, you confine it until you can. CodeWhale's mistake was letting a name that described intent stand in for a boundary that has to be enforced, and then handing that name the authority to write files.


Related work. This is another instance of a broader problem I have been working on in MICRM: a declared control property is not the same thing as evidence that the property held for the action that actually executed. Here, read-only was the declaration. The enforced consequence boundary was the missing property. MICRM →


References: CVE-2026-75913 (CodeWhale git_show, arbitrary file write), CVE-2026-75912 (CodeWhale git_blame, arbitrary file read), CVE-2026-24685 (OpenProject, same git show argument injection), CWE-88. All findable on NVD and osv.dev.