diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf984f..a035342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`ca2a start`**: run the reference transport from a config file. It resolves + the policy (`local_policy` or a Cedar `policy_bundle_path`, the latter relative + to the config file) and the attestation provider, builds a + `ca2a_runtime.node.PeerNode`, and serves it with + `ca2a_runtime.transport.server`. The wiring lives in `ca2a_runtime.bootstrap` + (`load_policy`, `select_provider`, `build_peer_node`) so a program that already + has a `Policy` and a provider can keep constructing a `PeerNode` directly; the + CLI adds no capability the library did not have. No new dependencies: the + reference transport is standard library only. Provider selection fails closed. + `software-only` has no hardware guarantee, so `auto` refuses to start when no + confidential-computing platform is detected rather than silently choosing it, + and a named hardware provider whose device node is absent is a startup error. + Software mode prints what a caller will appraise the channel key as + (`assurance="none"`) instead of letting the operator assume otherwise. This + closes the `ca2a start` gap the transport spec listed. See issue #47. + +### Changed + +- `Ca2aConfig.listen_addr` now defaults to `127.0.0.1:8443` and must name a host + explicitly. It was `0.0.0.0:8443` while nothing read it; now that `ca2a start` + binds it, defaulting to every interface would put a peer on the network by + omission. `transport.server.serve` already defaulted to loopback, so the two + agree. Bracketed IPv6 (`[::1]:8443`) parses, and a malformed address fails + `ca2a validate-config` rather than at bind time. +- Dropped `enclave_private_key_hex` from the config. A `PeerNode` generates its + own X25519 channel keypair and publishes the public half through the + attestation handshake, so the caller seals to a key the node attested. A + pre-shared key in a config file would be sealing to something nobody appraised. + ### Changed - **BREAKING: TRACE records now carry the v0.2 profile** `tag:agentrust-io.com,2026:trace-v0.2`. Pins move to `agentrust-trace>=0.5` and `agentrust-trace-tests>=0.4,<0.5`, which have to move together: the conformance suite cut over rather than dual-accepting, so 0.4.0 of the suite fails a v0.1 record and 0.3.x fails a v0.2 one. The v0.1 URI named `agentrust.io`, a domain this project never controlled, which RFC 4151 does not permit for a tag URI (agentrust-io/trace-spec#107). Nothing else about the record format changed. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index c85b103..98eca24 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -5,12 +5,12 @@ cA2A is a pre-release profile in active design. This document states plainly wha ## What is built - The delegation credential model and the offline chain verifier skeleton: signature checks, scope attenuation (a child grant must be a provable subset of its parent), depth limits, and cross-chain replay rejection. The hardest of these semantics is reused from [agent-manifest](https://github.com/agentrust-io/agent-manifest), where it is implemented and tested. -- Configuration, error registry, and the CLI surface. +- 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 -- **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, and there is no CLI listener (`ca2a start`) since serving is via `ca2a_runtime.transport.server.serve`. 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. Mutual simultaneous attestation is the remaining step, and both peers were driven by one operator's harness. +- **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. Mutual simultaneous attestation is the remaining step, 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. - **Real hardware attestation.** The **SEV-SNP and Intel TDX verifiers now appraise genuine hardware evidence end to end**: a real Azure CVM SEV-SNP report (VCEK chain to the AMD ARK-Milan root, ECDSA-P384 report signature, measurement binding) and a real GCP C3 DCAP v4 TDX quote (PCK chain to the Intel SGX Root CA, QE binding, quote signature, MRTD binding), both fail-closed and both rejecting a tampered copy. Runs are recorded in [docs/hardware-validation.md](docs/hardware-validation.md). The **TPM 2.0 verifier** (AK chain to a caller-supplied vendor root, AK signature, magic/type, qualifying-data and PCR-digest binding) is implemented but synthetic-vector validated only. Quote *generation* still requires the respective hardware for all three. This validates the verifier, not a running attested peer: until the `verifier` seam in `ca2a_runtime.attestation` is driven off a live quote on a confidential VM, cA2A must not be described as attested across trust domains. diff --git a/ROADMAP.md b/ROADMAP.md index c762677..b8472a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,7 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple ## 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`, 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` +- 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`, 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/`. diff --git a/docs/configuration.md b/docs/configuration.md index 2d0ff18..a73f782 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,9 @@ # Configuration -The cA2A runtime reads a YAML config. The peer runtime path is under construction; this page documents the surface it consumes, validated today by `ca2a validate-config`. +The cA2A runtime reads a YAML config. Offline commands validate it with +`ca2a validate-config`. `ca2a start` consumes the same file to build a +[`PeerNode`](spec/component-model.md) and serve it over the reference HTTP +transport. ## Reference @@ -10,26 +13,42 @@ attestation: enforcement_mode: enforcing # enforcing | advisory | silent max_delegation_depth: 8 # reject chains deeper than this -listen_addr: "0.0.0.0:8443" +listen_addr: "127.0.0.1:8443" -policy_bundle_path: policy/ # optional: Cedar bundle for local scope intersection +local_policy: ["read", "write"] # allow-set for scope intersection (or use Cedar below) +# policy_bundle_path: policy.cedar ``` ## Fields | Field | Default | Description | |---|---|---| -| `attestation.provider` | `auto` | TEE provider for peer attestation. `auto` selects the strongest available hardware; `software-only` requires no hardware. `opaque` is explicit opt-in and never auto-selected. | -| `attestation.enforcement_mode` | `enforcing` | `enforcing` denies peer calls that fail verification; `advisory` logs and proceeds; `silent` evaluates without logging or blocking. | +| `attestation.provider` | `auto` | TEE provider for peer attestation. `auto` selects a detected hardware provider and fails if there is none; it never falls back to `software-only`, which has to be named explicitly. `opaque` is not implemented. | +| `attestation.enforcement_mode` | `enforcing` | Intended mode. The peer path always fails closed on cA2A denials today; advisory and silent are accepted in config but not applied on the wire. | | `max_delegation_depth` | `8` | Chains deeper than this are rejected with `DELEGATION_DEPTH_EXCEEDED`. | -| `listen_addr` | `0.0.0.0:8443` | Address the peer runtime listens on. | -| `policy_bundle_path` | none | Directory of Cedar policy the runtime intersects with a delegated scope. | +| `listen_addr` | `127.0.0.1:8443` | Address `ca2a start` binds. The host is never defaulted, so serving on every interface has to be written out. | +| `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`. | -## Validate +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 +caller seals to a key the node attested rather than one written into a file. + +## Validate and start ```bash ca2a validate-config --config examples/minimal/ca2a-config.yaml -# ok: provider=auto enforcement=advisory +# ok: provider=software-only enforcement=enforcing + +ca2a start --config examples/minimal/ca2a-config.yaml +# note: software-only provider, callers appraise this channel key as +# assurance="none" and the seal carries no hardware guarantee +# ca2a listening on 127.0.0.1:8443 (provider=software-only) ``` +`ca2a start` needs no extra install: the reference transport is standard library +only. It is one way to run the peer path, not part of the profile. A program that +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. diff --git a/docs/spec/component-model.md b/docs/spec/component-model.md index 0494dd4..4696969 100644 --- a/docs/spec/component-model.md +++ b/docs/spec/component-model.md @@ -26,7 +26,7 @@ The cA2A runtime is a set of small, composable modules under `src/`. Each maps t ### config -`ca2a_runtime.config` holds `Ca2aConfig`, a frozen dataclass validated by `from_dict()` / `load()`. It defines the surface the runtime peer path will consume: `provider` (from `VALID_PROVIDERS`), `enforcement_mode` (from `VALID_ENFORCEMENT`), `max_delegation_depth`, `policy_bundle_path`, and `listen_addr`. Invalid values raise `CONFIG_ERROR`. The config surface is implemented and validated; the peer path that consumes `enforcement_mode`, `policy_bundle_path`, and `listen_addr` is Tier 2 and not yet built. +`ca2a_runtime.config` holds `Ca2aConfig`, a frozen dataclass validated by `from_dict()` / `load()`: `provider` (from `VALID_PROVIDERS`), `enforcement_mode` (from `VALID_ENFORCEMENT`), `max_delegation_depth`, `policy_bundle_path`, `local_policy`, and `listen_addr`. Invalid values raise `CONFIG_ERROR`. `ca2a_runtime.bootstrap` turns a validated config into a running `PeerNode`: it resolves the policy from `local_policy` or `policy_bundle_path` and the provider from `provider`, both fail-closed. `enforcement_mode` is still only recorded; the peer path always fails closed on a denial. ### errors @@ -34,7 +34,7 @@ The cA2A runtime is a set of small, composable modules under `src/`. Each maps t ### cli -`ca2a_runtime.cli` exposes the `ca2a` command with two subcommands: `validate-config --config` (loads and validates a `Ca2aConfig`) and `verify-chain --chain [--max-depth]` (calls `verify_chain_file` and prints a JSON result). Both are implemented and operate offline. +`ca2a_runtime.cli` exposes the `ca2a` command. `validate-config --config` loads and validates a `Ca2aConfig`, `verify-chain --chain [--max-depth]` calls `verify_chain_file`, and `verify-dag --dag [--chain]` verifies a provenance DAG; all three operate offline. `start --config` is the one online command: it builds a `PeerNode` through `ca2a_runtime.bootstrap` and serves it with `ca2a_runtime.transport.server`. ## Component map @@ -43,9 +43,10 @@ The cA2A runtime is a set of small, composable modules under `src/`. Each maps t | delegation | `ca2a_runtime.delegation.credential` | `DelegationCredential`, `new_keypair`, `verify_chain` | Implemented | | provenance | `ca2a_runtime.provenance` | `DelegationRecord`, `record_for`, `verify_dag`, `cross_check_chain` | Implemented | | verify | `ca2a_verify.verify` | `verify_delegation_chain`, `verify_chain_file`, `ChainResult` | Implemented | -| config | `ca2a_runtime.config` | `Ca2aConfig` | Surface implemented; peer path pending (Tier 2) | +| config | `ca2a_runtime.config` | `Ca2aConfig` | Implemented | +| bootstrap | `ca2a_runtime.bootstrap` | `load_policy`, `select_provider`, `build_peer_node` | Implemented | | errors | `ca2a_runtime.errors` | `CA2AError` and subclasses | Implemented | -| cli | `ca2a_runtime.cli` | `ca2a validate-config`, `ca2a verify-chain` | Implemented | +| cli | `ca2a_runtime.cli` | `ca2a validate-config`, `ca2a verify-chain`, `ca2a verify-dag`, `ca2a start` | Implemented | | channel | `ca2a_runtime.channel.sealed` | `SealedChannel` | Placeholder, fails closed (Tier 2) | | tee | `ca2a_runtime.tee.base` | `BaseProvider`, `AttestationReport` | Interface only; hardware backends pending (Tier 3) | diff --git a/docs/spec/transport.md b/docs/spec/transport.md index 613db09..cce2ea9 100644 --- a/docs/spec/transport.md +++ b/docs/spec/transport.md @@ -13,7 +13,7 @@ cA2A is a profile on A2A, not a competing transport. A2A moves tasks and context | Live attestation handshake on an inbound call | Implemented in software mode (`ca2a_runtime.attestation`, `assurance="none"`) | | 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 | Not yet: serving is via `transport.server.serve` | +| `ca2a start` CLI listener | Implemented: builds a `PeerNode` from a config file and serves it over the reference 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). @@ -80,6 +80,16 @@ message = attach_ca2a_metadata(message, request) `sealed_payload` is base64url-decoded into opaque `bytes` on `PeerRequest`. That decode step does not imply the ciphertext is bound to a verified attestation report. +## Running the reference transport + +A library caller builds a `PeerNode` and hands it to `ca2a_runtime.transport.server.serve`. `ca2a start` is the same thing driven from a config file: it resolves the policy (`local_policy` or a Cedar `policy_bundle_path`) and the attestation provider, builds the node, and binds `listen_addr`. + +```bash +ca2a start --config examples/minimal/ca2a-config.yaml +``` + +Provider selection fails closed rather than downgrading. `software-only` carries no hardware guarantee, so `auto` refuses to start when no confidential-computing platform is detected instead of quietly selecting it, and a named hardware provider whose device node is absent is a startup error. The node generates its own X25519 channel keypair and publishes the public half through the handshake, so there is no private key in the config file. See [configuration](../configuration.md). + ## What a cA2A peer enforces on inbound Once a `PeerRequest` has been parsed, the intended inbound order is: @@ -106,10 +116,10 @@ attestation: provider: auto enforcement_mode: enforcing max_delegation_depth: 8 -listen_addr: "0.0.0.0:8443" +listen_addr: "127.0.0.1:8443" ``` -`max_delegation_depth` bounds the chain length a peer will accept and is passed through to `verify_chain`. `listen_addr` is reserved for the future HTTP listener (`ca2a start`); this adapter does not open a socket. See [failure modes](failure-modes.md). +`max_delegation_depth` bounds the chain length a peer will accept and is passed through to `verify_chain`. `listen_addr` is the address `ca2a start` binds for the reference transport; the adapter itself does not open a socket. See [failure modes](failure-modes.md) and [configuration](../configuration.md). ## Transport stability diff --git a/examples/cross-operator-delegation/ca2a-config.yaml b/examples/cross-operator-delegation/ca2a-config.yaml index e0ff4f4..66c2ffa 100644 --- a/examples/cross-operator-delegation/ca2a-config.yaml +++ b/examples/cross-operator-delegation/ca2a-config.yaml @@ -10,4 +10,4 @@ attestation: max_delegation_depth: 8 policy_bundle_path: policy.cedar -listen_addr: "0.0.0.0:8443" +listen_addr: "127.0.0.1:8443" diff --git a/examples/minimal/ca2a-config.yaml b/examples/minimal/ca2a-config.yaml index f15e4ce..05bee10 100644 --- a/examples/minimal/ca2a-config.yaml +++ b/examples/minimal/ca2a-config.yaml @@ -1,6 +1,7 @@ attestation: - provider: auto # auto | tpm | sev-snp | tdx | opaque | software-only - enforcement_mode: advisory # enforcing | advisory | silent + provider: software-only # auto | tpm | sev-snp | tdx | opaque | software-only + enforcement_mode: enforcing # the peer path always fails closed today max_delegation_depth: 8 -listen_addr: "0.0.0.0:8443" +listen_addr: "127.0.0.1:8443" +local_policy: ["read", "write"] diff --git a/src/ca2a_runtime/bootstrap.py b/src/ca2a_runtime/bootstrap.py new file mode 100644 index 0000000..4eb74db --- /dev/null +++ b/src/ca2a_runtime/bootstrap.py @@ -0,0 +1,101 @@ +"""Build a :class:`~ca2a_runtime.node.PeerNode` from a validated config file. + +``ca2a start`` is a thin wrapper over the reference transport: it reads a config +file, resolves the two things a node needs that a library caller would pass by +hand (a local policy and an attestation provider), and hands the node to +:func:`ca2a_runtime.transport.server.serve`. Nothing here is required to run a +peer; a program that already has a ``Policy`` and a provider should construct a +``PeerNode`` directly. + +Provider selection fails closed. ``software-only`` carries no hardware guarantee, +so it is only ever used when the config names it: a hardware provider whose +device node is absent is a startup error, not a silent downgrade. +""" + +from __future__ import annotations + +from pathlib import Path + +from ca2a_runtime.cedar import CedarPolicy +from ca2a_runtime.config import Ca2aConfig +from ca2a_runtime.errors import ConfigError +from ca2a_runtime.node import PeerNode +from ca2a_runtime.policy import LocalPolicy, Policy +from ca2a_runtime.tee.base import BaseProvider +from ca2a_runtime.tee.sev_snp import SevSnpProvider +from ca2a_runtime.tee.software import SoftwareProvider +from ca2a_runtime.tee.tdx import TdxProvider +from ca2a_runtime.tee.tpm import TpmProvider + +_HARDWARE_PROVIDERS: dict[str, type[BaseProvider]] = { + "sev-snp": SevSnpProvider, + "tdx": TdxProvider, + "tpm": TpmProvider, +} + + +def load_policy(config: Ca2aConfig, *, config_dir: Path | None = None) -> Policy: + """Resolve the callee policy from ``policy_bundle_path`` or ``local_policy``. + + Cedar wins when ``policy_bundle_path`` is set; a relative path resolves + against the config file's directory. Otherwise ``local_policy`` becomes a + ``LocalPolicy`` allow set. One of the two must be present: a node with no + policy would intersect every delegated scope to nothing, which looks like a + misconfiguration rather than a decision. + """ + if config.policy_bundle_path: + path = Path(config.policy_bundle_path) + if not path.is_absolute() and config_dir is not None: + path = config_dir / path + if not path.is_file(): + raise ConfigError(f"policy_bundle_path not found: {path}") + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(f"cannot read policy_bundle_path: {path}", detail=str(exc)) from exc + if not text.strip(): + raise ConfigError(f"policy_bundle_path is empty: {path}") + return CedarPolicy(text) + + if config.local_policy is not None: + return LocalPolicy(allow=config.local_policy) + + raise ConfigError("ca2a start requires local_policy or policy_bundle_path in the config") + + +def select_provider(config: Ca2aConfig) -> BaseProvider: + """Resolve the attestation provider named by ``attestation.provider``.""" + name = config.provider + if name == "software-only": + return SoftwareProvider() + + if name in _HARDWARE_PROVIDERS: + provider = _HARDWARE_PROVIDERS[name] + if not provider.detect(): + raise ConfigError( + f"attestation provider {name!r} is not available on this host", + detail="run on the matching confidential VM, or set provider " + "'software-only' to accept a no-hardware-guarantee posture", + ) + return provider() + + if name == "auto": + for candidate in _HARDWARE_PROVIDERS.values(): + if candidate.detect(): + return candidate() + raise ConfigError( + "no hardware attestation provider detected", + detail="set provider 'software-only' to run without a hardware " + "guarantee; auto never falls back to it", + ) + + raise ConfigError(f"attestation provider {name!r} is not implemented") + + +def build_peer_node(config: Ca2aConfig, *, config_dir: Path | None = None) -> PeerNode: + """Build the node ``ca2a start`` serves: policy, provider, and depth limit.""" + return PeerNode( + load_policy(config, config_dir=config_dir), + provider=select_provider(config), + max_depth=config.max_delegation_depth, + ) diff --git a/src/ca2a_runtime/cli.py b/src/ca2a_runtime/cli.py index df9f07e..3c03c8c 100644 --- a/src/ca2a_runtime/cli.py +++ b/src/ca2a_runtime/cli.py @@ -11,7 +11,7 @@ from ca2a_runtime import __version__ from ca2a_runtime.config import Ca2aConfig from ca2a_runtime.delegation import DelegationCredential, verify_chain -from ca2a_runtime.errors import CA2AError, InvalidCredential, ProvenanceLinkBroken +from ca2a_runtime.errors import CA2AError, ConfigError, InvalidCredential, ProvenanceLinkBroken from ca2a_runtime.provenance import DelegationRecord, cross_check_chain, verify_dag from ca2a_verify import verify_chain_file @@ -131,6 +131,53 @@ def _cmd_verify_dag(args: argparse.Namespace) -> int: return 0 +def _cmd_start(args: argparse.Namespace) -> int: + """Serve the reference HTTP transport from a config file (``ca2a start``).""" + from ca2a_runtime.bootstrap import build_peer_node + from ca2a_runtime.transport.server import serve + + try: + cfg = Ca2aConfig.load(args.config) + node = build_peer_node(cfg, config_dir=Path(args.config).resolve().parent) + host, port = cfg.listen_host_port() + except ConfigError as exc: + print(f"invalid config: {exc}", file=sys.stderr) + return 1 + + if cfg.enforcement_mode != "enforcing": + print( + f"note: enforcement_mode={cfg.enforcement_mode!r} is accepted in config, " + "but the peer path always fails closed on cA2A denials", + file=sys.stderr, + ) + + if cfg.provider == "software-only": + # The callee cannot claim an assurance level; the caller appraises the + # offer. Say what that appraisal will be so it is not a surprise. + print( + 'note: software-only provider, callers appraise this channel key as ' + 'assurance="none" and the seal carries no hardware guarantee', + file=sys.stderr, + ) + + try: + server = serve(node, host=host, port=port) + except OSError as exc: + print(f"cannot bind {host}:{port}: {exc}", file=sys.stderr) + return 1 + + # Flushed, because an operator redirecting stdout to a log needs to see the + # peer come up rather than wait on a full buffer. + print(f"ca2a listening on {host}:{port} (provider={node.provider.platform})", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + print("ca2a stopped", file=sys.stderr) + finally: + server.server_close() + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="ca2a", description="Confidential agent-to-agent") parser.add_argument("--version", action="version", version=f"ca2a {__version__}") @@ -154,6 +201,13 @@ def build_parser() -> argparse.ArgumentParser: vd.add_argument("--max-depth", type=int, default=8) vd.set_defaults(func=_cmd_verify_dag) + st = sub.add_parser( + "start", + help="Serve the reference HTTP peer transport from a config file", + ) + st.add_argument("--config", required=True, help="Path to ca2a-config.yaml") + st.set_defaults(func=_cmd_start) + return parser diff --git a/src/ca2a_runtime/config.py b/src/ca2a_runtime/config.py index 84561c0..106d7ca 100644 --- a/src/ca2a_runtime/config.py +++ b/src/ca2a_runtime/config.py @@ -1,7 +1,7 @@ """Runtime configuration for the cA2A peer runtime. -The runtime peer path is under construction (see ROADMAP.md); this module -defines and validates the configuration surface it will consume. +Defines and validates the configuration surface consumed by ``ca2a start`` +and the offline CLI. See ROADMAP.md / LIMITATIONS.md for claim boundaries. """ from __future__ import annotations @@ -19,6 +19,37 @@ ) VALID_ENFORCEMENT = frozenset({"enforcing", "advisory", "silent"}) +DEFAULT_LISTEN_ADDR = "127.0.0.1:8443" + + +def split_listen_addr(addr: str) -> tuple[str, int]: + """Split a ``host:port`` listen address, requiring the host to be explicit. + + An IPv6 host may be bracketed (``[::1]:8443``). The host is never defaulted: + binding every interface has to be written out in the config, not inherited + from an omitted field. + """ + if not isinstance(addr, str) or ":" not in addr: + raise ConfigError( + f"listen_addr must be host:port, got {addr!r}", + detail=f"for example {DEFAULT_LISTEN_ADDR}", + ) + host, _, port_text = addr.rpartition(":") + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + if not host: + raise ConfigError( + f"listen_addr must name a host explicitly, got {addr!r}", + detail=f"for example {DEFAULT_LISTEN_ADDR}", + ) + try: + port = int(port_text) + except ValueError: + raise ConfigError(f"listen_addr port must be an integer, got {port_text!r}") from None + if not 1 <= port <= 65535: + raise ConfigError(f"listen_addr port out of range: {port}") + return host, port + @dataclass(frozen=True) class Ca2aConfig: @@ -28,7 +59,12 @@ class Ca2aConfig: enforcement_mode: str = "enforcing" max_delegation_depth: int = 8 policy_bundle_path: str | None = None - listen_addr: str = "0.0.0.0:8443" + local_policy: frozenset[str] | None = None + listen_addr: str = DEFAULT_LISTEN_ADDR + + def listen_host_port(self) -> tuple[str, int]: + """Return ``listen_addr`` split into the host and port to bind.""" + return split_listen_addr(self.listen_addr) @classmethod def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig: @@ -51,12 +87,31 @@ def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig: if not isinstance(depth, int) or depth < 1: raise ConfigError("max_delegation_depth must be a positive integer") + raw_local = data.get("local_policy") + local_policy: frozenset[str] | None = None + if raw_local is not None: + if not isinstance(raw_local, list) or not all( + isinstance(item, str) and item for item in raw_local + ): + raise ConfigError( + "local_policy must be a list of non-empty capability strings" + ) + local_policy = frozenset(raw_local) + + bundle = data.get("policy_bundle_path") + if bundle is not None and not isinstance(bundle, str): + raise ConfigError("policy_bundle_path must be a string path") + + listen_addr = data.get("listen_addr", DEFAULT_LISTEN_ADDR) + split_listen_addr(listen_addr) + return cls( provider=provider, enforcement_mode=enforcement, max_delegation_depth=depth, - policy_bundle_path=data.get("policy_bundle_path"), - listen_addr=data.get("listen_addr", "0.0.0.0:8443"), + policy_bundle_path=bundle, + local_policy=local_policy, + listen_addr=listen_addr, ) @classmethod diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py new file mode 100644 index 0000000..166597b --- /dev/null +++ b/tests/unit/test_bootstrap.py @@ -0,0 +1,145 @@ +"""Tests for building a PeerNode from a config file, the path ``ca2a start`` takes. + +The pipeline the node runs is covered by test_live_call.py; what is checked here +is that a config file resolves to the right policy and provider, that provider +selection fails closed rather than downgrading to software, and that a node built +this way serves a real call. +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +import pytest + +from ca2a_runtime.bootstrap import build_peer_node, load_policy, select_provider +from ca2a_runtime.cedar import CedarPolicy +from ca2a_runtime.config import Ca2aConfig +from ca2a_runtime.delegation.credential import DelegationCredential, new_keypair +from ca2a_runtime.errors import CA2AError, ConfigError +from ca2a_runtime.policy import LocalPolicy +from ca2a_runtime.tee.sev_snp import SevSnpProvider +from ca2a_runtime.tee.software import SoftwareProvider +from ca2a_runtime.transport import client, server + + +def test_load_local_policy() -> None: + policy = load_policy(Ca2aConfig(local_policy=frozenset({"read"}))) + assert isinstance(policy, LocalPolicy) + assert policy.allow == frozenset({"read"}) + + +def test_load_cedar_bundle_relative_to_config_dir(tmp_path: Path) -> None: + cedar = tmp_path / "policy.cedar" + cedar.write_text('permit(principal, action == Action::"read", resource);') + policy = load_policy(Ca2aConfig(policy_bundle_path=cedar.name), config_dir=tmp_path) + assert isinstance(policy, CedarPolicy) + assert policy.permits("read") + assert not policy.permits("write") + + +def test_cedar_bundle_wins_over_local_policy(tmp_path: Path) -> None: + cedar = tmp_path / "policy.cedar" + cedar.write_text('permit(principal, action == Action::"read", resource);') + cfg = Ca2aConfig(policy_bundle_path=str(cedar), local_policy=frozenset({"write"})) + assert isinstance(load_policy(cfg), CedarPolicy) + + +def test_missing_policy_rejected() -> None: + with pytest.raises(ConfigError, match="local_policy or policy_bundle_path"): + load_policy(Ca2aConfig()) + + +def test_empty_cedar_bundle_rejected(tmp_path: Path) -> None: + cedar = tmp_path / "policy.cedar" + cedar.write_text(" \n") + with pytest.raises(ConfigError, match="empty"): + load_policy(Ca2aConfig(policy_bundle_path=str(cedar))) + + +def test_software_provider_is_explicit_only() -> None: + assert isinstance(select_provider(Ca2aConfig(provider="software-only")), SoftwareProvider) + + +def test_auto_never_falls_back_to_software() -> None: + # No confidential-computing device nodes in CI, so auto must refuse rather + # than silently serving with no hardware guarantee. + with pytest.raises(ConfigError, match="no hardware attestation provider"): + select_provider(Ca2aConfig(provider="auto")) + + +def test_absent_hardware_provider_rejected_at_startup() -> None: + with pytest.raises(ConfigError, match="not available on this host"): + select_provider(Ca2aConfig(provider="sev-snp")) + + +def test_auto_selects_a_detected_hardware_provider(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SevSnpProvider, "detect", classmethod(lambda cls: True)) + assert isinstance(select_provider(Ca2aConfig(provider="auto")), SevSnpProvider) + + +def test_unimplemented_provider_rejected() -> None: + with pytest.raises(ConfigError, match="not implemented"): + select_provider(Ca2aConfig(provider="opaque")) + + +def _delegation_chain() -> list[DelegationCredential]: + root_priv, root_pub = new_keypair() + callee_pub = new_keypair()[1] + cred = DelegationCredential( + credential_id="c0", + issuer=root_pub, + subject=callee_pub, + scope=frozenset({"read", "write"}), + depth=0, + ).sign(root_priv) + return [cred] + + +def _write_config(tmp_path: Path) -> Path: + path = tmp_path / "ca2a-config.yaml" + path.write_text( + "attestation:\n" + " provider: software-only\n" + " enforcement_mode: enforcing\n" + "max_delegation_depth: 3\n" + "local_policy:\n" + " - read\n" + "listen_addr: 127.0.0.1:8443\n", + encoding="utf-8", + ) + return path + + +def test_build_peer_node_carries_config(tmp_path: Path) -> None: + cfg = Ca2aConfig.load(_write_config(tmp_path)) + node = build_peer_node(cfg, config_dir=tmp_path) + assert isinstance(node.policy, LocalPolicy) + assert node.policy.allow == frozenset({"read"}) + assert isinstance(node.provider, SoftwareProvider) + assert node.max_depth == 3 + + +def test_config_built_node_serves_a_live_call(tmp_path: Path) -> None: + cfg = Ca2aConfig.load(_write_config(tmp_path)) + host, _ = cfg.listen_host_port() + # Port 0 rather than the configured one: the test needs a free port, and the + # host is what the config contributes here. + srv = server.serve(build_peer_node(cfg, config_dir=tmp_path), host=host, port=0) + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + try: + base = f"http://{host}:{srv.server_address[1]}" + chain = _delegation_chain() + + body = client.send_task(base, chain, "read", "r0", payload=b"from a config file") + assert body["accepted"] is True + assert body["granted_capability"] == "read" + + with pytest.raises(CA2AError) as exc_info: + client.send_task(base, chain, "write", "r1") + assert exc_info.value.code == "SCOPE_NOT_PERMITTED" + finally: + srv.shutdown() + srv.server_close() diff --git a/tests/unit/test_cli_start.py b/tests/unit/test_cli_start.py new file mode 100644 index 0000000..d9031f4 --- /dev/null +++ b/tests/unit/test_cli_start.py @@ -0,0 +1,100 @@ +"""Tests for ``ca2a start``: what it binds, and what it refuses to start with.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from ca2a_runtime import cli +from ca2a_runtime.node import PeerNode +from ca2a_runtime.tee.software import SoftwareProvider +from ca2a_runtime.transport import server + + +class _FakeServer: + """Stands in for a bound HTTP server so no test actually listens.""" + + def __init__(self, node: PeerNode) -> None: + self.node = node + self.closed = False + + def serve_forever(self) -> None: + raise KeyboardInterrupt + + def server_close(self) -> None: + self.closed = True + + +def _config(tmp_path: Path, body: str) -> str: + path = tmp_path / "ca2a-config.yaml" + path.write_text(body, encoding="utf-8") + return str(path) + + +def test_start_serves_the_configured_address( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bound: dict[str, Any] = {} + + def fake_serve(node: PeerNode, host: str = "127.0.0.1", port: int = 8443) -> _FakeServer: + bound.update(node=node, host=host, port=port) + return _FakeServer(node) + + monkeypatch.setattr(server, "serve", fake_serve) + config = _config( + tmp_path, + "attestation:\n provider: software-only\nlocal_policy:\n - read\n" + "listen_addr: 127.0.0.1:9443\n", + ) + + assert cli.main(["start", "--config", config]) == 0 + assert (bound["host"], bound["port"]) == ("127.0.0.1", 9443) + assert isinstance(bound["node"].provider, SoftwareProvider) + assert bound["node"].policy.allow == frozenset({"read"}) + + +def test_start_refuses_a_config_with_no_policy( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + config = _config(tmp_path, "attestation:\n provider: software-only\n") + assert cli.main(["start", "--config", config]) == 1 + assert "local_policy or policy_bundle_path" in capsys.readouterr().err + + +def test_start_refuses_auto_provider_off_hardware( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + config = _config(tmp_path, "attestation:\n provider: auto\nlocal_policy:\n - read\n") + assert cli.main(["start", "--config", config]) == 1 + assert "no hardware attestation provider" in capsys.readouterr().err + + +def test_start_reports_a_bind_failure_instead_of_raising( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def refuse(node: PeerNode, host: str, port: int) -> _FakeServer: + raise OSError("Address already in use") + + monkeypatch.setattr(server, "serve", refuse) + config = _config( + tmp_path, + "attestation:\n provider: software-only\nlocal_policy:\n - read\n" + "listen_addr: 127.0.0.1:9443\n", + ) + assert cli.main(["start", "--config", config]) == 1 + err = capsys.readouterr().err + assert "cannot bind 127.0.0.1:9443" in err + assert "ca2a listening" not in err + + +def test_start_warns_that_software_mode_has_no_guarantee( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(server, "serve", lambda node, host, port: _FakeServer(node)) + config = _config( + tmp_path, "attestation:\n provider: software-only\nlocal_policy:\n - read\n" + ) + assert cli.main(["start", "--config", config]) == 0 + assert 'assurance="none"' in capsys.readouterr().err diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index df40391..56f0cca 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -15,6 +15,7 @@ def test_defaults_from_empty_dict() -> None: assert cfg.provider == "auto" assert cfg.enforcement_mode == "enforcing" assert cfg.max_delegation_depth == 8 + assert cfg.listen_host_port() == ("127.0.0.1", 8443) def test_unknown_provider_rejected() -> None: @@ -57,3 +58,31 @@ def test_load_invalid_yaml(tmp_path: Path) -> None: p.write_text("attestation: [unclosed\n") with pytest.raises(ConfigError): Ca2aConfig.load(p) + + +def test_local_policy_from_dict() -> None: + cfg = Ca2aConfig.from_dict({"local_policy": ["read", "write"]}) + assert cfg.local_policy == frozenset({"read", "write"}) + + +def test_bad_local_policy_rejected() -> None: + with pytest.raises(ConfigError): + Ca2aConfig.from_dict({"local_policy": "read"}) + with pytest.raises(ConfigError): + Ca2aConfig.from_dict({"local_policy": [""]}) + + +def test_listen_addr_split() -> None: + cfg = Ca2aConfig.from_dict({"listen_addr": "10.0.0.4:9000"}) + assert cfg.listen_host_port() == ("10.0.0.4", 9000) + + +def test_listen_addr_ipv6_brackets_stripped() -> None: + cfg = Ca2aConfig.from_dict({"listen_addr": "[::1]:8443"}) + assert cfg.listen_host_port() == ("::1", 8443) + + +@pytest.mark.parametrize("addr", [":8443", "8443", "host:port", "host:0", "host:70000"]) +def test_bad_listen_addr_rejected(addr: str) -> None: + with pytest.raises(ConfigError): + Ca2aConfig.from_dict({"listen_addr": addr})