DeepConcepts

Agentic Security / agent / defenses / threat model

Why Prompt-Level Injection Defences Fail

The misconception

That a defence with a high enough catch rate is good enough, so the work is pushing 95% to 99%. The relevant number is not the catch rate but the residual multiplied by the number of attempts, and the attacker chooses the number of attempts. Nine hundred and ninety-nine blocked injections buy nothing if the thousandth lands, which is why guardrail benchmarks and 'ignore previous instructions' regexes measure the wrong quantity. The defences that do hold change the exponent's base to zero: the untrusted text cannot reach a privileged action at all — CaMeL reports 77% of AgentDojo tasks solved with provable security against 84% undefended, and that 7-point utility cost is the actual price of the fix.

13 min

A filter that blocks 92% of prompt injections does not make you 92% safe. It makes you safe against the 92%, and the attacker only ever sends one of the other 8%. The number that decides your outcome is not the block rate. It is whether a payload that gets through exists at all, because the attacker can look at your defence and go find it.

This is the thing that separates the defences that work from the ones that feel like they work, and it is arithmetic rather than opinion. Below is a corpus of twelve concrete injection variants — real techniques, each one described by what it actually does to the text — and a stack of defences you can switch on. The simulation runs every variant against the enabled defences and reports two numbers side by side: how many variants were blocked, and how many attempts the attacker needs.

Start with the keyword filter, the delimiter and the classifier all on. Read the block rate. Then read the number above it.

defences in the stack
who is attacking you

The classifier is modelled as a threshold on a per-variant obviousness score, which is the honest shape of a detector: it catches what looks like an attack, and legitimate text that looks like an attack goes with it. The benign corpus below it is ten real messages an agent of this kind reads every day, several of which quote injections for perfectly good reasons.

attempts until the first one lands
variants blocked
variants that still pass
legitimate messages refused
tasks the agent can still finish
type of guarantee
Twelve injection variants against the current stack

blocked, and by what · passes the whole stack. One row in magenta is enough; the attacker does not have to send the others.

The same stack, applied to ten messages that are not attacks

allowed through · refused. Every one of these is a real thing somebody sent to a real triage agent.

What is real and what is a model. The variants are real techniques and the feature each one exploits is stated on its row; which defence catches which variant follows from those features and nothing else — a regex over literal phrases cannot match a homoglyph, an escaper cannot see an instruction that forges no delimiter. The obviousness score and the threshold are a model of a detector: a real classifier's ordering would differ, but every detector has some ordering and some threshold, and the shape of the trade does not depend on the numbers. Two utility figures are measured: CaMeL reports 77% of AgentDojo tasks solved with provable security against 84% for the same agent undefended. The plan-then-execute cost is not measured — it is a flat 7% reduction applied on top, chosen to be visible rather than to be right, and combining it with capability restriction takes the figure below both anchors. Finally, when a structural defence is switched on, a blocked row names it rather than the filter that would also have caught the text, because the structural verdict is the one that does not depend on recognising anything.

With the keyword filter, the delimiter and the classifier at 40 you block 8 of 12 — 67%, which would be a respectable number on a slide. The hero readout says 1. There are four variants left, the attacker needs one of them, and finding out which one costs them a single refused request.

Why the block rate is the wrong number

Switch the attacker to someone working down a list in order. At the defaults the hero number jumps to 9, and the block rate has not changed at all. That gap is the whole disagreement. The block rate describes a distribution of attacks; the hero number describes an adversary. Only one of those is what you have.

An adaptive attacker pays almost nothing per attempt. Your refusal is free information — it tells them the variant they just sent is on your list, and the next one is a different technique, not a rephrasing. Twelve variants is a small corpus; a real attacker has a public catalogue of them, several thousand adversarial suffixes generated by gradient search, and the ability to test against a copy of your model. Against an adversary who can retry, a defence with any residual at all has an expected time-to-first-success of roughly one attempt.

This is why Simon Willison's line — "in web application security 95% is very much a failing grade" — is not rhetoric. Consider what 95% means for the two kinds of defence. A parameterised SQL query does not block 95% of SQL injections; the parameter cannot become part of the query plan, so the number of attempts is irrelevant. A cross-site-scripting filter that blocks 95% of payloads is a well-known catastrophe, for exactly the reason the hero readout keeps showing you. Prompt-injection filtering is the second kind. It has an attempt count in it.

OWASP is careful about this in the standard itself: under LLM01:2025 it states that "it is unclear if there are fool-proof methods of prevention", then lists the mitigations in an order that starts with constraining model behaviour and filtering — the ones with an attempt count — and reaches least privilege at number four. Read the list as a menu rather than a ranking.

The wall you hit when you turn the sensitivity up

The obvious response to three residual variants is to catch them, so drag the classifier sensitivity slider down. Watch both panels at once.

At a threshold of 40 the stack blocks 8 of 12 and already refuses 5 of the 10 legitimate messages. Four of those five never reach the classifier at all: the keyword filter takes them, because quoting an injection is their job — a security advisory being triaged, a commit message that says "disregard the above, force-pushed", a test fixture containing an example payload, a runbook full of System: log prefixes. At 12 the residual drops to one and eight legitimate messages are refused. At 8 the residual reaches zero and nine of ten are refused, which is not an agent, it is an outage. Note what the hero readout says at that point: not "never", but none of these 12. The corpus log line is the reason. Twelve variants were fixed before you touched the slider; the attacker's thirteenth is written afterwards, with your threshold visible in every refusal you have sent them.

There is no threshold at which the residual is zero and the agent still works. That is not a failure of this particular classifier. It is what it means for two populations to overlap: the lowest-obviousness attacks are written specifically to look like the highest-plausibility legitimate traffic, and the attacker chooses their position in that distribution after seeing where you put your threshold. Every point you move the threshold, they move too, and they are optimising a single example while you are defending a distribution.

The variant at the bottom of the list is the one to look at. It is a configuration snippet whose comment field contains the instruction, in the third person, addressed to nobody. It scores 8 because there is nothing detectable about it. It is also indistinguishable from the policy paragraph in CONTRIBUTING.md that the agent is supposed to obey — which is the same observation as the absence of a privileged channel, arriving from the detector's side instead of the model's.

The defences with no attempt count in them

Switch on capability restriction. The hero readout says never, and the guarantee readout changes from "probabilistic" to "structural". No variant in the corpus works, and no variant outside it would either, because the mechanism does not involve recognising the attack. Once untrusted bytes enter the window, secrets.get_deploy_key is not in the callable set. In the language of the lethal trifecta, this removes the private-data leg after the untrusted-content leg has already been played, and removing any leg is enough. A payload that persuades the model perfectly still produces a call the harness declines to make, and persuading the model harder does not help.

Read the cost on the same row. Utility drops from 84% to 77%, and those two numbers are measured rather than modelled: they are CaMeL's reported AgentDojo results — 77% of tasks solved with provable security against 84% for the same agent with no defence. Seven points of capability is the actual price of the actual fix, and it is a smaller number than most people expect when they hear "the agent may no longer read secrets after browsing".

CaMeL gets there by splitting the agent in two. A privileged model sees only the trusted user query and emits a program; a quarantined model processes untrusted data and, in the paper's words, the extracted control and data flows mean "the untrusted data retrieved by the LLM can never impact the program flow". Capabilities attached to each value then decide, at tool-call time, whether that value is allowed to reach that sink. The interesting part is not the two models. It is that the security property is a property of the interpreter, so it holds for inputs nobody has thought of.

The companion paper by Beurer-Kellner and colleagues generalises this into six patterns — Action-Selector, Plan-Then-Execute, LLM Map-Reduce, Dual LLM, Code-Then-Execute and Context-Minimization — under one rule: once an agent has ingested untrusted input, it must be constrained so that the input cannot trigger consequential actions. Every one of those patterns is a restriction on what is reachable, and none of them contains a detector.

Where the structural defences stop

Switch off capability restriction and switch on plan-then-execute alone. The hero number is not "never" — it is 1 against an adaptive attacker, 11 against the list-reader — and the two variants that survive are the last two on the list.

Plan-then-execute fixes the sequence of tool calls before any untrusted data is read, so an injection cannot introduce a call that was not in the plan. It does not fix the arguments, because the arguments are what the untrusted data is for — the plan says "post a comment summarising the issue", and the summary is computed from the issue. A payload that rewrites the content of a planned call, or the recipient of a planned message, is inside the plan. This is a known and documented limit of the pattern, not a flaw in this simulation, and it is why the patterns are meant to be composed rather than chosen.

The second boundary is scope. Capability restriction is only as good as the set of capabilities you enumerated. If secrets.get_deploy_key leaves the callable set but fs.read stays, and the key is on disk, you have restricted a name rather than a capability. Deciding what the capability actually is — the reachable data, not the function symbol — is the excessive agency problem, and it is decided by the permissions on the credential the tool authenticates with.

None of this makes the filters worthless. A keyword filter costs nothing, catches the drive-by traffic, and produces the log line that tells you somebody is trying. What it must not do is appear in a threat model as a control with a percentage next to it. Its correct entry is "raises the cost of the first attempt from zero to one".

Checking this on a system you actually run

Ask one question of every defence in your design: does its guarantee have an attempt count in it. If the answer is yes, it belongs in the detection column of your threat model, next to the alerting, not in the mitigation column next to the controls. This single reclassification usually empties the mitigation column, which is the accurate picture and the useful place to start. What refills it is a question about placement rather than accuracy: which layer the check sits in decides whether the constraint is one the model can talk its way past.

Then measure the residual rather than the block rate. Run your agent against AgentDojo — 97 realistic tasks and 629 security test cases, built so that attacks can adapt to your defence rather than being replayed at it — and report two numbers: utility with the defence on, and the count of security cases that still succeed. A defence that takes the second number to zero and costs you five points of the first is a good trade. A defence that takes it from 40 to 3 has not changed your exposure, because 3 is not 0 and the attacker picks.

In production, log the refusals. Every input your filter rejects is a labelled attack attempt, and the ratio of rejections to incidents is the only empirical handle you have on how much of the traffic is adversarial. Log the false positives with equal care, because the pressure that will eventually turn your classifier down comes from users who cannot file a bug report that quotes a security advisory, and that pressure is invisible until somebody counts it. Finally, keep the callable-tool set on every model call span in your traces. The claim "the secret tool was not available at that point" is either in your telemetry or it is a thing you believe.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.