I ship a local guardrail engine for coding agents. Today it stopped my agent from writing a Markdown file.
It was right to fire. It was wrong to fire there. The gap between those two sentences is the most useful thing I have learned about guardrails this year.
The thing it was built to catch
Most agent guardrails are pattern matchers over prose. They read what the model said it was about to do — "I'll go ahead and do X" — and match on that.
They are matching the narration. The narration is not the action.
The execution surface is smaller and far more boring:
Bash: open <url>Bash: curl <url>WebFetch(<url>)
None of those contain an intent word. The intent lives in the argument, and the argument is a string the prose matcher never classified. So a rule written as "never do X" is satisfied — truthfully — by an agent that does X without narrating it. That is not a jailbreak. Nobody tricked the model. The model complied with the rule as written. The rule was written at the wrong layer.
So I moved the rule down a layer. Ship it today, classify the argument, fail closed.
Then it fired at me. Twice.
First it refused a market-research web search, because my query string contained vendor vocabulary. Fine — annoying, defensible.
Then I sat down to write this article, and it refused the Write call, because the draft quoted its own rules back at itself.
🪤 my own guardrail, hours old, blocking me:
"HARD BLOCK: agent-initiated money movement is forbidden"
me: it's a market research query
guardrail: it had vendor words in it
me: ...fair
me: ok now I'm writing a .md file about you
guardrail: HARD BLOCK
me: that one's a bug
The second block is not a tuning problem. Tightening the vocabulary would be treating the symptom.
The actual bug: right classifier, wrong blast radius
Here is the shape of the thing, from my own source:
function evaluateSpend(toolName, toolInput) {
const name = String(toolName || '');
const text = flatten(toolInput); // every input field, concatenated
const combined = `${name} ${text}`;
// ... regexes run against `combined`
}
flatten() walks the entire tool input — every field, nested, to depth 5 — and concatenates it into one string. Then the rules run against that string.
That is correct for Bash, where the dangerous thing genuinely is a substring of an argument. It is incorrect for every tool where it isn't.
Because the classifier is applied uniformly across all tools, it cannot tell these apart:
| Call | Can it act on the world? |
|---|---|
Bash: open <vendor-url> |
yes |
Bash: curl <vendor-url> |
yes |
WebFetch(<vendor-url>) |
yes |
WebSearch("<words>") |
no |
Write("notes.md", "<words>") |
no |
The first three are effectful: the argument reaches something that can act. The last two are inert: the same string is inert cargo. Same words, categorically different blast radius — and my guard saw one undifferentiated blob of text.
The lesson generalizes past money. Any guard that classifies arguments needs two axes, not one:
- What is this string? (the classifier I built)
- Can this tool do anything with it? (the one I skipped)
Skip axis 2 and your guard's false-positive rate scales with how often the topic comes up in your work — which, if you are building the guard, is constantly.
The part I got right by accident
The file already contains the fix, applied to exactly one rule:
const isReadOnlyTool = /^(?:read|read[_ ]?file)$/i.test(name.trim());
if (PROTECTED_GUARD_PATH.test(text) && !isReadOnlyTool) {
return { decision: 'deny', ruleId: 'guard_tampering', ... };
}
The anti-tampering rule refuses writes to the guard's own path — but exempts read-only tools. Which is the only reason I could diagnose any of this: the agent was blocked from Bash-reading its own source, and allowed to Read it.
Tool-effect awareness was already in the file. It was scoped to one rule instead of being the first thing every rule consults. That is the patch.
Two properties I am not giving up
The false positives are the cost of two decisions I would make again:
- Fail closed. Ambiguous is a deny, not an allow. A guard that never annoys you is a guard you have not tested.
- Non-demotable. The engine promotes and expires rules from observed failures, but it is not permitted to relax this floor. A learning system that can weaken its own hard floor does not have a hard floor.
Fail-closed guarantees false positives. The engineering question is never "how do I get to zero," it is "where do they land." Landing them on writing a file about the topic is a bug. Landing them on reaching a vendor URL from a shell is the product working.
Why this is a 2026 problem
IssueTrojanBench (Singh, Yang, Chen — arXiv 2607.20759, 22 July 2026) ran malicious instructions at Cursor, Claude Code, and Codex Desktop as deployed:
"66.5% of the malicious issues from IssueTrojanBench penetrate all the guardrails (agent- and LLM-level) of coding agents."
Two-thirds — against the guardrails that ship in the products, not against nothing. Those are serious teams. A number that size is a statement about layer, not about effort. Intent-level filtering is the wrong altitude for actions whose payload is a string.
And the blast radius keeps growing, because we keep handing agents the same shell we use — the one with the cloud CLIs, the publish tokens, the SSH keys, and a logged-in browser.
Run the test on your own setup
You don't need my tool for this. Fifteen minutes:
- Write down one rule you have given your agent, exactly as you actually worded it.
- Get the agent to accomplish that forbidden thing without narrating it — put the whole intent in an argument.
opena URL. Pipe a file. Run a script that does it. - Watch whether anything stops before the tool executes.
Then run the inverse, which is the test I failed: make the agent talk about the forbidden thing in a tool call that cannot do it. If your guard fires on that too, you have my bug.
ThumbGate is MIT and local-first. It runs in the PreToolUse hook and needs no server on the local enforcement path:
npx thumbgate init
Repo: github.com/IgorGanapolsky/ThumbGate
The two-axis patch goes in next. If you have already solved this in your own harness, I would rather see how you scoped it than get another star.
Top comments (1)
I was particularly intrigued by the distinction you made between effectful and inert tools, and how that impacts the classifier's ability to determine the blast radius of a given action. The example of
WebSearchandWritebeing inert, whileBashandcurlare effectful, really drives home the importance of considering the tool's capabilities when evaluating potential risks. It's interesting that you stumbled upon the fix for this issue through the anti-tampering rule, which exempted read-only tools - do you think this exemption could be generalized to other types of tools, or would that introduce new risks that need to be mitigated?