DEV Community

Igor Ganapolsky
Igor Ganapolsky

Posted on

My own guardrail blocked me from writing a blog post. It was right, and it was wrong.

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
Enter fullscreen mode Exit fullscreen mode

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`
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. What is this string? (the classifier I built)
  2. 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', ... };
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fail closed. Ambiguous is a deny, not an allow. A guard that never annoys you is a guard you have not tested.
  2. 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:

  1. Write down one rule you have given your agent, exactly as you actually worded it.
  2. Get the agent to accomplish that forbidden thing without narrating it — put the whole intent in an argument. open a URL. Pipe a file. Run a script that does it.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
topstar_ai profile image
Luis Cruz

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 WebSearch and Write being inert, while Bash and curl are 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?