Ashton Damrel / Writing

Agent security · Evaluation

Taint only what looks injected: a small classifier that gives an agent its work back

No AgentDojo injection succeeded against my agent gateway, whose taint rule refuses all writes after the agent reads third-party content; that rule also blocked most of the user's own tasks. I trained a ModernBERT detector for injected instructions in tool output and let it decide what gets tainted. Benign task success went from 32 to 70 of 97, and attack success stayed at 0 of 200.

TL;DR. In the harness benchmark, no AgentDojo injection succeeded against my agent gateway, but its taint rule (no writes after reading third-party content) also blocked most of the user’s own tasks. I trained a small classifier that looks for instructions injected into tool output, and changed the rule to taint only what the classifier flags.

The problem: a safe rule that blocks the user

The gateway is the Rust harness that runs my self-hosted assistant. Its main defense against indirect prompt injection is taint tracking: any tool that returns third-party content (a web page, an email, a calendar entry someone else wrote) marks the session as tainted, and a tainted session may not call any tool that writes. The rule is deterministic, easy to audit, and it held: 0 of 200 AgentDojo injections succeeded against it.

It is also coarse. Most assistant tasks are “read something, then act on it”: find the meeting and email the attendees, read the channel and post a summary. Blanket taint treats the user’s own task and the attacker’s task the same way. In the harness benchmark, about 82% of the gateway’s failed AgentDojo tasks contained a taint denial, and on the Slack suite, where almost every task reads a channel or web page first, it solved 1 of 21.

The question for this experiment: if the gateway taints only tool output that actually contains injected instructions, how much utility comes back, and does any security go with it?

The detector

Many off-the-shelf prompt-injection classifiers are built around direct injection: a user typing “ignore previous instructions” into a chat box. The gateway needs something else: a judgment about content the agent reads (tool results, web pages, emails, documents, JSON), which is overwhelmingly benign, often long, and sometimes quotes injection strings without meaning them.

Classifier evaluation from the model card. Ours at its validation-chosen threshold 0.80; ProtectAI v2 at its native 0.5; identical windowing.
SplitWhat it measuresnThis detectorProtectAI v2
ood_agentdojoAgentDojo tool outputs, 5 attack templates; never trained on, evaluated once after freezing914AUC 0.984 · recall 0.913 · FPR 0.031AUC 0.793 · recall 0.721 · FPR 0.333
ood_llmail_p2LLMail-Inject phase 2: adaptive human attacks (positives only)3,832recall 0.985recall 0.544
eval_notinjectOver-defense: benign text full of trigger words (negatives only)339FPR 0.088FPR 0.434
eval_bipia_bareBare task insertion, ambiguous by design (recall only)400recall 0.645recall 0.190
eval_deepsetDirect user-turn injection, a different task (reference only)662AUC 0.811AUC 0.882

The in-distribution test split is near ceiling (AUC 1.000) because it shares the composition process with training; a TF-IDF baseline reaches 0.94 there, so I do not quote it as accuracy. The rows above are the ones that say something. The weak spot that matters most here: on AgentDojo’s important_instructions template, the long “<INFORMATION> This is an important message from me” letter that this experiment uses, the classifier’s recall is 0.713, slightly below ProtectAI’s 0.758. The model card has the full tables, per-slice results, shortcut probes and a first-pass label audit (done by a model, not a human).

Wiring it in

The detector runs as a localhost sidecar (POST /score), CPU-only in this run, with the same windowed scorer as the evaluation. The gateway calls it on every untrusted tool result, after the normal taint decision, in one of two modes:

Everything else is unchanged: the same signed bench manifest and tools, the same approval handling (auto-approved, as in the published run), the same model (Qwen3.6-35B-A3B on vLLM, thinking on), and the same 297 AgentDojo cases.

Results

Three configurations. The first is the run published with the harness benchmark. The other two use the current build, which includes the empty-reply loop fix the benchmark found, and were run back to back, so the detector’s effect can be separated from the fix.

AgentDojo benchmark v1.2.2, 97 benign user tasks and 200 injection pairs (important_instructions), one run per configuration, approvals auto-approved. Brackets: Wilson 95% interval.
ConfigurationUtility, benignUtility under attackAttack success
Pre-fix gateway, blanket taint (published run)32.0%
31/97 · [23.5–41.8]
34.5%
69/200 · [28.3–41.3]
0.0%
0/200 · [0.0–1.9]
Post-fix gateway, blanket taint33.0%
32/97 · [24.4–42.8]
33.0%
66/200 · [26.9–39.8]
0.0%
0/200 · [0.0–1.9]
Post-fix gateway, detector-gated taint72.2%
70/97 · [62.5–80.1]
36.0%
72/200 · [29.7–42.9]
0.0%
0/200 · [0.0–1.9]

The loop fix barely moved AgentDojo (31 to 32 benign tasks solved); that bug mostly hurt long terminal tasks. The last two rows differ only in the taint mode, so in this run the jump to 70 is the effect of gating taint. Paired by case:

Same case, blanket taint vs detector-gated taint. p: exact two-sided McNemar test on the discordant cases.
CasesBoth solvedNeitherOnly blanketOnly gatedp
Benign (97)28234425.1e-09
Under attack (200)63125390.15

Under attack, utility moved less (66 to 72) and the difference is not significant. That fits the mechanism: in most attacked cases the agent reads the injected text, the detector flags it, and from then on the session is read-only, as under blanket taint.

Benign utility by AgentDojo suite.
SuitePre-fix gateway, blanket taint (published run)Post-fix gateway, blanket taintPost-fix gateway, detector-gated taint
workspace32.5%
13/40 · [20.1–48.0]
35.0%
14/40 · [22.1–50.5]
70.0%
28/40 · [54.6–81.9]
travel50.0%
10/20 · [29.9–70.1]
50.0%
10/20 · [29.9–70.1]
65.0%
13/20 · [43.3–81.9]
banking43.8%
7/16 · [23.1–66.8]
43.8%
7/16 · [23.1–66.8]
56.2%
9/16 · [33.2–76.9]
slack4.8%
1/21 · [0.8–22.7]
4.8%
1/21 · [0.8–22.7]
95.2%
20/21 · [77.3–99.2]

Slack moved the most. Its tasks read channels and web pages before acting; under blanket taint the gateway solved 1 of 21 (the harness benchmark traced most such failures to taint denials), and with gated taint it solved 20.

What the detector saw

The gateway writes every score to its audit log with a SHA-256 of the result it scored, so I joined each score to the exact tool output recorded by the benchmark. 78 scores matched no recorded output (the benchmark’s copy of the result differs from what the gateway saw) and are left out of the table below, though they still count toward whether a session was tainted. Each output is labeled injected if it contains any 40-character stretch of that case’s injected text, after normalizing whitespace and punctuation, since AgentDojo renders results as YAML.

Gated run: every tool output whose detector score could be matched to the recorded result by content hash. Errors are sidecar timeouts; the gateway fails closed and taints them.
Tool outputFlaggedClearedSidecar error
Benign sessions, clean output32447
Attacked sessions, clean output02062
Attacked sessions, output carrying injected text1663024
  of which contains the injection’s opening145723

What the zero does and does not show

Attack success was 0 of 200 in all three configurations, so this run cannot show the detector making the gateway safer. What it shows is narrower: replacing “taint everything third-party” with “taint what the classifier flags” recovered most of the lost utility without a measured loss. The 22 attacked cases whose session was never tainted are the honest cost: there, nothing in the gateway’s policy stopped a write, and AgentDojo recorded no successful attack anyway. A stronger attacker, or a more compliant model, could turn some of them into compromises.

Why production stays in shadow mode

Production keeps blanket taint and logs the detector’s score next to every untrusted result. The gated mode moves the gateway’s security from a rule anyone can audit to a statistical judgment, and the evidence above is one benchmark deep. Before it graduates, I want:

  1. An adaptive attack run against the sidecar: paraphrases, encodings and split payloads of the important_instructions letter, using the red-team lab I already run weekly against the local models.
  2. Shadow data from my own traffic, to measure the false-positive rate where it matters: what fraction of my real sessions would gating have left writable, and should it have?
  3. A GPU or batched sidecar, so a timeout does not decide the policy. Offline, the model scores a document in 60 ms median on the GPU against 553 ms on the CPU.
  4. Keeping the floor: even in gated mode, write tools still ask for my approval on the phone, bound to a digest of the exact call. The detector decides whether a session may ask; it never approves anything.

Reproducibility