diff --git a/src/a2a/client/__init__.py b/src/a2a/client/__init__.py index d33c09481..d645ed67a 100644 --- a/src/a2a/client/__init__.py +++ b/src/a2a/client/__init__.py @@ -7,6 +7,14 @@ ) from a2a.client.base_client import BaseClient from a2a.client.card_resolver import A2ACardResolver +from a2a.client.card_validators import ( + CardValidator, + InvalidAgentCardError, + reject_non_https_urls, + reject_private_urls, + require_supported_interfaces, + validate_card, +) from a2a.client.client import ( Client, ClientCallContext, @@ -32,6 +40,7 @@ 'AgentCardResolutionError', 'AuthInterceptor', 'BaseClient', + 'CardValidator', 'Client', 'ClientCallContext', 'ClientCallInterceptor', @@ -39,6 +48,11 @@ 'ClientFactory', 'CredentialService', 'InMemoryContextCredentialStore', + 'InvalidAgentCardError', 'create_client', 'minimal_agent_card', + 'reject_non_https_urls', + 'reject_private_urls', + 'require_supported_interfaces', + 'validate_card', ] diff --git a/src/a2a/client/card_validators.py b/src/a2a/client/card_validators.py new file mode 100644 index 000000000..c7906b853 --- /dev/null +++ b/src/a2a/client/card_validators.py @@ -0,0 +1,85 @@ +"""Optional validators for AgentCard instances before client creation.""" + +from __future__ import annotations + +import ipaddress + +from collections.abc import Callable +from urllib.parse import urlparse + +from a2a.types.a2a_pb2 import AgentCard + + +CardValidator = Callable[[AgentCard], None] + + +class InvalidAgentCardError(ValueError): + """Raised when an AgentCard fails a registered validation hook.""" + + +def _iter_card_urls(card: AgentCard) -> list[str]: + urls = [ + interface.url + for interface in card.supported_interfaces + if interface.url + ] + if card.documentation_url: + urls.append(card.documentation_url) + if card.icon_url: + urls.append(card.icon_url) + return urls + + +def reject_non_https_urls(card: AgentCard) -> None: + """Reject cards whose interface or metadata URLs are not https://.""" + for url in _iter_card_urls(card): + if urlparse(url).scheme != 'https': + raise InvalidAgentCardError( + f'Card URL must use https, got: {url!r}' + ) + + +def reject_private_urls(card: AgentCard) -> None: + """Reject cards whose URLs use private, loopback, or link-local IP hosts.""" + for url in _iter_card_urls(card): + _check_not_private(url) + + +def _check_not_private(url: str) -> None: + parsed = urlparse(url) + host = parsed.hostname + if host is None: + raise InvalidAgentCardError(f'URL has no hostname: {url!r}') + try: + ip = ipaddress.ip_address(host) + except ValueError: + return + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise InvalidAgentCardError( + f'URL points to a private or loopback address: {url!r}' + ) + + +def require_supported_interfaces(card: AgentCard) -> None: + """Reject cards that do not declare at least one supported interface.""" + if not card.supported_interfaces: + raise InvalidAgentCardError( + 'Card must declare at least one supported interface.' + ) + for interface in card.supported_interfaces: + if not interface.url: + raise InvalidAgentCardError( + 'Each supported interface must include a URL.' + ) + if not interface.protocol_binding: + raise InvalidAgentCardError( + 'Each supported interface must include a protocol binding.' + ) + + +def validate_card( + card: AgentCard, validators: list[CardValidator] | None +) -> None: + """Run registered validators against an AgentCard.""" + for validator in validators or []: + validator(card) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index a59189ade..d708cd73a 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -11,6 +11,7 @@ from a2a.client.base_client import BaseClient from a2a.client.card_resolver import A2ACardResolver +from a2a.client.card_validators import CardValidator, validate_card from a2a.client.client import Client, ClientConfig from a2a.client.transports.base import ClientTransport from a2a.client.transports.jsonrpc import JsonRpcTransport @@ -76,6 +77,7 @@ class ClientFactory: def __init__( self, config: ClientConfig | None = None, + card_validators: list[CardValidator] | None = None, ): config = config or ClientConfig() httpx_client = config.httpx_client or httpx.AsyncClient() @@ -85,6 +87,7 @@ def __init__( self._config = config self._httpx_client = httpx_client + self._card_validators = card_validators or [] self._registry: dict[str, TransportProducer] = {} self._register_defaults(config.supported_protocol_bindings) @@ -319,6 +322,7 @@ def create( If there is no valid matching of the client configuration with the server configuration, a `ValueError` is raised. """ + validate_card(card, self._card_validators) client_set = self._config.supported_protocol_bindings or [ TransportProtocol.JSONRPC ] @@ -371,6 +375,7 @@ async def create_client( # noqa: PLR0913 relative_card_path: str | None = None, resolver_http_kwargs: dict[str, Any] | None = None, signature_verifier: Callable[[AgentCard], None] | None = None, + card_validators: list[CardValidator] | None = None, ) -> Client: """Create a `Client` for an agent from a URL or `AgentCard`. @@ -390,11 +395,13 @@ async def create_client( # noqa: PLR0913 httpx client when resolving the agent card. signature_verifier: A callable used to verify the agent card's signatures. + card_validators: Optional hooks run on resolved cards before a + client is created. Returns: A `Client` object. """ - factory = ClientFactory(client_config) + factory = ClientFactory(client_config, card_validators=card_validators) if isinstance(agent, str): return await factory.create_from_url( agent, diff --git a/tests/client/test_card_validators.py b/tests/client/test_card_validators.py new file mode 100644 index 000000000..e6cf46780 --- /dev/null +++ b/tests/client/test_card_validators.py @@ -0,0 +1,105 @@ +"""Tests for optional AgentCard validation hooks.""" + +import httpx +import pytest + +from a2a.client import ( + ClientConfig, + ClientFactory, + InvalidAgentCardError, + reject_non_https_urls, + reject_private_urls, + require_supported_interfaces, +) +from a2a.types.a2a_pb2 import ( + AgentCapabilities, + AgentCard, + AgentInterface, +) +from a2a.utils.constants import TransportProtocol + + +def _valid_card(**overrides) -> AgentCard: + card = AgentCard( + name='Test Agent', + description='An agent for testing.', + supported_interfaces=[ + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url='https://primary-url.com', + ) + ], + version='1.0.0', + capabilities=AgentCapabilities(), + skills=[], + default_input_modes=[], + default_output_modes=[], + ) + for key, value in overrides.items(): + if key == 'supported_interfaces': + card.ClearField('supported_interfaces') + card.supported_interfaces.extend(value) + else: + setattr(card, key, value) + return card + + +def test_require_supported_interfaces_rejects_empty_card(): + card = _valid_card() + card.ClearField('supported_interfaces') + + with pytest.raises(InvalidAgentCardError, match='supported interface'): + require_supported_interfaces(card) + + +def test_reject_non_https_urls_rejects_http_interface(): + card = _valid_card( + supported_interfaces=[ + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url='http://insecure.example.com', + ) + ] + ) + + with pytest.raises(InvalidAgentCardError, match='https'): + reject_non_https_urls(card) + + +def test_reject_private_urls_rejects_loopback(): + card = _valid_card( + supported_interfaces=[ + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url='https://127.0.0.1', + ) + ] + ) + + with pytest.raises(InvalidAgentCardError, match='private or loopback'): + reject_private_urls(card) + + +def test_client_factory_runs_validators_on_create(): + card = _valid_card( + supported_interfaces=[ + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url='http://insecure.example.com', + ) + ] + ) + factory = ClientFactory( + ClientConfig(httpx_client=httpx.AsyncClient()), + card_validators=[reject_non_https_urls], + ) + + with pytest.raises(InvalidAgentCardError): + factory.create(card) + + +def test_client_factory_default_has_no_validation(): + card = _valid_card() + factory = ClientFactory(ClientConfig(httpx_client=httpx.AsyncClient())) + client = factory.create(card) + assert client is not None