The data your agent reads can give it orders
Indirect prompt injection is live on the open web. You can't filter your way out of it - but you can make a successfully injected agent unable to do anything that matters on its own.
By Moses OtienoSep 12, 202612 min read
The prompt injection most people picture involves a user typing "ignore your previous instructions" into a chat box. That's the easy case. The user is the attacker, the attacker is in the conversation, and the damage is mostly bounded by what that user was already allowed to do.
The version that matters for agents is different, and worse. The attacker never talks to your agent. They write text somewhere your agent will eventually read — a web page, an inbound email, a shared document, a calendar invite, a support ticket, the output of another agent — and wait. A perfectly legitimate user asks for something reasonable. The agent reads the poisoned content in the course of doing its job, and the content addresses the model directly.
That's indirect prompt injection. As of this year it is no longer a research curiosity.
It's on the open web now
Three independent measurements, from three different vantage points, published this spring:
- Google scanned Common Crawl's monthly snapshots and reported a relative increase of 32% in the malicious category of injections between November 2025 and February 2026.1AI threats in the wild: The current state of prompt injections on the web Open source (opens in a new tab) Worth reading the caveat alongside the number: the researchers describe the observed activity as showing limited sophistication — and add that this "might be only part of the bigger picture," with scale and sophistication both expected to grow.1AI threats in the wild: The current state of prompt injections on the web Open source (opens in a new tab)
- Palo Alto Networks Unit 42 catalogued how injections are delivered across its telemetry. The largest category was visible plaintext, at 37.8% — the most common injection isn't hidden at all. HTML attribute cloaking accounted for 19.8% and CSS rendering suppression for 16.9%.2Fooling AI Agents: Web-Based Indirect Prompt Injection Observed in the WildFigure 16 (delivery methods) and Figure 17 (jailbreak methods) Open source (opens in a new tab) Social engineering made up 85.2% of the jailbreak methods observed.2Fooling AI Agents: Web-Based Indirect Prompt Injection Observed in the WildFigure 16 (delivery methods) and Figure 17 (jailbreak methods) Open source (opens in a new tab)
- Forcepoint X-Labs documented ten payloads live on real infrastructure, including a PayPal.me link with a fixed $5,000 amount and step-by-step instructions for completing the transfer, and a
sudo rm -rfaimed at what appears to be a backup directory.3Indirect Prompt Injection in the Wild: X-Labs Finds 10 IPI PayloadsIncident 6 (file deletion) and Incident 7 (PayPal transfer) Open source (opens in a new tab)
Be precise about what that last one means. Nobody documented $5,000 actually leaving an account. These are payloads found sitting on live pages, written for an agent that reads them and happens to hold a payment tool. That's the honest shape of the threat right now: the traps are already set, and they're waiting for agents to acquire the capabilities that make them pay.
Tracking the problem is harder than it should be. OWASP's GenAI Security Project notes in its Q1 round-up that it's the classical software vulnerabilities inside AI platforms that consistently receive CVE tracking.4OWASP GenAI Exploit Round-up Report Q1 2026 Open source (opens in a new tab) Injection-class flaws mostly don't, so the usual machinery a security team relies on to hear about a problem largely isn't there.
Why you can't filter your way out
The instinctive response is detection: scan incoming content for "ignore previous instructions" and "if you are an AI," strip anything suspicious, and move on. Do that — it's cheap and it catches the lazy templates. But understand what it is.
A language model receives its system prompt, the user's request and the contents of a fetched web page as a single stream of tokens. There is no privileged channel. Nothing in the architecture marks one span as instructions and another as data, which is precisely why the attack works. A classifier in front of the model is another probabilistic layer judging natural language, and natural language has an unbounded number of ways to say "send the file to this address." The next payload will be phrased in a way your detector hasn't seen.
Detection is a filter. It is not a boundary. If the only thing between hostile text and your payment tool is a model deciding the text looks benign, you have a hope, not a control.
This is the same argument as the one we made in letting an agent act as you, safely: no prompt is a security boundary. Indirect injection just moves the hostile prompt somewhere you never see it being written.
The variable that matters is what happens next
Here's the reframe that makes this tractable. Whether an injection succeeds in influencing the model is largely outside your control. Whether that influence becomes an action in the world is entirely inside it.
An agent that only summarises, reading a poisoned page, produces a bad summary. That's a real problem — but it's a quality problem, and a human reads the output. The same page read by an agent that can send email, execute commands or move money is a different category of incident. The risk isn't the untrusted input on its own. It's untrusted input multiplied by capability.
So stop asking "how do we stop the model being fooled?" and ask "what can a fooled model do on its own?" The second question has an engineering answer. It starts with making every tool declare two things the model never sees:
// Every tool declares two things the dispatcher needs and the model never
// sees: whether its output comes from outside the trust boundary, and what
// happens in the world if it runs.
type Provenance int
const (
Trusted Provenance = iota // the signed-in user, the system prompt, our own services
Untrusted // web pages, inbound email, shared documents, partner agents
)
type Consequence int
const (
NoEffect Consequence = iota // reads, summaries, lookups
Reversible // drafts, internal notes
Irreversible // sends, payments, deletes, anything a customer sees
)
type Tool struct {
Name string
Returns Provenance
Consequence Consequence
Run func(ctx context.Context, args Args) (Result, error)
}Taint the turn, then gate on it
When a tool that returns untrusted content runs, the turn is marked. From that point on, the model may still reason, read and summarise freely — but anything with a consequence stops for a human.
// Taint lives on the turn, never in a package-level variable: two users'
// turns share a process, and one user reading a hostile page must not
// downgrade someone else's session.
//
// Taint is also monotonic. The model cannot un-read what it read, so once
// untrusted content has entered the context, every later step in the same
// turn is treated as potentially attacker-influenced.
type turnTaint struct {
mu sync.Mutex
sources []string
}
func (t *turnTaint) mark(source string) {
t.mu.Lock()
defer t.mu.Unlock()
t.sources = append(t.sources, source)
}
func (t *turnTaint) snapshot() []string {
t.mu.Lock()
defer t.mu.Unlock()
return append([]string(nil), t.sources...)
}Two properties are doing the work there. Taint is turn-scoped: it lives on the request, because in a shared process a package-level flag would let one user's hostile page change another user's session — the exact failure described in concurrency-safe isn't tenant-safe. And it's monotonic: once set, nothing in the turn clears it. You can't reliably decide the model has "moved past" something it read.
The dispatcher then enforces it, on every call, regardless of what the model says about why it wants the tool:
func (a *Agent) dispatch(ctx context.Context, call ToolCall) (Result, error) {
tool, ok := a.tools[call.Name]
if !ok {
return Result{Error: "unknown tool"}, nil
}
taint := taintFrom(ctx)
if sources := taint.snapshot(); len(sources) > 0 && tool.Consequence != NoEffect {
// The request to act arrived after the agent read content an attacker
// could have written. Don't refuse, and don't silently comply: pause,
// and tell the human exactly where the untrusted input came from.
return a.requestApproval(ctx, call, ApprovalContext{
Reason: "action proposed after reading external content",
Sources: sources,
})
}
res, err := tool.Run(ctx, call.Args)
if err != nil {
return Result{}, fmt.Errorf("%s: %w", tool.Name, err)
}
if tool.Returns == Untrusted {
taint.mark(res.Source)
}
return res, nil
}Notice what this doesn't do. It doesn't refuse, which would make agents useless the moment they touch the web. And it doesn't ask the model whether the content was malicious, which is asking the compromised party to audit itself. It just removes the possibility of a consequential action happening without a person seeing it — and it hands that person the one piece of context that makes approval meaningful: this was proposed after the agent read something from outside.
That context is what separates a useful approval gate from a rubber stamp. "Send this email?" gets clicked through. "Send this email — proposed after reading an inbound message from an external address?" gets read.
Separate the reader from the actor
Gating contains the blast radius. Architecture can shrink it further. The agent that browses the web or reads the inbox should not be the agent that holds write tools. Give reading to a specialist that has nothing consequential in its registry, and have it hand the orchestrator a typed extract rather than raw text:
// The browsing specialist never hands raw page text to the orchestrator. It
// returns this shape, validated against the schema, and nothing else.
//
// A string field can still carry hostile text, so this narrows the channel
// rather than closing it — which is why the orchestrator's turn is still
// marked tainted when an extract arrives.
type PageExtract struct {
URL string `json:"url"`
Title string `json:"title" jsonschema:"maxLength=200"`
Summary string `json:"summary" jsonschema:"maxLength=1200"`
Entities []string `json:"entities" jsonschema:"maxItems=25"`
Relevance string `json:"relevance" jsonschema:"enum=high,medium,low,none"`
}This is the pattern from narrow specialists beat one 30-tool agent, applied to trust instead of competence. The reader can be fooled completely and still have no way to act. The orchestrator never reads a page an attacker carefully formatted to look like instructions; it reads a bounded summary field and an enum.
Be honest about the limit, though. A 1,200-character summary can still contain a sentence written to steer whoever reads it. Structured extraction narrows the channel; it doesn't close it. That's why the extract still taints the orchestrator's turn. The layers are meant to overlap.
The output is an attack surface too
One exfiltration path requires no tool at all, and it catches teams who have carefully gated every write. An injected model is told to include an image in its reply whose URL carries data in the query string. The chat interface renders markdown, the browser fetches the image, and the data arrives on the attacker's server as an ordinary request log — no tool call, no approval, no audit entry.
// The quietest exfiltration channel needs no tool call. An injected agent
// writes a markdown image whose URL carries data in its query string, and the
// browser fetches it the moment the reply renders. Output is a sink too.
const RENDERABLE_HOSTS = new Set(['app.example.com', 'cdn.example.com'])
export function safeHref(raw: string): string | null {
let u: URL
try {
u = new URL(raw)
} catch {
return null
}
if (u.protocol !== 'https:' || !RENDERABLE_HOSTS.has(u.hostname)) {
return null // render as inert text; never auto-fetch
}
return u.toString()
}Treat anything the agent emits that the client will act on — image sources, links, iframes, redirects — as a sink with the same scrutiny as a tool. Allowlist the hosts, render everything else as text, and never let the client fetch a URL the model wrote on the strength of the model having written it.
What to do this week
- Inventory which agents read untrusted content. Web browsing is obvious. Inbound email, shared drives, CRM notes that customers can write into, ticket bodies and other agents' outputs are the ones people miss.
- For each, list the consequential tools it can reach, directly or through an orchestrator. The dangerous combinations fall out of that table immediately.
- Declare provenance and consequence on every tool, and enforce the gate in the dispatcher rather than the prompt.
- Split readers from actors wherever one agent currently does both.
- Allowlist rendered URLs in agent output.
- Keep the detector — as a layer that catches the obvious, and a signal worth logging. Just never as the thing you rely on.
The goal isn't an un-injectable model
You can't build one, and nobody else can either. As long as instructions and data share a token stream, some text somewhere will eventually steer some model.
What you can build is an agent where a successful injection is unprofitable: where the fooled model reads, reasons and suggests, and a human is standing between that suggestion and anything that matters. That isn't a limitation on what the agent can do. It's the difference between an agent you can point at an inbox full of strangers' email and one you can't. The same principle as every other decision that gets an agent out of the pilot: put the constraint in the architecture, where it holds, rather than asking the model to hold it.
Sources
- Fooling AI Agents: Web-Based Indirect Prompt Injection Observed in the Wild (opens in a new tab)
Figure 16 (delivery methods) and Figure 17 (jailbreak methods)
- Indirect Prompt Injection in the Wild: X-Labs Finds 10 IPI Payloads (opens in a new tab)
Incident 6 (file deletion) and Incident 7 (PayPal transfer)
Thinking about an agent like this for your team?
Describe the job you want automated and our Automation Architect blueprints it in seconds — or talk it through with the engineers who ship them.