fix(a2a): strip additionalInterfaces and let configured metadata win over A2A request

- merge_agent_card no longer carries upstream additionalInterfaces through;
  storing those alternate URLs would let authenticated agent callers reach
  the backend directly and bypass proxy auth/budget/logging.
- apply_forward_metadata_to_completion_params now layers client-supplied A2A
  metadata UNDER any agent-owner-configured extra_body.metadata, so server-set
  run metadata stays authoritative on key conflicts.
This commit is contained in:
mateo-berri 2026-05-28 03:34:50 +00:00
parent a0944e1638
commit 06384db07b
No known key found for this signature in database
5 changed files with 64 additions and 14 deletions

View file

@ -107,9 +107,9 @@ class A2ACompletionBridgeHandler:
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
}
completion_params.update(litellm_params_to_add)
# Apply forward metadata AFTER the litellm_params merge so an
# agent-configured ``extra_body`` does not overwrite the forwarded
# A2A metadata; the helper merges into any existing ``extra_body``.
# Apply forward metadata AFTER the litellm_params merge so the helper
# sees any agent-owner-configured ``extra_body.metadata`` and can keep
# those keys authoritative over the client-supplied A2A metadata.
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
@ -222,9 +222,9 @@ class A2ACompletionBridgeHandler:
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
}
completion_params.update(litellm_params_to_add)
# Apply forward metadata AFTER the litellm_params merge so an
# agent-configured ``extra_body`` does not overwrite the forwarded
# A2A metadata; the helper merges into any existing ``extra_body``.
# Apply forward metadata AFTER the litellm_params merge so the helper
# sees any agent-owner-configured ``extra_body.metadata`` and can keep
# those keys authoritative over the client-supplied A2A metadata.
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,

View file

@ -100,14 +100,14 @@ class A2ACompletionBridgeTransformation:
extra_body = completion_params.get("extra_body")
if not isinstance(extra_body, dict):
extra_body = {}
# Merge into any existing ``extra_body.metadata`` so an
# agent-configured ``extra_body: {metadata: {...}}`` is preserved;
# forwarded A2A metadata takes precedence on key conflicts.
# Layer client-supplied A2A metadata under any agent-owner-configured
# ``extra_body.metadata`` so the configured keys remain authoritative
# and an A2A caller cannot overwrite server-set run metadata.
existing_metadata = extra_body.get("metadata")
merged_metadata: Dict[str, Any] = (
{**existing_metadata} if isinstance(existing_metadata, dict) else {}
existing_dict: Dict[str, Any] = (
existing_metadata if isinstance(existing_metadata, dict) else {}
)
merged_metadata.update(forward_metadata)
merged_metadata: Dict[str, Any] = {**forward_metadata, **existing_dict}
extra_body = {**extra_body, "metadata": merged_metadata}
completion_params["extra_body"] = extra_body

View file

@ -43,6 +43,12 @@ _ALLOWED_CAPABILITY_KEYS = {"streaming"}
# card as a defense against upstream drift. ``supportedInterfaces`` is kept
# verbatim per product spec even though it is not in the v1.0 schema — clients
# that expect it will find it; clients that don't will ignore it.
#
# ``additionalInterfaces`` is deliberately excluded: it advertises alternate
# upstream URLs (HTTP/JSONRPC/gRPC backends) that, if persisted and served,
# would let authenticated agent callers reach the backend directly and bypass
# the proxy's auth/budget/logging. The proxy publishes its own entrypoint via
# ``supportedInterfaces`` instead.
_ALLOWED_TOP_LEVEL_KEYS = {
"protocolVersion",
"name",
@ -53,7 +59,6 @@ _ALLOWED_TOP_LEVEL_KEYS = {
"defaultOutputModes",
"skills",
"preferredTransport",
"additionalInterfaces",
"supportedInterfaces",
"iconUrl",
"provider",

View file

@ -32,7 +32,10 @@ class TestA2AStreamingTransformation:
# Metadata is forwarded on the run payload only, not duplicated on messages.
assert "metadata" not in openai_messages[0]
completion_params: dict = {"model": "langgraph/agent", "messages": openai_messages}
completion_params: dict = {
"model": "langgraph/agent",
"messages": openai_messages,
}
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
@ -43,6 +46,38 @@ class TestA2AStreamingTransformation:
"skillId": "draft_reply",
}
def test_configured_metadata_wins_over_forwarded_a2a_metadata(self):
from litellm.a2a_protocol.litellm_completion_bridge.transformation import (
A2ACompletionBridgeTransformation,
)
# Agent-owner-configured run metadata in ``extra_body``.
completion_params: dict = {
"model": "langgraph/agent",
"messages": [],
"extra_body": {
"metadata": {"owner_tag": "prod", "trace": "server-set"},
"other": "keep",
},
}
# Client tries to overwrite ``trace`` and inject a new key.
message = {
"role": "user",
"parts": [{"text": "hi"}],
"metadata": {"trace": "client-spoof", "skillId": "draft_reply"},
}
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
params={"metadata": {"trace": "client-spoof-2"}},
)
assert completion_params["extra_body"]["other"] == "keep"
assert completion_params["extra_body"]["metadata"] == {
"owner_tag": "prod",
"trace": "server-set",
"skillId": "draft_reply",
}
def test_langgraph_transform_preserves_message_metadata(self):
from litellm.llms.langgraph.chat.transformation import LangGraphConfig

View file

@ -164,3 +164,13 @@ def test_does_not_mutate_input():
snapshot = dict(upstream)
merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
assert upstream == snapshot
def test_strips_additional_interfaces_to_prevent_backend_url_leak():
upstream = _full_upstream_card()
upstream["additionalInterfaces"] = [
{"url": "http://internal-backend:8080/", "transport": "JSONRPC"},
{"url": "grpc://internal-backend:50051", "transport": "GRPC"},
]
merged = merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
assert "additionalInterfaces" not in merged