Pydantic AI: a client-chosen tool call the model never made
GHSA-5pfm-2g3g-gh85
Pydantic AI ships UI adapters, VercelAIAdapter and AGUIAdapter, that let a chat frontend talk to an agent.
The frontend sends its conversation history with each request.
That is the design, and it is the reason the adapters have to be careful.
A tool call sitting unresolved at the end of a history is an instruction to execute a tool.
If the client can put one there, the client chooses which tool runs and with what arguments, and the model is never asked.
The adapters already knew this.
They strip unresolved tool calls from the end of client-submitted history, which was the fix for GHSA-jpr8-2v3g-wgf9.
The bug I reported is that a second sanitisation step ran after that one, and undid it.
Two operations, wrong order
When an application passes its own server-side message_history, the adapters do two things to the client's history:
- A. Strip unresolved tool calls from the end.
- B. Drop client-submitted compaction parts.
Both are correct.
Both are necessary.
They ran in the order A, then B.
Step A looks at the end of the history and asks "is the last thing an unresolved tool call?"
Step B then removes messages, which changes what the end of the history is.
Anything B uncovers was never checked.
The crafted history
Arrange the submitted history so the last two entries are:
[ ... , message containing an unresolved tool call , message containing only a compaction part ]Step A inspects the tail.
The tail is a compaction-only message, not an unresolved tool call, so there is nothing to strip.
The check passes.
Step B drops the compaction-only message.
[ ... , message containing an unresolved tool call ]The history now ends in exactly the thing step A exists to prevent, and step A has already run.
The agent executes that tool call with the client's arguments and no model request.
The compaction part is not load-bearing as an attack primitive.
It is just the lever: a thing the client is allowed to submit, which the server is guaranteed to remove, positioned so that removing it exposes something else.
What is bounded, and what is not
This matters for severity, and it is the part worth being honest about.
The call is not arbitrary code execution. It is constrained the same way the earlier advisory's was:
- Tool arguments are still validated against the tool's schema.
- Tools with
requires_approval=Trueor anApprovalRequiredToolsetare not executed by this path. - Only tools already available to the agent are reachable, and a client can often get the same call simply by asking the model for it.
What is actually lost is the model request, and everything attached to it:
| Control | Sees this call? |
|---|---|
| Tool argument schema validation | Yes |
| Approval requirement | Yes |
before_tool_execute guardrails |
Yes |
| Authorization inside the tool function | Yes |
| The model's own refusal | No |
before_model_request / after_model_request guardrails |
No |
So the real finding is narrower than "run any tool" and more specific: every guardrail you placed at the model boundary is blind to this call.
If your policy for a tool is "the model will not call it that way" or "my before_model_request hook catches that", this path walks past it.
You were affected if all four held:
- You serve an agent through the Vercel AI or AG-UI adapter.
- You pass a non-empty server-side
message_historyto the adapter's run method. manage_system_promptis'server', which is the default.- You rely on the model or a model-request hook, rather than on the tool function or
before_tool_execute, to constrain a tool that does not require approval.
The fix
Fixed in 2.58.0.
The adapters now drop client-submitted compaction parts before checking the end of the history.
Two statements, swapped.
Which is what you want a fix for this shape of bug to look like, because the alternative is a patch that defends against compaction parts specifically and waits for the next thing that mutates the tail.
Until you can upgrade, the mitigations are the controls in the table that still fire:
- Enforce authorization inside each tool function, against the authenticated user carried in your dependencies.
- Put tool-execution guardrails in
before_tool_execute, which runs however a tool call entered the history. - Require approval for tools whose effects matter.
What I took from it
The order of sanitisation steps is part of the security property, not an implementation detail.
Both steps here were individually correct and individually necessary. The vulnerability lived entirely in the sequence. That is a bug class that reads as clean code in review, because every line is defensible on its own.
A validated structure stops being validated the moment something mutates it.
Step A's conclusion was true when it was reached and false by the time it was used. Any transformation that runs after a check and can change what the check examined re-opens the check. This generalises well beyond chat histories, and it is the same shape as the WhoDB finding I reported five days earlier: a correct check, evaluated at a moment, trusted for a lifetime.
"The model would not do that" is not an access control.
Guardrails on the model request are useful, but they only defend paths that go through a model request. Anything that can reach the tool executor by another route needs the check sitting at the tool, which is why before_tool_execute and in-function authorization are the mitigations that survived this bug and would survive the next one.
Report the bounds.
Most of my write-up to the maintainers was about what the bug cannot do, and what would catch it. That is not hedging. A finding with honest limits gets triaged on its actual shape instead of being argued down from an inflated one, and here it got patched in five days.