feat(ca2a): appraise the caller before opening its payload - #90
Merged
Conversation
The reference transport was one-directional. The callee verified the caller's delegation chain, which says what a peer is allowed to ask for, and had no way to know whether the peer sending it a task was an enclave or a laptop. The handshake response now carries a callee-issued challenge, the caller binds its own channel key into a report under it (caller_offer in the A2A metadata), and the callee appraises that report before it opens the sealed payload. The ordering is the property, not an implementation detail: the payload is sealed to the callee's own key, so appraising afterwards would mean an unattested caller had already had its work done. The test for it was verified by making the swap and watching it fail. Off by default, opt-in one rung at a time (require_caller_attestation: none -> any -> hardware), because almost no caller can attest yet and a callee that refused them out of the box is a callee nobody can talk to. An offer that is present and does not appraise is refused at every rung including "none": demanding nothing means accepting a caller that proves nothing, not accepting a broken proof. BREAKING: caller_attestation is in the hashed record body, always, so every record hash changes. Absence would leave an auditor unable to tell a peer that checked and found nothing from a peer that never checked. The example DAGs are regenerated. Also closes two gaps found on the way: the 1 MiB request-body bound had no test above it, and the committed example DAGs were never verified as committed (the demos regenerate them, so the suite was checking its own output -- which is how the record-body change left cross-operator/dag.json broken on disk with everything still green). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 10, 2026
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finishes the mutual attestation work designed in #89. That PR landed the design and the stateless challenge primitive; this threads it through the transport so it actually runs.
What changed
The callee verified the caller's delegation chain (authorization: what the peer is allowed to ask for) and never appraised what the caller was running. A callee had no way to know whether the peer sending it a task was an enclave or a laptop.
Now: the handshake response carries a callee-issued challenge, the caller binds its own channel key into a report under it (
caller_offerin the A2A metadata), and the callee appraises that beforeopen_sealed.The ordering is the property
The payload is sealed to the callee's own channel key, so the callee can read it the instant it arrives. Appraising afterwards means an unattested caller has already had its work done, and every guarantee becomes a report on something that already happened.
test_sealed_payload_is_never_opened_when_appraisal_refusesassertsopen_sealedis never called when appraisal refuses. I verified the test by making the swap: moving the open-payload block above the appraisal (a valid, plausible refactor) fails exactly those 3 cases withthe callee opened the payload before appraising the caller, and nothing else. A first attempt at the mutation failed with anUnboundLocalErrorinstead, which would have proven nothing, so it was redone properly.Decisions taken (Imran, 2026-08-09)
require_caller_attestation:"none"(default) →"any"→"hardware"require="none"not_offeredDefault demands nothing, because cA2A is alpha, almost no caller can attest, and a control that breaks the common case gets switched off and never switched back on.
"hardware"without acaller_verifieris refused at construction rather than on every call.A present and unappraisable offer is refused at every rung including
"none". Demanding nothing means accepting a caller that proves nothing; it does not mean accepting a broken proof, or a misconfigured attestation path becomes indistinguishable from a caller that never had one.caller_attestationis in the hashedDelegationRecordbody, always, as one ofnot_offered/failed/software-only/hardware. Four values, not three: a peer that offered nothing and a peer whose offer did not appraise are different facts, and neither may read as the software-only case.Records predating the field are read as
not_offered. The example DAGs are regenerated.Two gaps found on the way
Content-Lengthover a raw socket, which is what the guard actually inspects (it refuses before reading, so the body is never buffered). Sending a real 1 MiB body races the early refusal and the client sees a connection abort, which is why it went through a socket.chain.json/dag.jsonon run, so the existing tests were checking the demo's own fresh output. Not hypothetical: this change leftexamples/cross-operator-delegation/dag.jsonbroken on disk with the whole suite green and the README still quotingca2a verify-dagas working.test_committed_examples_verify.pyreads the blobs out of git instead.What this still does not give you
Unchanged, and stated in the spec and
LIMITATIONS.md: it is not simultaneous (the caller commits a sealed payload before the callee has appraised it — that needs a commitment step, a larger protocol), it does not make either peer trustworthy, and it is software mode only. Making the protocol mutual does not make the recorded hardware run mutual.Also: a challenge does not cross instances (per-process secret, the stateless scheme's accepted cost).
test_a_challenge_from_another_instance_does_not_verifyholds that line.Tests
367 passed, 3 skipped. 35 new in
test_mutual_attestation.py, plus the two guards above.ruff checkandmypyclean.🤖 Generated with Claude Code