From 22c8cd2a4544753413d17338044b48c5c4dec706 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Wed, 10 Jun 2026 13:07:13 -0700 Subject: [PATCH] feat: add healthcare and multi-tenant-saas examples; add startup-tpm agent script Adds two new end-to-end examples for CC Summit (June 23 2026): - healthcare/ -- clinical decision support agent with EU AI Act Art. 14 HITL enforcement. Cedar Rule 2 blocks treatment plan writes when patient_risk_category=="high"; --trigger-hitl flag demonstrates the block. Three EHR tools, HIPAA PHI catalog, reference TRACE output included. - multi-tenant-saas/ -- SaaS platform with per-tenant Cedar policy isolation. Acme Corp uses advisory enforcement; Globex Financial uses hard deny for user_data_export outside the data-compliance-workflow. Separate config files point to separate policy bundles under tenants/. Reference TRACE outputs for both tenants included. Also adds startup-tpm/agent/echo_agent.py and updates startup-tpm/README.md. Co-Authored-By: Claude Sonnet 4.6 --- healthcare/README.md | 307 ++++++++++++++++++ healthcare/agent/clinical_decision_agent.py | 167 ++++++++++ healthcare/catalog.json | 89 +++++ healthcare/cmcp-config.yaml | 8 + healthcare/policy/allow.cedar | 47 +++ healthcare/policy/manifest.json | 6 + healthcare/policy/schema.cedarschema | 1 + .../trace-output/example-trust-record.json | 46 +++ multi-tenant-saas/README.md | 279 ++++++++++++++++ multi-tenant-saas/agent/saas_agent.py | 153 +++++++++ multi-tenant-saas/catalog.json | 82 +++++ multi-tenant-saas/cmcp-config-acme-corp.yaml | 8 + .../cmcp-config-globex-financial.yaml | 8 + .../tenants/acme-corp/policy/allow.cedar | 35 ++ .../tenants/acme-corp/policy/manifest.json | 6 + .../acme-corp/policy/schema.cedarschema | 1 + .../globex-financial/policy/allow.cedar | 54 +++ .../globex-financial/policy/manifest.json | 6 + .../policy/schema.cedarschema | 1 + .../trace-output/acme-corp-example.json | 43 +++ .../globex-financial-example.json | 42 +++ startup-tpm/README.md | 12 +- startup-tpm/agent/echo_agent.py | 79 +++++ 23 files changed, 1479 insertions(+), 1 deletion(-) create mode 100644 healthcare/README.md create mode 100644 healthcare/agent/clinical_decision_agent.py create mode 100644 healthcare/catalog.json create mode 100644 healthcare/cmcp-config.yaml create mode 100644 healthcare/policy/allow.cedar create mode 100644 healthcare/policy/manifest.json create mode 100644 healthcare/policy/schema.cedarschema create mode 100644 healthcare/trace-output/example-trust-record.json create mode 100644 multi-tenant-saas/README.md create mode 100644 multi-tenant-saas/agent/saas_agent.py create mode 100644 multi-tenant-saas/catalog.json create mode 100644 multi-tenant-saas/cmcp-config-acme-corp.yaml create mode 100644 multi-tenant-saas/cmcp-config-globex-financial.yaml create mode 100644 multi-tenant-saas/tenants/acme-corp/policy/allow.cedar create mode 100644 multi-tenant-saas/tenants/acme-corp/policy/manifest.json create mode 100644 multi-tenant-saas/tenants/acme-corp/policy/schema.cedarschema create mode 100644 multi-tenant-saas/tenants/globex-financial/policy/allow.cedar create mode 100644 multi-tenant-saas/tenants/globex-financial/policy/manifest.json create mode 100644 multi-tenant-saas/tenants/globex-financial/policy/schema.cedarschema create mode 100644 multi-tenant-saas/trace-output/acme-corp-example.json create mode 100644 multi-tenant-saas/trace-output/globex-financial-example.json create mode 100644 startup-tpm/agent/echo_agent.py diff --git a/healthcare/README.md b/healthcare/README.md new file mode 100644 index 0000000..76c807a --- /dev/null +++ b/healthcare/README.md @@ -0,0 +1,307 @@ +# healthcare: Clinical Decision Support Agent Demo + +End-to-end demo of a hospital AI agent processing patient records through a cMCP Runtime with Cedar policy enforcement and TRACE Trust Records for healthcare regulatory compliance (EU AI Act Art. 14, HIPAA). + +--- + +## What the demo shows + +**1. EU AI Act Article 14 — human oversight for high-risk AI** +Article 14 requires that high-risk AI systems in healthcare allow human supervisors to intervene. The Cedar Rule 2 in `policy/allow.cedar` operationalises this: any treatment plan write where `patient_risk_category == "high"` is blocked until an attending physician approves. The TRACE Trust Record records the advisory deny so the block is auditable. + +**2. HIPAA PHI protection at the tool boundary** +All three tools are classified `compliance_domain: hipaa_phi` and `sensitivity_level: confidential` in the catalog. Cedar Rule 3 prevents any session that has been downgraded to `public` sensitivity from calling any tool in the `hipaa_phi` domain — enforcing the minimum-necessary principle at runtime rather than in application code. + +**3. Cryptographic proof of tool call sequence** +The cMCP Runtime records every EHR tool call in a signed, hash-chained audit log. The TRACE Trust Record seals the entire session — which tools ran, in what order, whether a HITL block fired — into a JWT signed by the runtime's attestation key. A compliance officer or regulator can verify the record without trusting the agent process. + +**4. Two demo paths: standard and HITL** +Run without flags to see the happy path (all three calls allowed). Run with `--trigger-hitl` to see the EU AI Act Art. 14 block fire on the treatment plan write. + +--- + +## Architecture + +``` + +------------------------------------------------------------------+ + | Clinical Decision Support Agent (LLM) | + | clinical_decision_agent.py -- JSON-RPC 2.0 over HTTP | + +-------------------------------+----------------------------------+ + | tools/call (MCP) + v + +------------------------------------------------------------------+ + | cMCP Runtime :8443 | + | | + | +---------------+ +------------------+ +------------------+ | + | | Cedar engine | | Catalog checker | | TRACE recorder | | + | | allow.cedar | | catalog.json | | /trace endpoint | | + | +---------------+ +------------------+ +------------------+ | + +-------------------------------+----------------------------------+ + | proxied tool call + v + +------------------------------------------------------------------+ + | Hospital EHR MCP Server :8080 | + | ehr.patient_record_lookup | + | ehr.clinical_decision_support | + | ehr.treatment_plan_writer | + +------------------------------------------------------------------+ +``` + +--- + +## Prerequisites + +| Requirement | Version | Notes | +|---|---|---| +| Python | 3.11+ | `python3 --version` | +| pip | any recent | `pip --version` | +| httpx | 0.27+ | installed by `pip install cmcp-runtime` | +| cmcp-runtime | latest | `pip install cmcp-runtime` | + +No hardware TEE or TPM required for this demo. The runtime runs in `CMCP_DEV_MODE=1`. + +--- + +## Step 1 — Clone the examples repo + +```bash +git clone https://github.com/agentrust-io/examples.git +cd examples +``` + +--- + +## Step 2 — Install dependencies + +```bash +pip install cmcp-runtime httpx +``` + +--- + +## Step 3 — Review the files + +``` +healthcare/ + cmcp-config.yaml Runtime configuration + catalog.json Three-tool EHR catalog + policy/ + manifest.json Policy bundle metadata + allow.cedar Four Cedar rules (including HITL rule) + schema.cedarschema Cedar schema + agent/ + clinical_decision_agent.py Demo agent (run this) + trace-output/ + example-trust-record.json Reference TRACE output (happy path) +``` + +--- + +## Step 4 — Understand the Cedar policy + +`policy/allow.cedar` contains four rules: + +**Rule 1 — Workflow permit** + +```cedar +permit ( + principal, + action == Action::"tool_call", + resource +) when { + context.workflow_id == "clinical-decision-support" +}; +``` + +Only the `clinical-decision-support` workflow may call the three EHR tools. + +**Rule 2 — EU AI Act Art. 14 HITL block** + +```cedar +forbid ( + principal, + action == Action::"tool_call", + resource == Tool::"ehr.treatment_plan_writer" +) when { + context.patient_risk_category == "high" +} advice { + "reason": "human-review-required", + "regulation": "eu-ai-act-art-14", + "reviewer_role": "attending-physician" +}; +``` + +Any treatment plan write where `patient_risk_category == "high"` is blocked with an advisory deny. The advice payload is returned to the caller and recorded in the TRACE Trust Record. + +**Rule 3 — HIPAA PHI session protection** + +```cedar +forbid ( + principal, + action == Action::"tool_call", + resource +) when { + context.session_max_sensitivity == "public" && + resource.compliance_domain == "hipaa_phi" +}; +``` + +Prevents accidental PHI access if session sensitivity is downgraded. + +**Rule 4 — Catch-all permit** + +```cedar +permit (principal, action, resource); +``` + +--- + +## Step 5 — Start the runtime + +```bash +CMCP_DEV_MODE=1 cmcp start --config healthcare/cmcp-config.yaml +``` + +Run from the root of the examples repo. Expected startup output: + +``` +[cmcp] policy bundle loaded: clinical-hipaa-v2.1 +[cmcp] catalog loaded: 3 tools +[cmcp] ehr.patient_record_lookup (confidential) +[cmcp] ehr.clinical_decision_support (confidential) +[cmcp] ehr.treatment_plan_writer (confidential) +[cmcp] attestation: dev-mode (CMCP_DEV_MODE=1) +[cmcp] enforcement: enforcing +[cmcp] listening on 0.0.0.0:8443 +``` + +Leave this terminal open. + +--- + +## Step 6 — Run the happy path (no HITL) + +In a second terminal: + +```bash +python healthcare/agent/clinical_decision_agent.py +``` + +Expected output: + +``` +Connecting to cMCP gateway at http://localhost:8443 +Patient: P-2024-008471 | Risk category: standard + +[1/3] Calling ehr.patient_record_lookup ... + -> decision: allow +[2/3] Calling ehr.clinical_decision_support ... + -> decision: allow +[3/3] Calling ehr.treatment_plan_writer ... + -> decision: allow + +Fetching TRACE Trust Record from gateway ... + +=== TRACE Trust Record === +{ + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + ... +} + +All tool calls completed. TRACE Trust Record generated. +``` + +--- + +## Step 7 — Run the HITL path + +```bash +python healthcare/agent/clinical_decision_agent.py --trigger-hitl +``` + +Expected output: + +``` +Connecting to cMCP gateway at http://localhost:8443 +Patient: P-2024-008471 | Risk category: high +Mode: --trigger-hitl enabled -- treatment plan write will require HITL approval + +[1/3] Calling ehr.patient_record_lookup ... + -> decision: allow +[2/3] Calling ehr.clinical_decision_support ... + -> decision: allow +[3/3] Calling ehr.treatment_plan_writer ... + -> decision: advisory_deny + + HITL advisory payload: + reason: human-review-required + regulation: eu-ai-act-art-14 + reviewer_role: attending-physician + + The treatment plan was NOT written to the EHR. + An attending physician must review and approve before the plan takes effect. + The TRACE Trust Record records this as an advisory_deny for EU AI Act audit purposes. +``` + +The TRACE Trust Record for the HITL path records `"decision": "advisory_deny"` for the treatment plan write. This entry is the machine-readable evidence that the human oversight requirement was applied. + +--- + +## Step 8 — Verify with cmcp-verify + +```bash +curl -s http://localhost:8443/trace > trace.json +cmcp-verify trace.json +``` + +Expected output: + +``` +[cmcp-verify] signature: valid +[cmcp-verify] attestation: dev-mode (not hardware-backed) +[cmcp-verify] policy version: clinical-hipaa-v2.1 +[cmcp-verify] tool transcript: 3 calls (2 allowed, 1 advisory_deny) +[cmcp-verify] data_class: confidential (session maximum) +[cmcp-verify] RESULT: PASS (dev-mode) +``` + +--- + +## Regulatory field mapping + +| TRACE field | EU AI Act | HIPAA | +|---|---|---| +| `policy.bundle_hash` | Art. 9 — risk management system version | 45 CFR 164.312 — access controls documentation | +| `policy.version` | Art. 12 — log versioning | 45 CFR 164.308 — audit log | +| `tool_transcript[].decision` | Art. 14 — human oversight record | 45 CFR 164.308(a)(1)(ii)(D) — activity review | +| `data_class` (per call) | Art. 10 — data governance | 45 CFR 164.502 — minimum necessary | +| `runtime.tee_type` + `runtime.measurement` | Art. 12 — tamper-evident logging | 45 CFR 164.312(c) — integrity | +| `subject` | Art. 12 — traceability to specific run | 45 CFR 164.308(a)(5) — access monitoring | + +--- + +## Extending this example + +### Connect a real EHR MCP server + +Replace the `server.url` values in `catalog.json` with your actual MCP server endpoint and update `tls_fingerprint`: + +```bash +openssl s_client -connect ehr.hospital.example:443 < /dev/null 2>/dev/null \ + | openssl x509 -fingerprint -sha256 -noout \ + | sed 's/sha256 Fingerprint=//;s/://g' +``` + +### Switch to hardware attestation + +Remove `CMCP_DEV_MODE=1` and provision a VM with TPM 2.0 or AMD SEV-SNP. The `runtime.tee_type` in the TRACE record will change from `dev-mode` to `tpm2` or `sev-snp`. + +### Add SPIFFE identity for the agent + +If your hospital deploys SPIRE, the runtime will automatically obtain a SPIFFE SVID and include the `subject` field in the TRACE record as a hardware-attested SPIFFE URI instead of a self-signed placeholder. + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/healthcare/agent/clinical_decision_agent.py b/healthcare/agent/clinical_decision_agent.py new file mode 100644 index 0000000..8ca43a0 --- /dev/null +++ b/healthcare/agent/clinical_decision_agent.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +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. + +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. +""" + +import argparse +import json +import sys +import httpx + +DEFAULT_GATEWAY = "http://localhost:8443" + +# 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} +DIAGNOSIS = "Type 2 Diabetes Mellitus with Hypertension" +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.""" + 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: + 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", {}) + + +def fetch_trace(client: httpx.Client, gateway: str) -> dict: + resp = client.get(f"{gateway}/trace", 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"Patient: {PATIENT_ID} | Risk category: {risk_category}") + if trigger_hitl: + print("Mode: --trigger-hitl enabled — treatment plan write will require HITL approval") + print() + + 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. + 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. + 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', '')}") + 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() + + # 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.") + sys.exit(1) + + print() + print("All tool calls completed. TRACE Trust Record generated.") + + +# --------------------------------------------------------------------------- +# 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", + ) + args = parser.parse_args() + run(args.gateway, args.trigger_hitl) diff --git a/healthcare/catalog.json b/healthcare/catalog.json new file mode 100644 index 0000000..9965de8 --- /dev/null +++ b/healthcare/catalog.json @@ -0,0 +1,89 @@ +[ + { + "tool_name": "ehr.patient_record_lookup", + "server": { + "display_name": "Hospital EHR MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Look up patient clinical record from the EHR system", + "input_schema": { + "type": "object", + "properties": { + "patient_id": {"type": "string"}, + "record_type": { + "type": "string", + "enum": ["demographics", "diagnoses", "medications", "labs", "vitals", "full"] + } + }, + "required": ["patient_id"] + } + }, + "definition_hash": "sha256:8849c45c3aab0a43885a66b44a836578a2e6c5f006154d5cda2bb72ba5c85be3", + "compliance_domain": "hipaa_phi", + "requires_baa": true, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "compliance@hospital.example" + }, + { + "tool_name": "ehr.clinical_decision_support", + "server": { + "display_name": "Hospital EHR MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Run AI-assisted differential diagnosis and clinical decision support", + "input_schema": { + "type": "object", + "properties": { + "patient_id": {"type": "string"}, + "presenting_symptoms": {"type": "array", "items": {"type": "string"}}, + "lab_values": {"type": "object"} + }, + "required": ["patient_id", "presenting_symptoms"] + } + }, + "definition_hash": "sha256:7a4320c8f4f12c1350ab74bbf266510e6a732f6bbb921ac78c717ba5621f5d20", + "compliance_domain": "hipaa_phi", + "requires_baa": true, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "compliance@hospital.example" + }, + { + "tool_name": "ehr.treatment_plan_writer", + "server": { + "display_name": "Hospital EHR MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write a treatment plan to the patient record", + "input_schema": { + "type": "object", + "properties": { + "patient_id": {"type": "string"}, + "diagnosis": {"type": "string"}, + "treatment": {"type": "string"}, + "patient_risk_category": { + "type": "string", + "enum": ["standard", "high"] + } + }, + "required": ["patient_id", "diagnosis", "treatment", "patient_risk_category"] + } + }, + "definition_hash": "sha256:a1b8ce368e99d97299d9d2a5b85798ad88204f7da84747d2dae66c50c0e7b8fa", + "compliance_domain": "hipaa_phi", + "requires_baa": true, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "compliance@hospital.example" + } +] diff --git a/healthcare/cmcp-config.yaml b/healthcare/cmcp-config.yaml new file mode 100644 index 0000000..3678de8 --- /dev/null +++ b/healthcare/cmcp-config.yaml @@ -0,0 +1,8 @@ +policy_bundle_path: ./policy +catalog_path: ./catalog.json +listen_addr: 0.0.0.0:8443 +audit_db_path: ./audit.db +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 86400 diff --git a/healthcare/policy/allow.cedar b/healthcare/policy/allow.cedar new file mode 100644 index 0000000..a11bb66 --- /dev/null +++ b/healthcare/policy/allow.cedar @@ -0,0 +1,47 @@ +// Cedar policy bundle for hospital clinical decision support agent +// version: clinical-hipaa-v2.1 +// author: compliance@hospital.example + +// 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 2: Advisory forbid on treatment plan writes for high-risk patients. +// EU AI Act Article 14 requires human oversight for high-risk AI system outputs +// in healthcare settings. When patient_risk_category == "high", the treatment +// plan must be reviewed and approved by a clinician before it takes effect. +// In enforcing mode this blocks the write and returns a 403 with the HITL payload. +forbid ( + 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" +}; + +// Rule 3: Deny confidential tool calls if the session sensitivity has been +// downgraded to "public". Prevents accidental HIPAA PHI leakage. +forbid ( + principal, + action == Action::"tool_call", + resource +) when { + context.session_max_sensitivity == "public" && + resource.compliance_domain == "hipaa_phi" +}; + +// Rule 4: Catch-all permit — any call not matched by a forbid above is allowed. +permit ( + principal, + action, + resource +); diff --git a/healthcare/policy/manifest.json b/healthcare/policy/manifest.json new file mode 100644 index 0000000..6091d01 --- /dev/null +++ b/healthcare/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "clinical-hipaa-v2.1", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "compliance@hospital.example", + "commit_sha": "0000000000000000000000000000000000000000" +} diff --git a/healthcare/policy/schema.cedarschema b/healthcare/policy/schema.cedarschema new file mode 100644 index 0000000..889ad3f --- /dev/null +++ b/healthcare/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/healthcare/trace-output/example-trust-record.json b/healthcare/trace-output/example-trust-record.json new file mode 100644 index 0000000..fa1e57f --- /dev/null +++ b/healthcare/trace-output/example-trust-record.json @@ -0,0 +1,46 @@ +{ + "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" + } + }, + "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" + }, + { + "tool": "ehr.clinical_decision_support", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool": "ehr.treatment_plan_writer", + "data_class": "confidential", + "decision": "allow" + } + ], + "cnf": { + "kid": "cmcp-b2c3d4e5" + } +} diff --git a/multi-tenant-saas/README.md b/multi-tenant-saas/README.md new file mode 100644 index 0000000..3eb3620 --- /dev/null +++ b/multi-tenant-saas/README.md @@ -0,0 +1,279 @@ +# multi-tenant-saas: Per-Tenant Cedar Policy Isolation + +End-to-end demo of a SaaS platform serving multiple tenants with different compliance requirements, each enforced by a separate Cedar policy bundle loaded into the cMCP Runtime. + +The same three tool calls produce different outcomes depending on which tenant's policy is active. Acme Corp allows data export with an advisory warning; Globex Financial hard-blocks it because the calling workflow is not the designated data-compliance workflow. + +--- + +## What the demo shows + +**1. Policy-as-isolation at the tool boundary** +Each tenant gets their own Cedar bundle (`tenants/acme-corp/policy/` and `tenants/globex-financial/policy/`). The runtime loads one bundle at startup. Restarting with a different config file switches to the other tenant's rules. Every enforcement decision is recorded in a per-tenant TRACE Trust Record. + +**2. Progressive compliance posture** +Acme Corp uses advisory enforcement for missing GDPR justifications — the call is logged and flagged but not blocked. Globex Financial, as a regulated financial firm, uses hard deny for the same scenario. The same cMCP Runtime enforces both postures; only the Cedar bundle differs. + +**3. Auditable per-tenant TRACE records** +Because each tenant runs with a different policy version (`acme-corp-v1.0` vs `globex-financial-v3.2`), the `policy.version` field in the TRACE record identifies exactly which tenant policy was enforced. Regulators, auditors, and tenants can verify their own records independently. + +--- + +## Architecture + +``` + +------------------------------------------------------------------+ + | SaaS Agent (LLM) -- analytics-workflow | + | saas_agent.py -- JSON-RPC 2.0 over HTTP | + +-------------------------------+----------------------------------+ + | tools/call (MCP) + v + +------------------------------------------------------------------+ + | cMCP Runtime :8443 | + | | + | Policy bundle loaded at startup -- one per tenant: | + | tenants/acme-corp/policy/ (permissive) | + | tenants/globex-financial/policy/ (strict GDPR) | + +-------------------------------+----------------------------------+ + | proxied tool call + v + +------------------------------------------------------------------+ + | SaaS Platform MCP Server :8080 | + | saas.analytics_query | + | saas.user_data_export | + | saas.config_update | + +------------------------------------------------------------------+ +``` + +--- + +## Tenant policy comparison + +| Tool | acme-corp | globex-financial | +|---|---|---| +| `saas.analytics_query` | allow | allow | +| `saas.user_data_export` | advisory_deny (no GDPR justification) | deny (wrong workflow) | +| `saas.config_update` | allow | advisory_deny (wrong workflow) | + +--- + +## File layout + +``` +multi-tenant-saas/ + cmcp-config-acme-corp.yaml Runtime config for Acme Corp + cmcp-config-globex-financial.yaml Runtime config for Globex Financial + catalog.json Shared three-tool catalog + tenants/ + acme-corp/ + policy/ + manifest.json acme-corp-v1.0 + allow.cedar Permissive rules + schema.cedarschema + globex-financial/ + policy/ + manifest.json globex-financial-v3.2 + allow.cedar Strict GDPR rules + schema.cedarschema + agent/ + saas_agent.py Demo agent (run this) + trace-output/ + acme-corp-example.json Reference TRACE output for Acme Corp + globex-financial-example.json Reference TRACE output for Globex Financial +``` + +--- + +## Prerequisites + +| Requirement | Version | Notes | +|---|---|---| +| Python | 3.11+ | `python3 --version` | +| pip | any recent | `pip --version` | +| httpx | 0.27+ | installed by `pip install cmcp-runtime` | +| cmcp-runtime | latest | `pip install cmcp-runtime` | + +No hardware TEE or TPM required for this demo. The runtime runs in `CMCP_DEV_MODE=1`. + +--- + +## Step 1 — Clone and install + +```bash +git clone https://github.com/agentrust-io/examples.git +cd examples +pip install cmcp-runtime httpx +``` + +--- + +## Step 2 — Run the demo for Acme Corp + +**Terminal 1 — start the runtime with Acme Corp's policy:** + +```bash +CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-acme-corp.yaml +``` + +Expected startup output: + +``` +[cmcp] policy bundle loaded: acme-corp-v1.0 +[cmcp] catalog loaded: 3 tools +[cmcp] listening on 0.0.0.0:8443 +``` + +**Terminal 2 — run the agent:** + +```bash +python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp +``` + +Expected output: + +``` +Connecting to cMCP gateway at http://localhost:8443 +Tenant: acme-corp +Workflow: analytics-workflow + +Running the same three tool calls against acme-corp's policy bundle. + +[1/3] Calling saas.analytics_query ... + -> decision: allow +[2/3] Calling saas.user_data_export ... + -> decision: advisory_deny + reason: gdpr-justification-missing + regulation: gdpr-art-6 +[3/3] Calling saas.config_update ... + -> decision: allow + +=== TRACE Trust Record === +{ "policy": { "version": "acme-corp-v1.0", ... }, ... } +``` + +`user_data_export` is advisory: the call was logged and flagged but not blocked. + +--- + +## Step 3 — Run the demo for Globex Financial + +Stop the runtime (Ctrl-C), then restart with Globex Financial's policy: + +**Terminal 1:** + +```bash +CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-globex-financial.yaml +``` + +Expected startup output: + +``` +[cmcp] policy bundle loaded: globex-financial-v3.2 +[cmcp] catalog loaded: 3 tools +[cmcp] listening on 0.0.0.0:8443 +``` + +**Terminal 2:** + +```bash +python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial +``` + +Expected output: + +``` +Connecting to cMCP gateway at http://localhost:8443 +Tenant: globex-financial +Workflow: analytics-workflow + +Running the same three tool calls against globex-financial's policy bundle. + +[1/3] Calling saas.analytics_query ... + -> decision: allow +[2/3] Calling saas.user_data_export ... + -> decision: deny +[3/3] Calling saas.config_update ... + -> decision: advisory_deny + reason: config-update-requires-admin-workflow + +=== TRACE Trust Record === +{ "policy": { "version": "globex-financial-v3.2", ... }, ... } +``` + +`user_data_export` is a hard deny: the call was blocked. `config_update` is advisory. + +The `policy.version` field in the TRACE record shows `globex-financial-v3.2` — this is the field that identifies which tenant's policy was enforced for each session. + +--- + +## Step 4 — Verify the TRACE record + +```bash +curl -s http://localhost:8443/trace > trace.json +cmcp-verify trace.json +``` + +--- + +## Understanding the Cedar policies + +### Acme Corp (`tenants/acme-corp/policy/allow.cedar`) + +- Permit analytics-workflow for all tools +- Advisory forbid on `saas.user_data_export` when no GDPR justification is present (logged only) +- Catch-all permit + +### Globex Financial (`tenants/globex-financial/policy/allow.cedar`) + +- Permit `data-compliance-workflow` only for `saas.user_data_export` +- Permit `admin-workflow` only for `saas.config_update` +- Permit any workflow for `saas.analytics_query` +- Hard deny `saas.user_data_export` for all other workflows +- Advisory deny `saas.config_update` for all other workflows + +The difference: Acme Corp trusts its analytics workflow to handle data exports (advisory only). Globex Financial requires a purpose-specific workflow for any personal data access (hard deny by default). + +--- + +## Running both tenants simultaneously + +To demonstrate both tenants side by side without restarting, run on different ports: + +**Terminal 1 — Acme Corp on 8443:** + +```yaml +# cmcp-config-acme-corp.yaml: listen_addr: 0.0.0.0:8443 +``` + +```bash +CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-acme-corp.yaml +``` + +**Terminal 2 — Globex Financial on 8444:** + +Edit `cmcp-config-globex-financial.yaml` to set `listen_addr: 0.0.0.0:8444`, then: + +```bash +CMCP_DEV_MODE=1 cmcp start --config multi-tenant-saas/cmcp-config-globex-financial.yaml +``` + +**Terminal 3:** + +```bash +python multi-tenant-saas/agent/saas_agent.py --tenant acme-corp --gateway http://localhost:8443 +python multi-tenant-saas/agent/saas_agent.py --tenant globex-financial --gateway http://localhost:8444 +``` + +--- + +## Production path + +In production, per-tenant isolation is enforced by provisioning one cMCP Runtime instance per tenant (or per tenant isolation boundary), each started with that tenant's policy bundle. The runtime's TEE attestation report covers the specific policy hash that was loaded — the TRACE Trust Record is evidence that *this specific bundle* was enforced for *this session*. + +Hot-reload (`policy_reload_interval_seconds` in the config) allows policy updates without restarts, but the hash pinning (`CMCP_POLICY_HASH` env var) must be updated to match the new bundle. + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/multi-tenant-saas/agent/saas_agent.py b/multi-tenant-saas/agent/saas_agent.py new file mode 100644 index 0000000..f00dcbd --- /dev/null +++ b/multi-tenant-saas/agent/saas_agent.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +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. + +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 + +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) +""" + +import argparse +import json +import sys +import httpx + +DEFAULT_GATEWAY = "http://localhost:8443" +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.""" + 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: + 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() + + +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']}") + else: + print(f" -> decision: {result.get('cmcp_decision', 'allow')}") + + +# --------------------------------------------------------------------------- +# Main demo flow +# --------------------------------------------------------------------------- + +def run(gateway: str, tenant: str) -> None: + print(f"Connecting to cMCP gateway 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() + + 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. + 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. + 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) + + 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.") + sys.exit(1) + + print() + print("Done. Start 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", + ) + args = parser.parse_args() + run(args.gateway, args.tenant) diff --git a/multi-tenant-saas/catalog.json b/multi-tenant-saas/catalog.json new file mode 100644 index 0000000..62002cb --- /dev/null +++ b/multi-tenant-saas/catalog.json @@ -0,0 +1,82 @@ +[ + { + "tool_name": "saas.user_data_export", + "server": { + "display_name": "SaaS Platform MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Export user account and usage data for the tenant", + "input_schema": { + "type": "object", + "properties": { + "user_id": {"type": "string"}, + "format": {"type": "string", "enum": ["json", "csv"]}, + "gdpr_justification": {"type": "string"} + }, + "required": ["user_id"] + } + }, + "definition_hash": "sha256:f12befaccc0eb9801583cb94d6902f6899364d13a3d6b2f8c07f450aee4dc521", + "compliance_domain": "pii", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "platform-security@saas.example" + }, + { + "tool_name": "saas.analytics_query", + "server": { + "display_name": "SaaS Platform MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Run a read-only analytics query over tenant event data", + "input_schema": { + "type": "object", + "properties": { + "metric": {"type": "string"}, + "time_range_days": {"type": "integer"}, + "filters": {"type": "object"} + }, + "required": ["metric"] + } + }, + "definition_hash": "sha256:dbfe9ce96ebc7580a162e45e82c65c076e62f39c293c32b93ed1ce20a4b59655", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "internal", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "platform-security@saas.example" + }, + { + "tool_name": "saas.config_update", + "server": { + "display_name": "SaaS Platform MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Update platform configuration settings for the tenant", + "input_schema": { + "type": "object", + "properties": { + "key": {"type": "string"}, + "value": {"type": "string"} + }, + "required": ["key", "value"] + } + }, + "definition_hash": "sha256:9bf90691ce6f3ed9a18da33b6b41f71d12ee77e51d8a81137c1723ea97e1647a", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "internal", + "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 new file mode 100644 index 0000000..58e3481 --- /dev/null +++ b/multi-tenant-saas/cmcp-config-acme-corp.yaml @@ -0,0 +1,8 @@ +policy_bundle_path: ./tenants/acme-corp/policy +catalog_path: ./catalog.json +listen_addr: 0.0.0.0:8443 +audit_db_path: ./audit-acme-corp.db +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 86400 diff --git a/multi-tenant-saas/cmcp-config-globex-financial.yaml b/multi-tenant-saas/cmcp-config-globex-financial.yaml new file mode 100644 index 0000000..402adf6 --- /dev/null +++ b/multi-tenant-saas/cmcp-config-globex-financial.yaml @@ -0,0 +1,8 @@ +policy_bundle_path: ./tenants/globex-financial/policy +catalog_path: ./catalog.json +listen_addr: 0.0.0.0:8443 +audit_db_path: ./audit-globex-financial.db +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 86400 diff --git a/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar b/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar new file mode 100644 index 0000000..0014a81 --- /dev/null +++ b/multi-tenant-saas/tenants/acme-corp/policy/allow.cedar @@ -0,0 +1,35 @@ +// 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). + +// 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 2: Advisory forbid on user data export without a GDPR justification. +// Logged in the TRACE record for DPO review; does not block the call. +forbid ( + principal, + action == Action::"tool_call", + resource == Tool::"saas.user_data_export" +) when { + !context.has("gdpr_justification") || context.gdpr_justification == "" +} advice { + "reason": "gdpr-justification-missing", + "regulation": "gdpr-art-6", + "action": "advisory-log-only" +}; + +// Rule 3: Catch-all permit. +permit ( + principal, + action, + resource +); diff --git a/multi-tenant-saas/tenants/acme-corp/policy/manifest.json b/multi-tenant-saas/tenants/acme-corp/policy/manifest.json new file mode 100644 index 0000000..81ba9df --- /dev/null +++ b/multi-tenant-saas/tenants/acme-corp/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "acme-corp-v1.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "security@acme-corp.example", + "commit_sha": "0000000000000000000000000000000000000000" +} diff --git a/multi-tenant-saas/tenants/acme-corp/policy/schema.cedarschema b/multi-tenant-saas/tenants/acme-corp/policy/schema.cedarschema new file mode 100644 index 0000000..889ad3f --- /dev/null +++ b/multi-tenant-saas/tenants/acme-corp/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar b/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar new file mode 100644 index 0000000..d8d9d0f --- /dev/null +++ b/multi-tenant-saas/tenants/globex-financial/policy/allow.cedar @@ -0,0 +1,54 @@ +// 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. + +// Rule 1: Permit data-compliance-workflow to export user data. +permit ( + principal, + action == Action::"tool_call", + resource == Tool::"saas.user_data_export" +) when { + context.workflow_id == "data-compliance-workflow" +}; + +// Rule 2: Permit admin-workflow to update configuration. +permit ( + principal, + action == Action::"tool_call", + resource == Tool::"saas.config_update" +) when { + 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. +forbid ( + principal, + action == Action::"tool_call", + resource == Tool::"saas.user_data_export" +) when { + 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. +forbid ( + principal, + action == Action::"tool_call", + resource == Tool::"saas.config_update" +) when { + context.workflow_id != "admin-workflow" +} advice { + "reason": "config-update-requires-admin-workflow", + "action": "advisory-log-and-notify-security" +}; diff --git a/multi-tenant-saas/tenants/globex-financial/policy/manifest.json b/multi-tenant-saas/tenants/globex-financial/policy/manifest.json new file mode 100644 index 0000000..9f4c89b --- /dev/null +++ b/multi-tenant-saas/tenants/globex-financial/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "globex-financial-v3.2", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "dpo@globex-financial.example", + "commit_sha": "0000000000000000000000000000000000000000" +} diff --git a/multi-tenant-saas/tenants/globex-financial/policy/schema.cedarschema b/multi-tenant-saas/tenants/globex-financial/policy/schema.cedarschema new file mode 100644 index 0000000..889ad3f --- /dev/null +++ b/multi-tenant-saas/tenants/globex-financial/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/multi-tenant-saas/trace-output/acme-corp-example.json b/multi-tenant-saas/trace-output/acme-corp-example.json new file mode 100644 index 0000000..014a7f4 --- /dev/null +++ b/multi-tenant-saas/trace-output/acme-corp-example.json @@ -0,0 +1,43 @@ +{ + "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" + }, + "data_class": "confidential", + "tool_transcript": [ + { + "tool": "saas.analytics_query", + "data_class": "internal", + "decision": "allow" + }, + { + "tool": "saas.user_data_export", + "data_class": "confidential", + "decision": "advisory_deny", + "advice": { + "reason": "gdpr-justification-missing", + "regulation": "gdpr-art-6", + "action": "advisory-log-only" + } + }, + { + "tool": "saas.config_update", + "data_class": "internal", + "decision": "allow" + } + ], + "cnf": { + "kid": "cmcp-c3d4e5f6" + } +} diff --git a/multi-tenant-saas/trace-output/globex-financial-example.json b/multi-tenant-saas/trace-output/globex-financial-example.json new file mode 100644 index 0000000..8b9e13c --- /dev/null +++ b/multi-tenant-saas/trace-output/globex-financial-example.json @@ -0,0 +1,42 @@ +{ + "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" + }, + { + "tool": "saas.user_data_export", + "data_class": "confidential", + "decision": "deny" + }, + { + "tool": "saas.config_update", + "data_class": "internal", + "decision": "advisory_deny", + "advice": { + "reason": "config-update-requires-admin-workflow", + "action": "advisory-log-and-notify-security" + } + } + ], + "cnf": { + "kid": "cmcp-d4e5f6a7" + } +} diff --git a/startup-tpm/README.md b/startup-tpm/README.md index 054fc87..ad069b5 100644 --- a/startup-tpm/README.md +++ b/startup-tpm/README.md @@ -61,6 +61,8 @@ startup-tpm/ manifest.json policy bundle metadata allow.cedar permit-all policy schema.cedarschema Cedar schema (minimal) + agent/ + echo_agent.py minimal agent script ``` --- @@ -115,7 +117,15 @@ Expected startup output: ## Step 5 — Make a test tool call -In a second terminal: +**Option A — agent script (recommended):** + +```bash +python startup-tpm/agent/echo_agent.py +``` + +The script calls `test.echo`, prints the policy decision, and fetches the TRACE Trust Record in one shot. + +**Option B — curl:** ```bash curl -X POST http://localhost:8443/mcp \ diff --git a/startup-tpm/agent/echo_agent.py b/startup-tpm/agent/echo_agent.py new file mode 100644 index 0000000..92d6421 --- /dev/null +++ b/startup-tpm/agent/echo_agent.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Minimal echo agent for the cMCP startup quickstart. + +Calls test.echo through the gateway and prints the TRACE Trust Record. + +Usage: + python echo_agent.py [--gateway http://localhost:8443] +""" + +import argparse +import json +import sys +import httpx + +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() + + 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 = "" + content = result.get("content", []) + if content: + echoed = content[0].get("text", "") + print(f" -> decision: {decision}") + if echoed: + print(f" -> echoed: {echoed}") + 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") + sys.exit(1) + + 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})", + ) + args = parser.parse_args() + run(args.gateway)