diff --git a/tests/conformance/README.md b/tests/conformance/README.md index 08ad059..cf03ec8 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -101,3 +101,4 @@ Spec: [trace-a2a-profile.md](../../docs/spec/trace-a2a-profile.md), [provenance- | 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`. | +| ACTION-008 | MUST | An action whose delegation chain contains a credential with an invalid signature is rejected as provenance-invalid before authorization or outcome handling. | `INVALID_CREDENTIAL`. | diff --git a/tests/conformance/test_profile_conformance.py b/tests/conformance/test_profile_conformance.py index b923bec..09fd14a 100644 --- a/tests/conformance/test_profile_conformance.py +++ b/tests/conformance/test_profile_conformance.py @@ -5,7 +5,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace import pytest from cryptography.hazmat.primitives.asymmetric import ec @@ -404,3 +404,34 @@ def test_action_007_controller_rejection_is_valid_negative_outcome() -> None: LocalPolicy.of(["robot.move"]), ) assert result == _ActionEvidenceResult("valid_negative_outcome", "CONTROLLER_REJECTED") + + +def test_action_008_invalid_delegation_signature_is_provenance_invalid() -> None: + chain = _action_chain() + records = _records(chain) + leaf = chain[-1] + tampered_signature = f"{int(leaf.signature[:2], 16) ^ 1:02x}{leaf.signature[2:]}" + bad_chain = [*chain[:-1], replace(leaf, signature=tampered_signature)] + evidence = _action_evidence( + records, + requested_capability="robot.inspect", + controller_decision="rejected", + ) + restrictive_policy = LocalPolicy.of(["robot.move"]) + permissive_policy = LocalPolicy.of(["robot.move", "robot.inspect"]) + + # The controls establish both downstream classifications that invalid provenance must preempt. + assert _verify_action_evidence(chain, records, evidence, restrictive_policy) == ( + _ActionEvidenceResult("authorization_invalid", "SCOPE_NOT_PERMITTED") + ) + assert _verify_action_evidence(chain, records, evidence, permissive_policy) == ( + _ActionEvidenceResult("valid_negative_outcome", "CONTROLLER_REJECTED") + ) + + # ACTION-008 verifies provenance validation precedes authorization and controller outcome classification. + assert _verify_action_evidence(bad_chain, records, evidence, restrictive_policy) == ( + _ActionEvidenceResult("provenance_invalid", "INVALID_CREDENTIAL") + ) + assert _verify_action_evidence(bad_chain, records, evidence, permissive_policy) == ( + _ActionEvidenceResult("provenance_invalid", "INVALID_CREDENTIAL") + )