Skip to content

[Feat]: Agent card validation hook #975

Open

Description

@ishymko

Is your feature request related to a problem? Please describe.

#895 mentions lack of agent card validation on the client which may be used to trigger calls to private URLs when agent card is coming from an untrusted source.

Currently no hooks are provided for such validation, which is especially important for functions like create_client and create_from_url which support URL fetch.

Describe the solution you'd like

The SDK should not define the rules of validation and private URLs should not be forbidden as it can be a perfectly valid use case.

UPD: see #1023

Describe alternatives you've considered

The PR referenced implements such validation unconditionally, however using A2A for internal addresses is a valid scenario.

Activity

  1. added theissue type on Apr 16, 2026
  2. mtsmyassin commented on Apr 17, 2026

    @mtsmyassin

    Proposed Implementation

    Here's a concrete sketch of the agent card validation hook:

    1. Validator types (src/a2a/client/card_validators.py)

    """Built-in validators for agent cards."""
    from __future__ import annotations
    
    import ipaddress
    from collections.abc import Callable
    from urllib.parse import urlparse
    
    from a2a.types import AgentCard
    
    # Type alias: a validator raises on invalid, returns None on success
    CardValidator = Callable[[AgentCard], None]
    
    
    class InvalidAgentCardError(ValueError):
        """Raised when an agent card fails validation."""
    
    
    def reject_non_https_urls(card: AgentCard) -> None:
        """Rejects cards whose primary URL is not https://."""
        parsed = urlparse(card.url)
        if parsed.scheme != "https":
            raise InvalidAgentCardError(
                f"Agent card URL must use https, got: {card.url!r}"
            )
        # Also validate any interface URLs if present
        for iface in getattr(card, "additional_interfaces", None) or []:
            if urlparse(iface.url).scheme != "https":
                raise InvalidAgentCardError(
                    f"Additional interface URL must use https, got: {iface.url!r}"
                )
    
    
    def reject_private_urls(card: AgentCard) -> None:
        """Rejects cards that point to private/loopback/link-local addresses."""
        _check_not_private(card.url)
        for iface in getattr(card, "additional_interfaces", None) or []:
            _check_not_private(iface.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:
            # Not a raw IP - skip (DNS-level SSRF protection needs separate handling)
            return
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
            raise InvalidAgentCardError(
                f"URL points to private/loopback address: {url!r}"
            )

    2. Client factory integration (src/a2a/client/client_factory.py)

    class ClientFactory:
        def __init__(
            self,
            ...,
            card_validators: list[CardValidator] | None = None,
        ) -> None:
            ...
            self._card_validators = card_validators or []
    
        async def create_from_url(self, url: str, ...) -> Client:
            card = await self._fetch_agent_card(url)
            self._validate_card(card)
            return self.create(card=card, ...)
    
        def create(self, card: AgentCard, ...) -> Client:
            self._validate_card(card)
            ...
    
        def _validate_card(self, card: AgentCard) -> None:
            for validator in self._card_validators:
                validator(card)  # raises InvalidAgentCardError on failure

    3. Usage

    from a2a.client import ClientFactory
    from a2a.client.card_validators import (
        reject_non_https_urls,
        reject_private_urls,
    )
    
    # Opt-in strict validation
    factory = ClientFactory(
        card_validators=[reject_non_https_urls, reject_private_urls],
    )
    client = await factory.create_from_url("https://agent.example.com/.well-known/agent-card")
    
    # Default - no validation (backwards compatible)
    factory_permissive = ClientFactory()

    Design notes

    • Validators are a list[Callable[[AgentCard], None]] rather than a single callback, so users can compose multiple policies.
      • Validators raise exceptions rather than returning bool, so error messages can be descriptive.
        • Using InvalidAgentCardError(ValueError) subclass so it can be caught specifically but also by generic ValueError handlers.
          • Applied at both create() and create_from_url() entry points.
            • Default is empty list = no validation = backwards compatible, matching the issue's concern about internal-address use cases.

    Related

    This also overlaps with #786 (SSRF protection). A DNS-resolving validator (resolving the hostname and checking the resolved IP) would close the full SSRF loop but is more invasive since it requires a network call. Probably best to keep DNS resolution out of this hook and handle it at the HTTP transport layer with a custom resolver, as #786 discusses.

    I'd be happy to draft a PR against client_factory.py with the above if this design looks reasonable.

  3. piyushbag commented on Jul 21, 2026

    @piyushbag

    Planning to open a PR adding opt-in agent card validation hooks on ClientFactory / create_client (composable validators, default no-op for backward compatibility). Will include built-in helpers and unit tests.

  4. piyushbag commented on Jul 21, 2026

    @piyushbag

    Planning to open a PR adding opt-in card validation hooks on ClientFactory / create_client (composable validators, default no-op for backward compatibility). Will include built-in helpers and unit tests.

  5. ishymko commented on Jul 22, 2026

    @ishymko
    MemberAuthor

    Hi @piyushbag, thank you for your PR, please note that the issue is labeled with maintainers-only, we're not accepting external contributions for it at this point.

  6. kuangmi-bit commented on Aug 6, 2026

    @kuangmi-bit
    Contributor

    Hi @ishymko — this is exactly the shape we ended up with in the A2A ecosystem's Go SDK, so I'd like to align on the hook design before proposing an implementation.

    Reference pattern (a2a-go, a2acrypto package): the SDK defines a KeyResolver interface and the verifier delegates all trust decisions to it. The default resolver is safe-by-default (only fetches from a verifier-configured JWKS URL), but callers can inject their own resolver for private-network deployments. The SDK never hardcodes validation rules — it provides the extension point.

    Mapping that to a2a-python:

    class AgentCardValidator(Protocol):
        def validate(self, agent_card: AgentCard) -> None:  # raises on rejection
            ...
    
    client = create_client(
        url=...,
        agent_card_validator=MyValidator(),   # optional; None = no validation
    )

    Key decisions to confirm:

    1. Hook vs. rule set. feat(client): add opt-in card validation hooks #1142 was closed unmerged — was the objection that unconditional validation (SDK-defined rules) is wrong, or something else in the approach? The hook-as-protocol shape above avoids SDK-defined rules entirely; the SDK just calls the injected validator when fetching a card from an untrusted source (create_client / create_from_url).
    2. Scope of the hook. Should validation apply only to card fetches (URL → AgentCard), or also to per-call URL targets derived from the card (the fix(security): SSRF via AgentCard URL and context ID Injection (A2A-SSRF-01, A2A-INJ-01) #895 SSRF vector)? We can offer both hooks with one default no-op.
    3. Sync vs. async. Card fetch is async; a sync validator keeps the hook simple, but network-based validation (e.g. JWKS fetch) needs async. Proposal: support both (AgentCardValidator sync, or an AsyncAgentCardValidator that takes precedence when provided).

    Happy to write the implementation if this direction looks right — the composable URL-validation utilities from #1114 can stay as-is, and the hook just sits on top.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions