Skip to content

feat(transport): A2A extension adapter for PeerRequest#48

Merged
imran-siddique merged 3 commits into
agentrust-io:mainfrom
Susanpdl:feat/a2a-transport-adapter
Jul 19, 2026
Merged

feat(transport): A2A extension adapter for PeerRequest#48
imran-siddique merged 3 commits into
agentrust-io:mainfrom
Susanpdl:feat/a2a-transport-adapter

Conversation

@Susanpdl

Copy link
Copy Markdown
Contributor

Summary

  • Adds ca2a_runtime.transport to parse A2A SendMessage metadata into PeerRequest, and attach the same fields on the way out.
  • Uses extension URI https://agentrust.io/extensions/ca2a/v0.1. Malformed cA2A metadata fails closed (TRANSPORT_ERROR); no cA2A keys means ordinary A2A (None).
  • Updates docs/spec/transport.md and LIMITATIONS.md so this is clearly parse/attach only. No HTTP server, ca2a start, or seal-to-verified-measurement binding.

Closes the first Tier 2 checkbox on #47 (adapter only), matching the acceptance notes from @carloshvp.

Test plan

  • ruff check src/ tests/
  • mypy src/ca2a_runtime/ src/ca2a_verify/
  • pytest tests/unit/ (141 passed locally)
  • CI green on this PR

Add ca2a_runtime.transport to map A2A SendMessage metadata to PeerRequest
(and reverse) under https://agentrust.io/extensions/ca2a/v0.1. Fail closed
on malformed cA2A fields; absence of all keys returns None (ordinary A2A).
Docs and LIMITATIONS keep the claim boundary: no live server or attestation.

Signed-off-by: Susan Poudel <susanpdl77@gmail.com>

@carloshvp carloshvp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on one transport-boundary issue.

The new adapter promises that malformed cA2A metadata fails closed with TRANSPORT_ERROR, but sealed_payload currently uses base64.urlsafe_b64decode() without alphabet validation. Python's default decoder can ignore some non-base64url characters, so malformed extension metadata can be accepted and converted into opaque bytes instead of failing closed.

Repro against this PR head:

PYTHONPATH=src python3 - <<'PY'
from ca2a_runtime.transport import (
    KEY_DELEGATION_CHAIN,
    KEY_PARENT_RECORD_HASH,
    KEY_RECORD_ID,
    KEY_REQUESTED_CAPABILITY,
    KEY_SEALED_PAYLOAD,
    parse_peer_request,
)
from tests.unit.test_a2a_adapter import _cred_dicts

meta = {
    KEY_DELEGATION_CHAIN: _cred_dicts([frozenset({"read", "write"}), frozenset({"read"})]),
    KEY_REQUESTED_CAPABILITY: "read",
    KEY_RECORD_ID: "rec-invalid-b64",
    KEY_PARENT_RECORD_HASH: None,
    KEY_SEALED_PAYLOAD: "abcd?",
}

try:
    req = parse_peer_request({"metadata": meta})
    print("ACCEPTED", req.sealed_payload)
except Exception as exc:
    print("RAISED", type(exc).__name__, str(exc), getattr(exc, "code", None))
PY

Actual result: ACCEPTED b'i\xb7\x1d'.

That conflicts with the PR's fail-closed contract for any present-but-malformed cA2A metadata, and it leaves the parser accepting a value that is not actually base64url. Please make the decode strict, for example by validating the URL-safe alphabet/padding before decoding or by translating to standard base64 and using base64.b64decode(..., validate=True), and add a regression test for a character outside the base64url alphabet such as abcd?.

Validation performed: inspected the PR diff, ran ruff check src/ tests/ successfully, ran pytest tests/unit/test_a2a_adapter.py tests/unit/test_errors.py successfully, reproduced the malformed sealed_payload acceptance above. mypy src/ca2a_runtime/ src/ca2a_verify/ did not complete in my local temp checkout because types-PyYAML is not installed; pytest tests/unit/ also did not complete locally because cedarpy is not installed. GitHub CI for the PR is green except the maintainer-approval gate.

base64.urlsafe_b64decode silently ignores characters outside the alphabet,
so a value like "abcd?" was accepted as opaque bytes instead of failing
closed. Validate the base64url alphabet before decoding and add regression
tests for characters outside it.

Signed-off-by: Susan Poudel <susanpdl77@gmail.com>
@Susanpdl

Copy link
Copy Markdown
Contributor Author

Good catch, thanks. Fixed in 9e5a2b7.

_b64url_decode now validates the base64url alphabet before decoding, so a present-but-malformed sealed_payload raises TRANSPORT_ERROR instead of being accepted as opaque bytes. Your exact repro ("abcd?") now raises TRANSPORT_ERROR.

Added a parametrized regression test covering characters outside the alphabet: abcd?, ab cd, ab==cd, a+b/c, %%%%. Full unit suite passes (146), ruff and mypy clean.

@carloshvp carloshvp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on one remaining adapter-shape issue.

The strict sealed_payload fix on this head addresses my previous blocker. The remaining problem is in attach_ca2a_metadata(): it only writes to out["metadata"]. If a caller passes the full A2A JSON-RPC message/send / SendMessage envelope that parse_peer_request() explicitly accepts, the helper attaches cA2A fields at the envelope root instead of under params.message.metadata or params.metadata. That means the helper can produce an object that the companion parser immediately treats as ordinary A2A.

Repro against 9e5a2b76ce40c523ac3599edb2cd37918f1f01dd:

PYTHONPATH=src python3 - <<'PY'
from ca2a_runtime.peer import PeerRequest
from ca2a_runtime.transport import attach_ca2a_metadata, parse_peer_request, KEY_DELEGATION_CHAIN
from tests.unit.conftest import build_chain

request = PeerRequest(
    chain=build_chain([frozenset({"read", "write"}), frozenset({"read"})]),
    requested_capability="read",
    record_id="rec-envelope",
    sealed_payload=None,
    parent_record_hash=None,
)
envelope = {
    "jsonrpc": "2.0",
    "id": "1",
    "method": "message/send",
    "params": {
        "message": {
            "messageId": "msg-1",
            "role": "user",
            "parts": [{"text": "task"}],
            "metadata": {"https://example.com/ext/other/v1/note": "keep"},
        }
    },
}

attached = attach_ca2a_metadata(envelope, request)
print("top_level_has_metadata", "metadata" in attached)
print("message_has_ca2a", KEY_DELEGATION_CHAIN in attached["params"]["message"].get("metadata", {}))
print("parse_attached_returns", parse_peer_request(attached))
PY

Actual result:

top_level_has_metadata True
message_has_ca2a False
parse_attached_returns None

That is a transport-boundary bug, not just a test-shape nit: the PR advertises a parse/attach helper for A2A SendMessage-shaped data, but the attach half can silently drop the cA2A envelope from the actual A2A metadata location. Please either make attach_ca2a_metadata() mirror collect_metadata()'s supported shapes and attach under params.message.metadata / params.metadata for envelopes, or narrow the API/docs/tests so it only accepts a message object and fails closed on a JSON-RPC envelope instead of producing a misleading root-level metadata field. Add a round-trip regression for the full envelope shape.

Validation performed: inspected the updated PR diff; ran ruff check src/ tests/ successfully; ran pytest tests/unit/test_a2a_adapter.py tests/unit/test_errors.py -q successfully; reproduced the envelope round-trip failure above in a temp checkout. GitHub CI is green except the maintainer-approval gate.

attach_ca2a_metadata only wrote to the root metadata, so a full JSON-RPC
message/send envelope got cA2A fields at the root while parse_peer_request
reads params.message.metadata / params.metadata. The result was an object
the parser treated as ordinary A2A. Attach now mirrors collect_metadata's
shapes so attach then parse round-trips for both bare messages and
envelopes. Add round-trip regressions for the envelope shapes.

Signed-off-by: Susan Poudel <susanpdl77@gmail.com>
@Susanpdl

Copy link
Copy Markdown
Contributor Author

Fixed in a321bb6.

attach_ca2a_metadata() now mirrors the shapes collect_metadata() reads, so it attaches under params.message.metadata (or params.metadata when there is no nested message) for JSON-RPC envelopes, and top-level metadata for a bare message. Attach then parse now round-trips for both shapes.

Your repro now prints:

top_level_has_metadata False
message_has_ca2a True
parse_attached_returns True

Added round-trip regressions for the full message/send envelope and for the params.metadata (no nested message) case. Full unit suite passes (148), ruff and mypy clean.

@Susanpdl
Susanpdl requested a review from carloshvp July 14, 2026 23:04

@carloshvp carloshvp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rechecked a321bb6 against both earlier findings. The strict decoder rejects sealed_payload="abcd?" with TRANSPORT_ERROR, and attach_ca2a_metadata() places cA2A fields under params.message.metadata so a full SendMessage envelope round-trips through parse_peer_request().

The final diff matches the #47 acceptance bar. The adapter preserves unrelated metadata and A2A routing/payload fields. The docs keep HTTP serving and seal-to-verified-measurement binding outside this PR’s claims.

Validation on this head:

  • ruff check src/ tests/
  • mypy src/ca2a_runtime/ src/ca2a_verify/
  • pytest tests/unit/ tests/conformance/ (178 passed)
  • direct reproductions for both prior blockers

I found no remaining blocking issues.

@Susanpdl

Copy link
Copy Markdown
Contributor Author

@imran-siddique friendly ping: @carloshvp approved the latest head (a321bb6) after the two review rounds (strict base64url decode + attach/parse envelope round-trip). CI is green; the maintainer-approval gate is the remaining blocker. Could you take a look when you have a moment?

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving and merging. Carlos raised two transport-boundary blockers (base64url alphabet validation on sealed_payload, and attach_ca2a_metadata writing to the wrong envelope level). The author fixed both, and Carlos re-reviewed a321bb6 and confirmed: the strict decoder rejects malformed sealed_payload with TRANSPORT_ERROR, and cA2A fields now round-trip under params.message.metadata through parse_peer_request. Matches the #47 acceptance bar. Thanks Susanpdl and Carlos.

@imran-siddique
imran-siddique merged commit 3a8938d into agentrust-io:main Jul 19, 2026
10 of 12 checks passed
imran-siddique added a commit that referenced this pull request Jul 20, 2026
#48 added the A2A profile adapter; this branch adds a reference HTTP
server/client, the attestation handshake, and PeerNode. The scope docs still
said the A2A transport was flatly out of scope and that nothing served a live
inbound call, which is now false in software mode.

Reconcile honestly, keeping the transport in core (a reference, not the
profile) and preserving the software-vs-hardware boundary:
- LIMITATIONS: reference transport + handshake are built (software mode);
  the remaining gap is the hardware verifier seam, not live serving. Reframe
  the out-of-scope bullet: the profile mandates no transport, a non-normative
  reference one ships.
- transport.md: status table and the overlay claim distinguish the profile
  (no new endpoint) from the reference transport (which does expose endpoints).
- call-graph.md, concepts.md: the live path now runs in software mode; the
  hardware quote via the verifier seam is what remains.
- CHANGELOG: record the reference transport under Unreleased.

No code change: the transport already layers cleanly on #48's adapter after
the merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
imran-siddique added a commit that referenced this pull request Jul 20, 2026
…sport) (#53)

* feat(transport): live-call wiring (attestation handshake and A2A transport)

Compose the landed decision core (peer.handle_peer_request), Cedar policy,
sealed channel, and provenance DAG into a runnable live call, closing the two
v0.2 "Remaining" items in ROADMAP: wire the decision core to a live A2A
transport, and bind the seal to a verified attestation report on a live call.

- attestation.py: handshake that gates the sealed channel on a verified channel
  key (offer, verify, seal). Software mode records assurance="none"; hardware
  plugs in via an injected verifier callable, so this module carries no hardware
  dependency.
- transport/a2a.py: A2A wire binding. Parse an A2A message's ca2a block into a
  PeerRequest, build that message, and serialize result, error, and channel offer.
- node.py: PeerNode holding the enclave channel key, policy, and provider.
- transport/server.py, transport/client.py: standard-library-only reference HTTP
  server and client that run the full inbound pipeline over the wire.
- tee/software.py: explicit, never-auto-selected software provider (no hardware
  guarantee), so the path runs off confidential hardware.
- tests/unit/test_live_call.py: in-process pipeline, HTTP end-to-end, and
  fail-closed on over-scope, tampered payload, and stale nonce.

Software mode runs off confidential hardware; the real-hardware quote path is
the verifier seam. ruff, mypy --strict, and the full unit suite (136 tests) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: reconcile scope with the shipped reference transport

#48 added the A2A profile adapter; this branch adds a reference HTTP
server/client, the attestation handshake, and PeerNode. The scope docs still
said the A2A transport was flatly out of scope and that nothing served a live
inbound call, which is now false in software mode.

Reconcile honestly, keeping the transport in core (a reference, not the
profile) and preserving the software-vs-hardware boundary:
- LIMITATIONS: reference transport + handshake are built (software mode);
  the remaining gap is the hardware verifier seam, not live serving. Reframe
  the out-of-scope bullet: the profile mandates no transport, a non-normative
  reference one ships.
- transport.md: status table and the overlay claim distinguish the profile
  (no new endpoint) from the reference transport (which does expose endpoints).
- call-graph.md, concepts.md: the live path now runs in software mode; the
  hardware quote via the verifier seam is what remains.
- CHANGELOG: record the reference transport under Unreleased.

No code change: the transport already layers cleanly on #48's adapter after
the merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(transport): reject non-HTTP(S) URLs in the reference client (bandit B310)

bandit flagged B310 on the reference client's two urllib.request.urlopen calls
(file:/ and custom schemes). The existing `# noqa: S310` only silenced ruff, not
bandit, so `bandit -r src/` failed CI. Validate the URL scheme against an
http/https allowlist before opening, failing closed with TransportError, and
annotate the calls for both tools. Add a test for the rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants