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.

Read this before you ship if
  • 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

IDRiskWhat it looks likeFirst control
ASI01Agent Goal HijackA document redirects the agent's objectiveKeep retrieved content apart from instructions, and confirm sensitive actions
ASI02Tool Misuse and ExploitationA legitimate tool used for a bad outcomePer-tool scopes, rate limits and egress allowlists
ASI03Identity and Privilege AbuseA hijacked agent inherits a broad tokenPer-agent identity with short-lived, task-scoped credentials
ASI04Agentic Supply Chain VulnerabilitiesA poisoned MCP server, skill or promptSigned releases, pinned versions and an AI bill of materials
ASI05Unexpected Code Execution (RCE)Text reaches a shell or interpreterSandbox with egress denied by default
ASI06Memory & Context PoisoningPlanted text activates in a later sessionValidate writes, and scope memory per user and task
ASI07Insecure Inter-Agent CommunicationA spoofed or replayed agent messageMutual authentication and signed messages
ASI08Cascading FailuresOne bad output spreads through everything that trusts itBlast-radius limits and circuit breakers
ASI09Human-Agent Trust ExploitationA persuasive summary gets a bad action approvedShow the raw action, not the agent's description of it
ASI10Rogue AgentsAn agent drifts outside policy and still looks healthyBehaviour 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

⚠️ Common Mistakes

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.