diff --git a/litellm/a2a_protocol/litellm_completion_bridge/handler.py b/litellm/a2a_protocol/litellm_completion_bridge/handler.py index 36ad4f2444b..67ffcf4f8f7 100644 --- a/litellm/a2a_protocol/litellm_completion_bridge/handler.py +++ b/litellm/a2a_protocol/litellm_completion_bridge/handler.py @@ -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, diff --git a/litellm/a2a_protocol/litellm_completion_bridge/transformation.py b/litellm/a2a_protocol/litellm_completion_bridge/transformation.py index 69723f5f917..856978a4439 100644 --- a/litellm/a2a_protocol/litellm_completion_bridge/transformation.py +++ b/litellm/a2a_protocol/litellm_completion_bridge/transformation.py @@ -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 diff --git a/litellm/proxy/a2a/agent_card.py b/litellm/proxy/a2a/agent_card.py index 2e485c6af9a..0238815ff90 100644 --- a/litellm/proxy/a2a/agent_card.py +++ b/litellm/proxy/a2a/agent_card.py @@ -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", diff --git a/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py index e83aad92039..1b3e5f86020 100644 --- a/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py +++ b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py @@ -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 diff --git a/tests/test_litellm/proxy/a2a/test_agent_card.py b/tests/test_litellm/proxy/a2a/test_agent_card.py index 34183dbbcb2..0600776c5e8 100644 --- a/tests/test_litellm/proxy/a2a/test_agent_card.py +++ b/tests/test_litellm/proxy/a2a/test_agent_card.py @@ -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