Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **A bridge to the official `a2a-sdk`, so cA2A reaches the SDK that A2A agents actually run (#91).** cA2A describes itself as a profile on A2A and, until now, integrated with no A2A implementation: `transport.a2a_adapter` parsed A2A-shaped dicts and `transport.server` was a bespoke standard-library HTTP server. Both are honest about being a *reference*, but the practical effect was that a team already running the official SDK could only adopt the profile by replacing their transport with ours, which nobody does to try an alpha. A2A reached v1.0 in April 2026 under the Linux Foundation with SDKs in six languages, and is wired into Google ADK, Azure AI Foundry, Amazon Bedrock AgentCore and Copilot Studio; the profile reached none of it.

`ca2a_runtime.transport.a2a_sdk` is deliberately thin. The SDK carries A2A `metadata` as a `google.protobuf.Struct`, so converting that to a plain mapping hands the existing adapter exactly what it already parses: one parser, one set of tests, and the profile stays transport-agnostic. Optional extra (`pip install 'ca2a[a2a-sdk]'`); the base install still depends on no A2A implementation.

**The protobuf round trip nearly broke every chain, and the reason it does not is worth knowing.** `Struct` has no integer type, so a credential's `depth` of `0` arrives as `0.0` — and a credential signature covers the RFC 8785 canonical bytes of its body, where `canonicalize` *refuses floats outright*. Chains verify because `DelegationCredential.from_dict` coerces `depth` with `int()` before anything is canonicalized, so what gets verified is the integer form the signer signed. A non-integral float cannot be smuggled past it either: it coerces to a *different* integer and the signature then fails. Both directions are tested against the real SDK, over multi-hop chains so the non-zero depths actually cross the boundary.

The SDK is in the `dev` extra rather than only the optional one, so these tests run in CI instead of skipping. A bridge whose tests only ever skip is a bridge nobody has exercised.

- **The callee now appraises the caller, not just its authority (mutual attestation).** The reference transport was one-directional: the callee verified the caller's delegation chain, which says what a peer is *allowed to ask for*, and had no way to know whether the peer sending it a task was an enclave or a laptop. The handshake response now carries a callee-issued challenge, the caller binds its own channel key into a report under it (`caller_offer` in the A2A metadata), and the callee appraises that report **before it opens the sealed payload**. That ordering is the property: the payload is sealed to the callee's own key, so appraising afterwards would mean an unattested caller had already had its work done. `tests/unit/test_mutual_attestation.py` fails if the two calls are swapped, verified by making the swap.

Off by default and opt-in one rung at a time (`require_caller_attestation`: `"none"` → `"any"` → `"hardware"`), because almost no caller can attest yet and a callee that refused them out of the box is a callee nobody can talk to. `"hardware"` without a verifier is refused at construction rather than on every call. An offer that is *present and does not appraise* is refused at every rung including `"none"`: demanding nothing means accepting a caller that proves nothing, not accepting a broken proof, otherwise a misconfigured attestation path is indistinguishable from a caller that never had one. See `docs/spec/mutual-attestation.md`.
Expand Down
1 change: 1 addition & 0 deletions docs/spec/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ cA2A is a profile on A2A, not a competing transport. A2A moves tasks and context
| Seal gated on the appraised channel key on a live call | Implemented in software mode |
| Seal bound to a hardware-verified measurement | Not yet: needs a real quote via the `verifier` seam (Tier 3) |
| `ca2a start` CLI listener | Implemented: builds a `PeerNode` from a config file and serves it over the reference transport |
| Bridge to the official `a2a-sdk` | Implemented (`ca2a_runtime.transport.a2a_sdk`, optional extra `ca2a[a2a-sdk]`): converts the SDK's protobuf `metadata` Struct to the mapping the adapter already parses, so an SDK server adopts the profile without replacing its transport |

The reference HTTP server/client run a live call end to end in software mode (`assurance="none"`). That is progress on Tier 2 transport wiring and a convenience for running the peer path off hardware. It is not evidence that cA2A is attested across trust domains: that needs the hardware `verifier` seam driven by a real quote. See [LIMITATIONS.md](../../LIMITATIONS.md) and [ROADMAP.md](../../ROADMAP.md).

Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ dependencies = [
tpm = [
"tpm2-pytss>=2.2; sys_platform == 'linux'",
]
# Bridge to the official A2A SDK (ca2a_runtime.transport.a2a_sdk). Optional:
# the reference transport and the profile itself need no A2A implementation, and
# the base install stays dependency-light.
a2a-sdk = [
"a2a-sdk>=1.1,<2",
]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
Expand All @@ -54,6 +60,9 @@ dev = [
"pip-audit>=2.6",
# Conformance suite: asserts emitted records pass the TRACE Level checks.
"agentrust-trace-tests>=0.4,<0.5",
# So tests/unit/test_a2a_sdk_bridge.py runs in CI rather than skipping. A
# bridge whose tests only ever skip is a bridge nobody has exercised.
"a2a-sdk>=1.1,<2",
]

[project.scripts]
Expand Down
134 changes: 134 additions & 0 deletions src/ca2a_runtime/transport/a2a_sdk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Bridge between the official ``a2a-sdk`` and the cA2A profile.

cA2A is a profile *on* A2A, and until this module it integrated with no A2A
implementation: :mod:`ca2a_runtime.transport.a2a_adapter` parsed A2A-shaped
``dict``s and :mod:`ca2a_runtime.transport.server` was a bespoke HTTP server. A
team already running the official SDK had no way to adopt the profile short of
replacing their transport with ours, which nobody does to try an alpha.

This is the whole bridge, and it is deliberately thin: the SDK carries A2A
``metadata`` as a ``google.protobuf.Struct``, so converting that to a plain
mapping hands the existing adapter exactly what it already parses. One parser,
one set of tests, and the profile stays transport-agnostic. Nothing here
verifies, enforces, or appraises; it converts and delegates.

**The Struct round trip loses integer-ness, and that is safe here for a reason
worth stating.** ``Struct`` has no integer type, so a credential's ``depth`` of
``0`` comes back as ``0.0``. That matters because a credential's signature covers
the RFC 8785 canonical bytes of its body and
:func:`ca2a_runtime.canonical.canonicalize` *refuses floats outright*. The chain
still verifies because ``DelegationCredential.from_dict`` coerces ``depth`` with
``int()`` before anything is canonicalized, so the bytes that get verified are
the integer form the signer signed. A float that is not integral cannot be
smuggled through either: it would coerce to a different integer and the
signature would then fail. ``tests/unit/test_a2a_sdk_bridge.py`` holds that.

Install with the extra::

pip install 'ca2a[a2a-sdk]'
"""

from __future__ import annotations

from typing import Any

from ca2a_runtime.errors import TransportError
from ca2a_runtime.peer import PeerRequest
from ca2a_runtime.transport import a2a_adapter
from ca2a_runtime.transport.constants import EXTENSION_URI

try: # pragma: no cover - exercised by whichever branch the environment takes
from google.protobuf import json_format

_IMPORT_ERROR: ImportError | None = None
except ImportError as exc: # pragma: no cover
json_format = None
_IMPORT_ERROR = exc

__all__ = [
"EXTENSION_HEADER",
"attach_to_sdk_message",
"metadata_from_sdk_message",
"opted_in",
"parse_sdk_message",
]

#: The header an A2A client uses to opt in to an extension. Mirrors the SDK's own
#: ``a2a.extensions.common.HTTP_EXTENSION_HEADER``, restated rather than imported
#: so this constant is readable without the SDK installed.
EXTENSION_HEADER = "A2A-Extensions"


def _require_protobuf() -> None:
if json_format is None:
raise TransportError(
"the a2a-sdk bridge needs protobuf",
detail=f"install 'ca2a[a2a-sdk]' ({_IMPORT_ERROR})",
)


def metadata_from_sdk_message(message: Any) -> dict[str, Any]:
"""Return an SDK message's ``metadata`` Struct as a plain mapping.

Accepts anything carrying a ``metadata`` attribute (the SDK's ``Message``, or
a ``RequestContext``'s message). A message with no metadata yields an empty
mapping, which the adapter reads as "not a cA2A message" rather than as a
malformed one.
"""
_require_protobuf()
metadata = getattr(message, "metadata", None)
if metadata is None:
return {}
return dict(json_format.MessageToDict(metadata))


def parse_sdk_message(message: Any) -> PeerRequest | None:
"""Parse an SDK message into a :class:`PeerRequest`, or None.

Returns None when the message carries no cA2A extension keys: it is ordinary
A2A input and must not be treated as a partial trust state. Fails closed with
:class:`~ca2a_runtime.errors.TransportError` when cA2A keys are present but
malformed, exactly as the dict-shaped adapter does, because it *is* the
dict-shaped adapter.
"""
return a2a_adapter.parse_peer_request({"metadata": metadata_from_sdk_message(message)})


def attach_to_sdk_message(message: Any, request: PeerRequest) -> Any:
"""Attach the cA2A metadata for ``request`` onto an SDK message, in place.

Mutates and returns ``message``. Unlike the dict-shaped
:func:`~ca2a_runtime.transport.a2a_adapter.attach_ca2a_metadata`, which
deep-copies, an SDK message is a protobuf object the caller is building, so
copying it would surprise more than it protects. Existing non-cA2A metadata
keys survive; A2A routing fields and ``parts`` are untouched.

Also appends the extension URI to ``message.extensions`` when that repeated
field exists, which is the per-message half of the opt-in the profile
requires. The HTTP header half is the caller's, since this module does not
speak HTTP.
"""
_require_protobuf()
merged = dict(metadata_from_sdk_message(message))
merged.update(a2a_adapter.attach_ca2a_metadata({}, request)["metadata"])
message.metadata.Clear()
json_format.ParseDict(merged, message.metadata)

extensions = getattr(message, "extensions", None)
if extensions is not None and EXTENSION_URI not in extensions:
extensions.append(EXTENSION_URI)
return message


def opted_in(header_values: list[str] | None) -> bool:
"""Whether an ``A2A-Extensions`` header opts in to this profile.

The header may repeat and may carry comma-separated values, so both are
handled. Absence is False and is not an error: the profile is an overlay, and
a peer that says nothing is a peer using plain A2A.
"""
if not header_values:
return False
return any(
item.strip() == EXTENSION_URI for value in header_values for item in value.split(",")
)
Loading