The rule up front: if a client sends message history back to your agent, that history is untrusted input. Re-attach your system prompt on the server, and sanitize everything that came back — system prompts, file references, and half-finished tool calls. Pydantic AI v2.5.0 (July 3, 2026) ships a sanitize_messages step on its AG-UI adapter that does exactly this. This guide covers what it strips, the subtle ordering bug it also fixed, and how to replicate the checks if you don't use that adapter.

Why returned history is an injection surface#

Most chat UIs are stateless on the server. The browser holds the conversation and posts the whole array back every turn so the model has context. Convenient — and it means the exact ModelMessage list your model sees originated on a machine you don't control.

A modified or malicious client can do three structural things that have nothing to do with clever wording inside a user message:

None of these require jailbreak prose. They exploit the shape of the message list, which is why "we tell the model to ignore malicious instructions" doesn't touch them.

The bytes your model sees came back from the client. That is a request body, and you validate request bodies.

What sanitize_messages actually does#

This shipped as part of the rapid v2 point-release run that followed the v2 "capabilities" harness rework: v2.1 (Anthropic web-tool), v2.2 (Claude Sonnet 5), v2.3 (Z.AI provider), v2.4 (a GEval evaluator), and v2.5 (this sanitizer) all landed in the first ten days of July. Pydantic AI's AG-UI UIAdapter exposes sanitize_messages to clean the history the front end returns. Per the docs, it:

  1. Strips client-supplied system prompts — you re-attach yours server-side, so instructions come from your code, not the wire.
  2. Drops file URLs whose scheme is not HTTP(S) — no file://, no gs://, no smuggled local paths.
  3. Resets FileUrl.force_download to False for any URL not on your allowlist.
  4. Drops uploaded-file references the client shouldn't be able to conjure.
  5. Removes unresolved tool calls at the end of the history, so a turn can't begin on a dangling call.

Wire it in where you accept the run, then run the agent on the cleaned list — not the raw one:

from pydantic_ai.ag_ui import UIAdapter

# `incoming` is the message history the browser POSTed back.
adapter = UIAdapter(agent)
safe_messages = adapter.sanitize_messages(incoming)   # v2.5.0+

result = await agent.run(
    user_input,
    message_history=safe_messages,   # never the raw client array
)

The exact signature and options live in the docs; the load-bearing idea is that safe_messages — not incoming — is what reaches the model.

The bug the same release fixed#

Worth knowing because it shows how easy this is to get subtly wrong. Advisory GHSA-jpr8-2v3g-wgf9 (CWE-863, Incorrect Authorization), fixed in 2.5.0, was in the dangling-tool-call strip itself.

The strip decided which trailing message to remove using an index computed before the rest of sanitization ran. But sanitization can drop a trailing client message — and when it did, that pre-computed index now pointed at the wrong element. The result: an unresolved tool call could be re-exposed as the new tail of the "sanitized" history, defeating the very check that was supposed to remove it.

The fix is the lesson: compute the tail after cleaning, not before. Any order-dependent validation has to run on the post-sanitization list, or an earlier step silently invalidates a later one's assumptions. If you hand-roll this, sanitize first, then look for a dangling call.

If you don't use the AG-UI adapter#

The vulnerability isn't specific to Pydantic AI — it's specific to trusting returned history. Any framework where the client round-trips the conversation has the same five holes. If you're on the AG-UI adapter, the move is trivial: upgrade to >=2.5.0 and route history through sanitize_messages. If you assemble history yourself, replicate the checks:

That's the whole pattern. It rode in on a version bump, but it outlives it: history that touched a client is input, and input gets sanitized before it reaches the model.