From e201631f59b38ada4eb17594e4961ee2b792933b Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Tue, 18 Aug 2026 13:42:11 -0700 Subject: [PATCH 1/3] feat(manifest): validate v0.2 COSE identity at startup --- docs/configuration.md | 16 ++ pyproject.toml | 2 +- src/ca2a_runtime/agent_manifest.py | 165 ++++++++++++++++ src/ca2a_runtime/bootstrap.py | 30 +++ src/ca2a_runtime/config.py | 28 +++ src/ca2a_runtime/node.py | 3 + tests/unit/test_agent_manifest_cose.py | 177 ++++++++++++++++++ tests/unit/test_config.py | 33 ++++ tests/unit/test_dependency_security_floors.py | 4 + 9 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 src/ca2a_runtime/agent_manifest.py create mode 100644 tests/unit/test_agent_manifest_cose.py diff --git a/docs/configuration.md b/docs/configuration.md index 70f4dc2..287581e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,12 @@ trusted_root_issuers: local_policy: ["read", "write"] # allow-set for scope intersection (or use Cedar below) # policy_bundle_path: policy.cedar + +# Optional: verify and bind this peer's Agent Manifest identity at startup. +# agent_manifest: +# path: manifest.cose # v0.2 COSE envelope, or signed v0.1 JSON +# trust_anchor_path: manifest-key.json +# authenticated_subject: spiffe://example.test/agent/ca2a ``` ## Fields @@ -32,6 +38,9 @@ local_policy: ["read", "write"] # allow-set for scope intersection (or use Ced | `trusted_root_issuers` | none | Ed25519 public keys allowed to originate delegation chains. At least one is required by `ca2a start`; an internally valid chain from any other root is denied before policy evaluation. | | `local_policy` | none | Capability allow set for `LocalPolicy`. Required for `ca2a start` unless `policy_bundle_path` is set. | | `policy_bundle_path` | none | Path to a Cedar policy file, resolved relative to the config file. When set, used instead of `local_policy`. | +| `agent_manifest.path` | none | Optional signed Agent Manifest. Content is sniffed: v0.1 JSON and v0.2 COSE are accepted; a bare v0.2 JSON payload is rejected because its COSE envelope is the signature. | +| `agent_manifest.trust_anchor_path` | none | JSON trust anchor containing one `public_key_base64url` or a `keys` array. Relative paths resolve against the config file. | +| `agent_manifest.authenticated_subject` | none | SPIFFE URI independently configured for this peer. Startup fails unless it equals the verified manifest's `agent_id`. All three `agent_manifest` fields must be configured together. | There is no key field: a `PeerNode` generates its own X25519 channel keypair at startup and publishes the public half through the attestation handshake, so a @@ -55,3 +64,10 @@ already has a `Policy` and a provider can build a `PeerNode` and serve it from its own A2A server instead. Invalid values fail fast with a `CONFIG_ERROR` and a message naming the offending field. + +When `agent_manifest` is configured, startup verifies the signature, supported +version, expiry and revocation state before constructing the node. The verified +identity is available as `PeerNode.agent_manifest`. cA2A does not claim runtime +policy or tool-catalog artifact matching here: unlike cMCP it has no tool +catalog, and its policy may be an inline allow set rather than a hash-addressed +bundle. diff --git a/pyproject.toml b/pyproject.toml index f754c14..f47e8df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "agentrust-trace>=0.5", # Shared hardware-attestation verification (generic cert-chain verifier, # SNP/TDX/TPM primitives) consumed via PyPI instead of duplicated per repo. - "agent-manifest>=0.10", + "agent-manifest>=0.11", "rfc8785>=0.1", ] diff --git a/src/ca2a_runtime/agent_manifest.py b/src/ca2a_runtime/agent_manifest.py new file mode 100644 index 0000000..f2d9393 --- /dev/null +++ b/src/ca2a_runtime/agent_manifest.py @@ -0,0 +1,165 @@ +"""Agent Manifest loading, verification, and runtime identity binding.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import agent_manifest as agent_manifest_sdk + +from ca2a_runtime.errors import ConfigError + +_B64URL_RE = re.compile(r"^[A-Za-z0-9\-_]*$") + + +@dataclass(frozen=True) +class LoadedAgentManifest: + """A decoded manifest and the COSE envelope it arrived in, if any.""" + + manifest: dict[str, Any] + envelope: bytes | None = None + + +@dataclass(frozen=True) +class AgentManifestBinding: + """Identity fields read only after Agent Manifest verification succeeds.""" + + manifest_id: str + agent_id: str + issuer: str + authenticated_subject: str + version: str + + +def _b64url_decode(value: str) -> bytes: + if not _B64URL_RE.fullmatch(value): + raise ConfigError("Agent Manifest signature/key must use base64url encoding") + padding = (-len(value)) % 4 + try: + return base64.urlsafe_b64decode(value + "=" * padding) + except ValueError as exc: + raise ConfigError("Agent Manifest signature/key is not valid base64url") from exc + + +def _b64url_encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _key_id(public_key: bytes) -> str: + return hashlib.sha256(public_key).hexdigest() + + +def load_agent_manifest_document(path: str | Path) -> LoadedAgentManifest: + """Load JSON v0.1 or a v0.2 COSE envelope, sniffing content not suffix.""" + try: + raw = Path(path).read_bytes() + except OSError as exc: + raise ConfigError(f"cannot read Agent Manifest: {exc}") from exc + + try: + manifest = json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError): + try: + decoded = agent_manifest_sdk.decode_cose_manifest(raw) + except Exception as exc: # noqa: BLE001 - SDK errors become config failures + raise ConfigError( + f"cannot read Agent Manifest: not JSON and not a COSE envelope ({exc})" + ) from exc + if not isinstance(decoded.manifest, dict): + raise ConfigError("Agent Manifest COSE payload must be a JSON object") from None + return LoadedAgentManifest(manifest=decoded.manifest, envelope=raw) + + if not isinstance(manifest, dict): + raise ConfigError("Agent Manifest must be a JSON object") + if manifest.get("version") == agent_manifest_sdk.COSE_MANIFEST_VERSION and ( + "signature" not in manifest + ): + raise ConfigError( + "Agent Manifest declares version " + f"{agent_manifest_sdk.COSE_MANIFEST_VERSION} but was supplied as bare JSON; " + "v0.2 requires the COSE envelope" + ) + return LoadedAgentManifest(manifest=manifest) + + +def load_agent_manifest_trust_anchor(path: str | Path) -> dict[str, bytes]: + """Load one Ed25519 key or a ``keys`` array from a JSON trust-anchor file.""" + try: + raw = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigError(f"cannot read Agent Manifest trust anchor: {exc}") from exc + + items: list[Any] + if isinstance(raw, dict) and "public_key_base64url" in raw: + items = [raw] + elif isinstance(raw, dict) and isinstance(raw.get("keys"), list): + items = raw["keys"] + else: + raise ConfigError( + "Agent Manifest trust anchor must contain public_key_base64url or keys[]" + ) + + anchors: dict[str, bytes] = {} + for item in items: + if not isinstance(item, dict): + raise ConfigError("Agent Manifest trust anchor keys must be objects") + public_key = _b64url_decode(str(item.get("public_key_base64url", ""))) + if len(public_key) != 32: + raise ConfigError("Agent Manifest trust anchor contains an invalid Ed25519 key") + key_id = str(item.get("key_id") or _key_id(public_key)) + if key_id != _key_id(public_key): + raise ConfigError("Agent Manifest trust anchor key_id does not match public key") + anchors[key_id] = public_key + return anchors + + +def verify_agent_manifest_binding( + loaded: LoadedAgentManifest, + trusted_keys: dict[str, bytes], + *, + authenticated_subject: str, +) -> AgentManifestBinding: + """Verify the supplied artifact, then bind its identity to the configured subject.""" + sdk_keys = {key_id: _b64url_encode(key) for key_id, key in trusted_keys.items()} + result = agent_manifest_sdk.verify_manifest( + loaded.envelope if loaded.envelope is not None else loaded.manifest, + agent_manifest_sdk.VerificationContext(trusted_keys=sdk_keys), + agent_manifest_sdk.RevocationStore(), + ) + if result.result != agent_manifest_sdk.OverallResult.VALID: + warnings = "; ".join(getattr(result, "warnings", None) or []) + detail = f": {warnings}" if warnings else "" + raise ConfigError(f"Agent Manifest verification failed ({result.result.value}){detail}") + if result.signature_verified is not True: + raise ConfigError("Agent Manifest signature verification failed") + + manifest = loaded.manifest + manifest_id = manifest.get("manifest_id") + agent_id = manifest.get("agent_id") + issuer = manifest.get("issuer") + version = manifest.get("version") + if not isinstance(manifest_id, str) or not manifest_id: + raise ConfigError("Agent Manifest manifest_id is missing") + if not isinstance(agent_id, str) or not agent_id.startswith("spiffe://"): + raise ConfigError("Agent Manifest agent_id must be a SPIFFE URI") + if not isinstance(issuer, str) or not issuer.startswith("spiffe://"): + raise ConfigError("Agent Manifest issuer must be a SPIFFE URI") + if not isinstance(version, str) or not version: + raise ConfigError("Agent Manifest version is missing") + if not authenticated_subject.startswith("spiffe://"): + raise ConfigError("Agent Manifest authenticated_subject must be a SPIFFE URI") + if authenticated_subject != agent_id: + raise ConfigError("Agent Manifest agent_id does not match authenticated_subject") + + return AgentManifestBinding( + manifest_id=manifest_id, + agent_id=agent_id, + issuer=issuer, + authenticated_subject=authenticated_subject, + version=version, + ) diff --git a/src/ca2a_runtime/bootstrap.py b/src/ca2a_runtime/bootstrap.py index 66da9e3..0160e2e 100644 --- a/src/ca2a_runtime/bootstrap.py +++ b/src/ca2a_runtime/bootstrap.py @@ -16,6 +16,11 @@ from pathlib import Path +from ca2a_runtime.agent_manifest import ( + load_agent_manifest_document, + load_agent_manifest_trust_anchor, + verify_agent_manifest_binding, +) from ca2a_runtime.cedar import CedarPolicy from ca2a_runtime.config import Ca2aConfig from ca2a_runtime.errors import ConfigError @@ -100,9 +105,34 @@ def build_peer_node(config: Ca2aConfig, *, config_dir: Path | None = None) -> Pe "ca2a start requires at least one trusted_root_issuer", detail="pin the Ed25519 public key of each authority allowed to originate delegation chains", ) + manifest_binding = None + if config.agent_manifest_path is not None: + if ( + config.agent_manifest_trust_anchor_path is None + or config.agent_manifest_authenticated_subject is None + ): + raise ConfigError( + "Agent Manifest startup requires path, trust anchor, and authenticated subject" + ) + manifest_path = Path(config.agent_manifest_path) + trust_path = Path(config.agent_manifest_trust_anchor_path) + if config_dir is not None: + if not manifest_path.is_absolute(): + manifest_path = config_dir / manifest_path + if not trust_path.is_absolute(): + trust_path = config_dir / trust_path + loaded = load_agent_manifest_document(manifest_path) + trusted_keys = load_agent_manifest_trust_anchor(trust_path) + manifest_binding = verify_agent_manifest_binding( + loaded, + trusted_keys, + authenticated_subject=config.agent_manifest_authenticated_subject, + ) + return PeerNode( policy, provider=select_provider(config), max_depth=config.max_delegation_depth, trusted_root_issuers=config.trusted_root_issuers, + agent_manifest=manifest_binding, ) diff --git a/src/ca2a_runtime/config.py b/src/ca2a_runtime/config.py index 28b2ced..0c4f4cb 100644 --- a/src/ca2a_runtime/config.py +++ b/src/ca2a_runtime/config.py @@ -60,6 +60,9 @@ class Ca2aConfig: local_policy: frozenset[str] | None = None listen_addr: str = DEFAULT_LISTEN_ADDR trusted_root_issuers: frozenset[str] = frozenset() + agent_manifest_path: str | None = None + agent_manifest_trust_anchor_path: str | None = None + agent_manifest_authenticated_subject: str | None = None def listen_host_port(self) -> tuple[str, int]: """Return ``listen_addr`` split into the host and port to bind.""" @@ -108,6 +111,28 @@ def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig: ): raise ConfigError("trusted_root_issuers must be a list of non-empty public-key strings") + manifest = data.get("agent_manifest", {}) or {} + if not isinstance(manifest, dict): + raise ConfigError("agent_manifest must be a mapping") + manifest_path = manifest.get("path") + trust_anchor_path = manifest.get("trust_anchor_path") + authenticated_subject = manifest.get("authenticated_subject") + for name, value in ( + ("path", manifest_path), + ("trust_anchor_path", trust_anchor_path), + ("authenticated_subject", authenticated_subject), + ): + if value is not None and not isinstance(value, str): + raise ConfigError(f"agent_manifest.{name} must be a string") + configured = [manifest_path is not None, trust_anchor_path is not None, authenticated_subject is not None] + if any(configured) and not all(configured): + raise ConfigError( + "agent_manifest.path, trust_anchor_path, and authenticated_subject " + "must be configured together" + ) + if authenticated_subject is not None and not authenticated_subject.startswith("spiffe://"): + raise ConfigError("agent_manifest.authenticated_subject must be a SPIFFE URI") + return cls( provider=provider, enforcement_mode=enforcement, @@ -116,6 +141,9 @@ def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig: local_policy=local_policy, listen_addr=listen_addr, trusted_root_issuers=frozenset(raw_roots), + agent_manifest_path=manifest_path, + agent_manifest_trust_anchor_path=trust_anchor_path, + agent_manifest_authenticated_subject=authenticated_subject, ) @classmethod diff --git a/src/ca2a_runtime/node.py b/src/ca2a_runtime/node.py index fba26d4..675e634 100644 --- a/src/ca2a_runtime/node.py +++ b/src/ca2a_runtime/node.py @@ -15,6 +15,7 @@ from collections.abc import Collection from typing import Any +from ca2a_runtime.agent_manifest import AgentManifestBinding from ca2a_runtime.attestation import ChannelOffer, Verifier, attest_channel from ca2a_runtime.challenge import DEFAULT_TTL_SECONDS, generate_secret, issue_challenge from ca2a_runtime.channel import generate_channel_keypair @@ -54,6 +55,7 @@ def __init__( challenge_ttl_seconds: int = DEFAULT_TTL_SECONDS, require_holder_proof: bool = True, trusted_root_issuers: Collection[str] = (), + agent_manifest: AgentManifestBinding | None = None, ) -> None: if require_caller_attestation not in REQUIREMENT_VALUES: raise ConfigError( @@ -77,6 +79,7 @@ def __init__( self.challenge_ttl_seconds = challenge_ttl_seconds self.require_holder_proof = require_holder_proof self.trusted_root_issuers = frozenset(trusted_root_issuers) + self.agent_manifest = agent_manifest self._private_key, self.channel_public_key = generate_channel_keypair() self._challenge_secret = generate_secret() diff --git a/tests/unit/test_agent_manifest_cose.py b/tests/unit/test_agent_manifest_cose.py new file mode 100644 index 0000000..703c1b9 --- /dev/null +++ b/tests/unit/test_agent_manifest_cose.py @@ -0,0 +1,177 @@ +"""Agent Manifest v0.1/v0.2 consumption through ca2a's startup binding path.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +import agent_manifest as sdk +import pytest + +from ca2a_runtime.agent_manifest import ( + load_agent_manifest_document, + load_agent_manifest_trust_anchor, + verify_agent_manifest_binding, +) +from ca2a_runtime.bootstrap import build_peer_node +from ca2a_runtime.config import Ca2aConfig +from ca2a_runtime.errors import ConfigError + +AGENT_ID = "spiffe://factory.example/agent/ca2a/dev" +ISSUER = "spiffe://factory.example/signing-authority/development" + + +def _manifest(version: str) -> dict: + return { + "@context": "https://manifest.agentrust-io.com/v0.2/context.json", + "@type": "AgentManifest", + "manifest_id": "0197739a-8c00-7000-8000-000000000315", + "agent_id": AGENT_ID, + "version": version, + "issued_at": "2026-06-12T00:00:00Z", + "expires_at": "2099-09-10T00:00:00Z", + "issuer": ISSUER, + "crypto_profile": "standard", + "artifacts": {}, + } + + +def _cose() -> tuple[bytes, dict, str, bytes]: + keypair = sdk.generate_ed25519() + manifest = _manifest("0.2") + return ( + sdk.sign_manifest_cose(manifest, keypair), + manifest, + keypair.key_id, + keypair.public_bytes, + ) + + +def _v01() -> tuple[dict, str, bytes]: + keypair = sdk.generate_ed25519() + manifest = _manifest("0.1") + manifest["@context"] = "https://agentmanifest.agentrust-io.com/v0.1/context.json" + manifest["signature"] = sdk.Ed25519Signer(keypair).sign(manifest) + return manifest, keypair.key_id, keypair.public_bytes + + +def _trust_file(path: Path, key_id: str, public_key: bytes) -> Path: + path.write_text( + json.dumps( + { + "key_id": key_id, + "public_key_base64url": base64.urlsafe_b64encode(public_key) + .rstrip(b"=") + .decode(), + } + ), + encoding="utf-8", + ) + return path + + +def test_v02_cose_binds_end_to_end(tmp_path: Path) -> None: + envelope, manifest, key_id, public_key = _cose() + path = tmp_path / "manifest.cose" + path.write_bytes(envelope) + + loaded = load_agent_manifest_document(path) + binding = verify_agent_manifest_binding( + loaded, {key_id: public_key}, authenticated_subject=AGENT_ID + ) + + assert loaded.envelope == envelope + assert binding.manifest_id == manifest["manifest_id"] + assert binding.agent_id == AGENT_ID + assert binding.version == "0.2" + + +def test_v01_json_still_binds(tmp_path: Path) -> None: + manifest, key_id, public_key = _v01() + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + + binding = verify_agent_manifest_binding( + load_agent_manifest_document(path), + {key_id: public_key}, + authenticated_subject=AGENT_ID, + ) + assert binding.version == "0.1" + + +def test_v02_bare_json_is_rejected(tmp_path: Path) -> None: + path = tmp_path / "manifest.json" + path.write_text(json.dumps(_manifest("0.2")), encoding="utf-8") + with pytest.raises(ConfigError, match="COSE envelope"): + load_agent_manifest_document(path) + + +def test_tampered_cose_is_rejected(tmp_path: Path) -> None: + envelope, _manifest_doc, key_id, public_key = _cose() + tampered = bytearray(envelope) + tampered[len(tampered) // 2] ^= 1 + path = tmp_path / "manifest.cose" + path.write_bytes(tampered) + + try: + loaded = load_agent_manifest_document(path) + except ConfigError: + return + with pytest.raises(ConfigError, match="verification failed"): + verify_agent_manifest_binding( + loaded, {key_id: public_key}, authenticated_subject=AGENT_ID + ) + + +def test_untrusted_cose_is_rejected(tmp_path: Path) -> None: + envelope, _manifest_doc, _key_id, _public_key = _cose() + other = sdk.generate_ed25519() + path = tmp_path / "manifest.cose" + path.write_bytes(envelope) + with pytest.raises(ConfigError, match="verification failed"): + verify_agent_manifest_binding( + load_agent_manifest_document(path), + {other.key_id: other.public_bytes}, + authenticated_subject=AGENT_ID, + ) + + +def test_subject_mismatch_is_rejected(tmp_path: Path) -> None: + envelope, _manifest_doc, key_id, public_key = _cose() + path = tmp_path / "manifest.cose" + path.write_bytes(envelope) + with pytest.raises(ConfigError, match="does not match"): + verify_agent_manifest_binding( + load_agent_manifest_document(path), + {key_id: public_key}, + authenticated_subject="spiffe://factory.example/agent/other/dev", + ) + + +def test_startup_carries_verified_manifest_binding(tmp_path: Path) -> None: + envelope, manifest, key_id, public_key = _cose() + manifest_path = tmp_path / "manifest.cose" + manifest_path.write_bytes(envelope) + trust_path = _trust_file(tmp_path / "manifest-key.json", key_id, public_key) + config = Ca2aConfig( + provider="software-only", + local_policy=frozenset({"read"}), + trusted_root_issuers=frozenset({"delegation-root"}), + agent_manifest_path=manifest_path.name, + agent_manifest_trust_anchor_path=trust_path.name, + agent_manifest_authenticated_subject=AGENT_ID, + ) + + node = build_peer_node(config, config_dir=tmp_path) + + assert node.agent_manifest is not None + assert node.agent_manifest.manifest_id == manifest["manifest_id"] + assert node.agent_manifest.agent_id == AGENT_ID + + +def test_trust_anchor_key_id_must_match_key(tmp_path: Path) -> None: + keypair = sdk.generate_ed25519() + path = _trust_file(tmp_path / "manifest-key.json", "0" * 64, keypair.public_bytes) + with pytest.raises(ConfigError, match="key_id does not match"): + load_agent_manifest_trust_anchor(path) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e7174ad..af114d6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -17,6 +17,7 @@ def test_defaults_from_empty_dict() -> None: assert cfg.max_delegation_depth == 8 assert cfg.listen_host_port() == ("127.0.0.1", 8443) assert cfg.trusted_root_issuers == frozenset() + assert cfg.agent_manifest_path is None def test_trusted_root_issuers_are_loaded() -> None: @@ -98,3 +99,35 @@ def test_listen_addr_ipv6_brackets_stripped() -> None: def test_bad_listen_addr_rejected(addr: str) -> None: with pytest.raises(ConfigError): Ca2aConfig.from_dict({"listen_addr": addr}) + + +def test_agent_manifest_config_is_loaded_as_a_complete_set() -> None: + cfg = Ca2aConfig.from_dict( + { + "agent_manifest": { + "path": "manifest.cose", + "trust_anchor_path": "manifest-key.json", + "authenticated_subject": "spiffe://example.test/agent/ca2a", + } + } + ) + assert cfg.agent_manifest_path == "manifest.cose" + assert cfg.agent_manifest_trust_anchor_path == "manifest-key.json" + assert cfg.agent_manifest_authenticated_subject == "spiffe://example.test/agent/ca2a" + + +@pytest.mark.parametrize( + "manifest", + [ + {"path": "manifest.cose"}, + {"path": "manifest.cose", "trust_anchor_path": "key.json"}, + { + "path": "manifest.cose", + "trust_anchor_path": "key.json", + "authenticated_subject": "not-spiffe", + }, + ], +) +def test_incomplete_or_unbound_agent_manifest_config_is_rejected(manifest: dict) -> None: + with pytest.raises(ConfigError, match="agent_manifest"): + Ca2aConfig.from_dict({"agent_manifest": manifest}) diff --git a/tests/unit/test_dependency_security_floors.py b/tests/unit/test_dependency_security_floors.py index 27094fa..9ad8b7b 100644 --- a/tests/unit/test_dependency_security_floors.py +++ b/tests/unit/test_dependency_security_floors.py @@ -15,6 +15,10 @@ def test_runtime_cryptography_floor_includes_2026_security_fixes() -> None: assert "cryptography>=50.0" in _project()["dependencies"] +def test_agent_manifest_floor_includes_v02_cose_verification() -> None: + assert "agent-manifest>=0.11" in _project()["dependencies"] + + def test_a2a_sdk_extra_cannot_resolve_vulnerable_aiohttp() -> None: extras = _project()["optional-dependencies"] assert "aiohttp>=3.14.3" in extras["a2a-sdk"] From f37c6ec6b6b20d80c856acd6e55c1d49e9c8d39b Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Tue, 18 Aug 2026 13:50:18 -0700 Subject: [PATCH 2/3] style: apply repository Ruff format --- src/ca2a_runtime/agent_manifest.py | 4 +--- src/ca2a_runtime/config.py | 6 +++++- tests/unit/test_agent_manifest_cose.py | 8 ++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/ca2a_runtime/agent_manifest.py b/src/ca2a_runtime/agent_manifest.py index f2d9393..fbb5917 100644 --- a/src/ca2a_runtime/agent_manifest.py +++ b/src/ca2a_runtime/agent_manifest.py @@ -100,9 +100,7 @@ def load_agent_manifest_trust_anchor(path: str | Path) -> dict[str, bytes]: elif isinstance(raw, dict) and isinstance(raw.get("keys"), list): items = raw["keys"] else: - raise ConfigError( - "Agent Manifest trust anchor must contain public_key_base64url or keys[]" - ) + raise ConfigError("Agent Manifest trust anchor must contain public_key_base64url or keys[]") anchors: dict[str, bytes] = {} for item in items: diff --git a/src/ca2a_runtime/config.py b/src/ca2a_runtime/config.py index 0c4f4cb..c350b51 100644 --- a/src/ca2a_runtime/config.py +++ b/src/ca2a_runtime/config.py @@ -124,7 +124,11 @@ def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig: ): if value is not None and not isinstance(value, str): raise ConfigError(f"agent_manifest.{name} must be a string") - configured = [manifest_path is not None, trust_anchor_path is not None, authenticated_subject is not None] + configured = [ + manifest_path is not None, + trust_anchor_path is not None, + authenticated_subject is not None, + ] if any(configured) and not all(configured): raise ConfigError( "agent_manifest.path, trust_anchor_path, and authenticated_subject " diff --git a/tests/unit/test_agent_manifest_cose.py b/tests/unit/test_agent_manifest_cose.py index 703c1b9..fab17d7 100644 --- a/tests/unit/test_agent_manifest_cose.py +++ b/tests/unit/test_agent_manifest_cose.py @@ -61,9 +61,7 @@ def _trust_file(path: Path, key_id: str, public_key: bytes) -> Path: json.dumps( { "key_id": key_id, - "public_key_base64url": base64.urlsafe_b64encode(public_key) - .rstrip(b"=") - .decode(), + "public_key_base64url": base64.urlsafe_b64encode(public_key).rstrip(b"=").decode(), } ), encoding="utf-8", @@ -119,9 +117,7 @@ def test_tampered_cose_is_rejected(tmp_path: Path) -> None: except ConfigError: return with pytest.raises(ConfigError, match="verification failed"): - verify_agent_manifest_binding( - loaded, {key_id: public_key}, authenticated_subject=AGENT_ID - ) + verify_agent_manifest_binding(loaded, {key_id: public_key}, authenticated_subject=AGENT_ID) def test_untrusted_cose_is_rejected(tmp_path: Path) -> None: From f1b982f1d8c2429641c1f883a715de1881ce9543 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Tue, 18 Aug 2026 14:53:47 -0700 Subject: [PATCH 3/3] release: prepare cA2A 0.2.0 for adoption --- ADOPTERS.md | 2 +- CHANGELOG.md | 10 +++++++++- LIMITATIONS.md | 4 ++-- README.md | 14 ++++++++------ ROADMAP.md | 10 +++++----- docs/quickstart.md | 8 ++++---- docs/spec/mutual-attestation.md | 2 +- examples/rejection-with-proof/README.md | 4 +--- examples/trace-dag/README.md | 5 +---- pyproject.toml | 4 ++-- src/ca2a_runtime/peer.py | 2 +- src/ca2a_runtime/transport/a2a_sdk.py | 2 +- tests/unit/test_release_artifacts.py | 16 ++++++++++++++++ 13 files changed, 52 insertions(+), 31 deletions(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index 8a34b4c..34c679e 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -4,4 +4,4 @@ Organizations using cA2A in production or evaluation are listed here. To add yours, open a PR adding a line with your organization, the use case, and the month you started. Anything you are not able to say publicly, leave out; a one-line entry is worth more than a case study you have to get approved. -cA2A is alpha and the delegation surface is still moving, so early evaluations are especially useful to us even when they do not become entries here: open a [Discussion](https://github.com/orgs/agentrust-io/discussions) or contact the maintainers listed in [MAINTAINERS.md](MAINTAINERS.md). +cA2A is available as a Developer Preview, so evaluations are especially useful even when they do not become entries here: open a [Discussion](https://github.com/orgs/agentrust-io/discussions) or contact the maintainers listed in [MAINTAINERS.md](MAINTAINERS.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index b88fa4d..0c297e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-08-18 + +This is the first normal cA2A release. It promotes the project from its initial +preview package to a runnable Developer Preview with runtime enforcement, sealed +peer channels, signed TRACE provenance, real-evidence hardware appraisal, a +conformance suite, and startup-bound Agent Manifest identity. + ### Fixed - **`collect_report` confirms the configfs-TSM provider before reading `outblob` (#86 follow-up).** Reading `outblob` is what makes the platform generate and sign a report, so checking the provider afterwards meant a mismatched guest signed a report over the caller's binding and the result was then discarded. Nothing was returned and the entry was removed either way, so this was not a disclosure, but it asked the hardware to sign something no one could use. The provider check now gates the read. A test asserts `outblob` is never read on a mismatch, so the ordering cannot quietly regress. @@ -243,5 +250,6 @@ built/stubbed boundary. - The sealed channel does not by itself establish the enclave-held-private-key property; that is a hardware attestation guarantee that lands with real-hardware validation. - Alpha schemas: the delegation credential and TRACE link schemas are not yet stable or versioned, and peer attestation evidence is not yet RATS/EAT conformant. -[Unreleased]: https://github.com/agentrust-io/ca2a/compare/v0.1.0a1...main +[Unreleased]: https://github.com/agentrust-io/ca2a/compare/v0.2.0...main +[0.2.0]: https://github.com/agentrust-io/ca2a/compare/v0.1.0a1...v0.2.0 [0.1.0a1]: https://github.com/agentrust-io/ca2a/releases/tag/v0.1.0a1 diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 5758a70..933d3af 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -1,6 +1,6 @@ # Limitations -cA2A is a pre-release profile in active design. This document states plainly what is built, what is stubbed, and what is out of scope, so no claim in the documentation runs ahead of the code. This is a deliberate discipline: proof, not promises. +cA2A 0.2 is a Developer Preview with a runnable, tested profile and runtime. This document states plainly what is built, what remains before 1.0, and what is out of scope, so no claim in the documentation runs ahead of the code. This is a deliberate discipline: proof, not promises. ## What is built @@ -8,7 +8,7 @@ cA2A is a pre-release profile in active design. This document states plainly wha - Configuration, error registry, and the CLI surface, including `ca2a start`, which builds a `PeerNode` from a config file and serves it over the reference transport. - A reference HTTP transport and the attestation handshake, in software mode. `ca2a_runtime.transport.server` and `ca2a_runtime.transport.client` (standard library only) run a live inbound A2A-profile call end to end: the caller fetches the callee's attested channel key, seals a payload to it, and sends a delegated task; the callee parses the A2A metadata with the adapter, runs verify + policy + enforce + open-sealed + provenance, and replies. `ca2a_runtime.attestation` gates the seal on a verified channel key. This is a **reference** transport, not part of the profile: the profile mandates no wire protocol (see Out of scope), and in software mode the peer key is accepted at `assurance="none"`. -## What is stubbed or not yet implemented +## What remains before 1.0 - **Hardware-attested live binding.** The `verifier` seam in `ca2a_runtime.attestation` has now been driven off a real SEV-SNP quote on an Azure confidential VM: `verify_offer` returned `assurance="hardware"`, a payload was sealed to a channel key a hardware-verified measurement vouches for, and both a measurement mismatch and a stale nonce were rejected. See [docs/hardware-validation.md](docs/hardware-validation.md). Two gaps remain. First, the reference server/client still run in **software mode** by default (`assurance="none"`); the hardware path is a validated capability, not the default configuration. `ca2a start` inherits that: it refuses to start under `provider: auto` when no confidential-computing platform is detected, so a software-mode listener is always a config that names `software-only`, never a downgrade. Second, attestation on that run was one-directional: a follow-on cross-operator run (an Azure SEV-SNP peer calling a GCP Intel TDX peer, recorded in the same document) had the caller appraise the callee's real TDX quote before sealing, but the callee did not appraise the caller in return. The *protocol* is no longer one-directional -- a callee now issues a challenge and appraises the caller's offer before opening the sealed payload ([docs/spec/mutual-attestation.md](docs/spec/mutual-attestation.md)) -- but that is implemented and tested in **software mode only**, it is off by default, and making the protocol mutual does not make the recorded hardware run mutual. Mutual attestation on real silicon in both directions is still outstanding, it is still not *simultaneous* (the caller commits a sealed payload before the callee has appraised it), and both peers were driven by one operator's harness. - **Sealed peer channel (hardware property).** The channel is implemented: a payload is sealed to the peer's attested X25519 key (X25519 ECDH, HKDF-SHA256, ChaCha20-Poly1305), and only the holder of the peer's private key can open it. On a live call the handshake now gates the seal on a channel key the caller has appraised, but in software mode that appraisal is `assurance="none"`. Until the seal is bound to a hardware-verified measurement (above), do not assume a payload is confined to a specific attested measurement. Adapter-decoded `sealed_payload` bytes are opaque ciphertext only. diff --git a/README.md b/README.md index 20f8f4e..9adb012 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@

-[![CI](https://github.com/agentrust-io/ca2a/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/agentrust-io/ca2a/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/agentrust-io/ca2a/badge)](https://scorecard.dev/viewer/?uri=github.com/agentrust-io/ca2a) - -> **Pre-release draft.** cA2A is a profile in active design. The delegation semantics are implemented and tested in [agent-manifest](https://github.com/agentrust-io/agent-manifest); the runtime peer path and sealed channel in this repo are under construction. See [ROADMAP.md](ROADMAP.md) and [LIMITATIONS.md](LIMITATIONS.md) for exactly what is and is not built. +[![CI](https://github.com/agentrust-io/ca2a/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/agentrust-io/ca2a/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/agentrust-io/ca2a/badge)](https://scorecard.dev/viewer/?uri=github.com/agentrust-io/ca2a) + +[![PyPI](https://img.shields.io/pypi/v/ca2a-runtime)](https://pypi.org/project/ca2a-runtime/) + +> **Developer Preview.** cA2A 0.2 ships the profile, offline verifier, enforced peer runtime, sealed channel, signed TRACE provenance, and fail-closed SEV-SNP, TDX, and TPM appraisal. It may still introduce breaking changes before 1.0. See [ROADMAP.md](ROADMAP.md) and [LIMITATIONS.md](LIMITATIONS.md) for the remaining hardware and interoperability work. **cA2A (Confidential A2A) is the secure, confidential way to do agent-to-agent delegation on the [Agent2Agent (A2A)](https://a2a-protocol.org/) protocol.** It layers attested, attenuated delegation, a sealed peer channel, and an offline-verifiable provenance record on top of A2A, without replacing the transport. If you are looking for a secure version of A2A for multi-agent systems, this is the AgenTrust profile for it. @@ -62,10 +64,10 @@ cA2A is a trust profile layered on A2A, the way TRACE binds to IETF RATS, EAT, a ## Quick Start ```bash -pip install --pre ca2a-runtime +pip install ca2a-runtime ``` -> cA2A is in alpha; `--pre` opts into the pre-release. The runtime peer path is under construction (see [ROADMAP.md](ROADMAP.md)). Today you can build and verify delegation chains offline: +The same package supports both offline chain verification and the live peer runtime: ```bash ca2a verify-chain --chain ./examples/minimal/chain.json @@ -126,7 +128,7 @@ Agent A --(delegation cred, scope S_A)--> Agent B --(scope S_B ⊆ S_A)--> Agent 3. The task payload is sealed to B's attested measurement, so only B's verified enclave can read it. 4. Each hop emits a TRACE record linking to its parent, producing a delegation DAG any verifier can check offline without trusting an operator. -> **Status:** the delegation-chain verification and the provenance DAG (steps 1 and 4) are implemented and offline-verifiable today. The live inbound peer path (steps 2 and 3: verifying a peer's attestation on a real call and sealing the payload to a *verified* measurement) is under construction. See [LIMITATIONS.md](LIMITATIONS.md) and [ROADMAP.md](ROADMAP.md). +> **Status:** all four steps are implemented and exercised end to end. Software mode is available for evaluation; hardware-backed assurance requires a supported platform and pinned trust policy. See [LIMITATIONS.md](LIMITATIONS.md) and [ROADMAP.md](ROADMAP.md). --- diff --git a/ROADMAP.md b/ROADMAP.md index 780fc2b..3a1bd98 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,22 +13,22 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple - Cedar policy engine (cmcp) - Ed25519 + RFC 8785 canonicalization (all three repos; cA2A now ships a JCS canonicalizer in `ca2a_runtime.canonical`) -## v0.1: Profile and offline verifier +## Delivered in v0.1: Profile and offline verifier - cA2A profile specification published as an A2A binding (`docs/SPEC.md`) - TRACE A2A profile: optional delegation-link block (parent record hash + delegation credential id) and its validation (Tier 1, coordinated in trace-spec) - `ca2a-verify`: offline verification of a delegation chain and the delegation DAG, reusing the agent-manifest verifier - Wire the agent-manifest delegation verifier as a check the runtime can call on an inbound peer request (Tier 1) -## v0.2: Runtime enforcement and sealed channel +## Delivered in v0.2: Runtime enforcement and sealed channel - Runtime peer-delegation enforcement: **decision core landed** (`ca2a_runtime.peer.enforce_peer_call`: verify chain, intersect delegated scope with local policy, enforce, emit provenance record; claim C3 validated), now with a **real Cedar policy engine** option (`ca2a_runtime.cedar.CedarPolicy`) alongside the allow-set `LocalPolicy`. Live transport **landed** (`ca2a_runtime.transport`: A2A wire binding in `transport.a2a_adapter`, a reference standard-library HTTP server and client, and `ca2a_runtime.node.PeerNode`), exercised end to end in software mode by `tests/unit/test_live_call.py` and runnable from a config file with `ca2a start` - Sealed peer channel: **landed** (`ca2a_runtime.channel`: HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing to the peer's attested key; claim C4 validated). The seal is now **gated on a verified channel key** by the attestation handshake (`ca2a_runtime.attestation`: offer, verify, seal), so a payload is sealed only to an attested peer key; software mode records `assurance="none"` and hardware plugs in via a `verifier` callable. Remaining hardware property: the enclave holding the private key, established on a confidential VM - Linked runtime evidence: **landed** (`ca2a_runtime.trace_binding` emits a signed TRACE record per hop with the A2A `delegation` block; `ca2a_verify.verify_trace_dag` verifies the DAG offline, each link committing to the parent's full signed record). Built on `agentrust-trace` (Ed25519 + RFC 8785), reused not reimplemented. Software-mode records are Level 0; a hardware TEE run lifts them to Level 1. See `examples/trace-dag/`. -## Critical path, sequenced first (Tier 3) +## Current adoption path (post-v0.2) -Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via QVL/PCS, TPM AK cert + checkquote). This is a dependency for any cross-operator trust claim, single-agent or multi-agent, and is shared with cmcp. At least one real hardware backend must land before cA2A is marketed as attested across trust domains, so the demo matches the claim. +Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via QVL/PCS, TPM AK cert + checkquote) is shared with cMCP. Appraisal against genuine SEV-SNP and TDX evidence has landed; the work below tracks what remains before broadly claiming mutual, cross-operator hardware assurance. - **SEV-SNP verifier: landed and validated on real evidence.** Report parsing, VCEK chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed, run against a genuine Azure CVM report (see [docs/hardware-validation.md](docs/hardware-validation.md)). Report generation is implemented via configfs-TSM but is not yet hardware-validated, and Azure's paravisor shape is out of scope for it. See `ca2a_verify.sev_snp` and [docs/spec/attestation.md](docs/spec/attestation.md). - **TDX verifier: landed and validated on real evidence.** DCAP Quote v4 parsing (including the nested type-6 QE certification data), PCK chain to the genuine Intel SGX Root CA, QE report signature, attestation-key binding, quote signature, and MRTD binding, all fail-closed, run against a genuine GCP C3 quote. Quote generation is implemented via configfs-TSM but is not yet hardware-validated. See `ca2a_verify.tdx`. @@ -38,7 +38,7 @@ Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via - **Cross-operator, cross-TEE run: landed.** An Azure SEV-SNP peer appraised a GCP Intel TDX peer's real quote, sealed a delegated task to the attested key, and the TDX enclave opened it, enforced the attenuated scope, allowed `tool:search` and refused `tool:purchase` with a denial record returned across the boundary. See [docs/hardware-validation.md](docs/hardware-validation.md). - **Pending:** a hardware run of the SEV-SNP and TDX collectors (both implemented against configfs-TSM, neither yet exercised on silicon), mutual attestation on real silicon in both directions (the protocol now supports it in software mode and is off by default; that hardware run was one-directional), simultaneous attestation (which needs a commitment step neither peer can back out of, a larger protocol than what landed), and the TPM certificate-chain path. TPM parsing, bindings and the AK signature are validated against a real Azure vTPM quote; SEV-SNP and TDX appraisal of real evidence is done. The transport that parses A2A messages into a `PeerRequest` has **landed** (`ca2a_runtime.transport.a2a_adapter`), running in software mode; the hardware seam is the `verifier` callable in `ca2a_runtime.attestation`. -## v1.0: Stable profile +## v1.0 exit criteria: Stable profile - Stable delegation credential and TRACE link schema with documented versioning guarantees - Full RATS/EAT conformance for peer attestation evidence diff --git a/docs/quickstart.md b/docs/quickstart.md index 4e4bf5e..c5e6c4c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -5,7 +5,7 @@ This walkthrough builds a delegation chain and verifies it offline. It needs no ## Install ```bash -pip install --pre ca2a-runtime +pip install ca2a-runtime ``` Or run the published rootless container with a read-only configuration mount: @@ -14,7 +14,7 @@ Or run the published rootless container with a read-only configuration mount: docker run --rm -p 8443:8443 \ --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ -v "$PWD/ca2a-config.yaml:/etc/ca2a/config.yaml:ro" \ - ghcr.io/agentrust-io/ca2a-runtime:v0.1.0a1 \ + ghcr.io/agentrust-io/ca2a-runtime:v0.2.0 \ start --config /etc/ca2a/config.yaml ``` @@ -22,7 +22,7 @@ The image runs as UID/GID 10001. Hardware-backed providers additionally need the relevant device passed through with permissions for that identity; do not run the whole container as root to obtain device access. -cA2A is in alpha, so `--pre` is required to opt into the pre-release. Contributors working from a checkout can instead install from source: `pip install -e ".[dev]"`. +cA2A 0.2 is published as a normal release. Contributors working from a checkout can instead install from source: `pip install -e ".[dev]"`. ## Build an example chain @@ -75,4 +75,4 @@ verify_chain([root, child]) # raises on any violation ## What is not in this walkthrough -The runtime peer path (accepting a delegation credential on a live inbound A2A call, attesting the peer, sealing the payload) is under construction. See [ROADMAP.md](../ROADMAP.md) and [LIMITATIONS.md](../LIMITATIONS.md). +The runtime peer path accepts a delegation credential on a live inbound A2A call, appraises the peer, seals the payload, enforces local policy, and emits signed provenance. The walkthrough defaults to software assurance; see [ROADMAP.md](../ROADMAP.md) and [LIMITATIONS.md](../LIMITATIONS.md) before making hardware-backed claims. diff --git a/docs/spec/mutual-attestation.md b/docs/spec/mutual-attestation.md index 23aa8a7..2209e45 100644 --- a/docs/spec/mutual-attestation.md +++ b/docs/spec/mutual-attestation.md @@ -138,7 +138,7 @@ process, B gives at-most-once-per-window across many. ## Posture when the caller will not attest -Most callers, today, cannot: cA2A is alpha and the ecosystem is two peers we run. +Most callers, today, cannot: mutual hardware attestation is not yet broadly deployed across independently operated peers. A callee that refuses unattested callers by default is a callee nobody can talk to; one that accepts them silently has added a field nobody reads. diff --git a/examples/rejection-with-proof/README.md b/examples/rejection-with-proof/README.md index 4fb0811..fb10b2c 100644 --- a/examples/rejection-with-proof/README.md +++ b/examples/rejection-with-proof/README.md @@ -6,9 +6,7 @@ offline, from the committed files, without trusting the operator that produced them. ```bash -# From repo root, package installed editable (pip install -e ".[dev]"). -# The published ca2a-runtime 0.1.0a1 predates the `delegation` module this -# demo imports, so a PyPI install is not enough yet. +# From repo root with ca2a-runtime 0.2.0+ installed. python examples/rejection-with-proof/demo.py ``` diff --git a/examples/trace-dag/README.md b/examples/trace-dag/README.md index 8545941..a9d72f7 100644 --- a/examples/trace-dag/README.md +++ b/examples/trace-dag/README.md @@ -5,10 +5,7 @@ delegation hop, linked into a verifiable DAG via the A2A profile's `delegation` block, then verify the DAG offline. ```bash -# From repo root, package installed editable (pip install -e ".[dev]"). -# The published ca2a-runtime 0.1.0a1 predates the `delegation` and -# `trace_binding` modules this demo imports, so a PyPI install is not -# enough yet. +# From repo root with ca2a-runtime 0.2.0+ installed. python examples/trace-dag/demo.py ``` diff --git a/pyproject.toml b/pyproject.toml index f47e8df..e8fb952 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ca2a-runtime" -version = "0.1.0a1" +version = "0.2.0" description = "Confidential agent-to-agent runtime: attested, attenuated delegation and sealed peer channels for A2A" readme = "README.md" license = { text = "MIT" } @@ -13,7 +13,7 @@ authors = [ ] keywords = ["a2a", "agent-to-agent", "delegation", "tee", "attestation", "confidential-computing", "ai-agents"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/src/ca2a_runtime/peer.py b/src/ca2a_runtime/peer.py index 245395a..6ce9222 100644 --- a/src/ca2a_runtime/peer.py +++ b/src/ca2a_runtime/peer.py @@ -66,7 +66,7 @@ #: How much the callee demands of the caller's own runtime. #: #: ``"none"`` is the default and demands nothing: the outcome is recorded and the -#: call proceeds. It is the default because cA2A is alpha and almost no caller can +#: call proceeds. It is the default during the Developer Preview because not every caller can #: attest yet, so a callee that refused unattested callers out of the box would be #: a callee nobody could talk to -- and a control that breaks the common case gets #: switched off and never switched back on. Strictness is opt-in, one rung at a diff --git a/src/ca2a_runtime/transport/a2a_sdk.py b/src/ca2a_runtime/transport/a2a_sdk.py index 6276285..649169b 100644 --- a/src/ca2a_runtime/transport/a2a_sdk.py +++ b/src/ca2a_runtime/transport/a2a_sdk.py @@ -4,7 +4,7 @@ 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. +replacing their transport with ours, which should not be required to evaluate a profile. 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 diff --git a/tests/unit/test_release_artifacts.py b/tests/unit/test_release_artifacts.py index a495d5d..9693847 100644 --- a/tests/unit/test_release_artifacts.py +++ b/tests/unit/test_release_artifacts.py @@ -2,6 +2,7 @@ from __future__ import annotations +import tomllib from importlib.metadata import version from pathlib import Path @@ -18,6 +19,21 @@ def test_runtime_version_comes_from_installed_package_metadata() -> None: assert ca2a_runtime.__version__ == version("ca2a-runtime") +def test_public_release_metadata_is_stable() -> None: + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + assert project["version"] == "0.2.0" + assert "Development Status :: 4 - Beta" in project["classifiers"] + assert not any("Alpha" in classifier for classifier in project["classifiers"]) + + +def test_current_adoption_docs_do_not_require_prerelease_install() -> None: + for filename in ("README.md", "ADOPTERS.md", "LIMITATIONS.md", "docs/quickstart.md"): + text = Path(filename).read_text(encoding="utf-8").lower() + assert "alpha" not in text + assert "pre-release" not in text + assert "--pre" not in text + + def test_release_has_no_manual_publish_trigger() -> None: workflow = _release_workflow() triggers = workflow[True] # PyYAML 1.1 parses the YAML key `on` as True.