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.
- Utility: on AgentDojo’s 97 benign tasks, the gateway went from 32 to 70 solved, with the same model and build, run back to back. Paired by case, 42 tasks flipped to solved and 4 flipped the other way (p ≈ 5×10−9).
- Security: attack success stayed at 0 of 200, as it was under blanket taint. The detector did not add security in this run; it gave back the utility that blanket taint cost, with no measured security loss.
- The detector in the loop: it flagged 3 of 247 scored tool outputs in benign sessions. In 22 of the 180 attacked cases where the agent read injected text, it never tainted the session. AgentDojo recorded no successful attack in any of those 22.
- The model is public: drumshadow/modernbert-indirect-injection-detector (Apache-2.0, ModernBERT-base, 150M parameters). On AgentDojo tool outputs it scores AUC 0.984 and FPR 0.031, against 0.793 and 0.333 for ProtectAI’s prompt-injection v2 classifier.
- Production stays shadow-only for now: one benchmark, one attack template, one run, and no adaptive attacker. Why, and what would change that, is at the end.
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.
- Labeling rule. Positive if the content contains text trying to get the processing agent to act or answer in a way the content’s legitimate role does not call for: instructions addressed to the model, fake system or user turns, encoded instructions with a decode-and-follow cue. Instructions addressed to the human reader are negative. So are injection strings quoted as examples in a security write-up, unless something tells the reading agent to follow them.
- Data. About 90k training documents from permissively licensed sources: LLMail-Inject phase 1, InjecAgent, BIPIA attack strings, Gandalf and multilingual payloads, and an 18-family attack taxonomy composed into real carriers (web pages, emails, code, tool JSON) with 12 framings and 11 obfuscations. Negatives include FineWeb-Edu, Wikipedia in 8 languages, OASST1, permissively licensed code, and generated look-alikes such as IT emails and security write-ups that quote injections.
- Splits are by group (attack family, goal, framing, payload cluster, host document), never by row, and the build aborts on any leak. AgentDojo is never trained on: it was held out for this experiment, and the classifier was evaluated on it once, after the model and threshold were frozen.
- Scoring. Text is split into 512-token windows with 128-token overlap; the document score is the maximum over windows, so an injection buried at the end of a long page still counts.
| Split | What it measures | n | This detector | ProtectAI v2 |
|---|---|---|---|---|
ood_agentdojo | AgentDojo tool outputs, 5 attack templates; never trained on, evaluated once after freezing | 914 | AUC 0.984 · recall 0.913 · FPR 0.031 | AUC 0.793 · recall 0.721 · FPR 0.333 |
ood_llmail_p2 | LLMail-Inject phase 2: adaptive human attacks (positives only) | 3,832 | recall 0.985 | recall 0.544 |
eval_notinject | Over-defense: benign text full of trigger words (negatives only) | 339 | FPR 0.088 | FPR 0.434 |
eval_bipia_bare | Bare task insertion, ambiguous by design (recall only) | 400 | recall 0.645 | recall 0.190 |
eval_deepset | Direct user-turn injection, a different task (reference only) | 662 | AUC 0.811 | AUC 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:
- Shadow (production): taint applies as before; the score is written to the audit log and nothing else changes.
- Detector-gated taint (bench build only; the production binary refuses this mode): an untrusted result taints the session only if its score is at or above the frozen threshold (0.80). If the sidecar is unreachable, times out, or the result is over 64k characters, the result taints: the mode fails closed.
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.
| Configuration | Utility, benign | Utility under attack | Attack 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 taint | 33.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 taint | 72.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:
| Cases | Both solved | Neither | Only blanket | Only gated | p |
|---|---|---|---|---|---|
| Benign (97) | 28 | 23 | 4 | 42 | 5.1e-09 |
| Under attack (200) | 63 | 125 | 3 | 9 | 0.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.
| Suite | Pre-fix gateway, blanket taint (published run) | Post-fix gateway, blanket taint | Post-fix gateway, detector-gated taint |
|---|---|---|---|
| workspace | 32.5% 13/40 · [20.1–48.0] | 35.0% 14/40 · [22.1–50.5] | 70.0% 28/40 · [54.6–81.9] |
| travel | 50.0% 10/20 · [29.9–70.1] | 50.0% 10/20 · [29.9–70.1] | 65.0% 13/20 · [43.3–81.9] |
| banking | 43.8% 7/16 · [23.1–66.8] | 43.8% 7/16 · [23.1–66.8] | 56.2% 9/16 · [33.2–76.9] |
| slack | 4.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.
| Tool output | Flagged | Cleared | Sidecar error |
|---|---|---|---|
| Benign sessions, clean output | 3 | 244 | 7 |
| Attacked sessions, clean output | 0 | 206 | 2 |
| Attacked sessions, output carrying injected text | 166 | 30 | 24 |
| of which contains the injection’s opening | 145 | 7 | 23 |
- False alarms on benchmark traffic were rare. In benign sessions it flagged 3 of 247 scored outputs, spread over 3 tasks.
- Recall depends on what counts as injected. 166 of 196 scored outputs carrying injected text were flagged (85%). Counting only outputs that contain the opening of the injection, it is 145 of 152 (95%). The other 23 cleared outputs contain only part of the injected text.
- Missed sessions did not become compromises. In 180 attacked cases the agent read injected text. In 22 of them the detector never flagged anything and no scoring call failed, so the session was never tainted and writes stayed available (approved automatically in this benchmark). AgentDojo recorded no successful attack in any of the 22, and 13 completed the user’s task. Why the attacks failed there, I have not analyzed.
- The sidecar was the weakest part. 36 scoring calls failed, every one with “operation timed out” against the 10-second limit, on a CPU sidecar shared by three benchmark workers. Successful scores took 242 ms median and 1,991 ms at p95 (n = 724). 24 of the matched failures were on outputs carrying injected text. Fail-closed handling tainted every one; without it, each would have been treated as clean.
- One run error: travel/user_task_8/none/none (benign) ended with a harness error in the gated run and counts as unsolved.
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.
- One attack template. All 200 pairs use
important_instructions, AgentDojo’s strongest template, and the detector’s weakest AgentDojo template offline. - No adaptive attacker. Nobody optimized an injection against the classifier. A detector that is known and queryable can be evaded; treat the score as one signal.
- One run per configuration, one model, auto-approved prompts. The paired benign difference is very unlikely to be chance within this run; whether it repeats across runs and models is untested. The attacked-utility difference is not significant even within the run.
- Benign false-positive evidence is thin. 247 benign outputs say the rate is low on AgentDojo, not on my real traffic.
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:
- An adaptive attack run against the sidecar: paraphrases, encodings and split payloads of the
important_instructionsletter, using the red-team lab I already run weekly against the local models. - 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?
- 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.
- 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
- Model: Hugging Face, with the model card, data documentation (sources, licenses, labeling rule, splits) and thresholds.
- Agent runs: AgentDojo 0.1.35, benchmark v1.2.2, 297 cases per configuration, approvals auto-approved. Gateway bench build
f5ac9c9(binary sha256 startsa6edaf29) for the shadow and gated runs; the pre-fix row is the published run (3c43ec6). Detector checkpointmbert-base-v1, threshold 0.8023, sidecar timeout 10 s. - Raw data: detector-gated-taint-raw.tar.gz (2.3 MB) contains every case record for the three configurations, the gateway’s detector and taint audit events for the two new runs (exported from its state database; nothing else from it), the run script, both gateway configs, and the analysis. The owner’s home directory was replaced with
~, and an automated check found no credentials, tokens, keys or private addresses. Emails, account numbers and passwords in the traces are AgentDojo’s synthetic fixtures.SHA256SUMSis inside. - Recompute:
python3 analysis/analyze_detector.py agentdojo/runs > out.jsonproduces the summary that every agent-run table on this page is built from; the packaging script checks that it matches the published one exactly. The classifier table comes from the model card. - Source: the gateway and detector training repositories are private; source is available on request.