The Email Nobody Clicked
An assistant reads a user's inbox to summarise it. One message contains a line of text addressed to the assistant, telling it to gather recent files and send them to an outside address. Nobody clicks anything. The user asks for their morning summary, and the agent follows the instructions it just read, using permissions the user gave it for a completely different reason. CVE-2025-32711, known as EchoLeak, was this class of attack against a real product.
Your OWASP Top 10 habits still apply to an agent, but they don't cover this. A web app does what the code says. An agent does what it decides, based on whatever text reached it, using real credentials. So OWASP published a separate list in December 2025, the Top 10 for Agentic Applications, built with more than 100 contributors. It complements the LLM Top 10, because an agent inherits those risks and adds its own.
- Your agent reads anything a stranger could have written, such as email, tickets or web pages
- It has write access to anything
- It runs on a shared service account
- You have not asked what happens when it does the wrong thing confidently
Start With Least Agency
Least privilege limits what an agent can reach. OWASP adds Least-Agency, which limits what it can decide. Their advice is to avoid unnecessary autonomy, because deploying agentic behaviour where it isn't needed expands the attack surface without adding value. That is the most useful idea in the document, and it costs nothing to apply. If a workflow can be a fixed sequence with one model call in it, it should be.
The Ten Risks
| ID | Risk | What it looks like | First control |
|---|---|---|---|
| ASI01 | Agent Goal Hijack | A document redirects the agent's objective | Keep retrieved content apart from instructions, and confirm sensitive actions |
| ASI02 | Tool Misuse and Exploitation | A legitimate tool used for a bad outcome | Per-tool scopes, rate limits and egress allowlists |
| ASI03 | Identity and Privilege Abuse | A hijacked agent inherits a broad token | Per-agent identity with short-lived, task-scoped credentials |
| ASI04 | Agentic Supply Chain Vulnerabilities | A poisoned MCP server, skill or prompt | Signed releases, pinned versions and an AI bill of materials |
| ASI05 | Unexpected Code Execution (RCE) | Text reaches a shell or interpreter | Sandbox with egress denied by default |
| ASI06 | Memory & Context Poisoning | Planted text activates in a later session | Validate writes, and scope memory per user and task |
| ASI07 | Insecure Inter-Agent Communication | A spoofed or replayed agent message | Mutual authentication and signed messages |
| ASI08 | Cascading Failures | One bad output spreads through everything that trusts it | Blast-radius limits and circuit breakers |
| ASI09 | Human-Agent Trust Exploitation | A persuasive summary gets a bad action approved | Show the raw action, not the agent's description of it |
| ASI10 | Rogue Agents | An agent drifts outside policy and still looks healthy | Behaviour baselines, an owner and a tested kill switch |
The Pattern Behind Most of Them
The “lethal trifecta” is the quickest way I know to spot a dangerous design. An agent becomes exploitable when it has all three of: access to private data, exposure to untrusted content, and a way to send data out. Remove any one leg and that data-theft path closes, provided you counted every channel, including links and images the agent renders. Most fixes come down to removing a leg for the risky part of the flow, since you rarely need all three at once.
flowchart LR
U["Untrusted content<br/>email, web page"] --> A["Agent"]
P["Private data<br/>inbox, files"] --> A
A --> E["Outbound action<br/>send, fetch URL"]
A -.-> G{"Policy gate"}
G -- deny --> X["Blocked, logged"]
G -- approve --> E
Enforce It at the Tool Boundary
A sentence in the system prompt saying “never send data externally” is a request. Injection works precisely because the model can be talked out of requests. Put the rule in code that runs outside the model, where text can't reach it:
ALLOWED_RECIPIENT_DOMAINS = {"contoso.com"}
TAINTED_SOURCES = {"email", "web", "ticket"}
def authorize_tool_call(call: ToolCall, session: Session) -> Decision:
# Side effects come from your registry, never from the model's request.
# Anything unregistered is denied, so the policy fails closed.
tool = TOOL_REGISTRY.get(call.name)
if tool is None:
return Decision.deny("unregistered tool")
# The session records what the agent has read, so the check does not
# depend on what the model says about itself.
tainted = bool(session.sources_read & TAINTED_SOURCES)
if call.name == "send_email":
# Parse a structured recipient list: one string can hide extra addresses
domains = {parse_address(to).domain for to in call.args["to"]}
if not domains or not domains <= ALLOWED_RECIPIENT_DOMAINS:
return Decision.deny("external recipient")
if tainted:
return Decision.require_human(show_raw=call.args)
return Decision.allow()
if tool.side_effect == "destructive":
return Decision.require_human(show_raw=call.args)
if tool.side_effect == "read":
return Decision.allow()
return Decision.deny("no policy for this tool")
The session taint check is the useful bit. Once an agent has read untrusted content, treat everything it proposes afterwards as potentially influenced. Note the show_raw argument too. ASI09 exists because a person will approve whatever a confident summary says, so the approval screen has to show the real recipient and the real body.
The MCP Layer Has Its Own List
OWASP also maintains an MCP Top 10, currently in beta. Its items include token mismanagement, scope creep, tool poisoning, command injection and shadow MCP servers. If you run MCP in a company, the shadow-server item is the one to check today: count how many servers people have connected without telling anyone. The answer is rarely zero.
What Goes Wrong
1. Guardrails only in the prompt
Prompt rules reduce mistakes. They don't stop an attacker, because the attacker is writing into the same channel.
2. One service account for every agent
You lose the audit trail and hand every agent the union of all permissions. Give each agent its own identity, and act on behalf of the user where you can, using the OAuth 2.0 flows you already have.
3. Memory without provenance
If a poisoned fact gets into long-term memory, it will influence sessions for months. Record where each memory came from, and make it possible to find and remove everything from one source.
4. No kill switch you have tested
“We can revoke the token” is a plan. Run the drill and time it. Check that in-flight work stops as well, because durable runs will happily resume.
What I Would Do First
Draw one agent and mark each flow as private data, untrusted content or outbound action. Anywhere all three meet, add a gate in code. Then replace shared credentials with per-agent ones. Add the rest of the list in order of what your agent can actually reach, and put injection attempts into your red-team evals so the gate stays honest after the next model upgrade.

