diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56c131c..1b95487 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,32 @@ jobs: find . -name "*.yaml" -o -name "*.yml" | grep -v ".git" | while read f; do python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" "$f" && echo "OK: $f" || exit 1 done + + industrial-embodied-ai: + runs-on: ubuntu-latest + defaults: + run: + working-directory: industrial-embodied-ai + 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: industrial-embodied-ai/requirements.txt + + - name: Install controller test dependencies + run: python -m pip install cryptography + + - name: Run controller tests + run: python -m unittest discover -s tests -v + + - name: Install artifact validation dependencies + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + run: python -m pip install -r requirements.txt + + - 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 diff --git a/industrial-embodied-ai/README.md b/industrial-embodied-ai/README.md index e5b4c42..8fd61ea 100644 --- a/industrial-embodied-ai/README.md +++ b/industrial-embodied-ai/README.md @@ -8,6 +8,9 @@ The example also demonstrates evidence continuity: after the governed session closes, its saved TRACE Trust Record and audit bundle remain verifiable after the agent, cMCP Runtime and mock controller stop. This is continuity of evidence, not continuity of agent memory, reputation or process identity. +Live runs also attach a controller-signed `external_execution_evidence` +receipt to each motion decision when run with a cMCP Runtime that supports +issue #301 external evidence binding. The scenario is synthetic. It uses no robot hardware, vendor SDK, production endpoint, or proprietary industrial data. @@ -29,7 +32,10 @@ to produce durable evidence: 3. **Safety rejected:** cMCP authorizes the declared workflow, but the controller rejects motion after its current state reports a person in the safeguarded area. -4. **Closed-session evidence:** cMCP signs a TRACE Trust Record and audit +4. **Controller-signed execution receipts:** each motion decision includes a + mock controller receipt that cMCP can bind to the audit entry for that + `call_id`. +5. **Closed-session evidence:** cMCP signs a TRACE Trust Record and audit bundle that can be verified from the saved files without a running agent, runtime or controller. @@ -72,6 +78,7 @@ individually safe motion is not, by itself, a trusted one. | - validates fresh state token | | - rechecks current cell state | | - enforces speed and zone | + | - signs motion decisions | +---------------+---------------+ | | accepted command @@ -94,6 +101,7 @@ rather than a native runtime binding. | Governed tool access | cMCP intercepts each MCP request and evaluates the active Cedar policy before forwarding | | Scope over safety | cMCP denies an in-envelope motion that falls outside the declared workflow, a check the safety controller does not perform | | Physical authority | The independent controller rechecks current state and remains authoritative for simulated execution | +| External controller evidence | Motion outcomes can include controller-signed receipts bound to the cMCP audit call id | | Durable session evidence | TRACE and the signed audit bundle bind the cMCP session, policy, catalog and tool-call transcript | The example composes these boundaries without claiming that the developer @@ -128,9 +136,9 @@ Prerequisites: - Python 3.11 or newer - Git -The project is a developer preview. `requirements.txt` pins the cMCP and TRACE -commits used for this reproducible example until the summit release stack is -available from PyPI. +The project is a developer preview. `requirements.txt` pins the TRACE commit +and a cMCP external-evidence PR commit used for this reproducible example +until the summit release stack is available from PyPI. ```bash cd industrial-embodied-ai @@ -182,6 +190,7 @@ SAFETY REJECT TRACE VERIFICATION schema/signature/hashes/freshness: verified audit bundle: verified + controller receipts: verified (2) runtime platform: software-only hardware attestation: not verified (development mode) ``` @@ -248,6 +257,7 @@ The validator checks: - Agent Manifest artifact bindings and Ed25519 signature - runtime-issued TRACE schema and signature - signed audit-bundle integrity and binding to the TRACE record +- controller execution receipts when present in the audit bundle ## Evidence boundaries @@ -256,15 +266,15 @@ The validator checks: | Agent Manifest | The signed agent identity declaration and hashes of the approved prompt, policy and tools | That cMCP loaded the manifest or bound its agent identity to the runtime session | | cMCP decision | The active policy authorized or denied a cataloged tool request | That an authorized physical request was safe | | Controller accept | The motion was inside the safety envelope for this run | That the action was authorized, in declared scope, or issued by the reviewed agent | +| Controller execution receipt | The mock controller signed a specific accepted or rejected motion decision and linked it to a cMCP audit `call_id` | Hardware-backed execution, physical completion, or functional-safety compliance | | TRACE Trust Record | cMCP session identity, runtime, policy hash, catalog hash and tool-call transcript integrity | The Agent Manifest identity, controller acceptance, physical completion or functional-safety compliance | | Saved TRACE and audit files | The closed session can be checked after the processes stop | Continuity of agent memory, reputation or logical identity across a restart or replacement | -| Client-observed controller response | The mock controller's decision returned to the agent during this run | A signed, hardware-backed or independently retained execution record | -The current cMCP audit bundle records request hashes and authorization -decisions, but does not populate a response hash for the controller outcome. -The example therefore does not claim that TRACE proves controller acceptance -or physical completion. Binding independent controller evidence is a -follow-up design question, not something this example silently invents. +When run against a cMCP build with issue #301 support, the audit bundle records +the response hash and can bind the mock controller's signed receipt for each +motion decision. The receipt proves the mock controller signed a decision for +that call id. It does not claim that TRACE proves physical completion, +hardware-backed execution, or compliance with industrial safety standards. The committed TRACE subject identifies the cMCP session, while the Agent Manifest declares a separate agent identity. The validator confirms that the @@ -280,6 +290,7 @@ agent. | `agent-manifest.json` | Signed development declaration binding the prompt, policy bundle and tool catalog | | `manifest-public-key.json` | Public verification key for the Agent Manifest | | `artifact-hashes.json` | Approved cMCP and Agent Manifest artifact hashes | +| `controller-receipt-public-key.json` | Development-only public key for verifying mock controller receipts | | `catalog.json` | Attested definitions for safety-state and motion-request tools | | `cmcp-config.yaml` | cMCP configuration shared by development and hardware runs | | `policy/allow.cedar` | Explicit workflow-scoped permits with default deny | diff --git a/industrial-embodied-ai/agent/material_movement_agent.py b/industrial-embodied-ai/agent/material_movement_agent.py index 3fa6c35..9e8a4ba 100644 --- a/industrial-embodied-ai/agent/material_movement_agent.py +++ b/industrial-embodied-ai/agent/material_movement_agent.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import base64 import json import os from pathlib import Path @@ -19,6 +20,18 @@ WORKFLOW_ID = "industrial-material-movement" +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _external_evidence_keys() -> dict[str, bytes]: + key_doc = json.loads( + (EXAMPLE_DIR / "controller-receipt-public-key.json").read_text() + ) + key_id = key_doc["key_id"] + return {key_id: _b64url_decode(key_doc["public_key_base64url"])} + + def _headers() -> dict[str, str]: headers = {"Content-Type": "application/json"} if token := os.environ.get("CMCP_BEARER_TOKEN"): @@ -127,7 +140,11 @@ def _verify_evidence( tool_catalog_hash=expected["cmcp_catalog_hash"], ), ) - bundle_result = verify_audit_bundle(bundle, claim) + bundle_result = verify_audit_bundle( + bundle, + claim, + external_evidence_keys=_external_evidence_keys(), + ) required = { "schema", "signature", @@ -139,8 +156,18 @@ def _verify_evidence( missing = sorted(required - set(result.verified_fields)) print("TRACE VERIFICATION") - print(f" schema/signature/hashes/freshness: {'verified' if not missing else 'failed'}") + schema_status = "verified" if not missing else "failed" + print(f" schema/signature/hashes/freshness: {schema_status}") print(f" audit bundle: {'verified' if bundle_result.verified else 'failed'}") + receipt_count = sum( + 1 + for entry in bundle.get("entries", []) + if entry.get("external_execution_evidence") + ) + if receipt_count: + print(f" controller receipts: verified ({receipt_count})") + else: + print(" controller receipts: not present in this bundle") platform = claim.get("trace", {}).get("runtime", {}).get("platform", "unknown") print(f" runtime platform: {platform}") if platform == "software-only": @@ -149,7 +176,8 @@ def _verify_evidence( missing.append("hardware_attestation") else: hardware_verified = "hardware_attestation" in result.verified_fields - print(f" hardware attestation: {'verified' if hardware_verified else 'failed'}") + hardware_status = "verified" if hardware_verified else "failed" + print(f" hardware attestation: {hardware_status}") if require_hardware and not hardware_verified: missing.append("hardware_attestation") diff --git a/industrial-embodied-ai/controller-receipt-public-key.json b/industrial-embodied-ai/controller-receipt-public-key.json new file mode 100644 index 0000000..7891b7a --- /dev/null +++ b/industrial-embodied-ai/controller-receipt-public-key.json @@ -0,0 +1,7 @@ +{ + "key_id": "c1b47b97b024ed8ef89572fb0ed92a15fdd48496d6138ec260e018bd345d3d2d", + "public_key_base64url": "eOBimyX_-wLLvWhQ3Jl2KnRULU8ZU-vK0z7eAn2gNoo", + "algorithm": "Ed25519", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "usage": "development-only external_execution_evidence verification key" +} diff --git a/industrial-embodied-ai/controller.py b/industrial-embodied-ai/controller.py index be5ff63..6c8413c 100644 --- a/industrial-embodied-ai/controller.py +++ b/industrial-embodied-ai/controller.py @@ -14,6 +14,9 @@ import time from typing import Any, Callable +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + CONTROLLER_ID = "spiffe://factory.example/controller/robot-cell-7" MAX_STATE_AGE_MS = 5_000 @@ -61,6 +64,15 @@ def __init__( self._token_key = token_key or secrets.token_bytes(32) self._sequence = 0 self._consumed_sequences: set[int] = set() + # #301: deterministic development-only Ed25519 key the controller uses to + # sign execution receipts (external_execution_evidence). Fixed seed so the + # public key and any committed evidence are reproducible. Not for production. + self._receipt_key = Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(b"development-only-mock-controller-receipt-key").digest() + ) + self._receipt_pub = self._receipt_key.public_key().public_bytes( + encoding=Encoding.Raw, format=PublicFormat.Raw + ) self._state = { "operating_mode": "automatic", "emergency_stop_active": False, @@ -157,3 +169,44 @@ def request_motion(self, request: dict[str, Any]) -> dict[str, Any]: "controller_decision": "accepted", "execution_status": "completed", } + + @property + def receipt_key_id(self) -> str: + """Stable id for the receipt-signing public key (sha256 hex of the key).""" + return hashlib.sha256(self._receipt_pub).hexdigest() + + @property + def receipt_public_key_b64(self) -> str: + """base64url of the raw Ed25519 receipt-signing public key.""" + return _b64url_encode(self._receipt_pub) + + @property + def receipt_public_key_bytes(self) -> bytes: + """Raw Ed25519 receipt-signing public key bytes for cMCP verification.""" + return self._receipt_pub + + def sign_execution_receipt( + self, *, call_id: str, decision: dict[str, Any] + ) -> dict[str, str]: + """Return a controller-signed execution receipt for a decision. + + Matches the cMCP external_execution_evidence format (issue #301): the + signature is Ed25519 over the canonical receipt with the signature field + absent, and a verifier checks linked_call_id against the audit entry + call_id. This attests what the independent controller decided. It is not + a claim that a physical action was safe, accepted, or certified. + """ + receipt = { + "issuer": CONTROLLER_ID, + "issuer_key_id": self.receipt_key_id, + "evidence_hash": ( + "sha256:" + + hashlib.sha256(canonical_bytes(decision)).hexdigest() + ), + "evidence_type": "controller-execution-receipt/v1", + "linked_call_id": call_id, + } + receipt["signature"] = _b64url_encode( + self._receipt_key.sign(canonical_bytes(receipt)) + ) + return receipt diff --git a/industrial-embodied-ai/requirements.txt b/industrial-embodied-ai/requirements.txt index e89453c..68f4f6f 100644 --- a/industrial-embodied-ai/requirements.txt +++ b/industrial-embodied-ai/requirements.txt @@ -1,4 +1,4 @@ agentrust-trace @ git+https://github.com/agentrust-io/trace-spec.git@ae152e8baf6ba7c7443cdf73fb5d9ef1660526c0 -cmcp-runtime @ git+https://github.com/agentrust-io/cmcp.git@3a7f49fea5f0c97500ffe0c3c49ec2ff68d09555 +cmcp-runtime @ git+https://github.com/carloshvp/cmcp.git@29ef0379882a4d79505ffeeaf066498c43f5c35e cedarpy==4.8.4 httpx>=0.27,<1 diff --git a/industrial-embodied-ai/server/mock_robot_controller.py b/industrial-embodied-ai/server/mock_robot_controller.py index 2a0dec5..674ece6 100644 --- a/industrial-embodied-ai/server/mock_robot_controller.py +++ b/industrial-embodied-ai/server/mock_robot_controller.py @@ -57,6 +57,14 @@ def _request_motion(arguments: dict[str, Any]) -> dict[str, Any]: return result +def _with_execution_receipt(result: dict[str, Any], call_id: object) -> dict[str, Any]: + receipt = controller.sign_execution_receipt( + call_id=str(call_id), + decision=result, + ) + return {**result, "external_execution_evidence": receipt} + + TOOLS = { "cell.read_safety_state": _read_safety_state, "robot.request_motion": _request_motion, @@ -94,6 +102,8 @@ def do_POST(self) -> None: # noqa: N802 return result = handler(params.get("arguments", {})) + if tool_name == "robot.request_motion": + result = _with_execution_receipt(result, request.get("id")) self._reply( 200, { diff --git a/industrial-embodied-ai/tests/test_execution_receipt.py b/industrial-embodied-ai/tests/test_execution_receipt.py new file mode 100644 index 0000000..332ffa6 --- /dev/null +++ b/industrial-embodied-ai/tests/test_execution_receipt.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import sys +import unittest +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +from controller import IndependentSafetyController # noqa: E402 +from server import mock_robot_controller # noqa: E402 + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _verify_like_cmcp( + receipt: dict, + external_evidence_keys: dict[str, bytes], + call_id: str, +) -> None: + """Reproduce cmcp_verify's external_execution_evidence check: the + trusted key-id map, linked_call_id binding, and Ed25519 signature over the + canonical receipt with the signature field absent. Raises on verification + failure. + """ + if receipt["linked_call_id"] != call_id: + raise AssertionError("linked_call_id does not match the entry call_id") + key_id = receipt["issuer_key_id"] + public_key = external_evidence_keys[key_id] + if hashlib.sha256(public_key).hexdigest() != key_id: + raise AssertionError("issuer_key_id does not match trusted public key") + pub = Ed25519PublicKey.from_public_bytes(public_key) + signing_input = json.dumps( + {k: v for k, v in receipt.items() if k != "signature"}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() + pub.verify(_b64url_decode(receipt["signature"]), signing_input) + + +class ExecutionReceiptTests(unittest.TestCase): + def setUp(self) -> None: + self.controller = IndependentSafetyController( + token_key=b"unit-test-only-controller-key" + ) + + def test_receipt_has_required_fields(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", + decision={"controller_decision": "rejected", "reason": "human_detected"}, + ) + for field in ( + "issuer", + "issuer_key_id", + "signature", + "evidence_hash", + "evidence_type", + "linked_call_id", + ): + self.assertIn(field, receipt) + self.assertTrue(receipt["evidence_hash"].startswith("sha256:")) + self.assertEqual(receipt["evidence_type"], "controller-execution-receipt/v1") + self.assertEqual( + receipt["issuer_key_id"], + hashlib.sha256(self.controller.receipt_public_key_bytes).hexdigest(), + ) + + def test_receipt_verifies_like_cmcp(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", + decision={"controller_decision": "rejected", "reason": "human_detected"}, + ) + # Should not raise. + _verify_like_cmcp( + receipt, + {self.controller.receipt_key_id: self.controller.receipt_public_key_bytes}, + "c1", + ) + + def test_tampered_receipt_fails(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", decision={"controller_decision": "rejected"} + ) + receipt["evidence_hash"] = "sha256:" + "cd" * 32 # tamper after signing + with self.assertRaises(InvalidSignature): + _verify_like_cmcp( + receipt, + { + self.controller.receipt_key_id: ( + self.controller.receipt_public_key_bytes + ) + }, + "c1", + ) + + def test_linked_call_id_is_bound(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", decision={"controller_decision": "rejected"} + ) + with self.assertRaises(AssertionError): + _verify_like_cmcp( + receipt, + { + self.controller.receipt_key_id: ( + self.controller.receipt_public_key_bytes + ) + }, + "other", + ) + + def test_receipt_key_is_deterministic(self) -> None: + # Fixed dev seed: the receipt key id is reproducible across instances, + # so committed evidence stays stable. + other = IndependentSafetyController(token_key=b"different-token-key") + self.assertEqual(self.controller.receipt_key_id, other.receipt_key_id) + self.assertEqual( + self.controller.receipt_public_key_b64, + "eOBimyX_-wLLvWhQ3Jl2KnRULU8ZU-vK0z7eAn2gNoo", + ) + + def test_mock_server_attaches_receipt_to_motion_decision(self) -> None: + result = mock_robot_controller._with_execution_receipt( + {"controller_decision": "rejected", "reason": "human_detected"}, + "call-42", + ) + receipt = result["external_execution_evidence"] + self.assertEqual(receipt["linked_call_id"], "call-42") + _verify_like_cmcp( + receipt, + { + mock_robot_controller.controller.receipt_key_id: ( + mock_robot_controller.controller.receipt_public_key_bytes + ) + }, + "call-42", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/industrial-embodied-ai/validate_artifacts.py b/industrial-embodied-ai/validate_artifacts.py index bcf4613..c335cc9 100644 --- a/industrial-embodied-ai/validate_artifacts.py +++ b/industrial-embodied-ai/validate_artifacts.py @@ -34,6 +34,14 @@ def b64url_decode(value: str) -> bytes: return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) +def external_evidence_keys() -> dict[str, bytes]: + key_doc = json.loads( + (BASE / "controller-receipt-public-key.json").read_text() + ) + key_id = key_doc["key_id"] + return {key_id: b64url_decode(key_doc["public_key_base64url"])} + + def compute_policy_bundle_hash() -> str: policy_dir = BASE / "policy" manifest = json.loads((policy_dir / "manifest.json").read_text()) @@ -160,12 +168,25 @@ def main() -> None: assert required <= set(verification.verified_fields) assert claim["trace"]["runtime"]["platform"] == "software-only" - bundle_verification = verify_audit_bundle(audit_bundle, claim) + bundle_verification = verify_audit_bundle( + audit_bundle, + claim, + external_evidence_keys=external_evidence_keys(), + ) assert bundle_verification.verified, bundle_verification.failures + receipt_count = sum( + 1 + for entry in audit_bundle.get("entries", []) + if entry.get("external_execution_evidence") + ) print("Configuration and artifact hashes: valid") print("Agent Manifest signature: valid") print("Runtime-issued TRACE signature and audit bundle: valid") + if receipt_count: + print(f"Controller execution receipts: valid ({receipt_count})") + else: + print("Controller execution receipts: not present in committed fixture") print("Hardware attestation: not present in committed development fixture")