Skip to content

feat(cli): ca2a start, run the reference transport from a config file - #52

Merged
imran-siddique merged 1 commit into
agentrust-io:mainfrom
Susanpdl:feat/live-a2a-inbound-serving
Aug 9, 2026
Merged

feat(cli): ca2a start, run the reference transport from a config file#52
imran-siddique merged 1 commit into
agentrust-io:mainfrom
Susanpdl:feat/live-a2a-inbound-serving

Conversation

@Susanpdl

@Susanpdl Susanpdl commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes the ca2a start gap the transport spec listed as "Not yet: serving is via transport.server.serve".

What changed since the first review

This branch originally shipped its own Starlette JSON-RPC listener. #53 landed a reference HTTP transport, PeerNode, and the attestation handshake while this was open, which made that listener duplicate work and, worse, seal against a key nobody had appraised. So the branch is refit rather than rebased: the Starlette server, the [serve] extra, and the starlette/uvicorn/httpx dependencies are gone. What is left is the config-driven entry point.

What it does

ca2a start --config ca2a-config.yaml resolves a policy and an attestation provider from the config file, builds a PeerNode, and serves it with ca2a_runtime.transport.server. No new dependencies, since the reference transport is standard library only.

The wiring lives in ca2a_runtime.bootstrap (load_policy, select_provider, build_peer_node) so it is usable without the CLI, and so the CLI adds no capability the library did not already have. A program that already holds a Policy and a provider should keep constructing a PeerNode directly.

$ 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)

Decisions worth a look

Provider selection fails closed, following the rule in tee/software.py that a no-guarantee posture is never a silent fallback. auto refuses to start when no confidential-computing platform is detected instead of quietly picking software-only, and a named hardware provider whose device node is absent is a startup error rather than a failure at the first handshake. Software mode prints the assurance a caller will actually appraise, so an operator does not have to infer it.

enclave_private_key_hex is dropped. A PeerNode generates its own X25519 channel keypair and publishes the public half through the handshake, so a pre-shared key in a config file would mean sealing to something nobody appraised. That was the right model before #53 and the wrong one after it.

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 something 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 now agree. Bad addresses fail ca2a validate-config rather than at bind time, and bracketed IPv6 parses.

Claim boundary

Unchanged from #53. Software mode is assurance="none", and the seal is not bound to a hardware-verified measurement. ca2a start does not move that line, it just makes the existing software-mode path runnable from a config file. LIMITATIONS.md, ROADMAP.md, and the transport spec are updated to say exactly that.

Tests

237 pass, 3 skipped. New coverage in tests/unit/test_bootstrap.py (policy resolution, provider fail-closed behavior, and a config-built node serving a real HTTP call) and tests/unit/test_cli_start.py (what ca2a start binds, and the four ways it refuses to start). ruff, mypy, and bandit are clean.

Also smoke-tested against a real socket: the handshake endpoint returns the offer, an unknown path 404s, and a port conflict exits 1 with cannot bind 127.0.0.1:8443 instead of a traceback.

One thing for you

Running the suite rewrites examples/rejection-with-proof/chain.json and dag.json with fresh keys, since the test executes demo.py and the demo generates keypairs. I reverted the churn here, but it will bite anyone who runs pytest before committing. Happy to open a separate issue.

imran-siddique
imran-siddique previously approved these changes Jul 19, 2026

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the diff and thread. Scope is tight and matches the body: live inbound A2A JSON-RPC wiring for the second Tier 2 checkbox, with ordinary A2A returning ca2a: null rather than inventing a trust state, and malformed metadata plus denials failing closed with a structured ca2a_code. X25519 key handling validates length and is software-configured only, with the docs correctly caveating that it is not measurement-bound. No secrets in the diff; test keys are generated at runtime. Tests cover config, policy loader, and server paths, and all real checks are green. LGTM. Leaving the merge to a maintainer.

@imran-siddique

Copy link
Copy Markdown
Member

@Susanpdl this is approved and ready except for a merge conflict with main. Could you merge main into feat/live-a2a-inbound-serving (or rebase) and resolve the conflict? Once it is clean it can go in. Thanks.

@Susanpdl

Copy link
Copy Markdown
Contributor Author

Happy to resolve the conflict, but I want to check the shape with you first, because #53 landed after your approval here and it overlaps with this branch.

The conflict itself is small: CHANGELOG.md, LIMITATIONS.md, ROADMAP.md, docs/spec/transport.md, and pyproject.toml. No code conflicts.

The bigger question is duplication. #53 added transport/server.py, node.py, and the attestation handshake, so there is already a live HTTP serving path on main. This branch adds a second one (ca2a_runtime/server.py, a Starlette JSON-RPC listener for message/send), and it pulls in starlette and uvicorn, which cuts against the standard-library-only choice you made in #53.

What is still missing on main and is the part of this PR I think is worth keeping:

  • ca2a start. The CLI only has validate-config, verify-chain, and verify-dag, so there is no command to actually run a peer.
  • Config-driven policy: local_policy plus loading Cedar from policy_bundle_path, and listen_addr actually being consumed.
  • An optional configured enclave key for opening a sealed payload.

So my suggestion is to refit this branch instead of merging it as-is: keep ca2a start and the config/policy loading, but have it build a PeerNode and serve through transport.server.serve(), then drop my server.py and the [serve] extra. That removes the duplicate server, adds no new dependencies, and the CLI path inherits your handshake rather than using a raw configured key.

Want me to go that way, or would you rather keep the two transports separate (JSON-RPC alongside the reference server), or just close this and open a smaller CLI-only PR? Happy to do whichever you prefer.

@Susanpdl Susanpdl changed the title feat(runtime): live A2A JSON-RPC listener via ca2a start feat(cli): ca2a start, run the reference transport from a config file Jul 28, 2026
@Susanpdl

Copy link
Copy Markdown
Contributor Author

@carloshvp refit onto #53, ready for another look.

Short version: your reference transport made my Starlette listener redundant, so I deleted it rather than shipping two servers. The [serve] extra and the starlette/uvicorn/httpx deps are gone with it. What is left is ca2a start, which reads a config file, resolves a policy and a provider, builds a PeerNode, and hands it to transport.server.serve. That fills the row your transport spec listed as "Not yet: serving is via transport.server.serve".

Three judgment calls I would like you to check:

  1. auto now refuses to start when it detects no confidential-computing platform, instead of falling back to software-only. That follows the comment in tee/software.py about a no-guarantee posture never being a silent fallback, but it does mean the default config no longer starts a listener without an edit. Tell me if you would rather it warned and continued.

  2. I dropped enclave_private_key_hex. Your PeerNode generates and attests its own channel key, so a pre-shared key in a config file would be sealing to something nobody appraised.

  3. listen_addr now defaults to 127.0.0.1:8443 and requires an explicit host. It was 0.0.0.0:8443 while nothing read it, and serve() already defaulted to loopback, so this makes them agree.

Claim boundary is unchanged: software mode is assurance="none" and the seal is still not bound to a hardware-verified measurement. The CLI just makes the existing path runnable from a config file, and LIMITATIONS/ROADMAP/transport.md say so.

237 tests pass, ruff/mypy/bandit clean, and I smoke-tested it against a real socket.

Unrelated, but worth knowing: running pytest rewrites examples/rejection-with-proof/chain.json and dag.json, because the test runs demo.py and the demo generates fresh keypairs. I reverted that churn out of this PR. Want me to open an issue?

@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

imran-siddique
imran-siddique previously approved these changes Jul 28, 2026

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. All real checks pass on every platform and Python version; the only red is the maintainer-hold gate.

Reviewed the substance rather than the diffstat, and the two decisions that mattered are both right:

Provider selection fails closed. auto never degrades to software-only, and a named hardware provider whose device node is absent is a startup error rather than a silent downgrade. That is the same posture the rest of this stack holds to, and it is the difference between a software-mode listener being a stated configuration and being an accident.

Loopback default. Changing listen_addr from 0.0.0.0:8443 to 127.0.0.1:8443 was the important call, and you made it unprompted with the right reasoning: the old value was harmless while nothing read it, and the moment ca2a start binds it, defaulting to every interface would put a peer on the network by omission. Requiring the host explicitly is better than a permissive default with a warning.

Also good: load_policy refusing to start without one of policy_bundle_path or local_policy, since a node with no policy intersects every delegated scope to nothing and that reads as misconfiguration rather than a decision. Relative-path resolution against the config directory, the empty-file check, and OSError wrapped into ConfigError are all the right level of care for a config path.

Test coverage is proportionate (274 lines of tests for 216 of source), and the CHANGELOG is honest about what this does not do, including that attestation remains one-directional and software mode is still the default posture.

Leaving the merge to @imran-siddique.

@imran-siddique

Copy link
Copy Markdown
Member

Heads up: this has drifted into conflict with main since the approval on July 28, so it cannot merge as it stands. The approval holds, it just needs a rebase onto current main. Ping me once it is clean and I will take another look at the merge.

The transport spec listed serving as "Not yet: serving is via
transport.server.serve", so there was no way to run a peer from a config
file. ca2a start resolves a policy and an attestation provider from the
config, builds a PeerNode, and serves it with transport.server. No new
dependencies, since the reference transport is standard library only.

The wiring lives in ca2a_runtime.bootstrap (load_policy, select_provider,
build_peer_node) so it is usable without the CLI, and so the CLI adds no
capability the library did not already have.

Provider selection fails closed. software-only carries 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 rather
than a failure at the first handshake.

Default listen_addr to 127.0.0.1:8443 and require an explicit host. It
was 0.0.0.0:8443 while nothing read it; now that something 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 now agree.

Claim boundary is unchanged: software mode is assurance="none" and the
seal is not bound to a hardware-verified measurement.

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

Susanpdl commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@imran-siddique rebased onto current main (ddb9f2f). The conflict is gone and GitHub reports the PR mergeable again.

I collapsed the branch to a single commit while I was in there. The old history still had the Starlette listener commit followed by the merge that deleted it, so replaying it onto main would have reintroduced and then re-removed a server that no longer exists, with conflicts at each step. The net diff is unchanged: the same 575 insertions and 29 deletions across the same 14 files, and diffing the two patches shows only blob hashes and context lines from the new upstream content.

Locally: 278 passed, 3 skipped, and ruff, mypy, and bandit are clean on the scopes CI uses.

Worth flagging on CI, since the checks are red. Nothing there is a test failure. One job fails, test (3.11, ubuntu-latest), at the "Security scan" step, where pip-audit reports PYSEC-2026-3552 against cryptography 49.0.0. That step runs before Lint, Type check, and Test, so all three are skipped, and the other five matrix jobs are cancelled by fail-fast cascading off it. Main has failed the same way on every run since August 3, so the rebase did not introduce it.

It also cannot be fixed from this repo. Our floor is cryptography>=42.0, but agent-manifest 0.10.0 requires cryptography<50,>=42 and 0.10.0 is the latest on PyPI, so raising our floor to >=50 only trades the red for a ResolutionImpossible at install time. The unblock is upstream: agent-manifest relaxes the cap and releases, then ca2a raises its floor. I have not opened anything for that and am leaving the call to you rather than growing this PR.

For what it is worth, the advisory looks unreachable from here. PYSEC-2026-3552 is about PKCS#7 EnvelopedData decryption (pkcs7_decrypt_der, pkcs7_decrypt_pem, pkcs7_decrypt_smime). The only PKCS#7 use in ca2a is load_der_pkcs7_certificates and load_pem_pkcs7_certificates in tee/tpm.py, which parse a certificate bundle rather than decrypt anything, and neither agent_manifest nor agentrust_trace calls the decrypt functions at all.

The remaining red is the maintainer-approval gate, since the force-push dismissed your July 28 approval.

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-approving after the rebase. The re-run is green across all six matrix jobs; the earlier red was pip-audit on cryptography 49.0.0 (PYSEC-2026-3552), not this branch.

Reviewed the refit rather than assuming it. Deleting your Starlette listener once the reference transport landed was the right call, and dropping the [serve] extra with it keeps one server in the tree instead of two. select_provider failing closed is the part that matters: 'auto' raising rather than falling back to software-only means a missing device node can never quietly become a no-guarantee posture.

@imran-siddique
imran-siddique merged commit fd715ea into agentrust-io:main Aug 9, 2026
12 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants