diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b95487..618e9f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,24 @@ jobs: - name: Verify committed evidence artifacts if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository run: python validate_artifacts.py + + embodied-action-receipts: + runs-on: ubuntu-latest + defaults: + run: + working-directory: embodied-action-receipts + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: embodied-action-receipts/requirements.txt + + - name: Install dependencies + run: python -m pip install -r requirements.txt + + - name: Verify embodied-action receipt fixtures + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index 40b6886..f4911f5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ End-to-end integration examples showing cMCP, Agent Manifest, and TRACE working | Example | What it shows | Platform | Compliance | |---|---|---|---| +| `embodied-action-receipts/` | Fixture-style offline verification for embodied action receipts: accepted chain, missing receipt, signature mismatch and valid controller rejection | Software-only fixtures | TRACE action-receipt evidence boundary | | `financial-services/` | Credit risk agent: MiFID II escalation deny above EUR 500k with structured policy advice | SEV-SNP / TDX | EU AI Act Art. 9/12, MiFID II Art. 25, DORA Art. 9 | | `healthcare/` | Clinical decision agent: EU AI Act Art. 14 HITL deny on high-risk treatment plans | SEV-SNP / TDX | EU AI Act Art. 14, HIPAA | | `industrial-embodied-ai/` | Material-movement agent with cMCP authorization, an independent safety-controller boundary and offline-verifiable closed-session evidence | TEE / software-only development mode | OT security and industrial robot safety references | diff --git a/embodied-action-receipts/README.md b/embodied-action-receipts/README.md new file mode 100644 index 0000000..26b878e --- /dev/null +++ b/embodied-action-receipts/README.md @@ -0,0 +1,62 @@ +# Embodied Action Receipt Fixtures + +Fixture-style example for offline verification of embodied-action receipts. + +This example complements the cMCP Embodied Action Evidence Profile and the TRACE +`verification.action_receipts` axis. It demonstrates action-level evidence below +a session-level TRACE claim without claiming physical completion, controller +safety, or functional-safety certification. + +Related design threads: + +- cMCP Embodied Action Evidence Profile: agentrust-io/cmcp#339 +- TRACE action-receipt verification axis: agentrust-io/trace-spec#66 + +## What It Shows + +The fixtures cover four verifier outcomes: + +| Fixture | Expected result | What it demonstrates | +|---|---|---| +| `valid-chain.json` | `accepted` | A signed, hash-chained controller receipt sequence binds to the TRACE session, cMCP call id, and action reference. | +| `missing-receipt.json` | `missing` | `verification.action_receipts: required` fails when no action receipt is attached. | +| `signature-mismatch.json` | `invalid` | A receipt with a bad Ed25519 signature is rejected. | +| `controller-rejected.json` | `rejected` | A valid signed receipt can prove controller rejection; rejection is evidence, not a verifier failure. | + +## Boundary + +For embodied AI, `verification.action_receipts: required` means every externally +consequential action has offline-verifiable receipt evidence bound to the +session or cMCP audit `call_id`. + +It does not mean TRACE proves: + +- physical completion; +- controller safety; +- functional-safety certification; +- that the real world changed as intended. + +## Run + +```bash +cd embodied-action-receipts +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python -m unittest discover -s tests -v +``` + +Verify one fixture manually: + +```bash +python verify_receipts.py fixtures/valid-chain.json +``` + +Regenerate fixtures from the deterministic test key: + +```bash +python generate_fixtures.py +``` + +The deterministic signing seed is public test material. It is only used to make +the committed fixtures reproducible and must never be used in production. diff --git a/embodied-action-receipts/fixtures/controller-rejected.json b/embodied-action-receipts/fixtures/controller-rejected.json new file mode 100644 index 0000000..61d2a47 --- /dev/null +++ b/embodied-action-receipts/fixtures/controller-rejected.json @@ -0,0 +1,41 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "controller-rejected", + "expected": { + "receipt_state": "rejected", + "result": "valid" + }, + "note": "A valid rejected receipt is evidence of controller rejection, not physical completion.", + "receipts": [ + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:02Z", + "prev_receipt_hash": null, + "reason": "human_detected_in_safeguarded_area", + "receipt_id": "receipt-001", + "sequence": 1, + "signature": "ed25519:FvPr51RvrY9iSnUPGlTw_4dRRK8b5c7X8shDFzcaZ6hmiVoFu3m0fsWEjuSsTXi2DNbQ5NutMFZ6z8JHHSbjBQ", + "terminal_state": "controller_rejected", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "rejected" + } + ], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/embodied-action-receipts/fixtures/missing-receipt.json b/embodied-action-receipts/fixtures/missing-receipt.json new file mode 100644 index 0000000..ac3bb08 --- /dev/null +++ b/embodied-action-receipts/fixtures/missing-receipt.json @@ -0,0 +1,23 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "missing-receipt", + "expected": { + "receipt_state": "missing", + "result": "invalid" + }, + "receipts": [], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/embodied-action-receipts/fixtures/signature-mismatch.json b/embodied-action-receipts/fixtures/signature-mismatch.json new file mode 100644 index 0000000..2d463d3 --- /dev/null +++ b/embodied-action-receipts/fixtures/signature-mismatch.json @@ -0,0 +1,54 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "signature-mismatch", + "expected": { + "receipt_state": "invalid_signature", + "result": "invalid" + }, + "receipts": [ + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:01Z", + "prev_receipt_hash": null, + "receipt_id": "receipt-001", + "sequence": 1, + "signature": "ed25519:GqkLo5j8ZJzjOGwyv8hzS8vdgaYr_1VAZtdgoNTyjMU0NDuCAXK0oDiC2TTz2raMdXy7KiOoWlr4LgBDzWlGDA", + "terminal_state": "handoff_accepted", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + }, + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:05Z", + "prev_receipt_hash": "sha256:997a9dd7abb8e6a32bab079da314b62bf867605428f7920e91f2a806db1cd338", + "receipt_id": "receipt-002", + "sequence": 2, + "signature": "ed25519:RdJXHXzttRHLqKm6ockpPAU5-Odc3mZuEwzw4wNZRlm-QNPiQTvp2_LJ33TWqquhebJ9PFaq0SS4UEBW3G7bAQ", + "terminal_state": "controller_completed", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + } + ], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/embodied-action-receipts/fixtures/valid-chain.json b/embodied-action-receipts/fixtures/valid-chain.json new file mode 100644 index 0000000..0adba67 --- /dev/null +++ b/embodied-action-receipts/fixtures/valid-chain.json @@ -0,0 +1,54 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "valid-chain", + "expected": { + "receipt_state": "accepted", + "result": "valid" + }, + "receipts": [ + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:01Z", + "prev_receipt_hash": null, + "receipt_id": "receipt-001", + "sequence": 1, + "signature": "ed25519:GqkLo5j8ZJzjOGwyv8hzS8vdgaYr_1VAZtdgoNTyjMU0NDuCAXK0oDiC2TTz2raMdXy7KiOoWlr4LgBDzWlGDw", + "terminal_state": "handoff_accepted", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + }, + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:05Z", + "prev_receipt_hash": "sha256:997a9dd7abb8e6a32bab079da314b62bf867605428f7920e91f2a806db1cd338", + "receipt_id": "receipt-002", + "sequence": 2, + "signature": "ed25519:RdJXHXzttRHLqKm6ockpPAU5-Odc3mZuEwzw4wNZRlm-QNPiQTvp2_LJ33TWqquhebJ9PFaq0SS4UEBW3G7bAQ", + "terminal_state": "controller_completed", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + } + ], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/embodied-action-receipts/generate_fixtures.py b/embodied-action-receipts/generate_fixtures.py new file mode 100644 index 0000000..1d33746 --- /dev/null +++ b/embodied-action-receipts/generate_fixtures.py @@ -0,0 +1,163 @@ +"""Generate deterministic embodied-action receipt fixtures.""" + +from __future__ import annotations + +import base64 +import copy +import json +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from verify_receipts import action_preimage, canonical_bytes, receipt_hash, sha256_ref + + +ROOT = Path(__file__).parent +FIXTURES = ROOT / "fixtures" + +SEED = bytes(range(32)) +KEY = Ed25519PrivateKey.from_private_bytes(SEED) +KEY_ID = "robot-cell-7-controller" +ISSUER = "spiffe://factory.example/controller/robot-cell-7" +TRACE_ID = "trace-session-embodied-001" +CALL_ID = "call-material-move-001" + + +def b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode().rstrip("=") + + +PUBLIC_KEY_B64URL = b64url(KEY.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, +)) + + +def sign(receipt: dict[str, Any]) -> dict[str, Any]: + signed = copy.deepcopy(receipt) + signature = KEY.sign(canonical_bytes(signed)) + signed["signature"] = "ed25519:" + b64url(signature) + return signed + + +def base_trace() -> dict[str, Any]: + return { + "trace_id": TRACE_ID, + "cmcp_call_id": CALL_ID, + "policy_decision": "allow", + "verification": {"action_receipts": "required"}, + } + + +def base_action() -> dict[str, Any]: + action = { + "agent_id": "spiffe://factory.example/agent/material-movement/dev", + "action_type": "move_material", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + } + action["action_ref"] = sha256_ref(action_preimage(action)) + return action + + +def receipt(sequence: int, action: dict[str, Any], terminal_state: str, verdict: str, + previous_hash: str | None, observed_at: str) -> dict[str, Any]: + return { + "type": "embodied.action_receipt.v0", + "receipt_id": f"receipt-{sequence:03d}", + "issuer": ISSUER, + "issuer_key_id": KEY_ID, + "trace_id": TRACE_ID, + "call_id": CALL_ID, + "action_ref": action["action_ref"], + "sequence": sequence, + "prev_receipt_hash": previous_hash, + "verdict": verdict, + "terminal_state": terminal_state, + "observed_at": observed_at, + } + + +def valid_chain() -> dict[str, Any]: + action = base_action() + first = sign(receipt( + 1, action, "handoff_accepted", "accepted", None, "2026-06-25T16:30:01Z", + )) + second = sign(receipt( + 2, action, "controller_completed", "accepted", receipt_hash(first), + "2026-06-25T16:30:05Z", + )) + return { + "case": "valid-chain", + "trace": base_trace(), + "action": action, + "receipts": [first, second], + "expected": {"result": "valid", "receipt_state": "accepted"}, + } + + +def missing_receipt() -> dict[str, Any]: + return { + "case": "missing-receipt", + "trace": base_trace(), + "action": base_action(), + "receipts": [], + "expected": {"result": "invalid", "receipt_state": "missing"}, + } + + +def signature_mismatch() -> dict[str, Any]: + fixture = valid_chain() + fixture["case"] = "signature-mismatch" + fixture["receipts"][0]["signature"] = fixture["receipts"][0]["signature"][:-1] + "A" + fixture["expected"] = {"result": "invalid", "receipt_state": "invalid_signature"} + return fixture + + +def controller_rejected() -> dict[str, Any]: + action = base_action() + rejection = sign(receipt( + 1, action, "controller_rejected", "rejected", None, "2026-06-25T16:30:02Z", + )) + rejection["reason"] = "human_detected_in_safeguarded_area" + # The reason is part of the signed receipt, so sign again after adding it. + rejection.pop("signature") + rejection = sign(rejection) + return { + "case": "controller-rejected", + "trace": base_trace(), + "action": action, + "receipts": [rejection], + "expected": {"result": "valid", "receipt_state": "rejected"}, + "note": "A valid rejected receipt is evidence of controller rejection, not physical completion.", + } + + +def write_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def main() -> None: + FIXTURES.mkdir(exist_ok=True) + write_json(ROOT / "trusted-keys.json", { + "controller_signers": { + KEY_ID: { + "alg": "Ed25519", + "issuer": ISSUER, + "public_key_b64url": PUBLIC_KEY_B64URL, + }, + }, + "note": "Public test verifier key for deterministic fixtures. Never use the signing seed in production.", + }) + write_json(FIXTURES / "valid-chain.json", valid_chain()) + write_json(FIXTURES / "missing-receipt.json", missing_receipt()) + write_json(FIXTURES / "signature-mismatch.json", signature_mismatch()) + write_json(FIXTURES / "controller-rejected.json", controller_rejected()) + print(f"Wrote fixtures to {FIXTURES}") + + +if __name__ == "__main__": + main() + diff --git a/embodied-action-receipts/requirements.txt b/embodied-action-receipts/requirements.txt new file mode 100644 index 0000000..ca3c09a --- /dev/null +++ b/embodied-action-receipts/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=42.0.0 + diff --git a/embodied-action-receipts/tests/test_verify_receipts.py b/embodied-action-receipts/tests/test_verify_receipts.py new file mode 100644 index 0000000..c0d6150 --- /dev/null +++ b/embodied-action-receipts/tests/test_verify_receipts.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from verify_receipts import verify_fixture # noqa: E402 + + +class ReceiptFixtureTests(unittest.TestCase): + def test_fixtures_match_expected_results(self) -> None: + for path in sorted((ROOT / "fixtures").glob("*.json")): + with self.subTest(path=path.name): + fixture = json.loads(path.read_text()) + self.assertEqual(verify_fixture(path), fixture["expected"]) + + def test_rejected_receipt_is_valid_evidence(self) -> None: + result = verify_fixture(ROOT / "fixtures" / "controller-rejected.json") + self.assertEqual(result, {"result": "valid", "receipt_state": "rejected"}) + + +if __name__ == "__main__": + unittest.main() + diff --git a/embodied-action-receipts/trusted-keys.json b/embodied-action-receipts/trusted-keys.json new file mode 100644 index 0000000..989a3b6 --- /dev/null +++ b/embodied-action-receipts/trusted-keys.json @@ -0,0 +1,10 @@ +{ + "controller_signers": { + "robot-cell-7-controller": { + "alg": "Ed25519", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "public_key_b64url": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + }, + "note": "Public test verifier key for deterministic fixtures. Never use the signing seed in production." +} diff --git a/embodied-action-receipts/verify_receipts.py b/embodied-action-receipts/verify_receipts.py new file mode 100644 index 0000000..fb43d8d --- /dev/null +++ b/embodied-action-receipts/verify_receipts.py @@ -0,0 +1,129 @@ +"""Offline verifier for embodied-action receipt fixtures.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + +ROOT = Path(__file__).parent + + +def canonical_bytes(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + + +def b64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +def sha256_ref(value: Any) -> str: + return "sha256:" + hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def action_preimage(action: dict[str, Any]) -> dict[str, Any]: + return { + "agent_id": action["agent_id"], + "action_type": action["action_type"], + "action_scope": action["action_scope"], + "action_timestamp": action["action_timestamp"], + } + + +def receipt_preimage(receipt: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in receipt.items() if k != "signature"} + + +def receipt_hash(receipt: dict[str, Any]) -> str: + return sha256_ref(receipt) + + +def load_trusted_keys(path: Path = ROOT / "trusted-keys.json") -> dict[str, Ed25519PublicKey]: + data = json.loads(path.read_text()) + keys = {} + for key_id, key in data["controller_signers"].items(): + keys[key_id] = Ed25519PublicKey.from_public_bytes(b64url_decode(key["public_key_b64url"])) + return keys + + +def verify_fixture(path: Path, trusted_keys: dict[str, Ed25519PublicKey] | None = None) -> dict[str, Any]: + fixture = json.loads(path.read_text()) + trusted_keys = trusted_keys or load_trusted_keys() + + trace = fixture["trace"] + action = fixture["action"] + receipts = fixture.get("receipts", []) + expected_required = trace.get("verification", {}).get("action_receipts") == "required" + + recomputed_action_ref = sha256_ref(action_preimage(action)) + if recomputed_action_ref != action.get("action_ref"): + return {"result": "invalid", "receipt_state": "action_ref_mismatch"} + + if expected_required and not receipts: + return {"result": "invalid", "receipt_state": "missing"} + + previous_hash = None + final_state = "absent" + final_verdict = None + + for receipt in sorted(receipts, key=lambda r: r["sequence"]): + if receipt["call_id"] != trace["cmcp_call_id"]: + return {"result": "invalid", "receipt_state": "call_id_mismatch"} + if receipt["trace_id"] != trace["trace_id"]: + return {"result": "invalid", "receipt_state": "trace_id_mismatch"} + if receipt["action_ref"] != action["action_ref"]: + return {"result": "invalid", "receipt_state": "action_ref_mismatch"} + if receipt.get("prev_receipt_hash") != previous_hash: + return {"result": "invalid", "receipt_state": "chain_mismatch"} + + key = trusted_keys.get(receipt["issuer_key_id"]) + if key is None: + return {"result": "invalid", "receipt_state": "untrusted"} + + signature = receipt["signature"] + if not signature.startswith("ed25519:"): + return {"result": "invalid", "receipt_state": "signature_format"} + + try: + key.verify(b64url_decode(signature.removeprefix("ed25519:")), canonical_bytes(receipt_preimage(receipt))) + except InvalidSignature: + return {"result": "invalid", "receipt_state": "invalid_signature"} + + previous_hash = receipt_hash(receipt) + final_verdict = receipt["verdict"] + final_state = receipt["terminal_state"] + + if final_verdict == "rejected" or final_state.endswith("rejected"): + return {"result": "valid", "receipt_state": "rejected"} + if final_verdict == "accepted": + return {"result": "valid", "receipt_state": "accepted"} + return {"result": "valid", "receipt_state": final_state} + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: python verify_receipts.py ", file=sys.stderr) + return 2 + + path = Path(sys.argv[1]) + result = verify_fixture(path) + print(json.dumps(result, indent=2, sort_keys=True)) + + expected = json.loads(path.read_text()).get("expected") + if expected and result != expected: + print(f"expected {expected}, got {result}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +