From bb04004aaa4af6d5c90eaa22a8575ae82e9bac8f Mon Sep 17 00:00:00 2001 From: Carlos Hernandez-Vaquero Date: Sat, 4 Jul 2026 11:58:20 +0200 Subject: [PATCH] test: add delegation action evidence conformance Signed-off-by: Carlos Hernandez-Vaquero --- tests/conformance/README.md | 14 ++ tests/conformance/test_profile_conformance.py | 174 +++++++++++++++++- 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/tests/conformance/README.md b/tests/conformance/README.md index 77d98b2..08ad059 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -87,3 +87,17 @@ Spec: [call-graph.md](../../docs/spec/call-graph.md) | PIPE-001 | MUST | The handler grants only a capability in the effective scope and emits a linked provenance record. | Accepted call returns a verifiable record. | | PIPE-002 | MUST | A sealed payload with no enclave key available fails closed. | `SEALED_CHANNEL_ERROR`; no payload returned. | | PIPE-003 | MUST | An invalid delegation chain is rejected before any authorization or payload step. | The chain error is raised; no payload returned. | + +## Group 7: Delegation-linked action evidence + +Spec: [trace-a2a-profile.md](../../docs/spec/trace-a2a-profile.md), [provenance-dag.md](../../docs/spec/provenance-dag.md), [call-graph.md](../../docs/spec/call-graph.md) + +| ID | Level | Requirement | Expected outcome | +|---|---|---|---| +| ACTION-001 | MUST | A delegated action with a parent-linked TRACE/provenance record, matching credential id, and permitted capability verifies. | `verified`. | +| ACTION-002 | MUST | A child action record whose parent record hash does not match the canonical parent record hash is rejected as provenance-invalid. | `PROVENANCE_LINK_BROKEN`. | +| ACTION-003 | MUST | A non-root delegated action record without its parent record is rejected as provenance-invalid. | `PROVENANCE_LINK_BROKEN`. | +| ACTION-004 | MUST | Action evidence naming a delegation credential id that is not the verified leaf credential is rejected as provenance-invalid. | `PROVENANCE_LINK_BROKEN`. | +| ACTION-005 | MUST | A requested action outside the effective delegated scope is classified as authorization-invalid, not malformed provenance. | `SCOPE_NOT_PERMITTED`. | +| ACTION-006 | MUST | A valid delegated action denied by local policy is classified as authorization-invalid, not malformed provenance. | `SCOPE_NOT_PERMITTED`. | +| ACTION-007 | MUST | A valid delegated action whose controller outcome is negative remains valid evidence of a negative outcome. | `valid_negative_outcome`. | diff --git a/tests/conformance/test_profile_conformance.py b/tests/conformance/test_profile_conformance.py index 7d8ce76..b923bec 100644 --- a/tests/conformance/test_profile_conformance.py +++ b/tests/conformance/test_profile_conformance.py @@ -5,6 +5,8 @@ from __future__ import annotations +from dataclasses import dataclass + import pytest from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey @@ -15,6 +17,7 @@ AttestationFailed, AttestationUnsupported, BrokenDelegationLink, + CA2AError, CredentialReplay, DelegationDepthExceeded, InvalidCredential, @@ -25,15 +28,30 @@ ) from ca2a_runtime.peer import PeerRequest, effective_scope, handle_peer_request from ca2a_runtime.policy import LocalPolicy -from ca2a_runtime.provenance import cross_check_chain, record_for, verify_dag +from ca2a_runtime.provenance import DelegationRecord, cross_check_chain, record_for, verify_dag from ca2a_runtime.tee.sev_snp import SevSnpProvider from ca2a_runtime.tee.tdx import TdxProvider +from ca2a_verify import verify_delegation_chain from ca2a_verify.sev_snp import verify_sev_snp_report from ca2a_verify.tdx import verify_tdx_quote from tests.unit.conftest import build_chain, make_ec_cert, make_sev_snp_report from tests.unit.test_tdx import build_quote +@dataclass(frozen=True) +class _ActionEvidence: + trace_record_hash: str + credential_id: str + requested_capability: str + controller_decision: str = "accepted" + + +@dataclass(frozen=True) +class _ActionEvidenceResult: + classification: str + code: str + + def _narrowing(): return build_chain([frozenset({"read", "write", "admin"}), frozenset({"read", "write"})]) @@ -51,6 +69,67 @@ def _records(chain): return recs +def _action_chain() -> list[DelegationCredential]: + return build_chain([ + frozenset({"robot.move", "robot.inspect", "robot.stop"}), + frozenset({"robot.move", "robot.inspect"}), + ]) + + +def _action_evidence( + records: list[DelegationRecord], + *, + requested_capability: str = "robot.move", + controller_decision: str = "accepted", + credential_id: str | None = None, + trace_record_hash: str | None = None, +) -> _ActionEvidence: + leaf = records[-1] + return _ActionEvidence( + trace_record_hash=trace_record_hash or leaf.record_hash(), + credential_id=credential_id or leaf.credential_id, + requested_capability=requested_capability, + controller_decision=controller_decision, + ) + + +def _verify_action_evidence( + chain: list[DelegationCredential], + records: list[DelegationRecord], + evidence: _ActionEvidence, + policy: LocalPolicy, +) -> _ActionEvidenceResult: + try: + verify_delegation_chain(chain) + verify_dag(records) + cross_check_chain(records, chain) + except CA2AError as exc: + return _ActionEvidenceResult("provenance_invalid", exc.code) + + leaf = records[-1] + if evidence.trace_record_hash != leaf.record_hash(): + return _ActionEvidenceResult("provenance_invalid", ProvenanceLinkBroken.code) + if evidence.credential_id != leaf.credential_id: + return _ActionEvidenceResult("provenance_invalid", ProvenanceLinkBroken.code) + + try: + handle_peer_request( + PeerRequest( + chain=chain, + requested_capability=evidence.requested_capability, + record_id="action-attempt", + parent_record_hash=leaf.record_hash(), + ), + policy=policy, + ) + except ScopeNotPermitted as exc: + return _ActionEvidenceResult("authorization_invalid", exc.code) + + if evidence.controller_decision == "rejected": + return _ActionEvidenceResult("valid_negative_outcome", "CONTROLLER_REJECTED") + return _ActionEvidenceResult("verified", "ACCEPTED") + + # --- Group 1: Delegation --- def test_deleg_001_signature() -> None: @@ -232,3 +311,96 @@ def test_pipe_003_invalid_chain_rejected_first() -> None: req = PeerRequest(chain=bad, requested_capability="read", record_id="r0") with pytest.raises(ScopeEscalation): handle_peer_request(req, policy=LocalPolicy.of(["read", "write"])) + + +# --- Group 7: Delegation-linked action evidence --- + +def test_action_001_valid_delegated_action_evidence() -> None: + chain = _action_chain() + records = _records(chain) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records), + LocalPolicy.of(["robot.move", "robot.inspect"]), + ) + assert result == _ActionEvidenceResult("verified", "ACCEPTED") + + +def test_action_002_parent_trace_hash_mismatch_is_provenance_invalid() -> None: + chain = _action_chain() + records = _records(chain) + records[1] = DelegationRecord( + records[1].record_id, + records[1].credential_id, + records[1].subject, + records[1].scope, + parent_record_hash="sha256:wrong-parent", + ) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records), + LocalPolicy.of(["robot.move"]), + ) + assert result == _ActionEvidenceResult("provenance_invalid", "PROVENANCE_LINK_BROKEN") + + +def test_action_003_missing_parent_trace_record_is_provenance_invalid() -> None: + chain = _action_chain() + records = _records(chain) + result = _verify_action_evidence( + chain, + [records[1]], + _action_evidence(records), + LocalPolicy.of(["robot.move"]), + ) + assert result == _ActionEvidenceResult("provenance_invalid", "PROVENANCE_LINK_BROKEN") + + +def test_action_004_unknown_delegation_credential_id_is_provenance_invalid() -> None: + chain = _action_chain() + records = _records(chain) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records, credential_id="unknown-credential"), + LocalPolicy.of(["robot.move"]), + ) + assert result == _ActionEvidenceResult("provenance_invalid", "PROVENANCE_LINK_BROKEN") + + +def test_action_005_action_outside_delegated_scope_is_authorization_invalid() -> None: + chain = _action_chain() + records = _records(chain) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records, requested_capability="robot.stop"), + LocalPolicy.of(["robot.move", "robot.stop"]), + ) + assert result == _ActionEvidenceResult("authorization_invalid", "SCOPE_NOT_PERMITTED") + + +def test_action_006_local_policy_denial_is_authorization_invalid() -> None: + chain = _action_chain() + records = _records(chain) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records, requested_capability="robot.inspect"), + LocalPolicy.of(["robot.move"]), + ) + assert result == _ActionEvidenceResult("authorization_invalid", "SCOPE_NOT_PERMITTED") + + +def test_action_007_controller_rejection_is_valid_negative_outcome() -> None: + chain = _action_chain() + records = _records(chain) + result = _verify_action_evidence( + chain, + records, + _action_evidence(records, controller_decision="rejected"), + LocalPolicy.of(["robot.move"]), + ) + assert result == _ActionEvidenceResult("valid_negative_outcome", "CONTROLLER_REJECTED")