Direct answer
Which controls limit prompt-injection blast radius in a tool-using AI system?
Track where content came from, keep authorisation outside the model and grant each action only the capability required for that request. This narrows the reachable capability union even when the model has processed untrusted instructions.
Scope: This article covers control boundaries for tool-using systems. It does not prescribe a complete identity or tenancy architecture.
Where does the untrusted content actually arrive?
If you are going to defend a tool-holding system, you first have to be honest about how many doors it has. Most designs enumerate one.
There are four ingress lanes on a system of this shape, and they are not equally trusted in practice even though they should be. The first is the user turn, which everybody filters. The second is retrieved content from a knowledge layer, which most teams treat as trusted because they own the corpus, forgetting that the corpus is often populated from ticket bodies, uploaded documents, supplier submissions and crawled pages. The third is a document or attachment handed to the model in the course of the task. The fourth is the result returned by a tool call, which almost nobody treats as untrusted at all, and which is the subject of its own section below.
All four arrive in the same context window and are indistinguishable inside it. Delimiters help a little and prove nothing: a delimiter is a convention the model has learnt to respect, not a boundary the runtime enforces, and content that contains your delimiter is content the attacker chose.
What holds instead: the decision moves outside the model
If the model cannot be relied on to distinguish instruction from data, then the decision about whether an action happens has to be made somewhere the model does not reach. This is not a new idea and it now has a solid published lineage.
The Dual LLM pattern, described by Willison in 2023, is the earliest clean statement of it: one privileged model that sees the trusted user request and can use tools but never sees untrusted content, and one quarantined model that processes untrusted content and holds no tools at all. Values pass between them by reference rather than by value, so the privileged side manipulates variables whose contents it has never read.
CaMeL, published by Debenedetti and colleagues in 2025, is the version with the engineering worked through. The trusted query is used to generate an explicit program, so control flow and data flow are extracted from the user's request rather than emerging from a conversation. A custom interpreter executes that program, tracks the provenance of every value, and evaluates a security policy before each tool call. Untrusted data can populate a value, and it cannot alter the program. The paper reports 77 per cent of AgentDojo tasks solved with provable security, against 84 per cent for an undefended system, which is a real capability cost and an honest one to publish.
The number worth arguing about is not the 77. It is the seven points of difference, because that is the actual price of the guarantee, and it is a far smaller price than most architecture conversations assume when they reject deterministic control flow as too restrictive.
Two facts carry that decision. The first is provenance: where did each value in this call come from, and did any of it originate in untrusted content. The second is capability: what is this session actually permitted to do, decided before the session began.
// The decision point. Deterministic, outside the model, and reading two
// things the model cannot influence: where each value came from, and what
// this session was granted before it started.
//
// Written as an idiomatic illustration of the pattern, not as a
// transcription of any deployed system.
/** Follows a value everywhere it goes. Set once, at the point of ingress. */
type Provenance = "trusted_user" | "retrieved" | "document" | "tool_result";
interface Tainted<T> {
value: T;
/** Every lane the value passed through. Union, never overwritten: a value
derived from an untrusted one stays untrusted. */
from: ReadonlySet<Provenance>;
}
interface Capability {
tool: string;
/** read | write_proposal | outward_facing. The class decides whether an
untrusted-derived argument is admissible at all. */
actionClass: ActionClass;
}
/**
* Called by the runtime before any tool executes. Never called by the model,
* never described to the model, and not a tool the model can invoke.
*/
export function admit(call: ProposedCall, session: Session): Decision {
// 1. Capability. Not "is the model allowed to ask for this" but "was this
// session granted it before the first token was generated". A tool the
// session does not hold is absent, not refused, so there is nothing for
// the model to retry against.
const granted = session.capabilities.get(call.tool);
if (!granted) return refuse("capability_not_held", call);
// 2. Provenance. The rule that actually stops the opening example: no
// argument to a consequential call may carry untrusted provenance,
// however plausible the argument looks.
const tainted = call.args.some((arg) => arg.from.has("retrieved")
|| arg.from.has("document") || arg.from.has("tool_result"));
if (tainted && granted.actionClass !== "read") {
// Not a refusal to be softened later by a better model. The value is
// attacker-influenceable and the action is consequential, which is the
// definition of the thing being guarded against.
return refuse("untrusted_value_on_consequential_call", call);
}
// 3. Outward-facing calls stop regardless of provenance, because the cost
// of being wrong is set by the action, not by the confidence.
if (granted.actionClass === "outward_facing") {
return holdForPerson(call);
}
return allow(call);
}Neither of those is inferable from the tool call itself, which is why they have to be tracked alongside every value rather than reconstructed at the point of decision. A string that arrived from a retrieved document and a string the user typed are the same string by the time they reach a tool argument. Only the label tells them apart.
Injection blast radius is the reachable capability union
This is where the earlier work on this site meets the argument. The multi-agent piece defines blast radius as the set of things that are different if a step ran wrongly and nobody noticed, and settles autonomy per action against three tests: reversible, visible, outward-facing. That definition stands, and this is a narrower thing built on top of it.
Stated that way, the question a review can actually ask stops being whether the assistant is well behaved and becomes what an attacker can do with this session. That question has a determinate answer you can compute from a registration table, and it does not depend on anybody's confidence in a model.
It also changes the shape of a design conversation. A more capable model does not shrink this number. Better prompting does not shrink it. It shrinks when a tool is removed from a session, when a capability is scoped narrower, or when a value's provenance stops it from being used as an argument to a consequential call. Those are the only three levers, and all three are ordinary engineering.
The tooling side of this we have written about separately: authorisation tiers declared at registration rather than checked by convention, credentials resolved per request and never entering model context, and a session bound to one tenant so cross-tenant reach is a call the model cannot formulate rather than one it is asked not to make. The injection argument is the reason those properties are load-bearing rather than tidy. They are what makes the union small.
The return channel nobody treats as untrusted
The fourth lane is the one most likely to be missing from a threat model, because it does not look like input at all. When a tool returns, its result goes into the model's context, and that result is very often somebody else's content: a ticket body, a customer note, a product description, a page fetched from the web, a row in a table that a user filled in last year.
A system that carefully sanitises the user turn and then places raw tool output into context has built a filter on the front door and left the loading dock open. This is the loop that turns a single injection into a chain: the model calls a read tool, the result contains an instruction, the model calls a second tool in response, and the second tool is the one that matters.
This is also where OWASP's LLM05, improper output handling, earns its place, and where it is worth being exact about what output means. It is not only the text shown to a user. It is every value the model produced that something downstream consumes: a tool argument, an identifier, a URL, a query fragment, a value written to a store that another system will later read as fact. Each of those is untrusted input to whatever receives it, and each needs validating there rather than where it was generated.
The protocol layer has learnt the same lesson in its own vocabulary. The Model Context Protocol specification's security guidance is largely a catalogue of what goes wrong when a component trusts something it did not verify: a proxy acting as a confused deputy on someone else's consent, tokens passed through without their audience being checked, sessions used as though they were authentication, and scopes granted broadly up front because it was more convenient than elevating later. None of those is a model problem. All of them widen an injection's reach.