Skip to content

feat(ui): add UIAdapter.sanitize_messages and allowed_file_url_schemes#5228

Merged
DouweM merged 7 commits intomainfrom
harden-ui-adapter-message-sanitization
Apr 29, 2026
Merged

feat(ui): add UIAdapter.sanitize_messages and allowed_file_url_schemes#5228
DouweM merged 7 commits intomainfrom
harden-ui-adapter-message-sanitization

Conversation

@DouweM
Copy link
Copy Markdown
Collaborator

@DouweM DouweM commented Apr 28, 2026

Summary

Adds UIAdapter.sanitize_messages(), applied to messages produced from the protocol-specific run input before they're passed to the agent. Caller-supplied message_history is not affected (trusted as coming from server-side persistence).

Two sanitizations land in this PR:

  • allowed_file_url_schemes: frozenset[str] = frozenset({'http', 'https'}) drops FileUrl parts (ImageUrl/DocumentUrl/VideoUrl/AudioUrl) whose URL scheme isn't in the allowlist. Non-HTTP schemes like s3:// and gs:// cause the model provider to fetch the object using the server-side IAM role or service account, so they should only be accepted from trusted frontends. Apps that want a scheme can add it to the allowlist on the adapter.
  • Dangling ToolCallParts at the end of the client-submitted history are dropped unless matched by deferred_tool_results. Tool calls are produced by the model on the server side, so an unresolved tool call at the end of client-supplied history doesn't correspond to a paused agent run. HITL resumption still works when the caller passes explicit deferred_tool_results matching the pending tool call IDs.

Both behaviors emit a UserWarning when they trigger, matching the pattern from #4087 for frontend SystemPromptParts.

The adapter trust model is documented in a new section of docs/ui/overview.md, referenced from both adapter pages, with a matching note in docs/input.md for the non-adapter FileUrl case.

Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • PR title is fit for the release changelog.

DouweM added 2 commits April 24, 2026 00:21
`UIAdapter.sanitize_messages()` is applied to protocol-sourced messages in
`run_stream_native` before passing them to the agent. Caller-supplied
`message_history` is unaffected (trusted as coming from server persistence).

Two sanitizations:

- `allowed_file_url_schemes: frozenset[str] = frozenset({'http', 'https'})`
  drops `FileUrl` parts with disallowed schemes. Non-HTTP schemes like
  `s3://` and `gs://` cause the provider to fetch the object using the
  server-side IAM role or service account, so they should only be accepted
  from trusted frontends. Apps that want to accept a scheme can add it to
  the allowlist on the adapter.

- Dangling `ToolCallPart`s at the end of history are dropped unless matched
  by `deferred_tool_results`. Tool calls are produced by the model on the
  server side, so an unresolved tool call at the end of client-supplied
  history does not correspond to a paused agent run. HITL resumption
  continues to work when the caller passes explicit `deferred_tool_results`
  (approvals / call results) matching the pending tool call IDs.

Both behaviors emit a `UserWarning` when they kick in, matching the
pattern introduced in #4087 for frontend `SystemPromptPart`s.

Adapter trust model is documented in `docs/ui/overview.md`, referenced
from both adapter pages, with a matching note in `docs/input.md` for the
non-adapter `FileUrl` case.
@github-actions github-actions Bot added size: M Medium PR (101-500 weighted lines) feature New feature request, or PR implementing a feature (enhancement) labels Apr 28, 2026
dangling_tool_call_names.append(part.tool_name)
continue
new_response_parts.append(part)
sanitized.append(replace(message, parts=new_response_parts))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When all parts of the last ModelResponse are dangling tool calls (e.g. a client-submitted AssistantMessage with only fabricated tool calls and no text), this produces a ModelResponse(parts=[]) in the sanitized output. An empty response in the history could confuse downstream logic or the model.

Consider dropping the message entirely when no parts survive:

if new_response_parts:
    sanitized.append(replace(message, parts=new_response_parts))

The AG-UI test test_client_submitted_dangling_tool_calls_not_executed exercises exactly this scenario (an AssistantMessage with only a tool call and no text content) but doesn't assert on the resulting message structure, so the empty ModelResponse slips through.

elif isinstance(message, ModelResponse) and index == last_index:
new_response_parts: list[ModelResponsePart] = []
for part in message.parts:
if isinstance(part, ToolCallPart) and part.tool_call_id not in resolved_tool_call_ids:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dangling tool call check only covers ToolCallPart, but BuiltinToolCallPart is a separate type and isn't filtered here. In AG-UI, a client can craft an AssistantMessage with the BUILTIN_TOOL_CALL_ID_PREFIX in the tool call ID, which load_messages will load as a BuiltinToolCallPart. If that fabricated builtin call has no matching BuiltinToolReturnPart in the same response, it would survive sanitization.

In practice this may be low-risk since builtin tool calls are typically paired with returns in the same ModelResponse, but it's worth considering whether the isinstance check should be isinstance(part, (ToolCallPart, BuiltinToolCallPart)) for consistency with the stated goal of stripping tool calls "produced by the model on the server side".

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

DouweM added 2 commits April 28, 2026 07:29
- Match the dangling tool-call check on `BaseToolCallPart` so
  `BuiltinToolCallPart` is treated the same as `ToolCallPart`.
- Drop the trailing `ModelResponse` entirely when sanitization leaves it
  with no parts, instead of leaving an empty placeholder in history.
Restructure the AG-UI and base-adapter end-to-end tests for dangling
tool-call sanitization to assert via the captured model history rather
than via a registered tool whose body never runs by design (the whole
point of the test). Removes the unreachable lines that broke coverage.
Comment thread docs/ui/overview.md Outdated

For stricter conversation integrity (e.g. ensuring prior assistant turns and tool returns match what the server actually produced), persist the history server-side keyed by the thread/session ID and pass it to the adapter via `message_history` — caller-supplied history is trusted as coming from server-side persistence and isn't subject to this sanitization.

Tool approval responses in the Vercel AI protocol are trusted from the request by design, matching the protocol's round-trip through `useChat`'s `addToolApprovalResponse` and the reference Next.js backend. If your application needs the approval decision tied to server-side state rather than the request, intercept [`DeferredToolRequests`][pydantic_ai.DeferredToolRequests], persist the approval IDs server-side, and pass explicit `deferred_tool_results` when resuming.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to Vercel AI docs

deferred_tool_results = self.deferred_tool_results

frontend_messages = self.sanitize_messages(self.messages, deferred_tool_results=deferred_tool_results)
if self.manage_system_prompt == 'server' and any(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to move this to sanitize_messages?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the stripping implemented again?

DouweM added 2 commits April 28, 2026 17:40
- Move `SystemPromptPart` stripping (and the accompanying warning) into
  `sanitize_messages`, so the message claiming "they will be stripped" is
  truthful at the call site instead of relying on `ReinjectSystemPrompt`
  to strip later. The capability still runs with `replace_existing=True`
  to defend against caller-supplied `message_history` containing system
  prompts.
- Move the Vercel-AI-specific paragraph about trusting tool approval
  responses out of `docs/ui/overview.md` into `docs/ui/vercel-ai.md`,
  next to the rest of the approval flow documentation.
- Factor the per-message-kind sanitization into helpers to keep
  `sanitize_messages` under the cyclomatic complexity threshold.
Moving `SystemPromptPart` stripping into `sanitize_messages` left the
"all parts stripped" branch in `ReinjectSystemPrompt._strip_system_prompts`
unreachable from the UI tests that previously covered it, since the
adapter now strips before the capability runs. Add a focused test that
exercises the capability directly with a caller-supplied `message_history`
containing a `ModelRequest` whose only part is a `SystemPromptPart`.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment thread docs/input.md Outdated
!!! warning "Trust model for file URLs"
When URLs are forwarded to the provider, the provider fetches them under its own credentials. For cloud-storage schemes like `s3://` (Bedrock) and `gs://` (Vertex AI), those credentials are your server's IAM role or service account, so whoever controls the URL effectively controls what the provider can read on your behalf.

Don't construct [`ImageUrl`][pydantic_ai.messages.ImageUrl], [`AudioUrl`][pydantic_ai.messages.AudioUrl], [`VideoUrl`][pydantic_ai.messages.VideoUrl], or [`DocumentUrl`][pydantic_ai.messages.DocumentUrl] from untrusted user input without validating the scheme and scope. For frontend-initiated uploads to cloud storage, prefer pre-signed `https://` URLs over references like `s3://bucket/key`. If you do need to accept the raw cloud-storage scheme, set `force_download=True` to route the fetch through the library's HTTP client (which applies SSRF protection).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Misleading documentation advice: force_download=True with s3:// URLs

The new warning box at docs/input.md:172 says: "If you do need to accept the raw cloud-storage scheme, set force_download=True to route the fetch through the library's HTTP client (which applies SSRF protection)." However, download_item at pydantic_ai_slim/pydantic_ai/models/__init__.py:1395-1396 explicitly states "Only http:// and https:// protocols are allowed" and calls safe_download which rejects non-HTTP schemes. So force_download=True on an s3:// URL would raise a ValueError, not download the file. The phrasing starting with "If you do need to accept the raw cloud-storage scheme" implies the user wants it to work, but the suggested action would fail. This could confuse users. A more accurate recommendation would be to convert the s3:// reference to a pre-signed https:// URL before constructing the FileUrl.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Devin caught that the trust-model warning suggested setting
`force_download=True` on cloud-storage URLs, but `download_item` only
accepts `http(s)://` schemes. The right path for frontend-initiated
cloud uploads is to convert to a pre-signed `https://` URL server-side
before constructing the `FileUrl` part.
@DouweM DouweM merged commit 53964f0 into main Apr 29, 2026
48 checks passed
@DouweM DouweM deleted the harden-ui-adapter-message-sanitization branch April 29, 2026 02:28
Alex-Resch pushed a commit to Alex-Resch/pydantic-ai that referenced this pull request Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature request, or PR implementing a feature (enhancement) size: M Medium PR (101-500 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant