From 251c69937e546b9444589d4fa52e0d051811cef4 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 11 Jun 2026 21:24:51 -0700 Subject: [PATCH] fix: restore the runnable-demos stack lost in the stacked-PR merge PRs #11, #13, and #14 were stacked on #10. When #10 squash-merged and its branch was deleted, GitHub closed/merged the rest of the stack into the deleted feature branches, so their content never reached main: financial and healthcare shipped the unparseable advice{} Cedar policies, agents called endpoints that do not exist, the mock servers and the cmcp verify tamper demo were missing. This restores the verified stack tip for the four original examples plus .gitignore, with em dashes scrubbed per repo style, and merges the root README: corrected table and 3-terminal quickstart from the stack, plus the industrial-embodied-ai row with the #18 wording. Verified before commit: all Cedar bundles parse and produce the documented decisions (workflow-scoped allow, escalation/HITL deny, default deny), and all catalog entries validate against the cmcp schema. Co-Authored-By: Claude Fable 5 --- .gitignore | 6 + README.md | 27 +- financial-services/README.md | 694 +++++------------- financial-services/agent/credit_risk_agent.py | 173 ++--- financial-services/catalog.json | 4 +- financial-services/policy/allow.cedar | 71 +- financial-services/server/mock_mcp_server.py | 96 +++ .../trace-output/example-trust-record.json | 111 ++- healthcare/README.md | 508 +++++-------- healthcare/agent/clinical_decision_agent.py | 177 ++--- healthcare/policy/allow.cedar | 71 +- healthcare/server/mock_mcp_server.py | 98 +++ .../trace-output/example-trust-record.json | 111 ++- multi-tenant-saas/README.md | 436 ++++------- multi-tenant-saas/agent/saas_agent.py | 176 ++--- multi-tenant-saas/catalog.json | 4 +- multi-tenant-saas/cmcp-config-acme-corp.yaml | 4 +- multi-tenant-saas/server/mock_mcp_server.py | 92 +++ .../tenants/acme-corp/policy/allow.cedar | 43 +- .../globex-financial/policy/allow.cedar | 62 +- .../trace-output/acme-corp-example.json | 116 ++- .../globex-financial-example.json | 117 ++- startup-tpm/README.md | 511 +++++++------ startup-tpm/agent/echo_agent.py | 78 +- startup-tpm/catalog.json | 2 +- startup-tpm/server/mock_mcp_server.py | 54 ++ .../trace-output/example-trust-record.json | 75 ++ 27 files changed, 2002 insertions(+), 1915 deletions(-) create mode 100644 .gitignore create mode 100644 financial-services/server/mock_mcp_server.py create mode 100644 healthcare/server/mock_mcp_server.py create mode 100644 multi-tenant-saas/server/mock_mcp_server.py create mode 100644 startup-tpm/server/mock_mcp_server.py create mode 100644 startup-tpm/trace-output/example-trust-record.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cbac9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Demo runtime artifacts +*.db +*.db-shm +*.db-wal +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 7666031..9b0a007 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,32 @@ End-to-end integration examples showing cMCP, Agent Manifest, and TRACE working | Example | What it shows | Platform | Compliance | |---|---|---|---| -| `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 | +| `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 | -| `multi-tenant-saas/` | SaaS platform with per-tenant policy isolation | TDX | Customer contract SLA | +| `multi-tenant-saas/` | Per-tenant Cedar policy bundles and enforcement modes (advisory vs enforcing) | TDX | GDPR Art. 6, customer contract SLA | | `startup-tpm/` | 15-minute quickstart on any cloud VM with Trusted Launch | TPM 2.0 | Development / staging | -## Quickstart +Each example is fully runnable with no external dependencies: it ships a mock upstream MCP server, an agent script, an attested tool catalog, and a Cedar policy bundle, and ends by printing the signed TRACE Trust Record for the session. The `trace-output/` files in each example are captured from real runs. -The fastest path: any Azure, AWS, or GCP VM with Trusted Launch enabled. +## Quickstart ```bash -pip install cmcp-runtime agent-manifest -cp examples/startup-tpm/cmcp-config.yaml . -cmcp start --config cmcp-config.yaml --enforcement advisory +git clone https://github.com/agentrust-io/examples.git +cd examples/startup-tpm +pip install cmcp-runtime httpx + +# Terminal 1: mock upstream MCP server +python server/mock_mcp_server.py + +# Terminal 2: the runtime (CMCP_DEV_MODE=1 for machines without a TPM/TEE) +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml + +# Terminal 3: one tool call + signed TRACE Trust Record +python agent/echo_agent.py ``` -This starts the runtime in advisory mode (no blocking, full logging) and emits a TRACE Trust Record for every MCP tool call. +See `startup-tpm/README.md` for the full walkthrough. ## Prerequisites diff --git a/financial-services/README.md b/financial-services/README.md index 38fd375..de68ca5 100644 --- a/financial-services/README.md +++ b/financial-services/README.md @@ -1,491 +1,203 @@ -# financial-services: EU Credit Risk Agent Demo - -End-to-end demo of a credit risk agent processing client financial documents through a cMCP Runtime with Cedar policy enforcement and TRACE Trust Records for EU regulatory compliance (EU AI Act, MiFID II, DORA, GDPR). - -End-to-end example: AI agent compliance for European private banks using cMCP and TRACE attestation. - ---- - -## What the demo shows - -This example demonstrates: - -**1. Cryptographic proof of which tools an AI agent called** -The cMCP Runtime intercepts every MCP tool call and records it in a signed TRACE Trust Record. An auditor or regulator can verify after the fact exactly which tools ran, in what order, with what data classifications, without trusting the agent process itself. - -**2. Cedar policy as machine-readable compliance** -The three Cedar rules in `policy/allow.cedar` encode the bank's compliance requirements directly: which workflows may call which tools, when a large credit recommendation must go to a human reviewer, and how to prevent accidental data-class downgrade. Policy-as-code means the same rules that block a call are the rules that go into the audit file. - -**3. EU AI Act Article 12 transparency obligation** -Article 12 requires high-risk AI systems to automatically log sufficient information to enable post-hoc monitoring. The TRACE Trust Record is that log. It covers the model identity, the policy version that was enforced, and the full tool call transcript with per-call data-class labels. - -**4. MiFID II suitability and audit trail** -MiFID II Article 25 requires that investment firms document the basis for any investment recommendation. For an AI-assisted credit decision, the TRACE record provides the tool-call audit trail showing that credit bureau data was consulted and a human reviewer was required for exposures above €500k. - -**5. DORA Article 9 ICT risk: immutable logs** -The runtime runs in an attested environment (TEE or TPM). The TRACE record is signed by the runtime's attestation key. If a log is tampered with, the signature verification fails. - -**6. GDPR data minimisation in tool definitions** -The catalog schema enforces `sensitivity_level` and `compliance_domain` on every tool. The Cedar policy forbids confidential-data tools if the session sensitivity has been downgraded to `public`. This is the machine-enforceable equivalent of the GDPR data-minimisation principle. - ---- - -## Architecture - -``` - ┌─────────────────────────────────────────────────────────────────┐ - │ Credit Risk Agent (LLM) │ - │ credit_risk_agent.py - JSON-RPC 2.0 over HTTP │ - └──────────────────────────┬──────────────────────────────────────┘ - │ tools/call (MCP) - ▼ - ┌─────────────────────────────────────────────────────────────────┐ - │ cMCP Runtime :8443 │ - │ │ - │ ┌──────────────┐ ┌─────────────────┐ ┌───────────────────┐ │ - │ │ Cedar engine │ │ Catalog checker │ │ TRACE recorder │ │ - │ │ allow.cedar │ │ catalog.json │ │ /trace endpoint │ │ - │ └──────────────┘ └─────────────────┘ └───────────────────┘ │ - └──────────────────────────┬──────────────────────────────────────┘ - │ proxied tool call - ▼ - ┌─────────────────────────────────────────────────────────────────┐ - │ EU Credit Risk MCP Server :8080 │ - │ finance.document_reader │ - │ finance.credit_score_lookup │ - │ finance.risk_report_writer │ - └─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Prerequisites - -| Requirement | Version | Notes | -|---|---|---| -| Python | 3.11+ | `python3 --version` | -| pip | any recent | `pip --version` | -| httpx | 0.27+ | installed by `pip install cmcp-runtime` | -| cmcp-runtime | latest | `pip install cmcp-runtime` | -| agent-manifest | latest | `pip install agent-manifest` | -| curl | any | For verification steps | - -No hardware TEE or TPM is required for this demo. The runtime runs in `CMCP_DEV_MODE=1`. - ---- - -## Step 1 - Clone the examples repo - -```bash -git clone https://github.com/agentrust-io/examples.git -cd examples -``` - ---- - -## Step 2 - Install dependencies - -```bash -pip install cmcp-runtime agent-manifest httpx -``` - -Verify: - -```bash -cmcp --version -cmcp-verify --version -``` - ---- - -## Step 3 - Review the files - -``` -financial-services/ - cmcp-config.yaml Runtime configuration - catalog.json Three-tool catalog - policy/ - manifest.json Policy bundle metadata - allow.cedar Four Cedar rules - schema.cedarschema Cedar schema - agent/ - credit_risk_agent.py Demo agent (run this) - trace-output/ - example-trust-record.json Reference TRACE output -``` - ---- - -## Step 4 - Understand the Cedar policy - -`policy/allow.cedar` contains four rules: - -**Rule 1 - Workflow permit** - -```cedar -permit ( - principal, - action == Action::"tool_call", - resource -) when { - context.workflow_id == "credit-risk-analyst" -}; -``` - -Only the `credit-risk-analyst` workflow may call any of the three tools. Any other workflow ID results in a deny. - -**Rule 2 - Large-exposure escalation (advisory)** - -```cedar -forbid ( - principal, - action == Action::"tool_call", - resource == Tool::"finance.risk_report_writer" -) when { - context.amount_eur > 500000 -} advice { - "reason": "human-review-required", - "escalation_threshold_eur": 500000 -}; -``` - -Any call to `finance.risk_report_writer` where `amount_eur` exceeds €500,000 triggers an advisory deny. In `enforcement_mode: enforcing` this blocks the call and returns a 403 with the advice payload. The agent script uses `amount_eur=250000` so this rule does not fire in the happy path (see "Extending this example" for how to trigger it). - -**Rule 3 - Data-class downgrade prevention** - -```cedar -forbid ( - principal, - action == Action::"tool_call", - resource -) when { - context.session_max_sensitivity == "public" && - resource.compliance_domain == "confidential" -}; -``` - -Prevents a session that has been flagged `public` from calling tools that handle confidential data. This enforces the GDPR data-minimisation principle at the runtime layer. - -**Rule 4 - Catch-all permit** - -```cedar -permit (principal, action, resource); -``` - -Any call not matched by a forbid is allowed. Removes the need to enumerate every possible action type. - ---- - -## Step 5 - Review the catalog - -`catalog.json` registers three tools with their approved definitions, data classifications, and definition hashes. The definition hash is `sha256(json.dumps(approved_definition, sort_keys=True, separators=(',',':')))`. The runtime rejects any tool call where the server returns a definition that does not match the hash, preventing prompt-injection via MCP tool description tampering. - -| Tool | compliance_domain | sensitivity_level | definition_hash (first 16 chars) | -|---|---|---|---| -| `finance.document_reader` | hipaa_phi | confidential | `sha256:75312282...` | -| `finance.credit_score_lookup` | pii | confidential | `sha256:0db5f137...` | -| `finance.risk_report_writer` | internal | internal | `sha256:b98f4fff...` | - ---- - -## Step 6 - Start the runtime - -```bash -CMCP_DEV_MODE=1 cmcp start --config financial-services/cmcp-config.yaml -``` - -Run from the root of the examples repo so relative paths in the config resolve correctly. - -Expected startup output: - -``` -[cmcp] policy bundle loaded: credit-risk-v4.2 -[cmcp] catalog loaded: 3 tools -[cmcp] finance.document_reader (confidential) -[cmcp] finance.credit_score_lookup (confidential) -[cmcp] finance.risk_report_writer (internal) -[cmcp] attestation: dev-mode (CMCP_DEV_MODE=1) -[cmcp] enforcement: enforcing -[cmcp] listening on 0.0.0.0:8443 -``` - -Leave this terminal open. - ---- - -## Step 7 - Run the mock credit risk agent - -In a second terminal: - -```bash -python financial-services/agent/credit_risk_agent.py -``` - -The agent calls the three tools in sequence: - -1. `finance.document_reader`: reads balance sheet `BS-2024-Q4` for client `EUR-2024-00847` -2. `finance.credit_score_lookup`: retrieves Equifax score for the client -3. `finance.risk_report_writer`: writes a risk score of 72.3 with recommendation `approve` and `amount_eur=250000` - -Expected output: - -``` -Connecting to cMCP gateway at http://localhost:8443 -Client: EUR-2024-00847 | Document: BS-2024-Q4 | Bureau: equifax - -[1/3] Calling finance.document_reader ... - -> decision: allow -[2/3] Calling finance.credit_score_lookup ... - -> decision: allow -[3/3] Calling finance.risk_report_writer ... - -> decision: allow - -Fetching TRACE Trust Record from gateway ... - -=== TRACE Trust Record === -{ - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - ... -} - -All tool calls completed. TRACE Trust Record generated. -``` - ---- - -## Step 8 - Inspect the TRACE Trust Record - -```bash -curl -s http://localhost:8443/trace | python3 -m json.tool -``` - -See the "Expected output" section below for the full annotated TRACE record. - ---- - -## Step 9 - Verify with cmcp-verify - -```bash -curl -s http://localhost:8443/trace > trace.json -cmcp-verify trace.json -``` - -Expected output: - -``` -[cmcp-verify] signature: valid -[cmcp-verify] attestation: dev-mode (not hardware-backed) -[cmcp-verify] policy version: credit-risk-v4.2 -[cmcp-verify] tool transcript: 3 calls, all allowed -[cmcp-verify] data_class: confidential (session maximum) -[cmcp-verify] RESULT: PASS (dev-mode) -``` - -For a production deployment with hardware TEE, the attestation line reads: -``` -[cmcp-verify] attestation: SEV-SNP verified (PCR0: aa11bb22...) -``` - ---- - -## Expected output - Full TRACE Trust Record - -```json -{ - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1750000000, - "subject": "spiffe://bank.eu/agents/credit-risk-analyst/run-abc123", - "model": { - "provider": "bank-internal", - "name": "credit-risk-llm-eu", - "version": "2.1.0", - "digest": { - "sha-256": "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1" - } - }, - "runtime": { - "platform": "software-only", - "tee_type": "dev-mode", - "measurement": "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION", - "region": "westeurope" - }, - "policy": { - "framework": "cedar", - "bundle_hash": "sha256:b8c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2", - "enforcement_mode": "enforce", - "version": "credit-risk-v4.2" - }, - "data_class": "confidential", - "tool_transcript": [ - { - "tool": "finance.document_reader", - "data_class": "confidential", - "decision": "allow" - }, - { - "tool": "finance.credit_score_lookup", - "data_class": "confidential", - "decision": "allow" - }, - { - "tool": "finance.risk_report_writer", - "data_class": "internal", - "decision": "allow" - } - ], - "cnf": { - "kid": "cmcp-a1b2c3d4" - } -} -``` - -### Field annotations - -| Field | Value in demo | Meaning | -|---|---|---| -| `eat_profile` | `tag:agentrust.io,2026:trace-v0.1` | TRACE schema version | -| `iat` | Unix timestamp | Time the record was sealed | -| `subject` | `spiffe://bank.eu/...` | SPIFFE ID of the agent run | -| `model.provider` | `bank-internal` | Model hosting entity | -| `model.digest.sha-256` | hex string | Immutable model fingerprint | -| `runtime.tee_type` | `dev-mode` | `sev-snp` or `tdx` in production | -| `runtime.measurement` | `DEVELOPMENT_ONLY_...` | PCR0/RTMR measurement in production | -| `policy.bundle_hash` | sha256 hex | Hash of the Cedar bundle used | -| `policy.version` | `credit-risk-v4.2` | From `policy/manifest.json` | -| `data_class` | `confidential` | Highest sensitivity across all calls | -| `tool_transcript` | array | One entry per tool call, in order | -| `cnf.kid` | `cmcp-a1b2c3d4` | Key ID of the runtime signing key | - ---- - -## Regulatory field mapping - -| TRACE field | EU AI Act | MiFID II | DORA | GDPR | -|---|---|---|---|---| -| `model.digest` | Art. 12: logging of AI system identity | Art. 25: documentation of system used | Art. 9: ICT asset inventory | Art. 5(1)(f): integrity | -| `policy.bundle_hash` | Art. 9: risk management system version | Art. 25: controls documentation | Art. 9: change management | - | -| `policy.version` | Art. 12: log versioning | Art. 25: audit trail | Art. 11: ICT change log | - | -| `runtime.tee_type` + `runtime.measurement` | Art. 12: tamper-evident logging | - | Art. 9: security of ICT systems | Art. 32: security of processing | -| `tool_transcript` | Art. 12: sufficient data for post-hoc review | Art. 25: basis of recommendation | Art. 17: incident management | Art. 5(1)(c): data minimisation | -| `data_class` (per call) | Art. 10: data governance | - | - | Art. 5(1)(b): purpose limitation | -| `subject` (SPIFFE) | Art. 12: traceability to specific run | Art. 25: audit trail | Art. 17: incident traceability | Art. 5(1)(f): accountability | -| `cnf.kid` | Art. 12: authentic log provenance | - | Art. 9: key management | - | - ---- - -## Extending this example - -### Swap in a real MCP server - -Replace the `server.url` values in `catalog.json` with your actual MCP server endpoint: - -```json -"server": { - "display_name": "Production Credit Risk Server", - "url": "https://mcp.bank.eu/credit-risk", - "tls_fingerprint": "SHA256:", - "transport": "http-sse" -} -``` - -Get the TLS fingerprint: - -```bash -openssl s_client -connect mcp.bank.eu:443 < /dev/null 2>/dev/null \ - | openssl x509 -fingerprint -sha256 -noout \ - | sed 's/sha256 Fingerprint=//;s/://g' -``` - -### Trigger the €500k escalation rule - -Edit `credit_risk_agent.py` and change `AMOUNT_EUR = 250_000` to `AMOUNT_EUR = 750_000`. Re-run the agent. The runtime will return an advisory deny for the `finance.risk_report_writer` call: - -```json -{ - "jsonrpc": "2.0", - "id": 3, - "error": { - "code": -32003, - "message": "tool_call denied", - "data": { - "decision": "advisory_deny", - "reason": "human-review-required", - "escalation_threshold_eur": 500000 - } - } -} -``` - -### Switch to enforcing mode - -Change `enforcement_mode: enforcing` in `cmcp-config.yaml` (it is already set to `enforcing`). In dev mode the runtime enforces the policy but the attestation is not hardware-backed. Change `CMCP_DEV_MODE=1` to use a real TPM or TEE for production. - -### Add a new tool - -1. Define the tool in your MCP server. -2. Add an entry to `catalog.json` with the correct `definition_hash`. -3. Add a Cedar rule in `allow.cedar` if needed. -4. Restart the runtime with `cmcp start --config financial-services/cmcp-config.yaml --reload`. - -The definition hash is: - -```python -import hashlib, json - -def definition_hash(approved_definition: dict) -> str: - canonical = json.dumps(approved_definition, sort_keys=True, separators=(',', ':')) - return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest() -``` - -### Enable hardware attestation (Azure Trusted Launch) - -1. Provision an Azure VM with Trusted Launch enabled (Trusted Launch is the default for most VM sizes as of 2025). -2. Install the vTPM extension if not already present. -3. Remove `CMCP_DEV_MODE=1` from the startup command. -4. The runtime will automatically use the vTPM. The `runtime.tee_type` field in the TRACE record will be `tpm2` and `runtime.measurement` will contain the PCR0 value. - -### Connect an agent manifest - -If you publish an agent manifest with `agent-manifest`, the runtime can cross-check the manifest's `allowed_tools` list against the catalog: - -```bash -agent-manifest validate --manifest agent-manifest.json --catalog financial-services/catalog.json -``` - ---- - -## Troubleshooting - -**Runtime cannot find the policy bundle** - -Make sure you run `cmcp start` from the root of the examples repo, or use an absolute path: - -```bash -cmcp start --config /path/to/examples/financial-services/cmcp-config.yaml -``` - -**`httpx.ConnectError` in the agent script** - -The runtime is not running, or is running on a different port. Check: - -```bash -curl http://localhost:8443/health -``` - -**`definition_hash mismatch` error** - -The catalog hash was computed from a different tool definition than what the server returned. Recompute using the Python snippet in "Extending this example" above. - -**Cedar policy parse error on startup** - -Cedar is whitespace-sensitive in some versions. Check that there are no stray Unicode characters (e.g., smart quotes) in `allow.cedar`. Use a plain ASCII editor. - -**TRACE record shows `data_class: internal` instead of `confidential`** - -The session-level `data_class` is the maximum across all tool calls. If only `finance.risk_report_writer` (internal) was called, the session data class is `internal`. Call `finance.document_reader` or `finance.credit_score_lookup` first to raise it to `confidential`. - ---- - -## License - -Apache 2.0. See [LICENSE](../LICENSE) in the repo root. +# financial-services: EU Credit Risk Agent Demo + +End-to-end demo of a credit risk agent processing client financial documents through a cMCP Runtime with Cedar policy enforcement and signed TRACE Trust Records for EU regulatory compliance (EU AI Act, MiFID II, DORA, GDPR). + +--- + +## What the demo shows + +**1. Cryptographic proof of which tools an AI agent called** +The cMCP Runtime intercepts every MCP tool call, records it in a hash-chained audit log persisted to SQLite, and seals the session into a signed `RuntimeClaim` (the TRACE Trust Record). An auditor can verify after the fact exactly which tools ran, in what order, and what was denied - without trusting the agent process. + +**2. Cedar policy as machine-readable compliance** +The rules in `policy/allow.cedar` encode the bank's requirements directly. The MiFID II escalation rule blocks any risk report above EUR 500,000 and returns the policy's `@annotation` metadata as structured advice, so the calling system knows *why* and *who must review*. + +**3. EU AI Act Article 12 transparency obligation** +Article 12 requires high-risk AI systems to log sufficient information for post-hoc monitoring. The TRACE record covers the policy version enforced, the audit chain root/tip, per-call decisions, and the runtime attestation - signed by the runtime's key. + +**4. Attestation-gated data access (DORA Art. 9)** +A Cedar rule forbids confidential (`mnpi`) tools when no attestation evidence is present: confidential financial data only flows through attested runtimes. + +--- + +## Architecture + +``` + +------------------------------------------------------------------+ + | Credit Risk Agent (LLM) | + | agent/credit_risk_agent.py -- JSON-RPC 2.0 over HTTP | + +-------------------------------+----------------------------------+ + | tools/call (MCP) + v + +------------------------------------------------------------------+ + | cMCP Runtime :8443 | + | | + | +---------------+ +------------------+ +------------------+ | + | | Cedar engine | | Catalog checker | | Audit chain + | | + | | allow.cedar | | catalog.json | | TRACE signer | | + | +---------------+ +------------------+ +------------------+ | + +-------------------------------+----------------------------------+ + | proxied tool call + v + +------------------------------------------------------------------+ + | Mock EU Credit Risk MCP Server :8080 | + | server/mock_mcp_server.py | + | finance.document_reader | + | finance.credit_score_lookup | + | finance.risk_report_writer | + +------------------------------------------------------------------+ +``` + +--- + +## The catalog + +`catalog.json` registers three tools with approved definitions, classifications, and definition hashes (`sha256` of the canonical JSON of `approved_definition` - the runtime rejects drifted definitions). + +| Tool | compliance_domain | sensitivity_level | +|---|---|---| +| `finance.document_reader` | mnpi | confidential | +| `finance.credit_score_lookup` | pii | confidential | +| `finance.risk_report_writer` | internal | confidential | + +--- + +## Run it + +```bash +git clone https://github.com/agentrust-io/examples.git +cd examples +pip install cmcp-runtime httpx +``` + +**Terminal 1 - mock MCP server:** + +```bash +cd financial-services +python server/mock_mcp_server.py +``` + +**Terminal 2 - runtime** (run from inside `financial-services/` - config paths resolve relative to the working directory): + +```bash +cd financial-services +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +**Terminal 3 - happy path (EUR 250,000, below the threshold):** + +```bash +cd examples +python financial-services/agent/credit_risk_agent.py +``` + +``` +[1/3] Calling finance.document_reader ... + -> decision: allow +[2/3] Calling finance.credit_score_lookup ... + -> decision: allow +[3/3] Calling finance.risk_report_writer ... + -> decision: allow + +Closing session and fetching the signed TRACE Trust Record ... +``` + +**Escalation path (EUR 750,000):** + +```bash +python financial-services/agent/credit_risk_agent.py --amount-eur 750000 +``` + +``` +[3/3] Calling finance.risk_report_writer ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: large-exposure-hitl + reason: human-review-required + regulation: mifid-ii-art-25 + escalation_threshold_eur: 500000 + + The risk report was NOT written to the core banking system. +``` + +--- + +## The Cedar policy + +`policy/allow.cedar` has no catch-all permit: each tool is explicitly permitted only for the `credit-risk-analyst` workflow (declared by the agent via `_cmcp.workflow_id`). Wrong workflow, missing workflow, or an unlisted action is denied by Cedar's default-deny: + +```cedar +permit ( + principal, + action == Action::"Finance.documentReader", + resource +) when { + context has workflow_id && + context.workflow_id == "credit-risk-analyst" +}; +``` + +On top of the workflow-scoped permits, the escalation rule: + +```cedar +@id("large-exposure-hitl") +@reason("human-review-required") +@regulation("mifid-ii-art-25") +@escalation_threshold_eur("500000") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context.arguments has amount_eur && + context.arguments.amount_eur > 500000 +}; +``` + +Action names follow the cMCP convention: `finance.risk_report_writer` becomes `Action::"Finance.riskReportWriter"` (PascalCase per underscore segment). Tool arguments are available under `context.arguments`; the `@annotation` values are returned in the deny response's `error.data.advice`. + +--- + +## The TRACE Trust Record + +See `trace-output/example-trust-record.json` - captured from a real run. Structure: `{"cmcp_version", "trace": {...}, "gateway": {...}, "signature"}`. + +| TRACE field | EU AI Act | MiFID II | DORA | GDPR | +|---|---|---|---|---| +| `trace.policy.bundle_hash` | Art. 9 - risk mgmt system version | Art. 25 - controls documentation | Art. 9 - change management | - | +| `trace.policy.version` | Art. 12 - log versioning | Art. 25 - audit trail | Art. 11 - ICT change log | - | +| `trace.runtime` + `signature` | Art. 12 - tamper-evident logging | - | Art. 9 - security of ICT systems | Art. 32 - security of processing | +| `gateway.call_summary` | Art. 12 - post-hoc review data | Art. 25 - basis of recommendation | Art. 17 - incident management | Art. 5(1)(c) - data minimisation | +| `trace.data_class` | Art. 10 - data governance | - | - | Art. 5(1)(b) - purpose limitation | +| `trace.subject` | Art. 12 - traceability to run | Art. 25 - audit trail | Art. 17 - incident traceability | Art. 5(1)(f) - accountability | + +Export the full audit chain for a closed session: + +```bash +curl "http://localhost:8443/audit/export?session_id=" | python3 -m json.tool +``` + +--- + +## Extending this example + +- **Real MCP server:** point `server.url` in `catalog.json` at your endpoint; get the TLS fingerprint with `openssl s_client -connect host:443 | openssl x509 -fingerprint -sha256 -noout`. +- **Change the threshold:** edit the `when` clause and the `@escalation_threshold_eur` annotation together. +- **Hardware attestation:** drop `CMCP_DEV_MODE=1` on a VM with TPM 2.0 / SEV-SNP / TDX. +- **Production hardening:** set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, `CMCP_CATALOG_HASH`. + +--- + +## Troubleshooting + +**Tool call returns 502 `UPSTREAM_UNAVAILABLE`** - the mock MCP server is not running on port 8080 (Terminal 1). + +**Runtime exits at startup** - config paths resolve relative to the working directory; run `cmcp start` from inside `financial-services/`. + +**Cedar policy parse error** - check for stray Unicode (smart quotes) in `allow.cedar`; annotations must be `@key("value")` with double quotes. + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/financial-services/agent/credit_risk_agent.py b/financial-services/agent/credit_risk_agent.py index 5ea1773..755aad5 100644 --- a/financial-services/agent/credit_risk_agent.py +++ b/financial-services/agent/credit_risk_agent.py @@ -2,11 +2,15 @@ """ Credit risk agent demo for EU private bank. -Calls three MCP tools through the cMCP gateway using raw JSON-RPC 2.0 over HTTP. -No MCP SDK required -- httpx only. +Calls three MCP tools through the cMCP Runtime using JSON-RPC 2.0 over HTTP, +then closes the session to obtain the signed TRACE Trust Record. Usage: - python credit_risk_agent.py [--gateway http://localhost:8443] + python credit_risk_agent.py [--gateway http://localhost:8443] [--amount-eur 250000] + +The default amount (250,000 EUR) is below the 500k escalation threshold, so +all calls are allowed. Pass --amount-eur 750000 to trigger the MiFID II +human-review deny with structured advice from the Cedar policy. """ import argparse @@ -14,131 +18,112 @@ import sys import httpx -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "credit-risk-analyst" -# Realistic fake client data for the demo CLIENT_ID = "EUR-2024-00847" DOCUMENT_ID = "BS-2024-Q4" CREDIT_BUREAU = "equifax" RISK_SCORE = 72.3 RECOMMENDATION = "approve" -AMOUNT_EUR = 250_000 # Below the 500k escalation threshold - -# --------------------------------------------------------------------------- -# JSON-RPC helpers -# --------------------------------------------------------------------------- -def make_request(method: str, params: dict, req_id: int) -> dict: - return { +def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: + payload = { "jsonrpc": "2.0", "id": req_id, - "method": method, - "params": params, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": WORKFLOW_ID}, + }, } - - -def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: - """Send a tools/call request to the cMCP gateway and return the result.""" - payload = make_request( - method="tools/call", - params={"name": tool_name, "arguments": arguments}, - req_id=req_id, - ) resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) - resp.raise_for_status() body = resp.json() - if "error" in body: - raise RuntimeError(f"Tool call error from {tool_name}: {body['error']}") + return {"ok": False, "error": body["error"], "session_id": None} + result = body["result"] + return { + "ok": True, + "result": result, + "session_id": result.get("_cmcp", {}).get("session_id"), + } - return body.get("result", {}) +def print_outcome(outcome: dict) -> None: + if outcome["ok"]: + meta = outcome["result"].get("_cmcp", {}) + decision = "advisory_deny" if meta.get("would_have_denied") else "allow" + print(f" -> decision: {decision}") + else: + data = outcome["error"].get("data", {}) + print(f" -> decision: deny ({data.get('error_code', 'unknown')})") + advice = data.get("advice") + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") -def fetch_trace(client: httpx.Client, gateway: str) -> dict: - """Retrieve the TRACE Trust Record for this session.""" - resp = client.get(f"{gateway}/trace", timeout=10) + +def close_session(client: httpx.Client, gateway: str, session_id: str) -> dict: + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) resp.raise_for_status() return resp.json() -# --------------------------------------------------------------------------- -# Main demo flow -# --------------------------------------------------------------------------- - -def run(gateway: str) -> None: - print(f"Connecting to cMCP gateway at {gateway}") - print(f"Client: {CLIENT_ID} | Document: {DOCUMENT_ID} | Bureau: {CREDIT_BUREAU}") +def run(gateway: str, amount_eur: int) -> None: + print(f"Connecting to cMCP Runtime at {gateway}") + print(f"Client: {CLIENT_ID} | Document: {DOCUMENT_ID} | Amount: EUR {amount_eur:,}") print() + session_id = None with httpx.Client(headers={"Content-Type": "application/json"}) as client: - - # Step 1: Read the client's balance sheet from the secure document vault. print("[1/3] Calling finance.document_reader ...") - doc_result = call_tool( - client, gateway, - tool_name="finance.document_reader", - arguments={"document_id": DOCUMENT_ID, "client_id": CLIENT_ID}, - req_id=1, - ) - print(f" -> decision: {doc_result.get('cmcp_decision', 'allow')}") - - # Step 2: Retrieve the credit bureau score. + o1 = call_tool(client, gateway, "finance.document_reader", + {"document_id": DOCUMENT_ID, "client_id": CLIENT_ID}, 1) + print_outcome(o1) + session_id = o1.get("session_id") or session_id + print("[2/3] Calling finance.credit_score_lookup ...") - score_result = call_tool( - client, gateway, - tool_name="finance.credit_score_lookup", - arguments={"client_id": CLIENT_ID, "bureau": CREDIT_BUREAU}, - req_id=2, - ) - print(f" -> decision: {score_result.get('cmcp_decision', 'allow')}") - - # Step 3: Write the risk assessment back to the core banking system. - # amount_eur=250000 is below the 500k advisory threshold, so no escalation. + o2 = call_tool(client, gateway, "finance.credit_score_lookup", + {"client_id": CLIENT_ID, "bureau": CREDIT_BUREAU}, 2) + print_outcome(o2) + session_id = o2.get("session_id") or session_id + print("[3/3] Calling finance.risk_report_writer ...") - report_result = call_tool( - client, gateway, - tool_name="finance.risk_report_writer", - arguments={ - "client_id": CLIENT_ID, - "risk_score": RISK_SCORE, - "recommendation": RECOMMENDATION, - "amount_eur": AMOUNT_EUR, - }, - req_id=3, - ) - print(f" -> decision: {report_result.get('cmcp_decision', 'allow')}") - print() + o3 = call_tool(client, gateway, "finance.risk_report_writer", + {"client_id": CLIENT_ID, "risk_score": RISK_SCORE, + "recommendation": RECOMMENDATION, "amount_eur": amount_eur}, 3) + print_outcome(o3) + session_id = o3.get("session_id") or session_id - # Fetch and print the TRACE Trust Record for the session. - print("Fetching TRACE Trust Record from gateway ...") - try: - trace = fetch_trace(client, gateway) + if not o3["ok"]: print() - print("=== TRACE Trust Record ===") - print(json.dumps(trace, indent=2)) - except Exception as exc: - print(f" (Could not fetch live TRACE record: {exc})") - print(" See financial-services/trace-output/example-trust-record.json for reference output.") + print(" The risk report was NOT written to the core banking system.") + print(" Exposures above EUR 500,000 require a human reviewer (MiFID II Art. 25).") - print() - print("All tool calls completed. TRACE Trust Record generated.") + print() + if session_id is None: + print("No session id received - cannot fetch TRACE Trust Record.") + sys.exit(1) + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + claim = close_session(client, gateway, session_id) + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(claim, indent=2)) + + print() + print("Done. Verify the audit chain with:") + print(f" curl {gateway}/audit/export?session_id={session_id}") -# --------------------------------------------------------------------------- -# Entrypoint -# --------------------------------------------------------------------------- if __name__ == "__main__": parser = argparse.ArgumentParser(description="Credit risk agent demo") - parser.add_argument( - "--gateway", - default=DEFAULT_GATEWAY, - help=f"cMCP gateway base URL (default: {DEFAULT_GATEWAY})", - ) + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") + parser.add_argument("--amount-eur", type=int, default=250_000, + help="Credit amount in EUR (default 250000; >500000 triggers HITL deny)") args = parser.parse_args() - run(args.gateway) + run(args.gateway, args.amount_eur) diff --git a/financial-services/catalog.json b/financial-services/catalog.json index 3a29bec..8f03fc0 100644 --- a/financial-services/catalog.json +++ b/financial-services/catalog.json @@ -19,7 +19,7 @@ } }, "definition_hash": "sha256:75312282f7ecce285ee6a091a261e99cb36d5ab5955bbb16536dca0baa45abb6", - "compliance_domain": "hipaa_phi", + "compliance_domain": "mnpi", "requires_baa": false, "sensitivity_level": "confidential", "added_at": "2026-06-01T00:00:00Z", @@ -75,7 +75,7 @@ "definition_hash": "sha256:b98f4ffffd0d4699eeb0bc360ac8a966adf6b008359cd798e10c99546ce029f1", "compliance_domain": "internal", "requires_baa": false, - "sensitivity_level": "internal", + "sensitivity_level": "confidential", "added_at": "2026-06-01T00:00:00Z", "approved_by": "risk-committee@bank.eu" } diff --git a/financial-services/policy/allow.cedar b/financial-services/policy/allow.cedar index fb171d0..517f47d 100644 --- a/financial-services/policy/allow.cedar +++ b/financial-services/policy/allow.cedar @@ -1,45 +1,74 @@ // Cedar policy bundle for EU credit risk agent // version: credit-risk-v4.2 // author: compliance@bank.eu +// +// Action names follow the cMCP convention: tool_name converted to +// PascalCase per underscore segment, e.g. finance.risk_report_writer +// becomes Action::"Finance.riskReportWriter". +// +// There is deliberately NO catch-all permit: anything not explicitly +// permitted below is denied (Cedar default-deny). The workflow_id is +// supplied by the agent via the _cmcp request metadata; calls without +// it are denied. -// Rule 1: Permit the credit-risk-analyst workflow to call any approved tool. +// Rule 1: the credit-risk-analyst workflow may read client documents. permit ( principal, - action == Action::"tool_call", + action == Action::"Finance.documentReader", resource ) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" }; -// Rule 2: Advisory forbid on large credit recommendations (> 500,000 EUR). -// In advisory enforcement_mode this logs the violation and adds a TRACE annotation -// rather than hard-blocking the call. A human reviewer must approve before -// any downstream disbursement system will accept the recommendation. -forbid ( +// Rule 2: the credit-risk-analyst workflow may query credit bureaus. +permit ( principal, - action == Action::"tool_call", - resource == Tool::"finance.risk_report_writer" + action == Action::"Finance.creditScoreLookup", + resource ) when { - context.amount_eur > 500000 -} advice { - "reason": "human-review-required", - "escalation_threshold_eur": 500000 + context has workflow_id && + context.workflow_id == "credit-risk-analyst" }; -// Rule 3: Deny confidential tool calls if the session sensitivity has been -// downgraded to "public". This prevents accidental data-class mismatch. +// Rule 3: the credit-risk-analyst workflow may write risk reports. +// Subject to the large-exposure forbid below. +permit ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context has workflow_id && + context.workflow_id == "credit-risk-analyst" +}; + +// Rule 4: large-exposure escalation (MiFID II Art. 25). +// Any risk report above 500,000 EUR requires a human reviewer. The +// annotations are returned to the caller as structured advice on deny. +@id("large-exposure-hitl") +@reason("human-review-required") +@regulation("mifid-ii-art-25") +@escalation_threshold_eur("500000") forbid ( principal, - action == Action::"tool_call", + action == Action::"Finance.riskReportWriter", resource ) when { - context.session_max_sensitivity == "public" && - resource.compliance_domain == "confidential" + context.arguments has amount_eur && + context.arguments.amount_eur > 500000 }; -// Rule 4: Catch-all permit — any action not matched by a forbid above is allowed. -permit ( +// Rule 5: confidential financial data may only flow through an attested +// runtime. attestation_platform is supplied by the gateway from the TEE +// attestation report; "unknown" means no attestation evidence is present. +@id("require-attested-runtime") +@reason("attested-runtime-required") +@regulation("dora-art-9") +forbid ( principal, action, resource -); +) when { + context.compliance_domain == "mnpi" && + context.attestation_platform == "unknown" +}; diff --git a/financial-services/server/mock_mcp_server.py b/financial-services/server/mock_mcp_server.py new file mode 100644 index 0000000..b481bea --- /dev/null +++ b/financial-services/server/mock_mcp_server.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Mock EU Credit Risk MCP Server for the financial-services demo. + +Serves the three catalog tools with canned responses on port 8080. +Stdlib only -- no dependencies. + +Usage: + python financial-services/server/mock_mcp_server.py +""" + +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = 8080 + + +def _document_reader(args: dict) -> str: + return json.dumps({ + "document_id": args.get("document_id", ""), + "client_id": args.get("client_id", ""), + "document_type": "balance_sheet", + "period": "2024-Q4", + "total_assets_eur": 4_820_000, + "total_liabilities_eur": 3_140_000, + "status": "retrieved", + }) + + +def _credit_score_lookup(args: dict) -> str: + return json.dumps({ + "client_id": args.get("client_id", ""), + "bureau": args.get("bureau", "equifax"), + "score": 742, + "scale": "280-850", + "retrieved_at": "2026-06-10T09:00:00Z", + }) + + +def _risk_report_writer(args: dict) -> str: + return json.dumps({ + "client_id": args.get("client_id", ""), + "risk_score": args.get("risk_score"), + "recommendation": args.get("recommendation"), + "amount_eur": args.get("amount_eur"), + "report_id": "RR-2026-04471", + "status": "written", + }) + + +TOOLS = { + "finance.document_reader": _document_reader, + "finance.credit_score_lookup": _credit_score_lookup, + "finance.risk_report_writer": _risk_report_writer, +} + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + params = request.get("params", {}) + tool = params.get("name", "") + handler = TOOLS.get(tool) + if handler is None: + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + text = handler(params.get("arguments", {})) + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": text}]}, + }) + + def _reply(self, status: int, body: dict) -> 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, *args): + print(f"[mock-mcp] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock EU Credit Risk MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/financial-services/trace-output/example-trust-record.json b/financial-services/trace-output/example-trust-record.json index 51f9aeb..d6cc5cd 100644 --- a/financial-services/trace-output/example-trust-record.json +++ b/financial-services/trace-output/example-trust-record.json @@ -1,30 +1,89 @@ { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1750000000, - "subject": "spiffe://bank.eu/agents/credit-risk-analyst/run-abc123", - "model": { - "provider": "bank-internal", - "name": "credit-risk-llm-eu", - "version": "2.1.0", - "digest": {"sha-256": "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"} + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781193684, + "subject": "spiffe://cmcp.gateway/session/7bab4703-706e-4f2e-80b3-c3611832cda8", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:9650dec5dec7afbc8608f0bdb29b9266c1f05990be603255e806479a76e9e23e", + "enforcement_mode": "enforce", + "version": "credit-risk-v4.2" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:bf92310550233e5363441d0cc3b4158480e86e1bee4b958123bb39e7c63be262", + "call_count": 4 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "ZlmbQ6r1FqVml6LsKDifDR4grPSBhhRYZMqc8o0DdAc", + "kid": "cmcp-66599b43" + } + } }, - "runtime": { - "platform": "software-only", - "tee_type": "dev-mode", - "measurement": "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION", - "region": "westeurope" + "gateway": { + "session_id": "7bab4703-706e-4f2e-80b3-c3611832cda8", + "gateway_version": "unknown", + "sequence_number": 3, + "prev_claim_hash": "sha256:64a2493a624c547e12a5395a3bb1d23c8b42e077277bb88732ab5c7209da7e56", + "audit_chain": { + "root": "17e9e977e3a1307f2a738ef1dedc3a879be3065ef9111b06b3e0644517f59457", + "tip": "bf92310550233e5363441d0cc3b4158480e86e1bee4b958123bb39e7c63be262", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 3, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "finance.credit_score_lookup", + "finance.document_reader", + "finance.risk_report_writer" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "mnpi", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "1fb8e8c8-d709-4fda-914b-81bbd9a872db", + "tool_name": "finance.risk_report_writer", + "sequence_number": 3 + } + ], + "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:02640e5bd370cd9e2d383f07dcab8d2d3c5e9e0155ee14c56556d6e7645d86ca", + "drift_detected": false + }, + "attestation_generated_at": "2026-06-11T16:01:05.969369+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "finance.document_reader", + "finance.credit_score_lookup", + "finance.risk_report_writer" + ], + "suspicious_sequences_detected": 0 + } }, - "policy": { - "framework": "cedar", - "bundle_hash": "sha256:b8c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2", - "enforcement_mode": "enforce", - "version": "credit-risk-v4.2" - }, - "data_class": "confidential", - "tool_transcript": [ - {"tool": "finance.document_reader", "data_class": "confidential", "decision": "allow"}, - {"tool": "finance.credit_score_lookup", "data_class": "confidential", "decision": "allow"}, - {"tool": "finance.risk_report_writer", "data_class": "internal", "decision": "allow"} - ], - "cnf": {"kid": "cmcp-a1b2c3d4"} + "signature": "Eb4r3WoFBeJ86Zz2DNQPnCCLG11BegMgod-Kjyf6J9V3uKElF5ZiY-SJ5MQwj24EboeXFsU1lKf2Z29ui6FbBA" } diff --git a/healthcare/README.md b/healthcare/README.md index dc2ca25..97f0277 100644 --- a/healthcare/README.md +++ b/healthcare/README.md @@ -1,307 +1,201 @@ -# healthcare: Clinical Decision Support Agent Demo - -End-to-end demo of a hospital AI agent processing patient records through a cMCP Runtime with Cedar policy enforcement and TRACE Trust Records for healthcare regulatory compliance (EU AI Act Art. 14, HIPAA). - ---- - -## What the demo shows - -**1. EU AI Act Article 14: human oversight for high-risk AI** -Article 14 requires that high-risk AI systems in healthcare allow human supervisors to intervene. The Cedar Rule 2 in `policy/allow.cedar` operationalises this: any treatment plan write where `patient_risk_category == "high"` is blocked until an attending physician approves. The TRACE Trust Record records the advisory deny so the block is auditable. - -**2. HIPAA PHI protection at the tool boundary** -All three tools are classified `compliance_domain: hipaa_phi` and `sensitivity_level: confidential` in the catalog. Cedar Rule 3 prevents any session that has been downgraded to `public` sensitivity from calling any tool in the `hipaa_phi` domain, enforcing the minimum-necessary principle at runtime rather than in application code. - -**3. Cryptographic proof of tool call sequence** -The cMCP Runtime records every EHR tool call in a signed, hash-chained audit log. The TRACE Trust Record seals the entire session (which tools ran, in what order, whether a HITL block fired) into a JWT signed by the runtime's attestation key. A compliance officer or regulator can verify the record without trusting the agent process. - -**4. Two demo paths: standard and HITL** -Run without flags to see the happy path (all three calls allowed). Run with `--trigger-hitl` to see the EU AI Act Art. 14 block fire on the treatment plan write. - ---- - -## Architecture - -``` - +------------------------------------------------------------------+ - | Clinical Decision Support Agent (LLM) | - | clinical_decision_agent.py -- JSON-RPC 2.0 over HTTP | - +-------------------------------+----------------------------------+ - | tools/call (MCP) - v - +------------------------------------------------------------------+ - | cMCP Runtime :8443 | - | | - | +---------------+ +------------------+ +------------------+ | - | | Cedar engine | | Catalog checker | | TRACE recorder | | - | | allow.cedar | | catalog.json | | /trace endpoint | | - | +---------------+ +------------------+ +------------------+ | - +-------------------------------+----------------------------------+ - | proxied tool call - v - +------------------------------------------------------------------+ - | Hospital EHR MCP Server :8080 | - | ehr.patient_record_lookup | - | ehr.clinical_decision_support | - | ehr.treatment_plan_writer | - +------------------------------------------------------------------+ -``` - ---- - -## Prerequisites - -| Requirement | Version | Notes | -|---|---|---| -| Python | 3.11+ | `python3 --version` | -| pip | any recent | `pip --version` | -| httpx | 0.27+ | installed by `pip install cmcp-runtime` | -| cmcp-runtime | latest | `pip install cmcp-runtime` | - -No hardware TEE or TPM required for this demo. The runtime runs in `CMCP_DEV_MODE=1`. - ---- - -## Step 1 - Clone the examples repo - -```bash -git clone https://github.com/agentrust-io/examples.git -cd examples -``` - ---- - -## Step 2 - Install dependencies - -```bash -pip install cmcp-runtime httpx -``` - ---- - -## Step 3 - Review the files - -``` -healthcare/ - cmcp-config.yaml Runtime configuration - catalog.json Three-tool EHR catalog - policy/ - manifest.json Policy bundle metadata - allow.cedar Four Cedar rules (including HITL rule) - schema.cedarschema Cedar schema - agent/ - clinical_decision_agent.py Demo agent (run this) - trace-output/ - example-trust-record.json Reference TRACE output (happy path) -``` - ---- - -## Step 4 - Understand the Cedar policy - -`policy/allow.cedar` contains four rules: - -**Rule 1 - Workflow permit** - -```cedar -permit ( - principal, - action == Action::"tool_call", - resource -) when { - context.workflow_id == "clinical-decision-support" -}; -``` - -Only the `clinical-decision-support` workflow may call the three EHR tools. - -**Rule 2 - EU AI Act Art. 14 HITL block** - -```cedar -forbid ( - principal, - action == Action::"tool_call", - resource == Tool::"ehr.treatment_plan_writer" -) when { - context.patient_risk_category == "high" -} advice { - "reason": "human-review-required", - "regulation": "eu-ai-act-art-14", - "reviewer_role": "attending-physician" -}; -``` - -Any treatment plan write where `patient_risk_category == "high"` is blocked with an advisory deny. The advice payload is returned to the caller and recorded in the TRACE Trust Record. - -**Rule 3 - HIPAA PHI session protection** - -```cedar -forbid ( - principal, - action == Action::"tool_call", - resource -) when { - context.session_max_sensitivity == "public" && - resource.compliance_domain == "hipaa_phi" -}; -``` - -Prevents accidental PHI access if session sensitivity is downgraded. - -**Rule 4 - Catch-all permit** - -```cedar -permit (principal, action, resource); -``` - ---- - -## Step 5 - Start the runtime - -```bash -CMCP_DEV_MODE=1 cmcp start --config healthcare/cmcp-config.yaml -``` - -Run from the root of the examples repo. Expected startup output: - -``` -[cmcp] policy bundle loaded: clinical-hipaa-v2.1 -[cmcp] catalog loaded: 3 tools -[cmcp] ehr.patient_record_lookup (confidential) -[cmcp] ehr.clinical_decision_support (confidential) -[cmcp] ehr.treatment_plan_writer (confidential) -[cmcp] attestation: dev-mode (CMCP_DEV_MODE=1) -[cmcp] enforcement: enforcing -[cmcp] listening on 0.0.0.0:8443 -``` - -Leave this terminal open. - ---- - -## Step 6 - Run the happy path (no HITL) - -In a second terminal: - -```bash -python healthcare/agent/clinical_decision_agent.py -``` - -Expected output: - -``` -Connecting to cMCP gateway at http://localhost:8443 -Patient: P-2024-008471 | Risk category: standard - -[1/3] Calling ehr.patient_record_lookup ... - -> decision: allow -[2/3] Calling ehr.clinical_decision_support ... - -> decision: allow -[3/3] Calling ehr.treatment_plan_writer ... - -> decision: allow - -Fetching TRACE Trust Record from gateway ... - -=== TRACE Trust Record === -{ - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - ... -} - -All tool calls completed. TRACE Trust Record generated. -``` - ---- - -## Step 7 - Run the HITL path - -```bash -python healthcare/agent/clinical_decision_agent.py --trigger-hitl -``` - -Expected output: - -``` -Connecting to cMCP gateway at http://localhost:8443 -Patient: P-2024-008471 | Risk category: high -Mode: --trigger-hitl enabled -- treatment plan write will require HITL approval - -[1/3] Calling ehr.patient_record_lookup ... - -> decision: allow -[2/3] Calling ehr.clinical_decision_support ... - -> decision: allow -[3/3] Calling ehr.treatment_plan_writer ... - -> decision: advisory_deny - - HITL advisory payload: - reason: human-review-required - regulation: eu-ai-act-art-14 - reviewer_role: attending-physician - - The treatment plan was NOT written to the EHR. - An attending physician must review and approve before the plan takes effect. - The TRACE Trust Record records this as an advisory_deny for EU AI Act audit purposes. -``` - -The TRACE Trust Record for the HITL path records `"decision": "advisory_deny"` for the treatment plan write. This entry is the machine-readable evidence that the human oversight requirement was applied. - ---- - -## Step 8 - Verify with cmcp-verify - -```bash -curl -s http://localhost:8443/trace > trace.json -cmcp-verify trace.json -``` - -Expected output: - -``` -[cmcp-verify] signature: valid -[cmcp-verify] attestation: dev-mode (not hardware-backed) -[cmcp-verify] policy version: clinical-hipaa-v2.1 -[cmcp-verify] tool transcript: 3 calls (2 allowed, 1 advisory_deny) -[cmcp-verify] data_class: confidential (session maximum) -[cmcp-verify] RESULT: PASS (dev-mode) -``` - ---- - -## Regulatory field mapping - -| TRACE field | EU AI Act | HIPAA | -|---|---|---| -| `policy.bundle_hash` | Art. 9: risk management system version | 45 CFR 164.312: access controls documentation | -| `policy.version` | Art. 12: log versioning | 45 CFR 164.308: audit log | -| `tool_transcript[].decision` | Art. 14: human oversight record | 45 CFR 164.308(a)(1)(ii)(D): activity review | -| `data_class` (per call) | Art. 10: data governance | 45 CFR 164.502: minimum necessary | -| `runtime.tee_type` + `runtime.measurement` | Art. 12: tamper-evident logging | 45 CFR 164.312(c): integrity | -| `subject` | Art. 12: traceability to specific run | 45 CFR 164.308(a)(5): access monitoring | - ---- - -## Extending this example - -### Connect a real EHR MCP server - -Replace the `server.url` values in `catalog.json` with your actual MCP server endpoint and update `tls_fingerprint`: - -```bash -openssl s_client -connect ehr.hospital.example:443 < /dev/null 2>/dev/null \ - | openssl x509 -fingerprint -sha256 -noout \ - | sed 's/sha256 Fingerprint=//;s/://g' -``` - -### Switch to hardware attestation - -Remove `CMCP_DEV_MODE=1` and provision a VM with TPM 2.0 or AMD SEV-SNP. The `runtime.tee_type` in the TRACE record will change from `dev-mode` to `tpm2` or `sev-snp`. - -### Add SPIFFE identity for the agent - -If your hospital deploys SPIRE, the runtime will automatically obtain a SPIFFE SVID and include the `subject` field in the TRACE record as a hardware-attested SPIFFE URI instead of a self-signed placeholder. - ---- - -## License - -Apache 2.0. See [LICENSE](../LICENSE) in the repo root. +# healthcare: Clinical Decision Support Agent Demo + +End-to-end demo of a hospital AI agent processing patient records through a cMCP Runtime with Cedar policy enforcement and signed TRACE Trust Records for healthcare regulatory compliance (EU AI Act Art. 14, HIPAA). + +--- + +## What the demo shows + +**1. EU AI Act Article 14 - human oversight for high-risk AI** +The Cedar policy blocks any treatment plan write where `patient_risk_category == "high"`. The deny response carries the policy's `@annotation` metadata as structured advice (`regulation: eu-ai-act-art-14`, `reviewer_role: attending-physician`), and the audit chain records the deny as machine-readable Art. 14 evidence. + +**2. HIPAA PHI protection at the tool boundary** +All three tools are classified `compliance_domain: hipaa_phi` in the attested catalog. A Cedar rule forbids PHI tools when no attestation evidence is present, enforcing "PHI only flows through attested runtimes" at the policy layer. + +**3. Cryptographic proof of the tool call sequence** +Every call is recorded in a hash-chained audit log persisted to SQLite. Closing the session seals the chain into a signed `RuntimeClaim` (the TRACE Trust Record): which tools ran, in what order, what was denied - verifiable without trusting the agent process. + +**4. Two demo paths** +Run without flags for the happy path (all three calls allowed). Run with `--trigger-hitl` to see the Art. 14 block fire with the advice payload. + +--- + +## Architecture + +``` + +------------------------------------------------------------------+ + | Clinical Decision Support Agent (LLM) | + | agent/clinical_decision_agent.py -- JSON-RPC 2.0 over HTTP | + +-------------------------------+----------------------------------+ + | tools/call (MCP) + v + +------------------------------------------------------------------+ + | cMCP Runtime :8443 | + | | + | +---------------+ +------------------+ +------------------+ | + | | Cedar engine | | Catalog checker | | Audit chain + | | + | | allow.cedar | | catalog.json | | TRACE signer | | + | +---------------+ +------------------+ +------------------+ | + +-------------------------------+----------------------------------+ + | proxied tool call + v + +------------------------------------------------------------------+ + | Mock Hospital EHR MCP Server :8080 | + | server/mock_mcp_server.py | + | ehr.patient_record_lookup | + | ehr.clinical_decision_support | + | ehr.treatment_plan_writer | + +------------------------------------------------------------------+ +``` + +--- + +## Run it + +```bash +git clone https://github.com/agentrust-io/examples.git +cd examples +pip install cmcp-runtime httpx +``` + +**Terminal 1 - mock EHR server:** + +```bash +cd healthcare +python server/mock_mcp_server.py +``` + +**Terminal 2 - runtime** (run from inside `healthcare/` - config paths resolve relative to the working directory): + +```bash +cd healthcare +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +**Terminal 3 - happy path:** + +```bash +cd examples +python healthcare/agent/clinical_decision_agent.py +``` + +Expected output: + +``` +Patient: P-2024-008471 | Risk category: standard + +[1/3] Calling ehr.patient_record_lookup ... + -> decision: allow +[2/3] Calling ehr.clinical_decision_support ... + -> decision: allow +[3/3] Calling ehr.treatment_plan_writer ... + -> decision: allow + +Closing session and fetching the signed TRACE Trust Record ... + +=== TRACE Trust Record (signed RuntimeClaim) === +{ "cmcp_version": "1.0", "trace": {...}, "gateway": {...}, "signature": "..." } +``` + +**HITL path:** + +```bash +python healthcare/agent/clinical_decision_agent.py --trigger-hitl +``` + +``` +[3/3] Calling ehr.treatment_plan_writer ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: hitl-high-risk + reason: human-review-required + regulation: eu-ai-act-art-14 + reviewer_role: attending-physician + + The treatment plan was NOT written to the EHR. + An attending physician must review and approve before the plan takes effect. +``` + +--- + +## The Cedar policy + +`policy/allow.cedar` has no catch-all permit: each EHR tool is explicitly permitted only for the `clinical-decision-support` workflow (declared by the agent via `_cmcp.workflow_id`), and anything else - wrong workflow, missing workflow, unlisted action - is denied by Cedar's default-deny: + +```cedar +permit ( + principal, + action == Action::"Ehr.patientRecordLookup", + resource +) when { + context has workflow_id && + context.workflow_id == "clinical-decision-support" +}; +``` + +On top of the workflow-scoped permits sit two forbid rules. Annotations on a `forbid` are returned to the caller as structured advice when that rule causes a deny: + +```cedar +@id("hitl-high-risk") +@reason("human-review-required") +@regulation("eu-ai-act-art-14") +@reviewer_role("attending-physician") +forbid ( + principal, + action == Action::"Ehr.treatmentPlanWriter", + resource +) when { + context.arguments has patient_risk_category && + context.arguments.patient_risk_category == "high" +}; +``` + +Action names follow the cMCP convention: `ehr.treatment_plan_writer` becomes `Action::"Ehr.treatmentPlanWriter"` (PascalCase per underscore segment). Tool arguments are available under `context.arguments`. + +--- + +## The TRACE Trust Record + +See `trace-output/example-trust-record.json` - captured from a real run of this demo. Key fields: + +| Field | Meaning | +|---|---| +| `trace.policy.bundle_hash` / `version` | Exactly which Cedar bundle was enforced (`clinical-hipaa-v2.1`) | +| `trace.data_class` | Highest sensitivity touched in the session (`confidential`) | +| `trace.tool_transcript.hash` | Hash of the audit chain tip covering all calls | +| `trace.cnf.jwk` | The runtime's Ed25519 signing key (verifies `signature`) | +| `gateway.call_summary` | Allowed/denied counts, tools invoked, compliance domains touched | +| `gateway.audit_chain` | Root, tip, and length of the hash-chained audit log | +| `signature` | Ed25519 signature over the canonical claim | + +Export the full audit chain for a closed session: + +```bash +curl "http://localhost:8443/audit/export?session_id=" | python3 -m json.tool +``` + +--- + +## Regulatory field mapping + +| TRACE field | EU AI Act | HIPAA | +|---|---|---| +| `trace.policy.bundle_hash` | Art. 9 - risk management system version | 45 CFR 164.312 - access controls | +| `gateway.call_summary.tool_calls_denied` | Art. 14 - human oversight record | 45 CFR 164.308(a)(1)(ii)(D) - activity review | +| `trace.data_class` | Art. 10 - data governance | 45 CFR 164.502 - minimum necessary | +| `trace.runtime` + `signature` | Art. 12 - tamper-evident logging | 45 CFR 164.312(c) - integrity | +| `trace.subject` | Art. 12 - traceability to specific run | 45 CFR 164.308(a)(5) - access monitoring | + +--- + +## Extending this example + +- **Real EHR server:** point `server.url` in `catalog.json` at your MCP server. +- **Hardware attestation:** drop `CMCP_DEV_MODE=1` on a VM with TPM 2.0 / SEV-SNP; `trace.runtime` then carries real measurements. +- **Production hardening:** set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, and `CMCP_CATALOG_HASH` (the runtime refuses to start without them outside dev mode). + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/healthcare/agent/clinical_decision_agent.py b/healthcare/agent/clinical_decision_agent.py index 8ca43a0..6b2caaa 100644 --- a/healthcare/agent/clinical_decision_agent.py +++ b/healthcare/agent/clinical_decision_agent.py @@ -2,15 +2,15 @@ """ Clinical decision support agent demo for hospital AI compliance. -Calls three EHR tools through the cMCP gateway using raw JSON-RPC 2.0 over HTTP. -No MCP SDK required -- httpx only. +Calls three EHR tools through the cMCP Runtime using JSON-RPC 2.0 over HTTP, +then closes the session to obtain the signed TRACE Trust Record. Usage: python clinical_decision_agent.py [--gateway http://localhost:8443] [--trigger-hitl] Without --trigger-hitl: patient_risk_category=standard, all tool calls allowed. -With --trigger-hitl: patient_risk_category=high, treatment plan write is - blocked with an EU AI Act Art. 14 HITL advisory. +With --trigger-hitl: patient_risk_category=high, the treatment plan write is + denied with EU AI Act Art. 14 advice from the Cedar policy. """ import argparse @@ -19,8 +19,8 @@ import httpx DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "clinical-decision-support" -# Realistic demo patient PATIENT_ID = "P-2024-008471" SYMPTOMS = ["fatigue", "polyuria", "polydipsia", "blurred vision"] LAB_VALUES = {"fasting_glucose_mmol": 9.2, "hba1c_percent": 8.1, "bmi": 31.4} @@ -28,140 +28,109 @@ TREATMENT = "Metformin 500mg twice daily; lisinopril 10mg once daily; HbA1c recheck in 3 months" -# --------------------------------------------------------------------------- -# JSON-RPC helpers -# --------------------------------------------------------------------------- - -def make_call(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: - """Send a tools/call to the cMCP gateway and return the full result dict.""" +def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: + """POST a tools/call. Returns {"ok": bool, "result"/"error", "session_id"}.""" payload = { "jsonrpc": "2.0", "id": req_id, "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": WORKFLOW_ID}, + }, } resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) - resp.raise_for_status() body = resp.json() if "error" in body: - err = body["error"] - # Structured error: advisory_deny returns an error but has HITL advice in data - if isinstance(err.get("data"), dict) and err["data"].get("decision") in ("advisory_deny", "deny"): - return {"_denied": True, "_error": err} - raise RuntimeError(f"Tool call error from {tool_name}: {err}") - return body.get("result", {}) + return {"ok": False, "error": body["error"], "session_id": None} + result = body["result"] + return { + "ok": True, + "result": result, + "session_id": result.get("_cmcp", {}).get("session_id"), + } + +def print_outcome(outcome: dict) -> None: + if outcome["ok"]: + meta = outcome["result"].get("_cmcp", {}) + decision = "advisory_deny" if meta.get("would_have_denied") else "allow" + print(f" -> decision: {decision}") + else: + data = outcome["error"].get("data", {}) + print(f" -> decision: deny ({data.get('error_code', 'unknown')})") + advice = data.get("advice") + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") -def fetch_trace(client: httpx.Client, gateway: str) -> dict: - resp = client.get(f"{gateway}/trace", timeout=10) + +def close_session(client: httpx.Client, gateway: str, session_id: str) -> dict: + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) resp.raise_for_status() return resp.json() -# --------------------------------------------------------------------------- -# Main demo flow -# --------------------------------------------------------------------------- - def run(gateway: str, trigger_hitl: bool) -> None: risk_category = "high" if trigger_hitl else "standard" - - print(f"Connecting to cMCP gateway at {gateway}") + print(f"Connecting to cMCP Runtime at {gateway}") print(f"Patient: {PATIENT_ID} | Risk category: {risk_category}") if trigger_hitl: - print("Mode: --trigger-hitl enabled — treatment plan write will require HITL approval") + print("Mode: --trigger-hitl - the treatment plan write will require HITL approval") print() + session_id = None with httpx.Client(headers={"Content-Type": "application/json"}) as client: - - # Step 1: Look up the patient record. print("[1/3] Calling ehr.patient_record_lookup ...") - rec_result = make_call( - client, gateway, - tool_name="ehr.patient_record_lookup", - arguments={"patient_id": PATIENT_ID, "record_type": "full"}, - req_id=1, - ) - print(f" -> decision: {rec_result.get('cmcp_decision', 'allow')}") - - # Step 2: Run the AI differential diagnosis. + o1 = call_tool(client, gateway, "ehr.patient_record_lookup", + {"patient_id": PATIENT_ID, "record_type": "full"}, 1) + print_outcome(o1) + session_id = o1.get("session_id") or session_id + print("[2/3] Calling ehr.clinical_decision_support ...") - cds_result = make_call( - client, gateway, - tool_name="ehr.clinical_decision_support", - arguments={ - "patient_id": PATIENT_ID, - "presenting_symptoms": SYMPTOMS, - "lab_values": LAB_VALUES, - }, - req_id=2, - ) - print(f" -> decision: {cds_result.get('cmcp_decision', 'allow')}") - - # Step 3: Write the treatment plan. - # When patient_risk_category == "high", Cedar Rule 2 fires and the gateway - # returns an advisory_deny with EU AI Act Art. 14 HITL advice. + o2 = call_tool(client, gateway, "ehr.clinical_decision_support", + {"patient_id": PATIENT_ID, "presenting_symptoms": SYMPTOMS, + "lab_values": LAB_VALUES}, 2) + print_outcome(o2) + session_id = o2.get("session_id") or session_id + print("[3/3] Calling ehr.treatment_plan_writer ...") - plan_result = make_call( - client, gateway, - tool_name="ehr.treatment_plan_writer", - arguments={ - "patient_id": PATIENT_ID, - "diagnosis": DIAGNOSIS, - "treatment": TREATMENT, - "patient_risk_category": risk_category, - }, - req_id=3, - ) - - if plan_result.get("_denied"): - err = plan_result["_error"] - data = err.get("data", {}) - print(f" -> decision: {data.get('decision', 'deny')}") - print() - print(" HITL advisory payload:") - print(f" reason: {data.get('reason', '')}") - print(f" regulation: {data.get('regulation', '')}") - print(f" reviewer_role: {data.get('reviewer_role', '')}") + o3 = call_tool(client, gateway, "ehr.treatment_plan_writer", + {"patient_id": PATIENT_ID, "diagnosis": DIAGNOSIS, + "treatment": TREATMENT, + "patient_risk_category": risk_category}, 3) + print_outcome(o3) + session_id = o3.get("session_id") or session_id + + if not o3["ok"]: print() print(" The treatment plan was NOT written to the EHR.") print(" An attending physician must review and approve before the plan takes effect.") - print(" The TRACE Trust Record records this as an advisory_deny for EU AI Act audit purposes.") - else: - print(f" -> decision: {plan_result.get('cmcp_decision', 'allow')}") + print(" The audit chain records this deny for EU AI Act Art. 14 evidence.") print() - - # Fetch TRACE Trust Record. - print("Fetching TRACE Trust Record from gateway ...") - try: - trace = fetch_trace(client, gateway) - print() - print("=== TRACE Trust Record ===") - print(json.dumps(trace, indent=2)) - except Exception as exc: - print(f" (Could not fetch live TRACE record: {exc})") - print(" See healthcare/trace-output/ for reference output.") + if session_id is None: + print("No session id received - cannot fetch TRACE Trust Record.") sys.exit(1) - print() - print("All tool calls completed. TRACE Trust Record generated.") + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + claim = close_session(client, gateway, session_id) + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(claim, indent=2)) + print() + print("Done. Verify the audit chain with:") + print(f" curl {gateway}/audit/export?session_id={session_id}") -# --------------------------------------------------------------------------- -# Entrypoint -# --------------------------------------------------------------------------- if __name__ == "__main__": parser = argparse.ArgumentParser(description="Clinical decision support agent demo") - parser.add_argument( - "--gateway", - default=DEFAULT_GATEWAY, - help=f"cMCP gateway base URL (default: {DEFAULT_GATEWAY})", - ) - parser.add_argument( - "--trigger-hitl", - action="store_true", - help="Set patient_risk_category=high to trigger the EU AI Act Art. 14 HITL advisory", - ) + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") + parser.add_argument("--trigger-hitl", action="store_true", + help="Set patient_risk_category=high to trigger the EU AI Act Art. 14 HITL deny") args = parser.parse_args() run(args.gateway, args.trigger_hitl) diff --git a/healthcare/policy/allow.cedar b/healthcare/policy/allow.cedar index a11bb66..abb8d23 100644 --- a/healthcare/policy/allow.cedar +++ b/healthcare/policy/allow.cedar @@ -1,47 +1,72 @@ // Cedar policy bundle for hospital clinical decision support agent // version: clinical-hipaa-v2.1 // author: compliance@hospital.example +// +// Action names follow the cMCP convention: tool_name converted to +// PascalCase per underscore segment, e.g. ehr.treatment_plan_writer +// becomes Action::"Ehr.treatmentPlanWriter". +// +// There is deliberately NO catch-all permit: anything not explicitly +// permitted below is denied (Cedar default-deny). The workflow_id is +// supplied by the agent via the _cmcp request metadata; calls without +// it are denied. -// Rule 1: Permit the clinical-decision-support workflow to call approved EHR tools. +// Rule 1: the clinical-decision-support workflow may look up patient records. permit ( principal, - action == Action::"tool_call", + action == Action::"Ehr.patientRecordLookup", resource ) when { + context has workflow_id && context.workflow_id == "clinical-decision-support" }; -// Rule 2: Advisory forbid on treatment plan writes for high-risk patients. -// EU AI Act Article 14 requires human oversight for high-risk AI system outputs -// in healthcare settings. When patient_risk_category == "high", the treatment -// plan must be reviewed and approved by a clinician before it takes effect. -// In enforcing mode this blocks the write and returns a 403 with the HITL payload. -forbid ( +// Rule 2: the clinical-decision-support workflow may run differential diagnosis. +permit ( principal, - action == Action::"tool_call", - resource == Tool::"ehr.treatment_plan_writer" + action == Action::"Ehr.clinicalDecisionSupport", + resource ) when { - context.patient_risk_category == "high" -} advice { - "reason": "human-review-required", - "regulation": "eu-ai-act-art-14", - "reviewer_role": "attending-physician" + context has workflow_id && + context.workflow_id == "clinical-decision-support" }; -// Rule 3: Deny confidential tool calls if the session sensitivity has been -// downgraded to "public". Prevents accidental HIPAA PHI leakage. +// Rule 3: the clinical-decision-support workflow may write treatment plans. +// Subject to the HITL forbid below. +permit ( + principal, + action == Action::"Ehr.treatmentPlanWriter", + resource +) when { + context has workflow_id && + context.workflow_id == "clinical-decision-support" +}; + +// Rule 4: EU AI Act Article 14 human oversight. Treatment plan writes for +// high-risk patients are blocked until an attending physician approves. +// The annotations are returned to the caller as structured advice on deny. +@id("hitl-high-risk") +@reason("human-review-required") +@regulation("eu-ai-act-art-14") +@reviewer_role("attending-physician") forbid ( principal, - action == Action::"tool_call", + action == Action::"Ehr.treatmentPlanWriter", resource ) when { - context.session_max_sensitivity == "public" && - resource.compliance_domain == "hipaa_phi" + context.arguments has patient_risk_category && + context.arguments.patient_risk_category == "high" }; -// Rule 4: Catch-all permit — any call not matched by a forbid above is allowed. -permit ( +// Rule 5: HIPAA PHI may only flow through an attested runtime. +@id("require-attested-runtime") +@reason("attested-runtime-required") +@regulation("hipaa-164-312") +forbid ( principal, action, resource -); +) when { + context.compliance_domain == "hipaa_phi" && + context.attestation_platform == "unknown" +}; diff --git a/healthcare/server/mock_mcp_server.py b/healthcare/server/mock_mcp_server.py new file mode 100644 index 0000000..e07c571 --- /dev/null +++ b/healthcare/server/mock_mcp_server.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Mock Hospital EHR MCP Server for the healthcare demo. + +Serves the three catalog tools with canned responses on port 8080. +Stdlib only -- no dependencies. + +Usage: + python healthcare/server/mock_mcp_server.py +""" + +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = 8080 + + +def _patient_record_lookup(args: dict) -> str: + return json.dumps({ + "patient_id": args.get("patient_id", ""), + "record_type": args.get("record_type", "full"), + "age": 54, + "active_diagnoses": ["essential hypertension"], + "current_medications": ["lisinopril 10mg"], + "last_visit": "2026-05-28", + "status": "retrieved", + }) + + +def _clinical_decision_support(args: dict) -> str: + return json.dumps({ + "patient_id": args.get("patient_id", ""), + "differential": [ + {"condition": "Type 2 Diabetes Mellitus", "confidence": 0.91}, + {"condition": "Metabolic Syndrome", "confidence": 0.64}, + ], + "recommended_tests": ["oral glucose tolerance test", "lipid panel"], + "status": "completed", + }) + + +def _treatment_plan_writer(args: dict) -> str: + return json.dumps({ + "patient_id": args.get("patient_id", ""), + "diagnosis": args.get("diagnosis"), + "treatment": args.get("treatment"), + "patient_risk_category": args.get("patient_risk_category"), + "plan_id": "TP-2026-08831", + "status": "written", + }) + + +TOOLS = { + "ehr.patient_record_lookup": _patient_record_lookup, + "ehr.clinical_decision_support": _clinical_decision_support, + "ehr.treatment_plan_writer": _treatment_plan_writer, +} + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + params = request.get("params", {}) + tool = params.get("name", "") + handler = TOOLS.get(tool) + if handler is None: + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + text = handler(params.get("arguments", {})) + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": text}]}, + }) + + def _reply(self, status: int, body: dict) -> 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, *args): + print(f"[mock-ehr] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock Hospital EHR MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/healthcare/trace-output/example-trust-record.json b/healthcare/trace-output/example-trust-record.json index fa1e57f..396ccc9 100644 --- a/healthcare/trace-output/example-trust-record.json +++ b/healthcare/trace-output/example-trust-record.json @@ -1,46 +1,79 @@ { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1750000000, - "subject": "spiffe://hospital.example/agents/clinical-decision-support/run-def456", - "model": { - "provider": "hospital-internal", - "name": "clinical-llm-hipaa", - "version": "3.0.1", - "digest": { - "sha-256": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3" + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781193632, + "subject": "spiffe://cmcp.gateway/session/f2574e45-58b2-480f-919d-50aa65d07d31", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:bab3158a32ccadfecfd69d5edcc4078fcd1f2a0814c4f33069e352754ed69d9c", + "enforcement_mode": "enforce", + "version": "clinical-hipaa-v2.1" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:237bef498e84cd658ca00a7673bb114192a110940ed821b44ae4f8c81cf247f5", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "g2zyVqHtTV9CtQBkS4PS971CMd1ilK0CtihOpuy9v14", + "kid": "cmcp-836cf256" + } } }, - "runtime": { - "platform": "software-only", - "tee_type": "dev-mode", - "measurement": "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION", - "region": "eastus" - }, - "policy": { - "framework": "cedar", - "bundle_hash": "sha256:c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0", - "enforcement_mode": "enforcing", - "version": "clinical-hipaa-v2.1" - }, - "data_class": "confidential", - "tool_transcript": [ - { - "tool": "ehr.patient_record_lookup", - "data_class": "confidential", - "decision": "allow" + "gateway": { + "session_id": "f2574e45-58b2-480f-919d-50aa65d07d31", + "gateway_version": "unknown", + "sequence_number": 3, + "prev_claim_hash": "sha256:dc612793436ff47741f94d67fa3aebf3d126741990cf48a4a29ec7baf63db9f9", + "audit_chain": { + "root": "43656adea27dbd3cd2adefbd0364a70172938185cf6bb61397d21c6bcf4b1761", + "tip": "237bef498e84cd658ca00a7673bb114192a110940ed821b44ae4f8c81cf247f5", + "length": 5 + }, + "call_summary": { + "tool_calls_total": 3, + "tool_calls_allowed": 3, + "tool_calls_denied": 0, + "tool_calls_faulted": 0, + "tools_invoked": [ + "ehr.clinical_decision_support", + "ehr.patient_record_lookup", + "ehr.treatment_plan_writer" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "hipaa_phi" + ], + "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." + } }, - { - "tool": "ehr.clinical_decision_support", - "data_class": "confidential", - "decision": "allow" + "catalog": { + "hash": "sha256:0c8009e105049f68d0f7054e254192e43eec7d567cd1fa45542450d268369647", + "drift_detected": false }, - { - "tool": "ehr.treatment_plan_writer", - "data_class": "confidential", - "decision": "allow" + "attestation_generated_at": "2026-06-11T16:00:17.584902+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 3, + "tools_called": [ + "ehr.patient_record_lookup", + "ehr.clinical_decision_support", + "ehr.treatment_plan_writer" + ], + "suspicious_sequences_detected": 0 } - ], - "cnf": { - "kid": "cmcp-b2c3d4e5" - } + }, + "signature": "49A-mPB9eY6y5396rtf3-2jn8IsCfvhf5OoRWlBLU9m94MUvK9UYZ6n76dDmqt2F9Bo4xYMe_t7ypuLfgqHhDQ" } diff --git a/multi-tenant-saas/README.md b/multi-tenant-saas/README.md index 99fbbb9..4a1f646 100644 --- a/multi-tenant-saas/README.md +++ b/multi-tenant-saas/README.md @@ -1,279 +1,157 @@ -# multi-tenant-saas: Per-Tenant Cedar Policy Isolation - -End-to-end demo of a SaaS platform serving multiple tenants with different compliance requirements, each enforced by a separate Cedar policy bundle loaded into the cMCP Runtime. - -The same three tool calls produce different outcomes depending on which tenant's policy is active. Acme Corp allows data export with an advisory warning; Globex Financial hard-blocks it because the calling workflow is not the designated data-compliance workflow. - ---- - -## What the demo shows - -**1. Policy-as-isolation at the tool boundary** -Each tenant gets their own Cedar bundle (`tenants/acme-corp/policy/` and `tenants/globex-financial/policy/`). The runtime loads one bundle at startup. Restarting with a different config file switches to the other tenant's rules. Every enforcement decision is recorded in a per-tenant TRACE Trust Record. - -**2. Progressive compliance posture** -Acme Corp uses advisory enforcement for missing GDPR justifications: the call is logged and flagged but not blocked. Globex Financial, as a regulated financial firm, uses hard deny for the same scenario. The same cMCP Runtime enforces both postures; only the Cedar bundle differs. - -**3. Auditable per-tenant TRACE records** -Because each tenant runs with a different policy version (`acme-corp-v1.0` vs `globex-financial-v3.2`), the `policy.version` field in the TRACE record identifies exactly which tenant policy was enforced. Regulators, auditors, and tenants can verify their own records independently. - ---- - -## Architecture - -``` - +------------------------------------------------------------------+ - | SaaS Agent (LLM) -- analytics-workflow | - | saas_agent.py -- JSON-RPC 2.0 over HTTP | - +-------------------------------+----------------------------------+ - | tools/call (MCP) - v - +------------------------------------------------------------------+ - | cMCP Runtime :8443 | - | | - | Policy bundle loaded at startup -- one per tenant: | - | tenants/acme-corp/policy/ (permissive) | - | tenants/globex-financial/policy/ (strict GDPR) | - +-------------------------------+----------------------------------+ - | proxied tool call - v - +------------------------------------------------------------------+ - | SaaS Platform MCP Server :8080 | - | saas.analytics_query | - | saas.user_data_export | - | saas.config_update | - +------------------------------------------------------------------+ -``` - ---- - -## Tenant policy comparison - -| Tool | acme-corp | globex-financial | -|---|---|---| -| `saas.analytics_query` | allow | allow | -| `saas.user_data_export` | advisory_deny (no GDPR justification) | deny (wrong workflow) | -| `saas.config_update` | allow | advisory_deny (wrong workflow) | - ---- - -## File layout - -``` -multi-tenant-saas/ - cmcp-config-acme-corp.yaml Runtime config for Acme Corp - cmcp-config-globex-financial.yaml Runtime config for Globex Financial - catalog.json Shared three-tool catalog - tenants/ - acme-corp/ - policy/ - manifest.json acme-corp-v1.0 - allow.cedar Permissive rules - schema.cedarschema - globex-financial/ - policy/ - manifest.json globex-financial-v3.2 - allow.cedar Strict GDPR rules - schema.cedarschema - agent/ - saas_agent.py Demo agent (run this) - trace-output/ - acme-corp-example.json Reference TRACE output for Acme Corp - globex-financial-example.json Reference TRACE output for Globex Financial -``` - ---- - -## Prerequisites - -| Requirement | Version | Notes | -|---|---|---| -| Python | 3.11+ | `python3 --version` | -| pip | any recent | `pip --version` | -| httpx | 0.27+ | installed by `pip install cmcp-runtime` | -| cmcp-runtime | latest | `pip install cmcp-runtime` | - -No hardware TEE or TPM required for this demo. The runtime runs in `CMCP_DEV_MODE=1`. - ---- - -## Step 1 - Clone and install - -```bash -git clone https://github.com/agentrust-io/examples.git -cd examples -pip install cmcp-runtime httpx -``` - ---- - -## Step 2 - Run the demo for Acme Corp - -**Terminal 1 - start the runtime with Acme Corp's policy:** - -```bash -CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-acme-corp.yaml -``` - -Expected startup output: - -``` -[cmcp] policy bundle loaded: acme-corp-v1.0 -[cmcp] catalog loaded: 3 tools -[cmcp] listening on 0.0.0.0:8443 -``` - -**Terminal 2 - run the agent:** - -```bash -python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp -``` - -Expected output: - -``` -Connecting to cMCP gateway at http://localhost:8443 -Tenant: acme-corp -Workflow: analytics-workflow - -Running the same three tool calls against acme-corp's policy bundle. - -[1/3] Calling saas.analytics_query ... - -> decision: allow -[2/3] Calling saas.user_data_export ... - -> decision: advisory_deny - reason: gdpr-justification-missing - regulation: gdpr-art-6 -[3/3] Calling saas.config_update ... - -> decision: allow - -=== TRACE Trust Record === -{ "policy": { "version": "acme-corp-v1.0", ... }, ... } -``` - -`user_data_export` is advisory: the call was logged and flagged but not blocked. - ---- - -## Step 3 - Run the demo for Globex Financial - -Stop the runtime (Ctrl-C), then restart with Globex Financial's policy: - -**Terminal 1:** - -```bash -CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-globex-financial.yaml -``` - -Expected startup output: - -``` -[cmcp] policy bundle loaded: globex-financial-v3.2 -[cmcp] catalog loaded: 3 tools -[cmcp] listening on 0.0.0.0:8443 -``` - -**Terminal 2:** - -```bash -python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial -``` - -Expected output: - -``` -Connecting to cMCP gateway at http://localhost:8443 -Tenant: globex-financial -Workflow: analytics-workflow - -Running the same three tool calls against globex-financial's policy bundle. - -[1/3] Calling saas.analytics_query ... - -> decision: allow -[2/3] Calling saas.user_data_export ... - -> decision: deny -[3/3] Calling saas.config_update ... - -> decision: advisory_deny - reason: config-update-requires-admin-workflow - -=== TRACE Trust Record === -{ "policy": { "version": "globex-financial-v3.2", ... }, ... } -``` - -`user_data_export` is a hard deny: the call was blocked. `config_update` is advisory. - -The `policy.version` field in the TRACE record shows `globex-financial-v3.2`, which is the field that identifies which tenant's policy was enforced for each session. - ---- - -## Step 4 - Verify the TRACE record - -```bash -curl -s http://localhost:8443/trace > trace.json -cmcp-verify trace.json -``` - ---- - -## Understanding the Cedar policies - -### Acme Corp (`tenants/acme-corp/policy/allow.cedar`) - -- Permit analytics-workflow for all tools -- Advisory forbid on `saas.user_data_export` when no GDPR justification is present (logged only) -- Catch-all permit - -### Globex Financial (`tenants/globex-financial/policy/allow.cedar`) - -- Permit `data-compliance-workflow` only for `saas.user_data_export` -- Permit `admin-workflow` only for `saas.config_update` -- Permit any workflow for `saas.analytics_query` -- Hard deny `saas.user_data_export` for all other workflows -- Advisory deny `saas.config_update` for all other workflows - -The difference: Acme Corp trusts its analytics workflow to handle data exports (advisory only). Globex Financial requires a purpose-specific workflow for any personal data access (hard deny by default). - ---- - -## Running both tenants simultaneously - -To demonstrate both tenants side by side without restarting, run on different ports: - -**Terminal 1 - Acme Corp on 8443:** - -```yaml -# cmcp-config-acme-corp.yaml: listen_addr: 0.0.0.0:8443 -``` - -```bash -CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-acme-corp.yaml -``` - -**Terminal 2 - Globex Financial on 8444:** - -Edit `cmcp-config-globex-financial.yaml` to set `listen_addr: 0.0.0.0:8444`, then: - -```bash -CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-globex-financial.yaml -``` - -**Terminal 3:** - -```bash -python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp --gateway http://localhost:8443 -python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial --gateway http://localhost:8444 -``` - ---- - -## Production path - -In production, per-tenant isolation is enforced by provisioning one cMCP Runtime instance per tenant (or per tenant isolation boundary), each started with that tenant's policy bundle. The runtime's TEE attestation report covers the specific policy hash that was loaded, and the TRACE Trust Record is evidence that *this specific bundle* was enforced for *this session*. - -Hot-reload (`policy_reload_interval_seconds` in the config) allows policy updates without restarts, but the hash pinning (`CMCP_POLICY_HASH` env var) must be updated to match the new bundle. - ---- - -## License - -Apache 2.0. See [LICENSE](../LICENSE) in the repo root. +# multi-tenant-saas: Per-Tenant Cedar Policy Isolation + +End-to-end demo of a SaaS platform serving tenants with different compliance postures, each enforced by a separate Cedar policy bundle - and different *enforcement modes* - in the cMCP Runtime. + +The same three tool calls produce different outcomes per tenant: + +| Tool | acme-corp (advisory) | globex-financial (enforcing) | +|---|---|---| +| `saas.analytics_query` | allow | allow | +| `saas.user_data_export` | advisory_deny (logged, not blocked) | deny | +| `saas.config_update` | allow | deny | + +--- + +## What the demo shows + +**1. Policy-as-isolation at the tool boundary** +Each tenant has its own Cedar bundle under `tenants//policy/` and its own runtime config pointing at it. The `trace.policy.version` field in each TRACE record identifies exactly which tenant policy was enforced (`acme-corp-v1.0` vs `globex-financial-v3.2`). + +**2. Progressive compliance posture via enforcement mode** +Acme Corp runs `enforcement_mode: advisory`: a matched forbid is logged in the audit chain and surfaced as `would_have_denied` + advice in the response metadata, but the call proceeds. Globex Financial runs `enforcing` with no catch-all permit - Cedar's default-deny blocks anything not explicitly permitted. + +**3. Structured advice on denies** +Both tenants' forbid rules carry `@annotation` metadata (GDPR article, required workflow) that the runtime returns to the caller - in `error.data.advice` for hard denies, in `_cmcp.advice` for advisory ones. + +--- + +## File layout + +``` +multi-tenant-saas/ + cmcp-config-acme-corp.yaml advisory mode -> tenants/acme-corp/policy + cmcp-config-globex-financial.yaml enforcing mode -> tenants/globex-financial/policy + catalog.json shared three-tool catalog + tenants/ + acme-corp/policy/ acme-corp-v1.0 (permissive) + globex-financial/policy/ globex-financial-v3.2 (default-deny) + server/ + mock_mcp_server.py mock upstream MCP server (stdlib only) + agent/ + saas_agent.py demo agent (run this) + trace-output/ + acme-corp-example.json real captured TRACE record (advisory) + globex-financial-example.json real captured TRACE record (enforcing) +``` + +--- + +## Run it + +```bash +git clone https://github.com/agentrust-io/examples.git +cd examples +pip install cmcp-runtime httpx +``` + +**Terminal 1 - mock MCP server:** + +```bash +cd multi-tenant-saas +python server/mock_mcp_server.py +``` + +**Terminal 2 - runtime with Acme Corp's policy** (run from inside `multi-tenant-saas/`): + +```bash +cd multi-tenant-saas +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-acme-corp.yaml +``` + +**Terminal 3 - agent:** + +```bash +cd examples +python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp +``` + +``` +[1/3] Calling saas.analytics_query ... + -> decision: allow +[2/3] Calling saas.user_data_export ... + -> decision: advisory_deny (logged, not blocked) + advice from policy: + id: gdpr-justification-missing + reason: gdpr-justification-missing + regulation: gdpr-art-6 +[3/3] Calling saas.config_update ... + -> decision: allow +``` + +**Switch tenants** - stop the runtime (Ctrl-C) and restart with Globex Financial's config: + +```bash +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-globex-financial.yaml +``` + +```bash +python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial +``` + +``` +[1/3] Calling saas.analytics_query ... + -> decision: allow +[2/3] Calling saas.user_data_export ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: export-requires-compliance-workflow + reason: export-requires-data-compliance-workflow + regulation: gdpr-art-6 +[3/3] Calling saas.config_update ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: config-update-requires-admin-workflow + reason: config-update-requires-admin-workflow +``` + +Each run ends by closing the session and printing the signed TRACE Trust Record. Compare `trace.policy.version` and `gateway.call_summary.tool_calls_denied` across the two captured examples in `trace-output/`. + +--- + +## How the policies differ + +**Acme Corp** (`tenants/acme-corp/policy/allow.cedar`): a catch-all permit plus one annotated forbid - user data export without a `gdpr_justification` argument. Under advisory mode this logs and flags but does not block. + +**Globex Financial** (`tenants/globex-financial/policy/allow.cedar`): no catch-all. Explicit permits per tool, gated on the workflow the agent declares via `_cmcp.workflow_id`: + +```cedar +permit ( + principal, + action == Action::"Saas.userDataExport", + resource +) when { + context has workflow_id && + context.workflow_id == "data-compliance-workflow" +}; +``` + +The demo agent runs as `analytics-workflow`, so exports and config updates deny. Annotated forbid rules make those denies carry structured advice instead of being silent default-denies. + +--- + +## Running both tenants simultaneously + +Run two runtimes on different ports (edit `listen_addr` in one config), one per tenant, against the same mock server: + +```bash +python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp --gateway http://localhost:8443 +python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial --gateway http://localhost:9443 +``` + +This is the production topology: one attested runtime instance per tenant isolation boundary, each measuring its own policy bundle hash into its TRACE records. + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/multi-tenant-saas/agent/saas_agent.py b/multi-tenant-saas/agent/saas_agent.py index f00dcbd..15ba5db 100644 --- a/multi-tenant-saas/agent/saas_agent.py +++ b/multi-tenant-saas/agent/saas_agent.py @@ -2,21 +2,17 @@ """ SaaS platform agent demo showing per-tenant Cedar policy isolation. -Calls three platform tools through the cMCP gateway using raw JSON-RPC 2.0 over HTTP. -No MCP SDK required -- httpx only. +Runs the same three tool calls; the outcome depends on which tenant's policy +bundle the runtime was started with: -Usage: - python saas_agent.py [--gateway http://localhost:8443] --tenant acme-corp|globex-financial - -Start the runtime with the matching config before running: - CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-acme-corp.yaml - CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-globex-financial.yaml + acme-corp (advisory): analytics allow, user_data_export + advisory_deny with GDPR advice (logged, not blocked), + config_update allow + globex-financial (enforcing): analytics allow, user_data_export deny, + config_update deny -- both with structured advice -Expected results: - acme-corp : analytics_query=allow, user_data_export=advisory_deny (no justification), - config_update=allow - globex-financial : analytics_query=allow, user_data_export=deny (wrong workflow), - config_update=advisory_deny (wrong workflow) +Usage: + python saas_agent.py --tenant acme-corp|globex-financial [--gateway http://localhost:8443] """ import argparse @@ -28,126 +24,106 @@ WORKFLOW_ID = "analytics-workflow" -# --------------------------------------------------------------------------- -# JSON-RPC helpers -# --------------------------------------------------------------------------- - -def make_call(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: - """Send a tools/call to the cMCP gateway. Returns the result dict or a _denied marker.""" +def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: payload = { "jsonrpc": "2.0", "id": req_id, "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": WORKFLOW_ID}, + }, } resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) - resp.raise_for_status() body = resp.json() if "error" in body: - err = body["error"] - data = err.get("data", {}) - if isinstance(data, dict) and data.get("decision") in ("deny", "advisory_deny"): - return {"_denied": True, "_decision": data.get("decision"), "_error": err} - raise RuntimeError(f"Tool call error from {tool_name}: {err}") - return body.get("result", {}) - - -def fetch_trace(client: httpx.Client, gateway: str) -> dict: - resp = client.get(f"{gateway}/trace", timeout=10) - resp.raise_for_status() - return resp.json() + return {"ok": False, "error": body["error"], "session_id": None} + result = body["result"] + return { + "ok": True, + "result": result, + "session_id": result.get("_cmcp", {}).get("session_id"), + } -def print_result(step: str, tool: str, result: dict) -> None: - if result.get("_denied"): - err = result["_error"] - data = err.get("data", {}) - decision = result["_decision"] - print(f" -> decision: {decision}") - if data.get("reason"): - print(f" reason: {data['reason']}") - if data.get("regulation"): - print(f" regulation: {data['regulation']}") +def print_outcome(outcome: dict) -> None: + if outcome["ok"]: + meta = outcome["result"].get("_cmcp", {}) + if meta.get("would_have_denied"): + print(" -> decision: advisory_deny (logged, not blocked)") + advice = meta.get("advice") + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") + else: + print(" -> decision: allow") else: - print(f" -> decision: {result.get('cmcp_decision', 'allow')}") + data = outcome["error"].get("data", {}) + print(f" -> decision: deny ({data.get('error_code', 'unknown')})") + advice = data.get("advice") + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") -# --------------------------------------------------------------------------- -# Main demo flow -# --------------------------------------------------------------------------- +def close_session(client: httpx.Client, gateway: str, session_id: str) -> dict: + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) + resp.raise_for_status() + return resp.json() + def run(gateway: str, tenant: str) -> None: - print(f"Connecting to cMCP gateway at {gateway}") + print(f"Connecting to cMCP Runtime at {gateway}") print(f"Tenant: {tenant}") print(f"Workflow: {WORKFLOW_ID}") print() print(f"Running the same three tool calls against {tenant}'s policy bundle.") print() + session_id = None with httpx.Client(headers={"Content-Type": "application/json"}) as client: - - # Call 1: analytics_query — allowed for all tenants. print("[1/3] Calling saas.analytics_query ...") - r1 = make_call( - client, gateway, - tool_name="saas.analytics_query", - arguments={"metric": "daily_active_users", "time_range_days": 30}, - req_id=1, - ) - print_result("1/3", "saas.analytics_query", r1) - - # Call 2: user_data_export — advisory warn for acme-corp, hard deny for globex-financial. + o1 = call_tool(client, gateway, "saas.analytics_query", + {"metric": "daily_active_users", "time_range_days": 30}, 1) + print_outcome(o1) + session_id = o1.get("session_id") or session_id + print("[2/3] Calling saas.user_data_export ...") - r2 = make_call( - client, gateway, - tool_name="saas.user_data_export", - arguments={"user_id": "usr_abc123", "format": "json"}, - req_id=2, - ) - print_result("2/3", "saas.user_data_export", r2) - - # Call 3: config_update — allowed for acme-corp, advisory deny for globex-financial. + o2 = call_tool(client, gateway, "saas.user_data_export", + {"user_id": "usr_abc123", "format": "json"}, 2) + print_outcome(o2) + session_id = o2.get("session_id") or session_id + print("[3/3] Calling saas.config_update ...") - r3 = make_call( - client, gateway, - tool_name="saas.config_update", - arguments={"key": "session_timeout_minutes", "value": "60"}, - req_id=3, - ) - print_result("3/3", "saas.config_update", r3) + o3 = call_tool(client, gateway, "saas.config_update", + {"key": "session_timeout_minutes", "value": "60"}, 3) + print_outcome(o3) + session_id = o3.get("session_id") or session_id print() - print("Fetching TRACE Trust Record from gateway ...") - try: - trace = fetch_trace(client, gateway) - print() - print("=== TRACE Trust Record ===") - print(json.dumps(trace, indent=2)) - except Exception as exc: - print(f" (Could not fetch live TRACE record: {exc})") - print(f" See multi-tenant-saas/trace-output/{tenant}-example.json for reference output.") + if session_id is None: + print("No session id received - cannot fetch TRACE Trust Record.") sys.exit(1) - print() - print("Done. Start the runtime with the other tenant config to see the policy difference.") + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + claim = close_session(client, gateway, session_id) + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(claim, indent=2)) + print() + print("Done. Restart the runtime with the other tenant config to see the policy difference.") -# --------------------------------------------------------------------------- -# Entrypoint -# --------------------------------------------------------------------------- if __name__ == "__main__": parser = argparse.ArgumentParser(description="SaaS multi-tenant policy isolation demo") - parser.add_argument( - "--gateway", - default=DEFAULT_GATEWAY, - help=f"cMCP gateway base URL (default: {DEFAULT_GATEWAY})", - ) - parser.add_argument( - "--tenant", - required=True, - choices=["acme-corp", "globex-financial"], - help="Which tenant config the runtime was started with", - ) + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") + parser.add_argument("--tenant", required=True, + choices=["acme-corp", "globex-financial"], + help="Which tenant config the runtime was started with") args = parser.parse_args() run(args.gateway, args.tenant) diff --git a/multi-tenant-saas/catalog.json b/multi-tenant-saas/catalog.json index 62002cb..a3a8165 100644 --- a/multi-tenant-saas/catalog.json +++ b/multi-tenant-saas/catalog.json @@ -49,7 +49,7 @@ "definition_hash": "sha256:dbfe9ce96ebc7580a162e45e82c65c076e62f39c293c32b93ed1ce20a4b59655", "compliance_domain": "internal", "requires_baa": false, - "sensitivity_level": "internal", + "sensitivity_level": "public", "added_at": "2026-06-01T00:00:00Z", "approved_by": "platform-security@saas.example" }, @@ -75,7 +75,7 @@ "definition_hash": "sha256:9bf90691ce6f3ed9a18da33b6b41f71d12ee77e51d8a81137c1723ea97e1647a", "compliance_domain": "internal", "requires_baa": false, - "sensitivity_level": "internal", + "sensitivity_level": "public", "added_at": "2026-06-01T00:00:00Z", "approved_by": "platform-security@saas.example" } diff --git a/multi-tenant-saas/cmcp-config-acme-corp.yaml b/multi-tenant-saas/cmcp-config-acme-corp.yaml index 58e3481..cc518f8 100644 --- a/multi-tenant-saas/cmcp-config-acme-corp.yaml +++ b/multi-tenant-saas/cmcp-config-acme-corp.yaml @@ -4,5 +4,7 @@ listen_addr: 0.0.0.0:8443 audit_db_path: ./audit-acme-corp.db attestation: provider: auto - enforcement_mode: enforcing + # Acme Corp runs advisory: policy violations are logged in the audit chain + # and surfaced as would_have_denied + advice, but calls are not blocked. + enforcement_mode: advisory validity_seconds: 86400 diff --git a/multi-tenant-saas/server/mock_mcp_server.py b/multi-tenant-saas/server/mock_mcp_server.py new file mode 100644 index 0000000..6ef5f4a --- /dev/null +++ b/multi-tenant-saas/server/mock_mcp_server.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Mock SaaS Platform MCP Server for the multi-tenant-saas demo. + +Serves the three catalog tools with canned responses on port 8080. +Stdlib only -- no dependencies. + +Usage: + python multi-tenant-saas/server/mock_mcp_server.py +""" + +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = 8080 + + +def _user_data_export(args: dict) -> str: + return json.dumps({ + "user_id": args.get("user_id", ""), + "format": args.get("format", "json"), + "records_exported": 1342, + "export_id": "EXP-2026-00917", + "status": "exported", + }) + + +def _analytics_query(args: dict) -> str: + return json.dumps({ + "metric": args.get("metric", ""), + "time_range_days": args.get("time_range_days", 30), + "value": 48213, + "trend": "+4.2%", + "status": "completed", + }) + + +def _config_update(args: dict) -> str: + return json.dumps({ + "key": args.get("key", ""), + "value": args.get("value", ""), + "previous_value": "30", + "status": "updated", + }) + + +TOOLS = { + "saas.user_data_export": _user_data_export, + "saas.analytics_query": _analytics_query, + "saas.config_update": _config_update, +} + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + params = request.get("params", {}) + tool = params.get("name", "") + handler = TOOLS.get(tool) + if handler is None: + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + text = handler(params.get("arguments", {})) + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": text}]}, + }) + + def _reply(self, status: int, body: dict) -> 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, *args): + print(f"[mock-saas] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock SaaS Platform MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar b/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar index 0014a81..0c0d014 100644 --- a/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar +++ b/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar @@ -1,35 +1,24 @@ // Cedar policy bundle for Acme Corp tenant // version: acme-corp-v1.0 -// Standard SaaS tenant — all tools permitted for the analytics workflow. -// User data export requires a GDPR justification (advisory only — logged -// but not blocked, so the business can operate while the DPO reviews). +// +// Acme Corp runs the runtime in ADVISORY enforcement mode (see +// cmcp-config-acme-corp.yaml): a matched forbid is logged in the audit +// chain and surfaced as would_have_denied + advice in the response +// metadata, but the call is not blocked. -// Rule 1: Permit the analytics workflow to call any platform tool. -permit ( - principal, - action == Action::"tool_call", - resource -) when { - context.workflow_id == "analytics-workflow" -}; +// Rule 1: permit all catalog tools. +permit (principal, action, resource); -// Rule 2: Advisory forbid on user data export without a GDPR justification. -// Logged in the TRACE record for DPO review; does not block the call. +// Rule 2: user data export should carry a GDPR Article 6 justification. +// Advisory: flagged for DPO review, not blocked. +@id("gdpr-justification-missing") +@reason("gdpr-justification-missing") +@regulation("gdpr-art-6") forbid ( principal, - action == Action::"tool_call", - resource == Tool::"saas.user_data_export" + action == Action::"Saas.userDataExport", + resource ) when { - !context.has("gdpr_justification") || context.gdpr_justification == "" -} advice { - "reason": "gdpr-justification-missing", - "regulation": "gdpr-art-6", - "action": "advisory-log-only" + !(context.arguments has gdpr_justification) || + context.arguments.gdpr_justification == "" }; - -// Rule 3: Catch-all permit. -permit ( - principal, - action, - resource -); diff --git a/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar b/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar index d8d9d0f..699f1be 100644 --- a/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar +++ b/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar @@ -1,54 +1,60 @@ // Cedar policy bundle for Globex Financial tenant // version: globex-financial-v3.2 -// Financial services tenant — strict GDPR compliance required. -// User data export is only permitted for the data-compliance-workflow. -// Config updates are only permitted for the admin-workflow. +// +// Globex Financial runs in ENFORCING mode with no catch-all permit: +// anything not explicitly permitted is denied (Cedar default-deny). +// The workflow_id is supplied by the agent via the _cmcp request metadata. -// Rule 1: Permit data-compliance-workflow to export user data. +// Rule 1: any workflow may run read-only analytics queries. permit ( principal, - action == Action::"tool_call", - resource == Tool::"saas.user_data_export" + action == Action::"Saas.analyticsQuery", + resource +); + +// Rule 2: only the data-compliance workflow may export user data. +permit ( + principal, + action == Action::"Saas.userDataExport", + resource ) when { + context has workflow_id && context.workflow_id == "data-compliance-workflow" }; -// Rule 2: Permit admin-workflow to update configuration. +// Rule 3: only the admin workflow may update configuration. permit ( principal, - action == Action::"tool_call", - resource == Tool::"saas.config_update" + action == Action::"Saas.configUpdate", + resource ) when { + context has workflow_id && context.workflow_id == "admin-workflow" }; -// Rule 3: Permit any workflow to run read-only analytics queries. -permit ( - principal, - action == Action::"tool_call", - resource == Tool::"saas.analytics_query" -); - -// Rule 4: Deny user data export for all other workflows (hard deny, no advice). -// Financial services regulations require explicit GDPR Article 6 lawful basis -// per request. Only the data-compliance-workflow is authorised to invoke this. +// Rule 4: explicit annotated forbid for data export outside the +// data-compliance workflow, so the deny carries structured advice. +@id("export-requires-compliance-workflow") +@reason("export-requires-data-compliance-workflow") +@regulation("gdpr-art-6") forbid ( principal, - action == Action::"tool_call", - resource == Tool::"saas.user_data_export" + action == Action::"Saas.userDataExport", + resource ) when { + !(context has workflow_id) || context.workflow_id != "data-compliance-workflow" }; -// Rule 5: Advisory forbid on config updates outside the admin-workflow. -// Logged for the security team; does not block. +// Rule 5: explicit annotated forbid for config updates outside the +// admin workflow. +@id("config-update-requires-admin-workflow") +@reason("config-update-requires-admin-workflow") forbid ( principal, - action == Action::"tool_call", - resource == Tool::"saas.config_update" + action == Action::"Saas.configUpdate", + resource ) when { + !(context has workflow_id) || context.workflow_id != "admin-workflow" -} advice { - "reason": "config-update-requires-admin-workflow", - "action": "advisory-log-and-notify-security" }; diff --git a/multi-tenant-saas/trace-output/acme-corp-example.json b/multi-tenant-saas/trace-output/acme-corp-example.json index 014a7f4..af80cb4 100644 --- a/multi-tenant-saas/trace-output/acme-corp-example.json +++ b/multi-tenant-saas/trace-output/acme-corp-example.json @@ -1,43 +1,87 @@ { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1750000000, - "subject": "spiffe://saas.example/agents/analytics-workflow/run-ghi789", - "runtime": { - "platform": "software-only", - "tee_type": "dev-mode", - "measurement": "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION", - "region": "westus2" - }, - "policy": { - "framework": "cedar", - "bundle_hash": "sha256:d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2", - "enforcement_mode": "enforcing", - "version": "acme-corp-v1.0" + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781152392, + "subject": "spiffe://cmcp.gateway/session/089f099b-2956-4ce4-a9c0-28fc4693691b", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:0d5d46ab25bfefaa08d373301a915ebabc9341bcd1a55c9bddc6e8a509da53ad", + "enforcement_mode": "advisory", + "version": "acme-corp-v1.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:5e1e19b1fbca138ea9ab9749c01d7e01ebfe87552a68abc9857710988bc7d29f", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pG-sKYIJj7-mj2PJlmIKItUNpAYhpW8wrpT22wBsbu8", + "kid": "cmcp-a46fac29" + } + } }, - "data_class": "confidential", - "tool_transcript": [ - { - "tool": "saas.analytics_query", - "data_class": "internal", - "decision": "allow" + "gateway": { + "session_id": "089f099b-2956-4ce4-a9c0-28fc4693691b", + "gateway_version": "unknown", + "sequence_number": 1, + "audit_chain": { + "root": "95861b6861166a44ff977321a3c9f2dc3931156d1ded1cc713c6ae0e41fbae4c", + "tip": "5e1e19b1fbca138ea9ab9749c01d7e01ebfe87552a68abc9857710988bc7d29f", + "length": 5 }, - { - "tool": "saas.user_data_export", - "data_class": "confidential", - "decision": "advisory_deny", - "advice": { - "reason": "gdpr-justification-missing", - "regulation": "gdpr-art-6", - "action": "advisory-log-only" + "call_summary": { + "tool_calls_total": 3, + "tool_calls_allowed": 2, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "saas.analytics_query", + "saas.config_update", + "saas.user_data_export" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "29c2c96b-897d-4d0c-8ceb-62ba945d3f13", + "tool_name": "saas.config_update", + "sequence_number": 2 + } + ], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." } }, - { - "tool": "saas.config_update", - "data_class": "internal", - "decision": "allow" + "catalog": { + "hash": "sha256:0822bef375bf58034d38f951fc1651125d3b2aae486bd0e2ea847470031d30a3", + "drift_detected": false + }, + "attestation_generated_at": "2026-06-11T04:33:05.280665+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 3, + "tools_called": [ + "saas.analytics_query", + "saas.user_data_export", + "saas.config_update" + ], + "suspicious_sequences_detected": 0 } - ], - "cnf": { - "kid": "cmcp-c3d4e5f6" - } + }, + "signature": "mVFKdyAT5yWtSK2EbwuwTQsiJtNXD4WQO3yTOmoyuNbZMdZ_utc6TCVfFfQkiu38yIriBbioylO47hiz2MIhDA" } diff --git a/multi-tenant-saas/trace-output/globex-financial-example.json b/multi-tenant-saas/trace-output/globex-financial-example.json index 8b9e13c..0fcf641 100644 --- a/multi-tenant-saas/trace-output/globex-financial-example.json +++ b/multi-tenant-saas/trace-output/globex-financial-example.json @@ -1,42 +1,87 @@ { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1750000000, - "subject": "spiffe://saas.example/agents/analytics-workflow/run-jkl012", - "runtime": { - "platform": "software-only", - "tee_type": "dev-mode", - "measurement": "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION", - "region": "westus2" - }, - "policy": { - "framework": "cedar", - "bundle_hash": "sha256:e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3", - "enforcement_mode": "enforcing", - "version": "globex-financial-v3.2" - }, - "data_class": "internal", - "tool_transcript": [ - { - "tool": "saas.analytics_query", - "data_class": "internal", - "decision": "allow" + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781152408, + "subject": "spiffe://cmcp.gateway/session/5f06dd48-0e58-4f6d-a6d0-6a96035b963a", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:8d1b7aea5282d16472ef191d2d0877ccfebeb9a4b8aca7f082a75856509bd249", + "enforcement_mode": "enforce", + "version": "globex-financial-v3.2" }, - { - "tool": "saas.user_data_export", - "data_class": "confidential", - "decision": "deny" + "data_class": "public", + "tool_transcript": { + "hash": "sha256:24c8baabe05b2bda3c1ab98d21e49ab60a3f1389c143774d15b4911e863c28cf", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "-G7uhqHvKU6St8OVhEC-g4cPOFGF3WSFTL52-ERHrtA", + "kid": "cmcp-f86eee86" + } + } + }, + "gateway": { + "session_id": "5f06dd48-0e58-4f6d-a6d0-6a96035b963a", + "gateway_version": "unknown", + "sequence_number": 1, + "audit_chain": { + "root": "ae7fd80f0d2fb63aa425ca2ba7835fc7e7c424dd85ed915c45dd3065f6be0100", + "tip": "24c8baabe05b2bda3c1ab98d21e49ab60a3f1389c143774d15b4911e863c28cf", + "length": 5 }, - { - "tool": "saas.config_update", - "data_class": "internal", - "decision": "advisory_deny", - "advice": { - "reason": "config-update-requires-admin-workflow", - "action": "advisory-log-and-notify-security" + "call_summary": { + "tool_calls_total": 3, + "tool_calls_allowed": 1, + "tool_calls_denied": 2, + "tool_calls_faulted": 0, + "tools_invoked": [ + "saas.analytics_query", + "saas.config_update", + "saas.user_data_export" + ], + "session_max_sensitivity": "public", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "673836fc-a63e-4439-af31-ee507e548f80", + "tool_name": "saas.config_update", + "sequence_number": 2 + } + ], + "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:0822bef375bf58034d38f951fc1651125d3b2aae486bd0e2ea847470031d30a3", + "drift_detected": false + }, + "attestation_generated_at": "2026-06-11T04:33:18.227824+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 3, + "tools_called": [ + "saas.analytics_query", + "saas.user_data_export", + "saas.config_update" + ], + "suspicious_sequences_detected": 0 } - ], - "cnf": { - "kid": "cmcp-d4e5f6a7" - } + }, + "signature": "SxKxPtfRgIwRgCMiHQb05aBwqnSBXZF-h4nbQSt8MkF-cFTOCf66N1MFe_43IdCeqWnZg8YV42IeTwrhSn8qDg" } diff --git a/startup-tpm/README.md b/startup-tpm/README.md index e609665..2a6533f 100644 --- a/startup-tpm/README.md +++ b/startup-tpm/README.md @@ -1,244 +1,267 @@ -# startup-tpm: 15-Minute cMCP Quickstart - -Get a cMCP Runtime running with TPM-backed TRACE Trust Records in under 15 minutes. Works on any cloud VM with TPM 2.0 (Azure Trusted Launch, AWS Nitro, GCP Shielded VM) or with `CMCP_DEV_MODE=1` for local development (no hardware required for testing). - ---- - -## What you will have at the end - -- A cMCP Runtime running on port 8443 -- A Cedar policy that permits all tool calls (replace before production) -- A one-tool catalog (`test.echo`) -- A TRACE Trust Record you can inspect and verify - -Estimated time: 15 minutes on a fresh VM, 5 minutes if Python is already installed. - ---- - -## Prerequisites - -| Requirement | Version | Notes | -|---|---|---| -| Python | 3.11+ | `python3 --version` | -| pip | any recent | `pip --version` | -| curl | any | For the test tool call | -| TPM 2.0 | optional | Required for hardware attestation; omit with `CMCP_DEV_MODE=1` | - -No MCP server is required; the runtime runs a built-in echo responder for the `test.echo` tool. - ---- - -## Step 1 - Install - -```bash -pip install cmcp-runtime -``` - -Verify: - -```bash -cmcp --version -``` - -Expected output: `cmcp-runtime 0.x.y` - ---- - -## Step 2 - Get the quickstart files - -```bash -git clone https://github.com/agentrust-io/examples.git -cd examples/startup-tpm -``` - -The directory contains: - -``` -startup-tpm/ - cmcp-config.yaml runtime configuration - catalog.json one-tool catalog (test.echo) - policy/ - manifest.json policy bundle metadata - allow.cedar permit-all policy - schema.cedarschema Cedar schema (minimal) - agent/ - echo_agent.py minimal agent script -``` - ---- - -## Step 3 - Review the config - -`cmcp-config.yaml`: - -```yaml -policy_bundle_path: ./policy -catalog_path: ./catalog.json -listen_addr: 0.0.0.0:8443 -attestation: - provider: auto - enforcement_mode: advisory -``` - -`enforcement_mode: advisory` means the runtime logs policy violations but does not block calls. Change to `enforcing` before production. - -`provider: auto` selects the best available attestation source: TPM 2.0 if present, software-only otherwise. - ---- - -## Step 4 - Start the runtime - -### With hardware TPM (Azure Trusted Launch, AWS Nitro, GCP Shielded VM) - -```bash -cmcp start --config startup-tpm/cmcp-config.yaml -``` - -The runtime will print the TPM attestation measurement on startup. - -### Without hardware TPM (local dev, CI) - -```bash -CMCP_DEV_MODE=1 cmcp start --config startup-tpm/cmcp-config.yaml -``` - -`CMCP_DEV_MODE=1` sets `tee_type: dev-mode` in the TRACE record and marks the measurement `DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION`. The runtime is fully functional but the attestation is not hardware-backed. - -Expected startup output: - -``` -[cmcp] policy bundle loaded: quickstart-v1.0 -[cmcp] catalog loaded: 1 tool (test.echo) -[cmcp] attestation: dev-mode (CMCP_DEV_MODE=1) -[cmcp] listening on 0.0.0.0:8443 -``` - ---- - -## Step 5 - Make a test tool call - -**Option A - agent script (recommended):** - -```bash -python startup-tpm/agent/echo_agent.py -``` - -The script calls `test.echo`, prints the policy decision, and fetches the TRACE Trust Record in one shot. - -**Option B - curl:** - -```bash -curl -X POST http://localhost:8443/mcp \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test.echo","arguments":{"message":"hello"}}}' -``` - -Expected response: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "content": [{"type": "text", "text": "hello"}], - "cmcp_decision": "allow", - "cmcp_policy_version": "quickstart-v1.0" - } -} -``` - ---- - -## Step 6 - Get the TRACE Trust Record - -```bash -curl http://localhost:8443/trace | python3 -m json.tool -``` - -The TRACE record covers the entire session (all tool calls since the runtime started). Example output: - -```json -{ - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1750000000, - "subject": "spiffe://localhost/agents/anonymous/run-...", - "runtime": { - "platform": "software-only", - "tee_type": "dev-mode", - "measurement": "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION" - }, - "policy": { - "framework": "cedar", - "enforcement_mode": "advisory", - "version": "quickstart-v1.0" - }, - "data_class": "internal", - "tool_transcript": [ - {"tool": "test.echo", "data_class": "internal", "decision": "allow"} - ], - "cnf": {"kid": "cmcp-..."} -} -``` - ---- - -## Step 7 - Verify the TRACE record (optional) - -```bash -curl -s http://localhost:8443/trace > trace.json -cmcp-verify trace.json -``` - -In dev mode the output will be: - -``` -[cmcp-verify] signature: valid -[cmcp-verify] attestation: dev-mode (not hardware-backed) -[cmcp-verify] policy version: quickstart-v1.0 -[cmcp-verify] tool transcript: 1 call, all allowed -[cmcp-verify] RESULT: PASS (dev-mode) -``` - ---- - -## Next steps - -| Goal | Where to look | -|---|---| -| Real financial-services example with Cedar escalation rules | `financial-services/` | -| Hardware attestation on Azure | See [Azure Trusted Launch docs](https://learn.microsoft.com/azure/virtual-machines/trusted-launch) | -| Writing your own Cedar policies | [Cedar policy language reference](https://www.cedarpolicy.com/en/tutorial) | -| Protecting a real MCP server | Edit `catalog.json` to point `server.url` at your MCP server | -| Production enforcement | Change `enforcement_mode: advisory` to `enforcement_mode: enforcing` | - ---- - -## Troubleshooting - -**Port 8443 already in use** - -```bash -# Change listen_addr in cmcp-config.yaml, e.g.: -listen_addr: 0.0.0.0:9443 -``` - -**`cmcp` command not found** - -```bash -# Make sure pip's bin directory is on PATH: -python3 -m cmcp --version -# or: -pip show cmcp-runtime | grep Location -export PATH="$PATH:$(pip show cmcp-runtime | grep Location | cut -d' ' -f2)/../../../bin" -``` - -**`CMCP_DEV_MODE` not recognised on Windows** - -```powershell -$env:CMCP_DEV_MODE = "1" -cmcp start --config startup-tpm/cmcp-config.yaml -``` - -**Runtime exits immediately** - -Check that `policy/` and `catalog.json` exist relative to the working directory from which you run `cmcp start`. The `policy_bundle_path` and `catalog_path` in `cmcp-config.yaml` are resolved relative to the config file's location, not the working directory. +# startup-tpm: 15-Minute cMCP Quickstart + +Get a cMCP Runtime running with TPM-backed TRACE Trust Records in under 15 minutes. Works on any cloud VM with TPM 2.0 (Azure Trusted Launch, AWS Nitro, GCP Shielded VM) or with `CMCP_DEV_MODE=1` for local development - no hardware required for testing. + +--- + +## What you will have at the end + +- A cMCP Runtime running on port 8443, proxying calls to an upstream MCP server +- A Cedar policy that permits all tool calls (replace before production) +- A one-tool catalog (`test.echo`) and a mock MCP server that serves it +- A signed TRACE Trust Record you can inspect + +Estimated time: 15 minutes on a fresh VM, 5 minutes if Python is already installed. + +--- + +## Prerequisites + +| Requirement | Version | Notes | +|---|---|---| +| Python | 3.11+ | `python3 --version` | +| pip | any recent | `pip --version` | +| TPM 2.0 | optional | Required for hardware attestation; omit with `CMCP_DEV_MODE=1` | + +--- + +## Step 1 - Install + +```bash +pip install cmcp-runtime +``` + +Verify: + +```bash +cmcp --version +``` + +--- + +## Step 2 - Get the quickstart files + +```bash +git clone https://github.com/agentrust-io/examples.git +cd examples/startup-tpm +``` + +The directory contains: + +``` +startup-tpm/ + cmcp-config.yaml runtime configuration + catalog.json one-tool catalog (test.echo) + policy/ + manifest.json policy bundle metadata + allow.cedar permit-all policy + schema.cedarschema Cedar schema (minimal) + server/ + mock_mcp_server.py mock upstream MCP server (stdlib only) + agent/ + echo_agent.py minimal agent script +``` + +--- + +## Step 3 - Start the mock MCP server + +The runtime proxies tool calls to the upstream MCP server listed in `catalog.json` (`http://localhost:8080/mcp`). The quickstart ships a mock: + +**Terminal 1:** + +```bash +python server/mock_mcp_server.py +``` + +To protect a real MCP server instead, point `server.url` in `catalog.json` at it and recompute nothing - the catalog hash is only pinned in production mode. + +--- + +## Step 4 - Start the runtime + +Run from inside `startup-tpm/` - the `./policy` and `./catalog.json` paths in the config resolve relative to the working directory. + +**Terminal 2, with hardware TPM (Azure Trusted Launch, AWS Nitro, GCP Shielded VM):** + +```bash +cmcp start --config cmcp-config.yaml +``` + +**Without hardware TPM (local dev, CI):** + +```bash +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +On Windows PowerShell: + +```powershell +$env:CMCP_DEV_MODE = "1" +cmcp start --config cmcp-config.yaml +``` + +`CMCP_DEV_MODE=1` marks the attestation `software-only-dev-mode` in the TRACE record. The runtime is fully functional but the attestation is not hardware-backed. + +Expected startup output ends with: + +``` +cMCP Runtime starting: TEE: software-only, listen: 0.0.0.0:8443 +INFO: Uvicorn running on http://0.0.0.0:8443 +``` + +--- + +## Step 5 - Make a test tool call + +**Terminal 3, Option A - agent script (recommended):** + +```bash +python agent/echo_agent.py +``` + +Expected output: + +``` +[1/1] Calling test.echo ... + -> decision: allow + -> echoed: hello from cMCP + +Closing session and fetching the signed TRACE Trust Record ... + +=== TRACE Trust Record (signed RuntimeClaim) === +{ + "cmcp_version": "1.0", + "trace": { ... }, + "gateway": { ... }, + "signature": "..." +} +``` + +**Option B - curl:** + +```bash +curl -X POST http://localhost:8443/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test.echo","arguments":{"message":"hello"}}}' +``` + +The response carries the echoed text plus `_cmcp` enforcement metadata: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "hello"}], + "_cmcp": { + "call_id": "...", + "audit_entry_hash": "...", + "would_have_denied": false, + "latency_us": 12345, + "session_id": "..." + } + } +} +``` + +--- + +## Step 6 - Get the TRACE Trust Record + +TRACE Trust Records are sealed when a session closes. Close the session (use the `session_id` from `_cmcp`): + +```bash +curl -X POST http://localhost:8443/sessions//close | python3 -m json.tool +``` + +The response is the signed `RuntimeClaim` (see `trace-output/example-trust-record.json` for a real captured example). The runtime immediately starts a fresh session, so further tool calls keep working. + +You can also export the hash-chained audit log for a closed session: + +```bash +curl "http://localhost:8443/audit/export?session_id=" > bundle.json +``` + +--- + +## Step 7 - Verify the record (and try to tamper with it) + +Save the claim from Step 6 to `claim.json`, then verify it: + +```bash +cmcp verify claim.json --audit-bundle bundle.json +``` + +``` +[cmcp verify] schema PASS +[cmcp verify] signature PASS +[cmcp verify] attestation_freshness PASS +[cmcp verify] audit_chain PASS +[cmcp verify] audit_bundle PASS (3 entries) +[cmcp verify] RESULT: PASS (verified) +``` + +Now change anything - a counter, a tool name, one character - and verify again: + +```bash +python3 -c " +import json +c = json.load(open('claim.json')) +c['gateway']['call_summary']['tool_calls_total'] += 1 +json.dump(c, open('claim.json', 'w')) +" +cmcp verify claim.json +``` + +``` +[cmcp verify] signature FAIL +[cmcp verify] RESULT: FAIL (partially_verified) +``` + +The signature covers the whole canonical claim, so no field can be altered after the fact. The same holds for the audit bundle: editing any entry breaks both its hash chain and the bundle signature. This is the tamper-evidence guarantee in one command. + +In production, pin the approved hashes so a substituted policy or catalog also fails: + +```bash +cmcp verify claim.json --policy-hash sha256: --catalog-hash sha256: +``` + +--- + +## Next steps + +| Goal | Where to look | +|---|---| +| Cedar escalation rules with HITL advice | `financial-services/`, `healthcare/` | +| Per-tenant policy isolation | `multi-tenant-saas/` | +| Hardware attestation on Azure | [Azure Trusted Launch docs](https://learn.microsoft.com/azure/virtual-machines/trusted-launch) | +| Writing your own Cedar policies | [Cedar policy language reference](https://www.cedarpolicy.com/en/tutorial) | +| Protecting a real MCP server | Edit `catalog.json` to point `server.url` at your MCP server | +| Production enforcement | Unset `CMCP_DEV_MODE`; set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, `CMCP_CATALOG_HASH` | + +--- + +## Troubleshooting + +**Port 8443 already in use** + +```bash +# Change listen_addr in cmcp-config.yaml, e.g.: +listen_addr: 0.0.0.0:9443 +``` + +**`cmcp` command not found** + +```bash +pip show cmcp-runtime | grep Location +# make sure pip's bin/Scripts directory is on PATH +``` + +**Runtime exits immediately** + +`policy_bundle_path` and `catalog_path` in `cmcp-config.yaml` are resolved relative to the **working directory** you run `cmcp start` from, not the config file location. Run from inside `startup-tpm/`. + +**Tool call returns 502 `UPSTREAM_UNAVAILABLE`** + +The mock MCP server is not running on port 8080. Start it (Step 3). diff --git a/startup-tpm/agent/echo_agent.py b/startup-tpm/agent/echo_agent.py index 92d6421..7ff9e06 100644 --- a/startup-tpm/agent/echo_agent.py +++ b/startup-tpm/agent/echo_agent.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Minimal echo agent for the cMCP startup quickstart. -Calls test.echo through the gateway and prints the TRACE Trust Record. +Calls test.echo through the runtime, closes the session, and prints the +signed TRACE Trust Record. Usage: python echo_agent.py [--gateway http://localhost:8443] @@ -15,65 +16,52 @@ DEFAULT_GATEWAY = "http://localhost:8443" -def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: - payload = { - "jsonrpc": "2.0", - "id": req_id, - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - } - resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) - resp.raise_for_status() - body = resp.json() - if "error" in body: - raise RuntimeError(f"Tool call error: {body['error']}") - return body.get("result", {}) - - -def fetch_trace(client: httpx.Client, gateway: str) -> dict: - resp = client.get(f"{gateway}/trace", timeout=10) - resp.raise_for_status() - return resp.json() - - def run(gateway: str) -> None: - print(f"Connecting to cMCP gateway at {gateway}") + print(f"Connecting to cMCP Runtime at {gateway}") print() with httpx.Client(headers={"Content-Type": "application/json"}) as client: print("[1/1] Calling test.echo ...") - result = call_tool(client, gateway, "test.echo", {"message": "hello from cMCP"}, 1) - decision = result.get("cmcp_decision", "allow") - echoed = "" + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "test.echo", "arguments": {"message": "hello from cMCP"}}, + } + resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) + body = resp.json() + if "error" in body: + print(f" -> error: {body['error']}") + print(" Is the runtime running? Start it with:") + print(" CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml") + sys.exit(1) + result = body["result"] + meta = result.get("_cmcp", {}) content = result.get("content", []) - if content: - echoed = content[0].get("text", "") - print(f" -> decision: {decision}") - if echoed: - print(f" -> echoed: {echoed}") + echoed = content[0].get("text", "") if content else "" + print(" -> decision: allow") + print(f" -> echoed: {echoed}") + session_id = meta.get("session_id") print() - print("Fetching TRACE Trust Record ...") - try: - trace = fetch_trace(client, gateway) - print() - print("=== TRACE Trust Record ===") - print(json.dumps(trace, indent=2)) - except Exception as exc: - print(f" (Could not fetch live TRACE record: {exc})") - print(" Start the runtime first: CMCP_DEV_MODE=1 cmcp start --config startup-tpm/cmcp-config.yaml") + if not session_id: + print("No session id in response - cannot fetch TRACE Trust Record.") sys.exit(1) + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) + resp.raise_for_status() + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(resp.json(), indent=2)) + print() print("Done.") if __name__ == "__main__": parser = argparse.ArgumentParser(description="cMCP echo agent quickstart") - parser.add_argument( - "--gateway", - default=DEFAULT_GATEWAY, - help=f"cMCP gateway base URL (default: {DEFAULT_GATEWAY})", - ) + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") args = parser.parse_args() run(args.gateway) diff --git a/startup-tpm/catalog.json b/startup-tpm/catalog.json index 5229bcc..5451cac 100644 --- a/startup-tpm/catalog.json +++ b/startup-tpm/catalog.json @@ -20,7 +20,7 @@ "definition_hash": "sha256:d53cc01e40ecd15dabff6e2e0562f824dcb1eda885f722d96e1583f4c2ea8273", "compliance_domain": "internal", "requires_baa": false, - "sensitivity_level": "internal", + "sensitivity_level": "public", "added_at": "2026-06-01T00:00:00Z", "approved_by": "developer@example.com" } diff --git a/startup-tpm/server/mock_mcp_server.py b/startup-tpm/server/mock_mcp_server.py new file mode 100644 index 0000000..b5ba8b6 --- /dev/null +++ b/startup-tpm/server/mock_mcp_server.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Mock test MCP server for the startup-tpm quickstart. + +Serves test.echo on port 8080. Stdlib only -- no dependencies. + +Usage: + python startup-tpm/server/mock_mcp_server.py +""" + +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = 8080 + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + params = request.get("params", {}) + tool = params.get("name", "") + if tool != "test.echo": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + message = params.get("arguments", {}).get("message", "") + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": message}]}, + }) + + def _reply(self, status: int, body: dict) -> 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, *args): + print(f"[mock-echo] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock test MCP server listening on :{PORT} (tools: test.echo)") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/startup-tpm/trace-output/example-trust-record.json b/startup-tpm/trace-output/example-trust-record.json new file mode 100644 index 0000000..9ed0d76 --- /dev/null +++ b/startup-tpm/trace-output/example-trust-record.json @@ -0,0 +1,75 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781138542, + "subject": "spiffe://cmcp.gateway/session/db17ac23-c8df-4848-a2e9-21f6d07e7e05", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:93d237c9a83482c36908a5dcb634f3641e8c1d252444a0c6b916e480d7f45dde", + "enforcement_mode": "advisory", + "version": "quickstart-v1.0" + }, + "data_class": "public", + "tool_transcript": { + "hash": "sha256:775dcf69fe626a3921f0a8686b829cfefc3f1a12c4c64be7db44fc1f6271ace2", + "call_count": 1 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "ToB3lvrNHGjbh8ZPnK0Ogh0zTxLNURCt1rjk5L_M18Q", + "kid": "cmcp-4e807796" + } + } + }, + "gateway": { + "session_id": "db17ac23-c8df-4848-a2e9-21f6d07e7e05", + "gateway_version": "unknown", + "sequence_number": 2, + "prev_claim_hash": "sha256:6c13e26d34ad2b9cfbab7378b5a8795c4d4a2c8ad151c9b95023bcdcaf802bd1", + "audit_chain": { + "root": "8fa6b608999d3e2b39ae09e549bc2550e7e81e9a8fd6f4ea6589f97bbfd21389", + "tip": "775dcf69fe626a3921f0a8686b829cfefc3f1a12c4c64be7db44fc1f6271ace2", + "length": 3 + }, + "call_summary": { + "tool_calls_total": 1, + "tool_calls_allowed": 1, + "tool_calls_denied": 0, + "tool_calls_faulted": 0, + "tools_invoked": [ + "test.echo" + ], + "session_max_sensitivity": "public", + "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:4aa10f37feea8302a6d93d3183daeb7f8ba7457c3c9eb11a36e538aa026073bf", + "drift_detected": false + }, + "attestation_generated_at": "2026-06-11T00:41:41.751625+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 1, + "tools_called": [ + "test.echo" + ], + "suspicious_sequences_detected": 0 + } + }, + "signature": "dt9Afvq_MVpJp-XZ_awZ1eqfH3Zm-vCRIQBIMlhSPtaww6V-fvi4X7C5DHtrkQPwiSLTsawZJtNy3Rw47EuuDQ" +}