From 3621384a6e0b71034fbe037653bd74159092c40e Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Wed, 10 Jun 2026 21:39:15 -0700 Subject: [PATCH] fix: make all four demos runnable end-to-end against the real cMCP Runtime Verified by running every demo locally against cmcp (with upstream forwarding, Cedar advice, and session close): all decisions and advice payloads match the README narratives, and every trace-output/ file is now captured from a real run. - Cedar policies: rewritten in valid Cedar. The previous files used a nonexistent `advice {...}` clause (parse error -> every call denied) and matched on Action::"tool_call" / Tool::"..." scopes the runtime never sends. Rules now match real action names (Action::"Ehr.treatmentPlanWriter"), read arguments via context.arguments.*, and carry @annotations that the runtime returns as structured advice on denies. - Catalogs: sensitivity_level "internal" is not in the schema enum (runtime exited at startup); replaced with valid values. financial document_reader compliance_domain corrected from hipaa_phi to mnpi. - Agents: use the real API -- _cmcp metadata (session_id, would_have_denied, advice), error.data.advice on 403s, and POST /sessions/{id}/close to obtain the signed RuntimeClaim. Removed calls to GET /trace and cmcp-verify, neither of which exists. - Mock MCP servers: each example ships a stdlib-only server/mock_mcp_server.py serving its catalog tools on :8080, so demos run with zero external deps. - acme-corp tenant now runs enforcement_mode: advisory, making the advisory-vs-enforcing tenant contrast real (was impossible per-rule). - READMEs: 3-terminal flow, run from inside the example dir (config paths resolve relative to CWD), real expected outputs, real TRACE record shape. - financial agent: escalation now triggered via --amount-eur 750000 instead of editing source. Requires cmcp PRs #278-#282 (wiring, advice, forwarding, session close). Co-Authored-By: Claude Fable 5 --- .gitignore | 6 + README.md | 27 +- financial-services/README.md | 493 ++++-------------- financial-services/agent/credit_risk_agent.py | 173 +++--- financial-services/catalog.json | 4 +- financial-services/policy/allow.cedar | 57 +- financial-services/server/mock_mcp_server.py | 96 ++++ .../trace-output/example-trust-record.json | 110 +++- healthcare/README.md | 273 +++------- healthcare/agent/clinical_decision_agent.py | 177 +++---- healthcare/policy/allow.cedar | 57 +- healthcare/server/mock_mcp_server.py | 98 ++++ .../trace-output/example-trust-record.json | 110 ++-- multi-tenant-saas/README.md | 256 +++------ 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 | 165 +++--- 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, 1438 insertions(+), 1487 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 7dfde0b..08d0169 100644 --- a/README.md +++ b/README.md @@ -10,22 +10,31 @@ 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 | -| `multi-tenant-saas/` | SaaS platform with per-tenant policy isolation | TDX | Customer contract SLA | +| `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 | +| `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 0574bb3..a52b499 100644 --- a/financial-services/README.md +++ b/financial-services/README.md @@ -1,238 +1,97 @@ -# financial-services: EU Credit Risk Agent Demo +# 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. +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 -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. +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 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. +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 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. +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. -**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. +**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) │ - │ 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 │ - └─────────────────────────────────────────────────────────────────┘ + +------------------------------------------------------------------+ + | 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 | + +------------------------------------------------------------------+ ``` --- -## Prerequisites +## The catalog -| 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 | +`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). -No hardware TEE or TPM is required for this demo. The runtime runs in `CMCP_DEV_MODE=1`. +| Tool | compliance_domain | sensitivity_level | +|---|---|---| +| `finance.document_reader` | mnpi | confidential | +| `finance.credit_score_lookup` | pii | confidential | +| `finance.risk_report_writer` | internal | confidential | --- -## Step 1 — Clone the examples repo +## Run it ```bash git clone https://github.com/agentrust-io/examples.git cd examples +pip install cmcp-runtime httpx ``` ---- - -## Step 2 — Install dependencies +**Terminal 1 — mock MCP server:** ```bash -pip install cmcp-runtime agent-manifest httpx +cd financial-services +python server/mock_mcp_server.py ``` -Verify: +**Terminal 2 — runtime** (run from inside `financial-services/` — config paths resolve relative to the working directory): ```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 -}; +cd financial-services +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml ``` -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: +**Terminal 3 — happy path (EUR 250,000, below the threshold):** ```bash +cd examples 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 ... @@ -240,249 +99,89 @@ Client: EUR-2024-00847 | Document: BS-2024-Q4 | Bureau: equifax [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 +Closing session and fetching the signed TRACE Trust Record ... ``` -See the "Expected output" section below for the full annotated TRACE record. - ---- - -## Step 9 — Verify with cmcp-verify +**Escalation path (EUR 750,000):** ```bash -curl -s http://localhost:8443/trace > trace.json -cmcp-verify trace.json +python financial-services/agent/credit_risk_agent.py --amount-eur 750000 ``` -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) ``` +[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 -For a production deployment with hardware TEE, the attestation line reads: -``` -[cmcp-verify] attestation: SEV-SNP verified (PCR0: aa11bb22...) + The risk report was NOT written to the core banking system. ``` --- -## 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" - } -} -``` +## The Cedar policy -### Field annotations +`policy/allow.cedar` — the escalation rule: -| 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 +```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 +}; +``` -| 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 | — | +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`. --- -## Extending this example - -### Swap in a real MCP server +## The TRACE Trust Record -Replace the `server.url` values in `catalog.json` with your actual MCP server endpoint: +See `trace-output/example-trust-record.json` — captured from a real run. Structure: `{"cmcp_version", "trace": {...}, "gateway": {...}, "signature"}`. -```json -"server": { - "display_name": "Production Credit Risk Server", - "url": "https://mcp.bank.eu/credit-risk", - "tls_fingerprint": "SHA256:", - "transport": "http-sse" -} -``` +| 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 | -Get the TLS fingerprint: +Export the full audit chain for a closed session: ```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' +curl "http://localhost:8443/audit/export?session_id=" | python3 -m json.tool ``` -### 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: +## Extending this example -```bash -agent-manifest validate --manifest agent-manifest.json --catalog financial-services/catalog.json -``` +- **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 -**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. +**Tool call returns 502 `UPSTREAM_UNAVAILABLE`** — the mock MCP server is not running on port 8080 (Terminal 1). -**TRACE record shows `data_class: internal` instead of `confidential`** +**Runtime exits at startup** — config paths resolve relative to the working directory; run `cmcp start` from inside `financial-services/`. -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`. +**Cedar policy parse error** — check for stray Unicode (smart quotes) in `allow.cedar`; annotations must be `@key("value")` with double quotes. --- diff --git a/financial-services/agent/credit_risk_agent.py b/financial-services/agent/credit_risk_agent.py index 5ea1773..68a7fce 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..03f54f7 100644 --- a/financial-services/policy/allow.cedar +++ b/financial-services/policy/allow.cedar @@ -1,45 +1,42 @@ // 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". -// Rule 1: Permit the credit-risk-analyst workflow to call any approved tool. -permit ( - principal, - action == Action::"tool_call", - resource -) when { - context.workflow_id == "credit-risk-analyst" -}; +// Rule 1: the attested catalog is the allow-list; permit catalog tools and +// let the forbid rules below carry the compliance constraints. +permit (principal, action, resource); -// 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. +// Rule 2: 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", - resource == Tool::"finance.risk_report_writer" + action == Action::"Finance.riskReportWriter", + resource ) when { - context.amount_eur > 500000 -} advice { - "reason": "human-review-required", - "escalation_threshold_eur": 500000 + context.arguments has amount_eur && + context.arguments.amount_eur > 500000 }; -// Rule 3: Deny confidential tool calls if the session sensitivity has been -// downgraded to "public". This prevents accidental data-class mismatch. +// Rule 3: 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 == Action::"tool_call", + action, resource ) when { - context.session_max_sensitivity == "public" && - resource.compliance_domain == "confidential" + context.compliance_domain == "mnpi" && + context.attestation_platform == "unknown" }; - -// Rule 4: Catch-all permit — any action not matched by a forbid above is allowed. -permit ( - principal, - action, - resource -); 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..2336913 100644 --- a/financial-services/trace-output/example-trust-record.json +++ b/financial-services/trace-output/example-trust-record.json @@ -1,30 +1,88 @@ { - "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": 1781144717, + "subject": "spiffe://cmcp.gateway/session/27b1aaa2-6972-4224-951e-80cb9c89003a", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:eb87240ced9e589f93d4851f404f892690d2067039b90a4806a676e426bd674f", + "enforcement_mode": "enforce", + "version": "credit-risk-v4.2" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:f06d8771565a018e3f301cbe59e312cd9684eb2b3c84a7aa2d4a85f45075a6a3", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "uYElaypX9Nuhfdf-TXDGOMof81zP8HKJX2z6iWmAPKU", + "kid": "cmcp-b981256b" + } + } }, - "runtime": { - "platform": "software-only", - "tee_type": "dev-mode", - "measurement": "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION", - "region": "westeurope" + "gateway": { + "session_id": "27b1aaa2-6972-4224-951e-80cb9c89003a", + "gateway_version": "unknown", + "sequence_number": 1, + "audit_chain": { + "root": "e09e8744127cd61f36bed71052928f793d9be22f4f3e3b498413816e9f8cffc2", + "tip": "f06d8771565a018e3f301cbe59e312cd9684eb2b3c84a7aa2d4a85f45075a6a3", + "length": 5 + }, + "call_summary": { + "tool_calls_total": 3, + "tool_calls_allowed": 3, + "tool_calls_denied": 0, + "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": "e9608498-ac0b-4c19-ba67-4d9519c72dde", + "tool_name": "finance.risk_report_writer", + "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:02640e5bd370cd9e2d383f07dcab8d2d3c5e9e0155ee14c56556d6e7645d86ca", + "drift_detected": false + }, + "attestation_generated_at": "2026-06-11T00:42:59.157791+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 3, + "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": "wBYpJ89EcFwtJsS4kp0wyVZfGIKZMcazJB4aMQ36mIuhAKtsO04v9KIGlCIk5k818u1PyDqP1gI6_aNDTxgkDQ" } diff --git a/healthcare/README.md b/healthcare/README.md index 76c807a..91a051f 100644 --- a/healthcare/README.md +++ b/healthcare/README.md @@ -1,22 +1,22 @@ # 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). +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** -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. +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` 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. +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 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. +**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: 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. +**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. --- @@ -25,7 +25,7 @@ Run without flags to see the happy path (all three calls allowed). Run with `--t ``` +------------------------------------------------------------------+ | Clinical Decision Support Agent (LLM) | - | clinical_decision_agent.py -- JSON-RPC 2.0 over HTTP | + | agent/clinical_decision_agent.py -- JSON-RPC 2.0 over HTTP | +-------------------------------+----------------------------------+ | tools/call (MCP) v @@ -33,14 +33,15 @@ Run without flags to see the happy path (all three calls allowed). Run with `--t | cMCP Runtime :8443 | | | | +---------------+ +------------------+ +------------------+ | - | | Cedar engine | | Catalog checker | | TRACE recorder | | - | | allow.cedar | | catalog.json | | /trace endpoint | | + | | Cedar engine | | Catalog checker | | Audit chain + | | + | | allow.cedar | | catalog.json | | TRACE signer | | | +---------------+ +------------------+ +------------------+ | +-------------------------------+----------------------------------+ | proxied tool call v +------------------------------------------------------------------+ - | Hospital EHR MCP Server :8080 | + | Mock Hospital EHR MCP Server :8080 | + | server/mock_mcp_server.py | | ehr.patient_record_lookup | | ehr.clinical_decision_support | | ehr.treatment_plan_writer | @@ -49,148 +50,38 @@ Run without flags to see the happy path (all three calls allowed). Run with `--t --- -## 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 +## Run it ```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 +**Terminal 1 — mock EHR server:** ```bash -CMCP_DEV_MODE=1 cmcp start --config healthcare/cmcp-config.yaml +cd healthcare +python server/mock_mcp_server.py ``` -Run from the root of the examples repo. Expected startup output: +**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 ``` -[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: +**Terminal 3 — happy path:** ```bash +cd examples 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 ... @@ -200,69 +91,74 @@ Patient: P-2024-008471 | Risk category: standard [3/3] Calling ehr.treatment_plan_writer ... -> decision: allow -Fetching TRACE Trust Record from gateway ... +Closing session and fetching the signed TRACE Trust Record ... -=== TRACE Trust Record === -{ - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - ... -} - -All tool calls completed. TRACE Trust Record generated. +=== TRACE Trust Record (signed RuntimeClaim) === +{ "cmcp_version": "1.0", "trace": {...}, "gateway": {...}, "signature": "..." } ``` ---- - -## Step 7 — Run the HITL path +**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 + -> 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 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 +## The Cedar policy -```bash -curl -s http://localhost:8443/trace > trace.json -cmcp-verify trace.json +`policy/allow.cedar` — two enforcement rules over a base permit. 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" +}; ``` -Expected output: +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`. -``` -[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) +--- + +## 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 ``` --- @@ -271,34 +167,19 @@ Expected output: | 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 | +| `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 -### 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. +- **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). --- diff --git a/healthcare/agent/clinical_decision_agent.py b/healthcare/agent/clinical_decision_agent.py index 8ca43a0..39ca528 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..12075a3 100644 --- a/healthcare/policy/allow.cedar +++ b/healthcare/policy/allow.cedar @@ -1,47 +1,40 @@ // 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". -// Rule 1: Permit the clinical-decision-support workflow to call approved EHR tools. -permit ( - principal, - action == Action::"tool_call", - resource -) when { - context.workflow_id == "clinical-decision-support" -}; +// Rule 1: the attested catalog is the allow-list; permit catalog tools and +// let the forbid rules below carry the compliance constraints. +permit (principal, action, resource); -// 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. +// Rule 2: 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", - resource == Tool::"ehr.treatment_plan_writer" + action == Action::"Ehr.treatmentPlanWriter", + resource ) when { - context.patient_risk_category == "high" -} advice { - "reason": "human-review-required", - "regulation": "eu-ai-act-art-14", - "reviewer_role": "attending-physician" + context.arguments has patient_risk_category && + context.arguments.patient_risk_category == "high" }; -// Rule 3: Deny confidential tool calls if the session sensitivity has been -// downgraded to "public". Prevents accidental HIPAA PHI leakage. +// Rule 3: HIPAA PHI may only flow through an attested runtime. +@id("require-attested-runtime") +@reason("attested-runtime-required") +@regulation("hipaa-164-312") forbid ( principal, - action == Action::"tool_call", + action, resource ) when { - context.session_max_sensitivity == "public" && - resource.compliance_domain == "hipaa_phi" + context.compliance_domain == "hipaa_phi" && + context.attestation_platform == "unknown" }; - -// Rule 4: Catch-all permit — any call not matched by a forbid above is allowed. -permit ( - principal, - action, - resource -); 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..014fb08 100644 --- a/healthcare/trace-output/example-trust-record.json +++ b/healthcare/trace-output/example-trust-record.json @@ -1,46 +1,78 @@ { - "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": 1781138573, + "subject": "spiffe://cmcp.gateway/session/ec7cb161-d4a6-457d-b7e9-ff4e0f9a64f7", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:746a35668e06c8b6a6a84aa0a06b603c26b286c3e9ec65b2562e1555077be936", + "enforcement_mode": "enforce", + "version": "clinical-hipaa-v2.1" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:6439db6d0d0b4aaab9fb6fbe81bd26ed75c42aeb4198cc980ce5892725fb27c0", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "i8Ia9guwYclt5cUUMRPcB6_EXiXOMN3iv8A0l8-CPQE", + "kid": "cmcp-8bc21af6" + } } }, - "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": "ec7cb161-d4a6-457d-b7e9-ff4e0f9a64f7", + "gateway_version": "unknown", + "sequence_number": 1, + "audit_chain": { + "root": "b6e5d7adff480101d3a466d6ab051509be22cf377c96447c43b22ca9bf27861b", + "tip": "6439db6d0d0b4aaab9fb6fbe81bd26ed75c42aeb4198cc980ce5892725fb27c0", + "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-11T00:42:42.456843+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": "2fHMRi8zIDMqT292wfhJ7RCPu56L5VgdwRw5bJebJ0znqjUYDUR8ctuFkOimwjgJH9ACJ_k-gS7w25Ha7Dr_AQ" } diff --git a/multi-tenant-saas/README.md b/multi-tenant-saas/README.md index 3eb3620..e7cd2d9 100644 --- a/multi-tenant-saas/README.md +++ b/multi-tenant-saas/README.md @@ -1,59 +1,27 @@ # 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. +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 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. +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 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 | - +------------------------------------------------------------------+ -``` +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. -## 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) | +**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. --- @@ -61,43 +29,24 @@ Because each tenant runs with a different policy version (`acme-corp-v1.0` vs `g ``` 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 + 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/ - 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 + 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) + 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 + acme-corp-example.json real captured TRACE record (advisory) + globex-financial-example.json real captured TRACE record (enforcing) ``` --- -## 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 +## Run it ```bash git clone https://github.com/agentrust-io/examples.git @@ -105,172 +54,101 @@ 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:** +**Terminal 1 — mock MCP server:** ```bash -CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-acme-corp.yaml +cd multi-tenant-saas +python server/mock_mcp_server.py ``` -Expected startup output: +**Terminal 2 — runtime with Acme Corp's policy** (run from inside `multi-tenant-saas/`): -``` -[cmcp] policy bundle loaded: acme-corp-v1.0 -[cmcp] catalog loaded: 3 tools -[cmcp] listening on 0.0.0.0:8443 +```bash +cd multi-tenant-saas +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-acme-corp.yaml ``` -**Terminal 2 — run the agent:** +**Terminal 3 — agent:** ```bash +cd examples 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 + -> 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 - -=== 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:** +**Switch tenants** — stop the runtime (Ctrl-C) and restart with Globex Financial's config: ```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 +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-globex-financial.yaml ``` -**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 + -> 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: advisory_deny - reason: config-update-requires-admin-workflow - -=== TRACE Trust Record === -{ "policy": { "version": "globex-financial-v3.2", ... }, ... } + -> decision: deny (POLICY_DENY) + advice from policy: + id: config-update-requires-admin-workflow + reason: config-update-requires-admin-workflow ``` -`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` — this is the field that identifies which tenant's policy was enforced for each session. +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/`. --- -## 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`) +## How the policies differ -- Permit analytics-workflow for all tools -- Advisory forbid on `saas.user_data_export` when no GDPR justification is present (logged only) -- Catch-all permit +**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`) +**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`: -- 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 +```cedar +permit ( + principal, + action == Action::"Saas.userDataExport", + resource +) when { + context has workflow_id && + context.workflow_id == "data-compliance-workflow" +}; +``` -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). +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 -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:** +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:8444 +python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial --gateway http://localhost:9443 ``` ---- - -## 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 — 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. +This is the production topology: one attested runtime instance per tenant isolation boundary, each measuring its own policy bundle hash into its TRACE records. --- diff --git a/multi-tenant-saas/agent/saas_agent.py b/multi-tenant-saas/agent/saas_agent.py index f00dcbd..a94e495 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 ad069b5..2d83d80 100644 --- a/startup-tpm/README.md +++ b/startup-tpm/README.md @@ -1,4 +1,4 @@ -# startup-tpm: 15-Minute cMCP Quickstart +# 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. @@ -6,10 +6,10 @@ Get a cMCP Runtime running with TPM-backed TRACE Trust Records in under 15 minut ## What you will have at the end -- A cMCP Runtime running on port 8443 +- 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`) -- A TRACE Trust Record you can inspect and verify +- 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. @@ -21,11 +21,8 @@ Estimated time: 15 minutes on a fresh VM, 5 minutes if Python is already install |---|---|---| | 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 @@ -40,8 +37,6 @@ Verify: cmcp --version ``` -Expected output: `cmcp-runtime 0.x.y` - --- ## Step 2 — Get the quickstart files @@ -61,69 +56,87 @@ startup-tpm/ 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 — Review the config +## Step 3 — Start the mock MCP server -`cmcp-config.yaml`: +The runtime proxies tool calls to the upstream MCP server listed in `catalog.json` (`http://localhost:8080/mcp`). The quickstart ships a mock: -```yaml -policy_bundle_path: ./policy -catalog_path: ./catalog.json -listen_addr: 0.0.0.0:8443 -attestation: - provider: auto - enforcement_mode: advisory -``` +**Terminal 1:** -`enforcement_mode: advisory` means the runtime logs policy violations but does not block calls. Change to `enforcing` before production. +```bash +python server/mock_mcp_server.py +``` -`provider: auto` selects the best available attestation source: TPM 2.0 if present, software-only otherwise. +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 -### With hardware TPM (Azure Trusted Launch, AWS Nitro, GCP Shielded VM) +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 startup-tpm/cmcp-config.yaml +cmcp start --config cmcp-config.yaml ``` -The runtime will print the TPM attestation measurement on startup. - -### Without hardware TPM (local dev, CI) +**Without hardware TPM (local dev, CI):** ```bash -CMCP_DEV_MODE=1 cmcp start --config startup-tpm/cmcp-config.yaml +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` 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. +`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: +Expected startup output ends with: ``` -[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 +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 -**Option A — agent script (recommended):** +**Terminal 3, Option A — agent script (recommended):** ```bash -python startup-tpm/agent/echo_agent.py +python agent/echo_agent.py ``` -The script calls `test.echo`, prints the policy decision, and fetches the TRACE Trust Record in one shot. +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:** @@ -133,7 +146,7 @@ curl -X POST http://localhost:8443/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test.echo","arguments":{"message":"hello"}}}' ``` -Expected response: +The response carries the echoed text plus `_cmcp` enforcement metadata: ```json { @@ -141,8 +154,13 @@ Expected response: "id": 1, "result": { "content": [{"type": "text", "text": "hello"}], - "cmcp_decision": "allow", - "cmcp_policy_version": "quickstart-v1.0" + "_cmcp": { + "call_id": "...", + "audit_entry_hash": "...", + "would_have_denied": false, + "latency_us": 12345, + "session_id": "..." + } } } ``` @@ -151,52 +169,18 @@ Expected response: ## Step 6 — Get the TRACE Trust Record -```bash -curl http://localhost:8443/trace | python3 -m json.tool -``` +TRACE Trust Records are sealed when a session closes. Close the session (use the `session_id` from `_cmcp`): -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-..."} -} +```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. -## Step 7 — Verify the TRACE record (optional) +You can also export the hash-chained audit log for a closed session: ```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) +curl "http://localhost:8443/audit/export?session_id=" | python3 -m json.tool ``` --- @@ -205,11 +189,12 @@ In dev mode the output will be: | 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) | +| 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 | Change `enforcement_mode: advisory` to `enforcement_mode: enforcing` | +| Production enforcement | Unset `CMCP_DEV_MODE`; set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, `CMCP_CATALOG_HASH` | --- @@ -225,20 +210,14 @@ 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" +# make sure pip's bin/Scripts directory is on PATH ``` -**`CMCP_DEV_MODE` not recognised on Windows** +**Runtime exits immediately** -```powershell -$env:CMCP_DEV_MODE = "1" -cmcp start --config startup-tpm/cmcp-config.yaml -``` +`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/`. -**Runtime exits immediately** +**Tool call returns 502 `UPSTREAM_UNAVAILABLE`** -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. +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..aa2e65d 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" +}