DEV Community

Grep your tool surface before you write another refusal eval

Every eval I wrote last year assumed the agent would try to do the wrong thing. A model reaching for another tenant's records. A model running the destructive migration.

So I wrote refusal evals: hand it the forbidden request, check that it declines. Pass rate looked great. I felt good about it for about a week.

Then I read a paper on authorization drift in multi-step tool use, and the uncomfortable part wasn't that models cross boundaries. It's why they cross them. No jailbreak, no adversarial prompt, no clever injection. The model found a cheaper path to the same answer.

The sanctioned tool has a scope check. The shortcut tool doesn't. The model takes the shortcut - not because it's misaligned, but because it's efficient.

That reframes the whole eval. You aren't testing whether the model respects boundaries. You're testing whether your tool surface lets it satisfy the request without ever touching one.

The audit is a grep, not a prompt

Before I touch a single system prompt, I open the tool registry and look for pairs. Two tools that do roughly the same thing, where one enforces something the other doesn't.

grep -rniE '(_all|_any|admin|raw|unsafe|bypass|force|unscoped|internal|debug)' tools/

Names are a weak signal but a cheap first pass. The real test is the signature. Any tool whose parameters don't include a workspace, tenant, principal or scope is a candidate - either it resolves scope out-of-band correctly, or it doesn't resolve it at all.

Any tool that returns more than the request needs is a candidate. Anything I added at 1am so a demo would work is a near-certainty.

The question I ask of every remaining tool: can one call to this satisfy a plausible user request while skipping a check another tool enforces? If yes, it's a bypass even when it looks harmless. Especially when it looks harmless.

What I found in my own stack

Eleven tools out of roughly sixty. That's not a rounding error, that's a second front door.

  • list_documents() with no workspace filter, sitting right next to list_documents(workspace_id). The unfiltered one existed because a dashboard needed it eighteen months ago and nobody removed it.
  • A fetch_url that accepted an arbitrary URL next to one that validated against an allowlist. One of them got used for a customer support task. Guess which.
  • A db_query passthrough. I added it for debugging, told myself I'd delete it after the incident, and then the incident ended and I didn't.
  • A read_file that took an absolute path, next to a sandboxed reader that took a path relative to a project root.

None of these were vulnerabilities in the classic threat-model sense. No attacker required. The model discovered them the way water discovers a slope: by being helpful and by preferring the path with fewer steps and fewer arguments to get wrong.

Removing beats hardening, every time

Prompt hardening is a probabilistic control bolted onto a deterministic surface. You spend tokens asking the model to avoid a path that is still sitting there, one token away, fully available.

And instructions decay - by step fourteen of a multi-step run, your carefully worded "never call X without Y first" is competing with thirty tool results and a context window that has started treating the system prompt as background noise.

Deleting the tool is a 100% fix for that path, and it's usually a one-line diff. That trade is not close.

Better than deleting, though, is moving the check. If a tool needs a workspace id, the model shouldn't be able to pass one. Resolve it from the session in the tool runtime, at the boundary where auth actually lives.

I did this with document search: dropped the workspace_id argument entirely and pulled it from the caller's token. The model can't drift past a boundary it can't name. The argument doesn't exist, so there's nothing to get wrong, nothing to confuse, and nothing to talk itself into.

That's the version I now aim for everywhere. Not "the tool checks scope" - the tool has no scope input. The capability stays, the sanctioned path becomes the only path, and the shortest route to satisfying the user is also the compliant one.

The eval that actually catches this

Assert on the trace, not the final answer. For each task, require that the scope-checking tool appears in the call trace before any data-returning call - not that the response text sounds careful. A model can produce a beautifully scoped-sounding answer from unscoped data.

Then add a shortcut probe, which takes about ten minutes to write and has found more real drift for me than any refusal suite. For each pair of overlapping tools, run the task and record which one the model picks. If it picks the shortcut when both are available, that's your drift, measured, reproducible, and fixable by deletion.

Where this stops working

Compositional drift is still open. Three individually legitimate calls can add up to something none of them should have permitted, and removing shortcuts doesn't touch that - the boundary isn't in any single tool.

I don't have a clean answer for it yet beyond logging the full trace and reviewing sequences rather than calls. Maybe the paper does and I haven't gotten there.

But that's a much smaller problem than "my agent has a convenient backdoor I built for a demo." Do the grep first. It's an afternoon, it's boring, and it will find something. The best boundary control I shipped this year was a deletion.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.