Skip to content

UI adapters (AG-UI, Vercel AI): a dangling client-submitted tool call can execute when a trailing message is dropped during `sanitize_messages`

Moderate
dsfaccini published GHSA-jpr8-2v3g-wgf9 Jul 11, 2026

Package

pip pydantic-ai (pip)

Affected versions

>= 1.88.0, < 1.107.1
>= 2.0.0b1, < 2.5.0

Patched versions

1.107.1
2.5.0
pip pydantic-ai-slim (pip)
>= 1.88.0, < 1.107.1
>= 2.0.0b1, < 2.5.0
1.107.1
2.5.0

Description

Summary

Pydantic AI's UI adapters — AG-UI (Agent.to_ag_ui() / AGUIAdapter) and Vercel AI (VercelAIAdapter) — sanitize untrusted, client-submitted message history before it reaches the agent. Among other defaults, sanitize_messages strips unresolved ("dangling") client-submitted tool calls so that, by default, the agent does not execute an unresolved tool call the model never emitted. This sanitization is a best-effort default, not a security boundary — the boundary is authentication in front of the adapter endpoint and authorization inside each tool handler (see below).

The strip anchored to the message index computed before sanitization ran. When a trailing client message sanitized to empty and was dropped — for example a client system message under the default manage_system_prompt='server' — a preceding assistant response carrying an unresolved tool call became the new tail and was left unchecked, so the agent could dispatch it. A remote client could use this to have a registered server tool run with arguments it supplied rather than arguments the model produced.

Details

sanitize_messages computed its dangling-tool-call target once, from the pre-sanitization message list, and stripped unresolved tool calls only from that tail. A trailing message that sanitized to empty was then dropped, re-exposing a preceding response whose unresolved tool call was never inspected; on the promptless resume path a trailing response bearing tool calls is dispatched directly, so the re-exposed call executed. The behavior is shared by all UI adapters, as both AGUIAdapter and VercelAIAdapter sanitize through the same base UIAdapter.sanitize_messages.

This is a bug in a defense-in-depth default, not a break in the framework's security model:

  • Arguments are still schema-validated. Pydantic AI already validates every tool call's arguments against the tool's declared schema before the tool runs. A forged call cannot smuggle arguments that violate the tool's declared types or constraints — it is limited to well-formed, schema-valid arguments, exactly as a model-produced call is.
  • No capability beyond prompting the model. Because a client can already steer the model into calling any registered tool with any schema-valid arguments, a forged call grants an attacker nothing they could not also obtain by prompting the model to emit the same call. An injected unresolved call to an approval-gated tool (requires_approval=True / ApprovalRequiredToolset) is not auto-executed by this dangling-call path — it still routes through the deny-by-default approval pause. Approval and tool-handler authorization, behind an authenticated endpoint, are the boundary for sensitive tools; history sanitization is a best-effort default, not that boundary.

Impact

The single respect in which a forged call differs from a model-produced one is that it does not pass through a model request. An application that placed a tool-execution guardrail in a model-request hook (before_model_request / after_model_request) and relied on it to gate execution would have that guardrail bypassed, because the crafted call skips the model turn; a guardrail placed in the before_tool_execute hook runs regardless of how a call entered the history and is not affected. The concrete effect is otherwise bounded by what the application's non-approval tools do — and, per the point above, is no greater than what the same client could achieve by prompting the model directly.

Who Is Affected

You are affected if you serve an agent over a UI adapter — AG-UI (Agent.to_ag_ui() / AGUIAdapter) or Vercel AI (VercelAIAdapter) — in its default configuration, and you rely on the model, rather than on your tool handlers, to constrain how a non-approval (requires_approval=False) tool is called. In particular, applications that gate tool execution in a model-request hook (before_model_request / after_model_request) are affected, because a client-forged call skips the model turn and that hook with it.

This dangling-call path does not auto-execute an approval-gated tool (requires_approval=True / ApprovalRequiredToolset): an unresolved injected call to one still routes through the deny-by-default approval pause rather than running directly. As with any client-submitted history, authenticate the adapter endpoint and validate and authorize each tool call in its handler independently of how it was produced, as the framework's documented contract expects.

Remediation

Upgrade to 2.5.0 or later on the v2 line, or the corresponding patched v1 release. The dangling-tool-call strip now targets the surviving tail of the sanitized history rather than a pre-sanitization index, so a dropped trailing message can no longer re-expose an unresolved tool call.

Workaround for Unpatched Versions

The strip is a defense-in-depth default, not the boundary a tool's safety should rest on. Regardless of version, enforce tool-level authorization inside the tool handler, and place any tool-execution guardrail in before_tool_execute — which runs no matter how a tool call entered the message history — rather than in a model-request hook that a promptless call bypasses.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

CVE ID

CVE-2026-65975

Weaknesses

Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. Learn more on MITRE.

Credits