From 70a205ae09676fc7d48468d123304c808e0f067d Mon Sep 17 00:00:00 2001 From: Carlos Hernandez Date: Fri, 12 Jun 2026 00:46:35 +0200 Subject: [PATCH 1/2] feat: add industrial embodied AI governance example Signed-off-by: Carlos Hernandez --- README.md | 1 + industrial-embodied-ai/.gitignore | 4 + industrial-embodied-ai/README.md | 262 ++++++++++++++++ industrial-embodied-ai/agent-manifest.json | 88 ++++++ .../agent/material_movement_agent.py | 286 ++++++++++++++++++ industrial-embodied-ai/artifact-hashes.json | 6 + .../artifacts/system-prompt.txt | 7 + industrial-embodied-ai/catalog.json | 150 +++++++++ industrial-embodied-ai/cmcp-config.yaml | 9 + industrial-embodied-ai/controller.py | 159 ++++++++++ .../manifest-public-key.json | 5 + industrial-embodied-ai/policy/allow.cedar | 22 ++ industrial-embodied-ai/policy/manifest.json | 7 + .../policy/schema.cedarschema | 1 + industrial-embodied-ai/requirements.txt | 4 + .../server/mock_robot_controller.py | 130 ++++++++ .../tests/test_controller.py | 107 +++++++ .../trace-output/example-audit-bundle.json | 160 ++++++++++ .../trace-output/example-trust-record.json | 76 +++++ industrial-embodied-ai/validate_artifacts.py | 173 +++++++++++ 20 files changed, 1657 insertions(+) create mode 100644 industrial-embodied-ai/.gitignore create mode 100644 industrial-embodied-ai/README.md create mode 100644 industrial-embodied-ai/agent-manifest.json create mode 100644 industrial-embodied-ai/agent/material_movement_agent.py create mode 100644 industrial-embodied-ai/artifact-hashes.json create mode 100644 industrial-embodied-ai/artifacts/system-prompt.txt create mode 100644 industrial-embodied-ai/catalog.json create mode 100644 industrial-embodied-ai/cmcp-config.yaml create mode 100644 industrial-embodied-ai/controller.py create mode 100644 industrial-embodied-ai/manifest-public-key.json create mode 100644 industrial-embodied-ai/policy/allow.cedar create mode 100644 industrial-embodied-ai/policy/manifest.json create mode 100644 industrial-embodied-ai/policy/schema.cedarschema create mode 100644 industrial-embodied-ai/requirements.txt create mode 100644 industrial-embodied-ai/server/mock_robot_controller.py create mode 100644 industrial-embodied-ai/tests/test_controller.py create mode 100644 industrial-embodied-ai/trace-output/example-audit-bundle.json create mode 100644 industrial-embodied-ai/trace-output/example-trust-record.json create mode 100644 industrial-embodied-ai/validate_artifacts.py diff --git a/README.md b/README.md index 7dfde0b..687b8a1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ End-to-end integration examples showing cMCP, Agent Manifest, and TRACE working |---|---|---|---| | `financial-services/` | Payment agent with Cedar policy: blocks PII in tool call parameters | SEV-SNP / TDX | EU AI Act Art. 9/12, DORA Art. 9 | | `healthcare/` | Clinical decision agent with HITL approvals and EU AI Act Art. 14 compliance records | SEV-SNP / TDX | EU AI Act Art. 14, HIPAA | +| `industrial-embodied-ai/` | Material-movement agent with cMCP authorization and an independent safety-controller boundary | TEE / software-only development mode | OT security and industrial robot safety references | | `multi-tenant-saas/` | SaaS platform with per-tenant policy isolation | TDX | Customer contract SLA | | `startup-tpm/` | 15-minute quickstart on any cloud VM with Trusted Launch | TPM 2.0 | Development / staging | diff --git a/industrial-embodied-ai/.gitignore b/industrial-embodied-ai/.gitignore new file mode 100644 index 0000000..4bdd894 --- /dev/null +++ b/industrial-embodied-ai/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +audit.db +trace-output/latest-*.json diff --git a/industrial-embodied-ai/README.md b/industrial-embodied-ai/README.md new file mode 100644 index 0000000..8594310 --- /dev/null +++ b/industrial-embodied-ai/README.md @@ -0,0 +1,262 @@ +# industrial-embodied-ai: Governed Material Movement + +End-to-end example of an AI agent requesting motion from an industrial robot +cell through cMCP, with Agent Manifest declarations and a runtime-issued TRACE +Trust Record. + +The scenario is synthetic. It uses no robot hardware, vendor SDK, production +endpoint, or proprietary industrial data. + +## What the example demonstrates + +The agent runs three paths through a live cMCP Runtime: + +1. **Allowed and completed:** cMCP authorizes the declared workflow, then the + independent controller accepts and completes the simulated motion. +2. **Policy denied:** cMCP denies an undeclared workflow before the controller + receives the request. +3. **Safety rejected:** cMCP authorizes the declared workflow, but the + controller rejects motion after its current state reports a person in the + safeguarded area. + +The third path is the central boundary: + +> A cMCP `allow` decision means the software request is authorized. It does +> not mean that a physical action is safe, accepted by the controller, or +> completed by a machine. + +## Architecture + +```text + Material-movement agent + | + | MCP tools/call + v + +--------------------------+ Agent Manifest declares: + | cMCP Runtime | <--- agent, prompt, policy and tool hashes + | - attested tool catalog | + | - Cedar authorization | + | - hash-chained audit | + +------------+-------------+ + | + | authorized request + v + +-------------------------------+ + | Independent mock controller | + | - validates fresh state token | + | - rechecks current cell state | + | - enforces speed and zone | + +---------------+---------------+ + | + | accepted command + v + Simulated robot execution + + Session close -> signed TRACE Trust Record + signed audit bundle +``` + +## Run it + +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. + +```bash +cd industrial-embodied-ai +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Start the mock controller: + +```bash +python server/mock_robot_controller.py +``` + +In a second terminal, start cMCP in explicitly non-hardware development mode: + +```bash +cd industrial-embodied-ai +source .venv/bin/activate +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +In a third terminal, run the agent: + +```bash +cd industrial-embodied-ai +source .venv/bin/activate +python agent/material_movement_agent.py +``` + +Expected summary: + +```text +SUCCESS + cMCP policy: authorized + controller: accepted + execution: completed + +POLICY DENY + cMCP policy: denied + controller: not invoked + +SAFETY REJECT + cMCP policy: authorized + controller: rejected + reason: human_detected + execution: not_started + +TRACE VERIFICATION + schema/signature/hashes/freshness: verified + audit bundle: verified + runtime platform: software-only + hardware attestation: not verified (development mode) +``` + +The agent writes fresh evidence to: + +- `trace-output/latest-trust-record.json` +- `trace-output/latest-audit-bundle.json` + +These files are ignored by Git. The committed `example-*` files were captured +from a real run and remain available for offline inspection. + +## Hardware-attested run + +On a supported host, do not set `CMCP_DEV_MODE`. Pin the expected artifacts +and configure the same bearer token for the runtime and agent: + +```bash +export CMCP_BEARER_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" +export CMCP_POLICY_HASH="sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9" +export CMCP_CATALOG_HASH="sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb" + +cmcp start --config cmcp-config.yaml +``` + +Then require hardware verification in the agent: + +```bash +python agent/material_movement_agent.py --require-hardware +``` + +The runtime must detect and successfully verify one of its supported +attestation providers. Development mode intentionally cannot satisfy +`--require-hardware`. + +## Verify committed artifacts + +```bash +python validate_artifacts.py +python -m unittest discover -s tests -v +``` + +The validator checks: + +- cMCP configuration and catalog definition hashes +- policy bundle and catalog hashes +- Agent Manifest artifact bindings and Ed25519 signature +- runtime-issued TRACE schema and signature +- signed audit-bundle integrity and binding to the TRACE record + +To verify fresh output using the cMCP CLI: + +```bash +cmcp verify trace-output/latest-trust-record.json \ + --policy-hash sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9 \ + --catalog-hash sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb \ + --audit-bundle trace-output/latest-audit-bundle.json +``` + +## Evidence boundaries + +| Evidence | What it establishes | What it does not establish | +|---|---|---| +| Agent Manifest | The signed declaration and hashes of the approved prompt, policy and tools | That the deployed runtime loaded those artifacts | +| cMCP decision | The active policy authorized or denied a cataloged tool request | That an authorized physical request was safe | +| TRACE Trust Record | Runtime, policy hash, catalog hash and tool-call transcript integrity | Controller acceptance, physical completion or functional-safety compliance | +| Controller response | The mock controller's decision returned during this run | A 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. + +## AgentTrust artifacts + +| File | Purpose | +|---|---| +| `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 | +| `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 | +| `trace-output/example-trust-record.json` | TRACE Trust Record captured from the live development run | +| `trace-output/example-audit-bundle.json` | Signed cMCP audit bundle captured from the same session | + +The Agent Manifest is a signed declaration artifact in this example. The +current cMCP preview loads the policy and catalog directly; the validator +checks that their hashes agree with the manifest instead of claiming cMCP +consumes the manifest natively. + +## Threats illustrated + +- **Compromised planner:** only cataloged tools in the declared workflow are + authorized. +- **Policy bypass:** the undeclared workflow is denied before forwarding. +- **Untrusted request arguments:** the controller never accepts agent-supplied + safety booleans. +- **Stale or modified state:** short-lived authenticated state tokens fail + closed. +- **Replay:** a state token is single-use. +- **Time-of-check/time-of-use change:** the controller rechecks current state + and rejects motion even after cMCP authorization. + +The HMAC state token is a teaching mechanism, not an industrial security +protocol. A real deployment requires authenticated device identity, protected +keys, replay protection, secure time and a validated industrial communication +architecture. + +## Safety boundary + +This example does not provide or certify: + +- emergency-stop functions +- protective stops or safe torque off +- collision avoidance +- safe speed or separation monitoring +- safety-rated control logic +- machinery conformity assessment + +Those functions remain in independently engineered and validated +safety-related control systems. + +## External references + +- [NIST SP 800-82 Rev. 3](https://csrc.nist.gov/pubs/sp/800/82/r3/final) +- [ISO 10218-1:2025](https://www.iso.org/standard/73933.html) +- [ISO 10218-2:2025](https://www.iso.org/standard/73934.html) +- [ISO 13849-1:2023](https://www.iso.org/standard/73481.html) +- [IEC 62443-4-2:2019](https://webstore.iec.ch/en/publication/34421) +- [IETF RFC 9334](https://datatracker.ietf.org/doc/rfc9334/) +- [IETF RFC 9711](https://datatracker.ietf.org/doc/rfc9711/) +- [ROS 2 Security Enclaves](https://design.ros2.org/articles/ros2_security_enclaves.html) +- [ROS 2 Access Control Policies](https://design.ros2.org/articles/ros2_access_control_policies.html) +- [Regulation (EU) 2023/1230](https://eur-lex.europa.eu/eli/reg/2023/1230/oj/eng) +- [Regulation (EU) 2024/1689](https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng) + +These references inform the separation of responsibilities. The example is +not a claim of compliance with any standard or regulation. + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repository root. diff --git a/industrial-embodied-ai/agent-manifest.json b/industrial-embodied-ai/agent-manifest.json new file mode 100644 index 0000000..08aa806 --- /dev/null +++ b/industrial-embodied-ai/agent-manifest.json @@ -0,0 +1,88 @@ +{ + "@context": "https://agentmanifest.agentrust.io/v0.1/context.json", + "@type": "AgentManifest", + "manifest_id": "0197739a-8c00-7000-8000-000000000001", + "agent_id": "spiffe://factory.example/agent/material-movement/dev", + "version": "0.1", + "issued_at": "2026-06-12T00:00:00Z", + "expires_at": "2026-09-10T00:00:00Z", + "issuer": "spiffe://factory.example/signing-authority/development", + "crypto_profile": "standard", + "artifacts": { + "system_prompt": { + "hash": "sha256:9b364864a1d6682f56f572052dd7a0701b35d9726046bcb06d5fa5d87b49a70f", + "hash_algorithm": "SHA-256", + "version": "0.1.0", + "classification": "internal", + "bound_at": "2026-06-12T00:00:00Z" + }, + "policy_bundle": { + "hash": "sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9", + "policy_language": "cedar", + "version": "0.1.0", + "enforcement_mode": "enforce", + "scope": [ + "cell.read_safety_state", + "robot.request_motion" + ], + "bound_at": "2026-06-12T00:00:00Z" + }, + "tool_manifest": { + "catalog_hash": "sha256:3c7460301bbf38612b492de4a2095ccf40b7d921bdc9ce96e8ea0f50068d66e0", + "tools": [ + { + "tool_id": "spiffe://factory.example/tool/cell.read_safety_state", + "name": "cell.read_safety_state", + "server_id": "spiffe://factory.example/controller/robot-cell-7", + "schema_hash": "sha256:99334726611ccf58a148b0814696bfa6fe08c1b2d027e946beccf5a74331c9aa", + "description_hash": "sha256:1a4030f710f75b6d7cabddbde5a774ca541450f7a8dcb5f46fcbf9a119dd6de7", + "version": "0.1.0", + "permission_scope": "safety-state:read", + "egress_destinations": [] + }, + { + "tool_id": "spiffe://factory.example/tool/robot.request_motion", + "name": "robot.request_motion", + "server_id": "spiffe://factory.example/controller/robot-cell-7", + "schema_hash": "sha256:d74b377cfce397a9074d2b6b377615f32f370e218a59594346cbaadd8ca88d00", + "description_hash": "sha256:6b67d52c678da09fc6bce54367ba3bf0a44ff92f3930db8a66e3d43af7ab6975", + "version": "0.1.0", + "permission_scope": "motion:request", + "egress_destinations": [] + } + ], + "allow_dynamic_registration": false, + "rug_pull_policy": "deny-and-alert", + "bound_at": "2026-06-12T00:00:00Z" + }, + "model_identity": { + "provider": "example", + "model_id": "industrial-material-movement-planner", + "version": "development-simulation-v1", + "deployment_type": "api", + "bound_at": "2026-06-12T00:00:00Z" + } + }, + "delegation_chain": [], + "signature": { + "algorithm": "Ed25519", + "key_id": "cd3708166771d7bd74a49e8d307842c318aded09c0348688d7ac6e40952a5522", + "key_type": "software", + "signed_at": "2026-06-12T00:00:00Z", + "signed_fields": [ + "@context", + "@type", + "manifest_id", + "agent_id", + "version", + "issued_at", + "expires_at", + "issuer", + "crypto_profile", + "artifacts", + "delegation_chain", + "hitl_record" + ], + "signature_value": "zYvzfGDshwKcAApc_JWmCXigNytQDhokavCQCTUwOtsQeDLYEoPETxgii3Acz2hl7RNIH-O4FKTdtYlNXt6MBA" + } +} diff --git a/industrial-embodied-ai/agent/material_movement_agent.py b/industrial-embodied-ai/agent/material_movement_agent.py new file mode 100644 index 0000000..0e41387 --- /dev/null +++ b/industrial-embodied-ai/agent/material_movement_agent.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Run the industrial material-movement scenarios through a live cMCP Runtime.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +from typing import Any + +import httpx + +from cmcp_verify import ApprovedHashes, verify_audit_bundle, verify_trace_claim + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "industrial-material-movement" + + +def _headers() -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if token := os.environ.get("CMCP_BEARER_TOKEN"): + headers["Authorization"] = f"Bearer {token}" + return headers + + +def call_tool( + client: httpx.Client, + gateway: str, + tool_name: str, + arguments: dict[str, Any], + request_id: int, + *, + workflow_id: str = WORKFLOW_ID, +) -> dict[str, Any]: + response = client.post( + f"{gateway}/mcp", + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": workflow_id}, + }, + }, + timeout=30, + ) + body = response.json() + if "error" in body: + return { + "ok": False, + "status_code": response.status_code, + "error": body["error"], + } + + result = body["result"] + text = result.get("content", [{}])[0].get("text", "{}") + return { + "ok": True, + "payload": json.loads(text), + "session_id": result.get("_cmcp", {}).get("session_id"), + "cmcp": result.get("_cmcp", {}), + } + + +def _read_state( + client: httpx.Client, + gateway: str, + request_id: int, +) -> dict[str, Any]: + result = call_tool( + client, + gateway, + "cell.read_safety_state", + {}, + request_id, + ) + if not result["ok"]: + raise RuntimeError(f"Safety-state read failed: {result['error']}") + return result + + +def _request_motion( + client: httpx.Client, + gateway: str, + request_id: int, + snapshot: dict[str, Any], + motion_id: str, +) -> dict[str, Any]: + return call_tool( + client, + gateway, + "robot.request_motion", + { + "motion_id": motion_id, + "target": "transfer-station-b", + "max_speed_mps": 0.3, + "safety_state_token": snapshot["state_token"], + }, + request_id, + ) + + +def _save_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n") + + +def _verify_evidence( + claim: dict[str, Any], + bundle: dict[str, Any], + *, + require_hardware: bool, +) -> None: + expected = json.loads((EXAMPLE_DIR / "artifact-hashes.json").read_text()) + result = verify_trace_claim( + claim, + ApprovedHashes( + policy_bundle_hash=expected["cmcp_policy_bundle_hash"], + tool_catalog_hash=expected["cmcp_catalog_hash"], + ), + ) + bundle_result = verify_audit_bundle(bundle, claim) + required = { + "schema", + "signature", + "policy_bundle.hash", + "tool_catalog.hash", + "attestation_freshness", + "audit_chain", + } + missing = sorted(required - set(result.verified_fields)) + + print("TRACE VERIFICATION") + print(f" schema/signature/hashes/freshness: {'verified' if not missing else 'failed'}") + print(f" audit bundle: {'verified' if bundle_result.verified else 'failed'}") + platform = claim.get("trace", {}).get("runtime", {}).get("platform", "unknown") + print(f" runtime platform: {platform}") + if platform == "software-only": + print(" hardware attestation: not verified (development mode)") + if require_hardware: + missing.append("hardware_attestation") + else: + hardware_verified = "hardware_attestation" in result.verified_fields + print(f" hardware attestation: {'verified' if hardware_verified else 'failed'}") + if require_hardware and not hardware_verified: + missing.append("hardware_attestation") + + if bundle_result.failures: + print(f" audit failures: {'; '.join(bundle_result.failures)}") + if missing or not bundle_result.verified: + raise RuntimeError( + "Evidence verification failed: " + + ", ".join(missing or bundle_result.failures) + ) + + +def run( + gateway: str, + claim_out: Path, + audit_out: Path, + *, + require_hardware: bool, + print_claim: bool, +) -> None: + session_id: str | None = None + with httpx.Client(headers=_headers()) as client: + print("SUCCESS") + state = _read_state(client, gateway, 1) + session_id = state["session_id"] + success = _request_motion( + client, + gateway, + 2, + state["payload"], + "move-0001", + ) + if not success["ok"]: + raise RuntimeError(f"Success path failed: {success['error']}") + if ( + success["payload"].get("controller_decision") != "accepted" + or success["payload"].get("execution_status") != "completed" + ): + raise RuntimeError("Controller did not complete the expected safe motion") + print(" cMCP policy: authorized") + print(f" controller: {success['payload']['controller_decision']}") + print(f" execution: {success['payload']['execution_status']}") + print() + + print("POLICY DENY") + denied = call_tool( + client, + gateway, + "robot.request_motion", + { + "motion_id": "move-0002", + "target": "transfer-station-b", + "max_speed_mps": 0.2, + "safety_state_token": "not-forwarded", + }, + 3, + workflow_id="unapproved-diagnostics", + ) + if denied["ok"] or denied["status_code"] != 403: + raise RuntimeError("Unapproved workflow was not denied by cMCP") + print(" cMCP policy: denied") + print(" controller: not invoked") + print() + + print("SAFETY REJECT") + state = _read_state(client, gateway, 4) + rejected = _request_motion( + client, + gateway, + 5, + state["payload"], + "move-0003", + ) + if not rejected["ok"]: + raise RuntimeError(f"Safety-reject path failed: {rejected['error']}") + if rejected["payload"].get("controller_decision") != "rejected": + raise RuntimeError("Controller unexpectedly accepted the unsafe request") + print(" cMCP policy: authorized") + print(" controller: rejected") + print(f" reason: {rejected['payload']['reason']}") + print(" execution: not_started") + print() + + close = client.post( + f"{gateway}/sessions/{session_id}/close", + timeout=10, + ) + close.raise_for_status() + claim = close.json() + + audit = client.get( + f"{gateway}/audit/export", + params={"session_id": session_id}, + timeout=10, + ) + audit.raise_for_status() + bundle = audit.json() + + _save_json(claim_out, claim) + _save_json(audit_out, bundle) + _verify_evidence(claim, bundle, require_hardware=require_hardware) + print(f" claim: {claim_out}") + print(f" audit: {audit_out}") + if print_claim: + print() + print(json.dumps(claim, indent=2)) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run governed robot motion scenarios through cMCP" + ) + parser.add_argument("--gateway", default=DEFAULT_GATEWAY) + parser.add_argument( + "--claim-out", + type=Path, + default=EXAMPLE_DIR / "trace-output" / "latest-trust-record.json", + ) + parser.add_argument( + "--audit-out", + type=Path, + default=EXAMPLE_DIR / "trace-output" / "latest-audit-bundle.json", + ) + parser.add_argument("--require-hardware", action="store_true") + parser.add_argument("--print-claim", action="store_true") + args = parser.parse_args() + run( + args.gateway, + args.claim_out, + args.audit_out, + require_hardware=args.require_hardware, + print_claim=args.print_claim, + ) + + +if __name__ == "__main__": + main() diff --git a/industrial-embodied-ai/artifact-hashes.json b/industrial-embodied-ai/artifact-hashes.json new file mode 100644 index 0000000..83f0c7a --- /dev/null +++ b/industrial-embodied-ai/artifact-hashes.json @@ -0,0 +1,6 @@ +{ + "cmcp_catalog_hash": "sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb", + "cmcp_policy_bundle_hash": "sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9", + "agent_manifest_tool_catalog_root": "sha256:3c7460301bbf38612b492de4a2095ccf40b7d921bdc9ce96e8ea0f50068d66e0", + "system_prompt_hash": "sha256:9b364864a1d6682f56f572052dd7a0701b35d9726046bcb06d5fa5d87b49a70f" +} diff --git a/industrial-embodied-ai/artifacts/system-prompt.txt b/industrial-embodied-ai/artifacts/system-prompt.txt new file mode 100644 index 0000000..734655b --- /dev/null +++ b/industrial-embodied-ai/artifacts/system-prompt.txt @@ -0,0 +1,7 @@ +You are a material-movement planning agent for a synthetic industrial robot cell. + +You may read the cell safety state and request movement only through approved +tools. You must never claim that policy authorization proves a motion is safe. +The independent safety controller remains authoritative and may reject any +request. Report controller rejection or execution failure without retrying +unless a new approved workflow explicitly permits another attempt. diff --git a/industrial-embodied-ai/catalog.json b/industrial-embodied-ai/catalog.json new file mode 100644 index 0000000..9d9ac55 --- /dev/null +++ b/industrial-embodied-ai/catalog.json @@ -0,0 +1,150 @@ +[ + { + "tool_name": "cell.read_safety_state", + "server": { + "display_name": "Independent Robot Cell Safety Controller (mock)", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read an authenticated, short-lived safety-state snapshot from the independent robot cell controller.", + "input_schema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "controller_id": { + "type": "string" + }, + "sequence": { + "type": "integer" + }, + "observed_at_ms": { + "type": "integer" + }, + "operating_mode": { + "type": "string" + }, + "emergency_stop_active": { + "type": "boolean" + }, + "protective_stop_active": { + "type": "boolean" + }, + "human_detected": { + "type": "boolean" + }, + "state_token": { + "type": "string" + } + }, + "required": [ + "controller_id", + "sequence", + "observed_at_ms", + "operating_mode", + "emergency_stop_active", + "protective_stop_active", + "human_detected", + "state_token" + ], + "additionalProperties": false + } + }, + "definition_hash": "sha256:c17915c854b0da7a6bc4b1fd7388914fb3783a7f333d558525a38568401af990", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-11T00:00:00Z", + "approved_by": "community-example@agentrust.io" + }, + { + "tool_name": "robot.request_motion", + "server": { + "display_name": "Independent Robot Cell Safety Controller (mock)", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Request a bounded material-movement operation. The independent controller may reject the request regardless of cMCP authorization.", + "input_schema": { + "type": "object", + "properties": { + "motion_id": { + "type": "string" + }, + "target": { + "type": "string", + "enum": [ + "buffer-zone-1", + "transfer-station-b" + ] + }, + "max_speed_mps": { + "type": "number", + "minimum": 0, + "maximum": 0.5 + }, + "safety_state_token": { + "type": "string" + } + }, + "required": [ + "motion_id", + "target", + "max_speed_mps", + "safety_state_token" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "controller_id": { + "type": "string" + }, + "motion_id": { + "type": "string" + }, + "request_digest": { + "type": "string" + }, + "controller_decision": { + "type": "string", + "enum": [ + "accepted", + "rejected" + ] + }, + "execution_status": { + "type": "string", + "enum": [ + "not_started", + "completed", + "failed" + ] + } + }, + "required": [ + "controller_id", + "motion_id", + "request_digest", + "controller_decision", + "execution_status" + ], + "additionalProperties": false + } + }, + "definition_hash": "sha256:d65ecd2651a7ceaae0f6ef0e03ca951b2a52efea3052889899278fc6bfc067af", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-11T00:00:00Z", + "approved_by": "community-example@agentrust.io" + } +] diff --git a/industrial-embodied-ai/cmcp-config.yaml b/industrial-embodied-ai/cmcp-config.yaml new file mode 100644 index 0000000..bcdedd1 --- /dev/null +++ b/industrial-embodied-ai/cmcp-config.yaml @@ -0,0 +1,9 @@ +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 86400 + staleness_policy: fail_closed +policy_bundle_path: ./policy +catalog_path: ./catalog.json +listen_addr: 127.0.0.1:8443 +audit_db_path: ./audit.db diff --git a/industrial-embodied-ai/controller.py b/industrial-embodied-ai/controller.py new file mode 100644 index 0000000..be5ff63 --- /dev/null +++ b/industrial-embodied-ai/controller.py @@ -0,0 +1,159 @@ +"""Independent mock safety controller for the industrial example. + +This module models a controller boundary. It does not implement a safety +function, control physical hardware, or claim compliance with robot standards. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +import time +from typing import Any, Callable + + +CONTROLLER_ID = "spiffe://factory.example/controller/robot-cell-7" +MAX_STATE_AGE_MS = 5_000 +MAX_SPEED_MPS = 0.5 +ALLOWED_TARGETS = frozenset({"buffer-zone-1", "transfer-station-b"}) + + +class SafetyRejected(RuntimeError): + """The independent controller rejected physical execution.""" + + +def canonical_bytes(value: Any) -> bytes: + """Return stable JSON bytes for hashing and signing.""" + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() + + +def sha256_b64url(value: Any) -> str: + """Return a base64url-encoded SHA-256 digest.""" + return _b64url_encode(hashlib.sha256(canonical_bytes(value)).digest()) + + +def _b64url_encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +class IndependentSafetyController: + """Mock controller that remains authoritative for simulated motion.""" + + def __init__( + self, + *, + clock: Callable[[], float] = time.time, + token_key: bytes | None = None, + ) -> None: + self._clock = clock + self._token_key = token_key or secrets.token_bytes(32) + self._sequence = 0 + self._consumed_sequences: set[int] = set() + self._state = { + "operating_mode": "automatic", + "emergency_stop_active": False, + "protective_stop_active": False, + "human_detected": False, + } + + def set_state(self, **changes: Any) -> None: + unknown = set(changes) - set(self._state) + if unknown: + raise ValueError(f"Unknown controller state fields: {sorted(unknown)}") + self._state.update(changes) + + def read_safety_state(self) -> dict[str, Any]: + self._sequence += 1 + snapshot = { + "controller_id": CONTROLLER_ID, + "sequence": self._sequence, + "observed_at_ms": int(self._clock() * 1000), + **self._state, + } + payload = canonical_bytes(snapshot) + signature = hmac.new(self._token_key, payload, hashlib.sha256).digest() + return { + **snapshot, + "state_token": f"{_b64url_encode(payload)}.{_b64url_encode(signature)}", + } + + def _verify_state_token(self, token: str) -> dict[str, Any]: + try: + payload_b64, signature_b64 = token.split(".", maxsplit=1) + payload = _b64url_decode(payload_b64) + signature = _b64url_decode(signature_b64) + expected = hmac.new(self._token_key, payload, hashlib.sha256).digest() + if not hmac.compare_digest(signature, expected): + raise SafetyRejected("invalid_safety_state_token") + snapshot = json.loads(payload) + except (ValueError, json.JSONDecodeError) as exc: + raise SafetyRejected("malformed_safety_state_token") from exc + + try: + age_ms = int(self._clock() * 1000) - int(snapshot["observed_at_ms"]) + sequence = int(snapshot["sequence"]) + except (KeyError, TypeError, ValueError) as exc: + raise SafetyRejected("malformed_safety_state_token") from exc + + if age_ms < 0 or age_ms > MAX_STATE_AGE_MS: + raise SafetyRejected("stale_safety_state") + if snapshot.get("controller_id") != CONTROLLER_ID: + raise SafetyRejected("unexpected_controller_identity") + if sequence in self._consumed_sequences: + raise SafetyRejected("replayed_safety_state") + + self._consumed_sequences.add(sequence) + return snapshot + + def request_motion(self, request: dict[str, Any]) -> dict[str, Any]: + self._verify_state_token(str(request.get("safety_state_token", ""))) + + # The request token is evidence of a recent observation, not permission + # to move. The controller rechecks its authoritative current state. + if self._state["emergency_stop_active"]: + raise SafetyRejected("emergency_stop_active") + if self._state["protective_stop_active"]: + raise SafetyRejected("protective_stop_active") + if self._state["human_detected"]: + raise SafetyRejected("human_detected") + if self._state["operating_mode"] != "automatic": + raise SafetyRejected("controller_not_in_automatic_mode") + + motion_id = request.get("motion_id") + target = request.get("target") + try: + speed = float(request["max_speed_mps"]) + except (KeyError, TypeError, ValueError) as exc: + raise SafetyRejected("invalid_motion_request") from exc + + if not isinstance(motion_id, str) or not motion_id: + raise SafetyRejected("invalid_motion_request") + if not isinstance(target, str) or target not in ALLOWED_TARGETS: + raise SafetyRejected("target_outside_approved_zone") + if speed < 0 or speed > MAX_SPEED_MPS: + raise SafetyRejected("speed_exceeds_controller_limit") + + request_for_hash = { + key: value + for key, value in request.items() + if key != "safety_state_token" + } + return { + "controller_id": CONTROLLER_ID, + "motion_id": motion_id, + "request_digest": sha256_b64url(request_for_hash), + "controller_decision": "accepted", + "execution_status": "completed", + } diff --git a/industrial-embodied-ai/manifest-public-key.json b/industrial-embodied-ai/manifest-public-key.json new file mode 100644 index 0000000..626c67e --- /dev/null +++ b/industrial-embodied-ai/manifest-public-key.json @@ -0,0 +1,5 @@ +{ + "algorithm": "Ed25519", + "key_id": "cd3708166771d7bd74a49e8d307842c318aded09c0348688d7ac6e40952a5522", + "public_key_base64url": "LaAQ_icZoBzLRqqAH6yR6kbiU0ZfTc9hEpClDoVBOPs" +} diff --git a/industrial-embodied-ai/policy/allow.cedar b/industrial-embodied-ai/policy/allow.cedar new file mode 100644 index 0000000..b8089d8 --- /dev/null +++ b/industrial-embodied-ai/policy/allow.cedar @@ -0,0 +1,22 @@ +// Industrial material-movement policy bundle. +// cMCP maps cell.read_safety_state to Action::"Cell.readSafetyState" and +// robot.request_motion to Action::"Robot.requestMotion". +// No catch-all permit: only the declared workflow and tools are authorized. + +permit ( + principal, + action == Action::"Cell.readSafetyState", + resource +) when { + context has workflow_id && + context.workflow_id == "industrial-material-movement" +}; + +permit ( + principal, + action == Action::"Robot.requestMotion", + resource +) when { + context has workflow_id && + context.workflow_id == "industrial-material-movement" +}; diff --git a/industrial-embodied-ai/policy/manifest.json b/industrial-embodied-ai/policy/manifest.json new file mode 100644 index 0000000..f1a251d --- /dev/null +++ b/industrial-embodied-ai/policy/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "0.1.0", + "authored_at": "2026-06-11T00:00:00Z", + "author_identity": "community-example@agentrust.io", + "commit_sha": "development-example", + "approval_chain": [] +} diff --git a/industrial-embodied-ai/policy/schema.cedarschema b/industrial-embodied-ai/policy/schema.cedarschema new file mode 100644 index 0000000..875ae43 --- /dev/null +++ b/industrial-embodied-ai/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP":{}} diff --git a/industrial-embodied-ai/requirements.txt b/industrial-embodied-ai/requirements.txt new file mode 100644 index 0000000..c5b51bb --- /dev/null +++ b/industrial-embodied-ai/requirements.txt @@ -0,0 +1,4 @@ +agentrust-trace @ git+https://github.com/agentrust-io/trace-spec.git@6d57aa92775cf29ccde7f2c748e357a0c19db48c +cmcp-runtime @ git+https://github.com/agentrust-io/cmcp.git@6a69096f450c0227e17dc6c56acf7599cd12c9e6 +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 new file mode 100644 index 0000000..2a0dec5 --- /dev/null +++ b/industrial-embodied-ai/server/mock_robot_controller.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Mock robot-cell MCP server for the industrial embodied-AI example.""" + +from __future__ import annotations + +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +from controller import IndependentSafetyController, SafetyRejected # noqa: E402 + + +PORT = 8080 +controller = IndependentSafetyController( + token_key=b"development-only-mock-controller-key" +) +completed_motions = 0 + + +def _read_safety_state(arguments: dict[str, Any]) -> dict[str, Any]: + del arguments + snapshot = controller.read_safety_state() + + # After the first successful motion, the next read represents a person + # entering the cell after observation but before the motion request. + if completed_motions > 0: + controller.set_state(human_detected=True) + print("[mock-controller] environment changed: human_detected=true") + return snapshot + + +def _request_motion(arguments: dict[str, Any]) -> dict[str, Any]: + global completed_motions + try: + result = controller.request_motion(arguments) + completed_motions += 1 + return result + except SafetyRejected as exc: + reason = str(exc) + result = { + "controller_id": "spiffe://factory.example/controller/robot-cell-7", + "motion_id": arguments.get("motion_id"), + "controller_decision": "rejected", + "execution_status": "not_started", + "reason": reason, + } + if reason == "human_detected": + # Reset only the synthetic stage condition so the demo is repeatable. + controller.set_state(human_detected=False) + completed_motions = 0 + return result + + +TOOLS = { + "cell.read_safety_state": _read_safety_state, + "robot.request_motion": _request_motion, +} + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + + try: + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + except (ValueError, json.JSONDecodeError): + self._reply(400, {"error": "invalid JSON"}) + return + + params = request.get("params", {}) + tool_name = params.get("name", "") + handler = TOOLS.get(tool_name) + if handler is None: + self._reply( + 200, + { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": { + "code": -32601, + "message": f"unknown tool: {tool_name}", + }, + }, + ) + return + + result = handler(params.get("arguments", {})) + self._reply( + 200, + { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": { + "content": [ + { + "type": "text", + "text": json.dumps(result, sort_keys=True), + } + ] + }, + }, + ) + + def _reply(self, status: int, body: dict[str, Any]) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt: str, *args: Any) -> None: + print(f"[mock-controller] {fmt % args}") + + +if __name__ == "__main__": + print( + "Mock Robot Cell MCP Server listening on :8080 " + f"(tools: {', '.join(TOOLS)})" + ) + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/industrial-embodied-ai/tests/test_controller.py b/industrial-embodied-ai/tests/test_controller.py new file mode 100644 index 0000000..7ce236b --- /dev/null +++ b/industrial-embodied-ai/tests/test_controller.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +from controller import IndependentSafetyController, SafetyRejected # noqa: E402 + + +class MutableClock: + def __init__(self, value: float = 1_781_179_200.0) -> None: + self.value = value + + def __call__(self) -> float: + return self.value + + +class IndependentSafetyControllerTests(unittest.TestCase): + def setUp(self) -> None: + self.clock = MutableClock() + self.controller = IndependentSafetyController( + clock=self.clock, + token_key=b"unit-test-only-controller-key", + ) + + def request(self, token: str, **overrides: object) -> dict[str, object]: + request: dict[str, object] = { + "motion_id": "move-0001", + "target": "buffer-zone-1", + "max_speed_mps": 0.2, + "safety_state_token": token, + } + request.update(overrides) + return request + + def test_safe_motion_is_accepted(self) -> None: + snapshot = self.controller.read_safety_state() + result = self.controller.request_motion( + self.request(snapshot["state_token"]) + ) + self.assertEqual(result["controller_decision"], "accepted") + self.assertEqual(result["execution_status"], "completed") + + def test_controller_rechecks_current_state(self) -> None: + snapshot = self.controller.read_safety_state() + self.controller.set_state(human_detected=True) + with self.assertRaisesRegex(SafetyRejected, "human_detected"): + self.controller.request_motion( + self.request(snapshot["state_token"]) + ) + + def test_stale_state_token_is_rejected(self) -> None: + snapshot = self.controller.read_safety_state() + self.clock.value += 5.001 + with self.assertRaisesRegex(SafetyRejected, "stale_safety_state"): + self.controller.request_motion( + self.request(snapshot["state_token"]) + ) + + def test_modified_state_token_is_rejected(self) -> None: + snapshot = self.controller.read_safety_state() + token = snapshot["state_token"] + replacement = "A" if token[-1] != "A" else "B" + with self.assertRaisesRegex( + SafetyRejected, + "invalid_safety_state_token", + ): + self.controller.request_motion( + self.request(token[:-1] + replacement) + ) + + def test_state_token_cannot_be_replayed(self) -> None: + snapshot = self.controller.read_safety_state() + request = self.request(snapshot["state_token"]) + self.controller.request_motion(request) + with self.assertRaisesRegex(SafetyRejected, "replayed_safety_state"): + self.controller.request_motion( + {**request, "motion_id": "move-0002"} + ) + + def test_speed_limit_is_controller_authoritative(self) -> None: + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex( + SafetyRejected, + "speed_exceeds_controller_limit", + ): + self.controller.request_motion( + self.request(snapshot["state_token"], max_speed_mps=0.8) + ) + + def test_target_must_be_in_approved_zone(self) -> None: + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex( + SafetyRejected, + "target_outside_approved_zone", + ): + self.controller.request_motion( + self.request(snapshot["state_token"], target="loading-dock") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/industrial-embodied-ai/trace-output/example-audit-bundle.json b/industrial-embodied-ai/trace-output/example-audit-bundle.json new file mode 100644 index 0000000..70fb388 --- /dev/null +++ b/industrial-embodied-ai/trace-output/example-audit-bundle.json @@ -0,0 +1,160 @@ +{ + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "entries": [ + { + "entry_id": "4ad2ba95-1bdf-4172-95e5-5b3b32c82db9", + "sequence_number": 0, + "timestamp_utc": "2026-06-11T22:40:55.477088+00:00", + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "call_id": null, + "entry_type": "session_start", + "tool_name": null, + "server_identity": null, + "policy_decision": "n/a", + "policy_rule_matched": null, + "latency_us": null, + "request_payload_hash": null, + "response_payload_hash": null, + "response_inspection_result": "n/a", + "session_sensitivity_before": null, + "session_sensitivity_after": "public", + "detail": null, + "workflow_id": null, + "prev_entry_hash": "genesis", + "entry_hash": "fd9e4d49b299f3088024a69ed1fc1e3d288593c62186ab4e1d8cb152ef226d61" + }, + { + "entry_id": "9aa358b5-a3c3-4568-8cd3-add87dd6bb45", + "sequence_number": 1, + "timestamp_utc": "2026-06-11T22:41:00.509721+00:00", + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "call_id": "a5db9210-0d8f-42b8-accb-1376da5a9dd0", + "entry_type": "tool_call", + "tool_name": "cell.read_safety_state", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 26832, + "request_payload_hash": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "public", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "fd9e4d49b299f3088024a69ed1fc1e3d288593c62186ab4e1d8cb152ef226d61", + "entry_hash": "6a52c7f67d06c65c960e79bc74873a0fa10cfc006c764d0ef3c1822beafe591c" + }, + { + "entry_id": "000236bc-e37b-4e17-9f27-956707263512", + "sequence_number": 2, + "timestamp_utc": "2026-06-11T22:41:00.513614+00:00", + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "call_id": "244fcfe4-3be8-45e4-ae19-297f13084330", + "entry_type": "tool_call", + "tool_name": "robot.request_motion", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 2273, + "request_payload_hash": "sha256:f4ebe51008ab5b1288beccb5b17a16e0f2fbb5bd644c8902859a261e2d92e426", + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "6a52c7f67d06c65c960e79bc74873a0fa10cfc006c764d0ef3c1822beafe591c", + "entry_hash": "e8368d7e9fd8e54f233f402a63ee4dfc839931b2d455f6bb45e6a191b3f83137" + }, + { + "entry_id": "8e52cfae-1ebe-40bc-ba99-ae934dae68ec", + "sequence_number": 3, + "timestamp_utc": "2026-06-11T22:41:00.514876+00:00", + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "call_id": "0fab65d6-6867-44ff-94c5-c0c585d7e855", + "entry_type": "tool_call", + "tool_name": "robot.request_motion", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "deny", + "policy_rule_matched": "Policy denied tool call: robot.request_motion", + "latency_us": null, + "request_payload_hash": "sha256:338998c57bf57fcd05cea50a8d463d39f81ccb184ddad984a31fb7dab162f5ce", + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "unapproved-diagnostics", + "prev_entry_hash": "e8368d7e9fd8e54f233f402a63ee4dfc839931b2d455f6bb45e6a191b3f83137", + "entry_hash": "6b2ef02224320d6146c269b8d9817a82b72623350a0aa0845b1aab4023f34e23" + }, + { + "entry_id": "af8838a7-7bce-4c03-8352-9ed55ecf0868", + "sequence_number": 4, + "timestamp_utc": "2026-06-11T22:41:00.517758+00:00", + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "call_id": "decf2427-c918-4a17-a677-88f049d8e2cb", + "entry_type": "tool_call", + "tool_name": "cell.read_safety_state", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 1985, + "request_payload_hash": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "6b2ef02224320d6146c269b8d9817a82b72623350a0aa0845b1aab4023f34e23", + "entry_hash": "d8a7985af6b52b903eb11664fe16e29fc78759d2e947f287dc73fb46bf16e181" + }, + { + "entry_id": "0f65e244-2329-4f7e-a0d0-181252fe52a2", + "sequence_number": 5, + "timestamp_utc": "2026-06-11T22:41:00.520467+00:00", + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "call_id": "e030ccfc-6ec8-4454-9305-1b5eb48adb0b", + "entry_type": "tool_call", + "tool_name": "robot.request_motion", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 1773, + "request_payload_hash": "sha256:c01b77fa8da1e5875b3b173043d0f4ea1cbf82b1eae2cb8c5547178effb2d3ff", + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "d8a7985af6b52b903eb11664fe16e29fc78759d2e947f287dc73fb46bf16e181", + "entry_hash": "214a2ce17f8b0333c2b543493f61f21b205ccc8faa594b877a34108e83d25cd8" + }, + { + "entry_id": "c8cc8737-3689-4be5-8b61-09b13e46bbbc", + "sequence_number": 6, + "timestamp_utc": "2026-06-11T22:41:00.521203+00:00", + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "call_id": null, + "entry_type": "session_end", + "tool_name": null, + "server_identity": null, + "policy_decision": null, + "policy_rule_matched": null, + "latency_us": null, + "request_payload_hash": null, + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": null, + "prev_entry_hash": "214a2ce17f8b0333c2b543493f61f21b205ccc8faa594b877a34108e83d25cd8", + "entry_hash": "278f15cce18a1fcb6a22c121facdab23060c700f7cff55ea622ecc8701c9ed7c" + } + ], + "bundle_signature": "l6En40w_6kLkxc4h5KjOna7SdOvCs3DhWeqriz1qFNr4RaxIVg2_5728A-D8PcThd4t8ce777pGb4XixoIPqAw" +} diff --git a/industrial-embodied-ai/trace-output/example-trust-record.json b/industrial-embodied-ai/trace-output/example-trust-record.json new file mode 100644 index 0000000..0a95ab7 --- /dev/null +++ b/industrial-embodied-ai/trace-output/example-trust-record.json @@ -0,0 +1,76 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781217660, + "subject": "spiffe://cmcp.gateway/session/fdbdd187-b276-4c71-8e08-50f839d13bd1", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9", + "enforcement_mode": "enforce", + "version": "0.1.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:278f15cce18a1fcb6a22c121facdab23060c700f7cff55ea622ecc8701c9ed7c", + "call_count": 5 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "ebBLjGSAJYOTs9npzclQLGqGwZw_2fnG5YZ-Sbc0l54", + "kid": "cmcp-79b04b8c" + } + } + }, + "gateway": { + "session_id": "fdbdd187-b276-4c71-8e08-50f839d13bd1", + "gateway_version": "0.1.0", + "sequence_number": 1, + "audit_chain": { + "root": "fd9e4d49b299f3088024a69ed1fc1e3d288593c62186ab4e1d8cb152ef226d61", + "tip": "278f15cce18a1fcb6a22c121facdab23060c700f7cff55ea622ecc8701c9ed7c", + "length": 7 + }, + "call_summary": { + "tool_calls_total": 5, + "tool_calls_allowed": 4, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "cell.read_safety_state", + "robot.request_motion" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb", + "drift_detected": false + }, + "attestation_generated_at": "2026-06-11T22:40:55.264023+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 5, + "tools_called": [ + "cell.read_safety_state", + "robot.request_motion" + ], + "suspicious_sequences_detected": 0 + } + }, + "signature": "i3w2GxrsrGR9pHwO-VLeopxG9WTQTCVpy212-alhZB7bI8jxcH9mpW-AcKaKAu_TQTuGr-50IJo4gGitIKG6Dw" +} diff --git a/industrial-embodied-ai/validate_artifacts.py b/industrial-embodied-ai/validate_artifacts.py new file mode 100644 index 0000000..bcf4613 --- /dev/null +++ b/industrial-embodied-ai/validate_artifacts.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Validate the committed industrial example artifacts.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +from typing import Any + +from cmcp_runtime.config import load_config +from cmcp_verify import ApprovedHashes, verify_audit_bundle, verify_trace_claim +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + +BASE = Path(__file__).resolve().parent + + +def canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() + + +def hash_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def compute_policy_bundle_hash() -> str: + policy_dir = BASE / "policy" + manifest = json.loads((policy_dir / "manifest.json").read_text()) + policy_hashes = { + path.relative_to(policy_dir).as_posix(): hashlib.sha256( + path.read_text().encode() + ).hexdigest() + for path in sorted(policy_dir.glob("**/*.cedar")) + } + body = { + "manifest": manifest, + "policy_files": policy_hashes, + "schema_hash": hashlib.sha256( + (policy_dir / "schema.cedarschema").read_text().encode() + ).hexdigest(), + } + return hash_bytes(canonical_bytes(body)) + + +def compute_cmcp_catalog_hash(catalog: list[dict[str, Any]]) -> str: + return hash_bytes( + canonical_bytes(sorted(catalog, key=lambda entry: entry["tool_name"])) + ) + + +def compute_manifest_catalog_root(tools: list[dict[str, Any]]) -> str: + leaves = [] + for tool in sorted(tools, key=lambda item: item["tool_id"]): + preimage = ( + tool["tool_id"].encode() + + b"\x00" + + bytes.fromhex(tool["schema_hash"].split(":", maxsplit=1)[1]) + + bytes.fromhex(tool["description_hash"].split(":", maxsplit=1)[1]) + ) + leaves.append(hashlib.sha256(b"\x00" + preimage).digest()) + + def merkle_tree_hash(nodes: list[bytes]) -> bytes: + if len(nodes) == 1: + return nodes[0] + split = 1 + while split < len(nodes): + split <<= 1 + split >>= 1 + return hashlib.sha256( + b"\x01" + + merkle_tree_hash(nodes[:split]) + + merkle_tree_hash(nodes[split:]) + ).digest() + + return "sha256:" + merkle_tree_hash(leaves).hex() + + +def verify_manifest_signature(manifest: dict[str, Any]) -> None: + public_key = json.loads((BASE / "manifest-public-key.json").read_text()) + assert manifest["signature"]["key_id"] == public_key["key_id"] + signed_fields = manifest["signature"]["signed_fields"] + body = {key: manifest[key] for key in signed_fields if key in manifest} + Ed25519PublicKey.from_public_bytes( + b64url_decode(public_key["public_key_base64url"]) + ).verify( + b64url_decode(manifest["signature"]["signature_value"]), + canonical_bytes(body), + ) + + +def main() -> None: + expected = json.loads((BASE / "artifact-hashes.json").read_text()) + catalog = json.loads((BASE / "catalog.json").read_text()) + manifest = json.loads((BASE / "agent-manifest.json").read_text()) + claim = json.loads( + (BASE / "trace-output/example-trust-record.json").read_text() + ) + audit_bundle = json.loads( + (BASE / "trace-output/example-audit-bundle.json").read_text() + ) + + load_config(BASE / "cmcp-config.yaml") + + for entry in catalog: + definition_hash = hash_bytes( + canonical_bytes(entry["approved_definition"]) + ) + assert definition_hash == entry["definition_hash"], entry["tool_name"] + + policy_hash = compute_policy_bundle_hash() + catalog_hash = compute_cmcp_catalog_hash(catalog) + manifest_catalog_root = compute_manifest_catalog_root( + manifest["artifacts"]["tool_manifest"]["tools"] + ) + prompt_hash = hash_bytes( + (BASE / "artifacts/system-prompt.txt").read_bytes() + ) + + assert expected["cmcp_policy_bundle_hash"] == policy_hash + assert expected["cmcp_catalog_hash"] == catalog_hash + assert expected["agent_manifest_tool_catalog_root"] == manifest_catalog_root + assert expected["system_prompt_hash"] == prompt_hash + assert manifest["artifacts"]["policy_bundle"]["hash"] == policy_hash + assert ( + manifest["artifacts"]["tool_manifest"]["catalog_hash"] + == manifest_catalog_root + ) + assert manifest["artifacts"]["system_prompt"]["hash"] == prompt_hash + verify_manifest_signature(manifest) + + verification = verify_trace_claim( + claim, + ApprovedHashes( + policy_bundle_hash=policy_hash, + tool_catalog_hash=catalog_hash, + ), + # The committed fixture is expected to outlive the default 24-hour + # online freshness window. Live runs still use the default window. + max_attestation_age_seconds=315_360_000, + ) + required = { + "schema", + "signature", + "policy_bundle.hash", + "tool_catalog.hash", + "attestation_freshness", + "audit_chain", + } + assert required <= set(verification.verified_fields) + assert claim["trace"]["runtime"]["platform"] == "software-only" + + bundle_verification = verify_audit_bundle(audit_bundle, claim) + assert bundle_verification.verified, bundle_verification.failures + + print("Configuration and artifact hashes: valid") + print("Agent Manifest signature: valid") + print("Runtime-issued TRACE signature and audit bundle: valid") + print("Hardware attestation: not present in committed development fixture") + + +if __name__ == "__main__": + main() From 1b043294c37d6979d2c4a930eceef10637120c66 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 11 Jun 2026 16:47:33 -0700 Subject: [PATCH 2/2] fix: pin LF line endings for hash-bound artifacts core.autocrlf=true on Windows rewrites the system prompt to CRLF at checkout, which breaks the system_prompt_hash check in validate_artifacts.py. Mark hash-bound files as binary-equivalent so they check out byte-identical everywhere. Co-Authored-By: Claude Fable 5 --- industrial-embodied-ai/.gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 industrial-embodied-ai/.gitattributes diff --git a/industrial-embodied-ai/.gitattributes b/industrial-embodied-ai/.gitattributes new file mode 100644 index 0000000..454aa45 --- /dev/null +++ b/industrial-embodied-ai/.gitattributes @@ -0,0 +1,6 @@ +# Hash-bound artifacts must check out byte-identical on every platform. +# core.autocrlf=true otherwise rewrites LF to CRLF on Windows and breaks +# the system_prompt_hash check in validate_artifacts.py. +artifacts/** -text +artifact-hashes.json -text +trace-output/*.json -text