Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# agentrust-io demos

Runnable demos for [cMCP](https://github.com/agentrust-io/cmcp) and [TRACE](https://github.com/agentrust-io/trace-spec). Three demos, ~4 minutes total.
Runnable demos for [cMCP](https://github.com/agentrust-io/cmcp) and [TRACE](https://github.com/agentrust-io/trace-spec). Five demos, ~6 minutes total.

---

Expand Down Expand Up @@ -96,6 +96,38 @@ What you see:

---

## Demo 4 -- Context-aware enforcement (~90 seconds)

Demos 1-3 are the trust chain: enforce, tamper, verify. Demo 4 is a second axis. The same tool is allowed in one workflow and denied in another, so authorization tracks the declared call context, not the tool's identity and not the model's stated intent.

```
python demo-04-context-enforcement/run.py
```

What you see:
- `write_file` under `workflow_id="invoice-run"`: Cedar **allows** it (the `WriteFile` permit is guarded by `context.workflow_id == "invoice-run"`)
- the identical `write_file`, same arguments, under `workflow_id="chat-freeform"`: **denied by Cedar** (HTTP 403, POLICY_DENY) -- no permit matches, default-deny holds
- `read_file` under `workflow_id="chat-freeform"`: **allowed** (reads are permitted in any workflow, so the second workflow is not blocked wholesale; only the write capability is scoped)
- both the allow and the deny are committed to the signed audit chain under one `policy.bundle_hash`

---

## Demo 5 -- Attribute-based enforcement (~90 seconds)

Demo 1 denies by tool name, demo 4 by call context. Demo 5 denies by the tool's compliance attributes. A tool that is not BAA-covered is refused by a single guardrail rule, whatever it is named.

```
python demo-05-compliance-domain/run.py
```

What you see:
- `write_file` and `read_file` (`compliance_domain=clinical`, `baa_covered=true`): **allowed**
- `list_dir` (`compliance_domain=external-analytics`, `baa_covered=false`): **denied by Cedar** (HTTP 403, POLICY_DENY)
- the deny comes from `forbid ... when { context.baa_covered == false }`, which overrides the baseline permit; the call is refused on its attribute, not its name
- one rule covers every non-BAA-covered tool in the catalog, present and future

---

## Structure

```
Expand Down Expand Up @@ -123,7 +155,21 @@ demos/
| +-- run.py # Cross-platform launcher (use this)
| +-- run.sh # bash-only launcher
+-- demo-03-offline-trace/
+-- verify.py # cmcp_verify.verify_trace_claim (no network)
| +-- verify.py # cmcp_verify.verify_trace_claim (no network)
| +-- run.py # Cross-platform launcher (use this)
| +-- run.sh # bash-only launcher
+-- demo-04-context-enforcement/
| +-- cmcp-config.yaml
| +-- catalog.json # Approves write_file, read_file (compliance_domain: finance)
| +-- policies/ # Cedar: WriteFile permitted only when context.workflow_id == "invoice-run"
| +-- call.py # Same write in two workflows: one allowed, one denied
| +-- run.py # Cross-platform launcher (use this)
| +-- run.sh # bash-only launcher
+-- demo-05-compliance-domain/
+-- cmcp-config.yaml
+-- catalog.json # Tags tools with compliance_domain + BAA status
+-- policies/ # Cedar: forbid any tool when context.baa_covered == false
+-- call.py # Two BAA-covered tools allowed, one non-covered tool denied
+-- run.py # Cross-platform launcher (use this)
+-- run.sh # bash-only launcher
```
Expand Down
38 changes: 38 additions & 0 deletions demo-04-context-enforcement/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Demo 4: context-aware enforcement

**Duration:** ~90 seconds

Demo 1 gates on the tool's identity: which tools may be called at all. This demo gates on the **context of the call**. The same capability is permitted inside one workflow and denied inside another. The agent cannot widen its own authority by restating its intent, because cMCP evaluates the declared workflow context, it does not trust the model.

## Run

```bash
python demo-04-context-enforcement/run.py # cross-platform
# or, bash only:
bash demo-04-context-enforcement/run.sh
```

## What to show the audience

Three calls go through the gateway in one session:

1. `write_file` under `workflow_id="invoice-run"` — **allowed**. The Cedar `permit` for `WriteFile` is guarded by `context.workflow_id == "invoice-run"`.
2. The **identical** `write_file`, same arguments, under `workflow_id="chat-freeform"` — **denied, HTTP 403**. No `permit` matches `WriteFile` outside `invoice-run`, so Cedar's default-deny holds. Same tool, same payload, different context, different decision.
3. `read_file` under `workflow_id="chat-freeform"` — **allowed**. Reads are permitted in any workflow, so the point is not that the second workflow is blocked wholesale; only the write capability is scoped.

Then the session closes into a signed TRACE claim. Both the allow and the deny are committed to the hash-chained audit log under the same `policy.bundle_hash`, so you can prove which policy decided each call.

## Why it matters

"Trust the LLM to only write when it's supposed to" is not a control. Here the write authority is bound to a declared workflow and enforced before the call leaves the environment. A model that changes its stated intent does not change what it is allowed to do.

## Policy

`policies/workflow-scoped.cedar`:

- `ReadFile` — permitted for any principal, any workflow.
- `WriteFile` — permitted only `when { context.workflow_id == "invoice-run" }`.

The runtime maps `tool_name` to a PascalCase Cedar action (`write_file` → `Action::"WriteFile"`) and places call context (`workflow_id`, `compliance_domain`, `baa_covered`, `session_max_sensitivity`) on `context`. The agent supplies `workflow_id` per call via the `_cmcp` field; see `call.py`.

Runs in software-only mode with `CMCP_DEV_MODE=1`; no hardware required. On real TDX the policy bundle hash flows into RTMR[2] at startup exactly as in demo 1.
139 changes: 139 additions & 0 deletions demo-04-context-enforcement/call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Demo 4: context-aware enforcement.

The same capability (write_file) is permitted inside the approved "invoice-run"
workflow and denied under any other workflow. The agent does not get to widen
its own authority by changing its stated intent: cMCP evaluates the declared
workflow context, it does not trust the model.

Prerequisites:
server/server.py running on :9001 (plain HTTP JSON-RPC 2.0)
cmcp start --config cmcp-config.yaml running on :8443
CMCP_BEARER_TOKEN env var set (run.py / run.sh export this)

Usage:
python call.py (run from repo root, or via run.py)
"""
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request

GATEWAY_URL = "http://localhost:8443"
TOKEN = os.environ.get("CMCP_BEARER_TOKEN", "demo-token")
WORKSPACE = pathlib.Path(__file__).parent.parent / "workspace"

INVOICE_TEXT = "Invoice 4471 -- ACME Corp -- $12,400 -- net 30\n"


def _headers():
return {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"}


def _post(url, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers=_headers(), method="POST")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read()), resp.status
except urllib.error.HTTPError as exc:
return json.loads(exc.read()), exc.code


def mcp_call(name, arguments, workflow_id):
return _post(f"{GATEWAY_URL}/mcp", {
"jsonrpc": "2.0",
"id": int(time.time() * 1000),
"method": "tools/call",
"params": {
"name": name,
"arguments": arguments,
"_cmcp": {"workflow_id": workflow_id},
},
})


def main():
WORKSPACE.mkdir(exist_ok=True)
session_id = None

print("=== Demo 4: context-aware enforcement ===")
print("Approved tool: write_file. Approved workflow for writes: 'invoice-run'.\n")

# 1. write_file inside the approved workflow -- Cedar: permit (workflow matches)
print("[1/3] write_file workflow='invoice-run' [approved workflow]")
body, status = mcp_call("write_file", {"path": "invoice.txt", "content": INVOICE_TEXT}, "invoice-run")
if status == 200:
cmcp = body["result"].get("_cmcp", {})
session_id = cmcp.get("session_id")
print(f" ALLOWED {body['result']['content'][0]['text']!r}")
print(f" session_id={session_id}")
else:
print(f" ERROR {status}: {body}")
sys.exit(1)

# 2. the IDENTICAL write, under a different workflow -- Cedar: default-deny (no permit)
print()
print("[2/3] write_file workflow='chat-freeform' [same tool, same args, other workflow]")
body, status = mcp_call("write_file", {"path": "invoice.txt", "content": INVOICE_TEXT}, "chat-freeform")
if status == 403:
error = body.get("error", {})
code = error.get("data", {}).get("error_code", "?")
print(f" HTTP 403 -- {error.get('message', '?')} [{code}]")
print(" No permit matches WriteFile outside 'invoice-run'. Default-deny holds.")
print(" The model changed its stated intent; its authority did not change.")
else:
print(f" unexpected {status}: {body}")
sys.exit(1)

# 3. read_file under the same 'chat-freeform' workflow -- Cedar: permit (reads allowed anywhere)
print()
print("[3/3] read_file workflow='chat-freeform' [reads permitted in any workflow]")
body, status = mcp_call("read_file", {"path": "invoice.txt"}, "chat-freeform")
if status == 200:
session_id = body["result"].get("_cmcp", {}).get("session_id") or session_id
print(f" ALLOWED content={body['result']['content'][0]['text'].strip()!r}")
print(" The workflow is not blanket-blocked. Only the write capability is scoped.")
else:
print(f" ERROR {status}: {body}")

# close session -> signed TRACE claim
print()
print("Closing session -> TRACE claim...")
if not session_id:
print("ERROR: no session_id. Is cMCP returning _cmcp metadata?")
sys.exit(1)

body, status = _post(f"{GATEWAY_URL}/sessions/{session_id}/close", {})
if status != 200:
print(f"ERROR {status}: {body}")
sys.exit(1)
claim = body

trace = claim.get("trace", {})
gw = claim.get("gateway", {})
chain = gw.get("audit_chain", {})

print()
print("=== TRACE claim ===")
print(f" runtime.platform: {trace.get('runtime', {}).get('platform')}")
print(f" policy.bundle_hash: {trace.get('policy', {}).get('bundle_hash')}")
print(f" policy.mode: {trace.get('policy', {}).get('enforcement_mode')}")
print(f" audit_chain.length: {chain.get('length')} (session lifecycle + every allow/deny, hash-chained)")
sig = claim.get("signature", "")
print(f" signature: {sig[:40]}...")
print()
print(" The allow AND the deny are both in the signed audit chain.")
print(" You can prove which policy was in force when each call was decided.")

claim_path = WORKSPACE / "trace-claim-demo04.json"
claim_path.write_text(json.dumps(claim, indent=2))
print()
print(f" Claim saved to {claim_path}")


if __name__ == "__main__":
main()
80 changes: 80 additions & 0 deletions demo-04-context-enforcement/catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
[
{
"tool_name": "write_file",
"server": {
"display_name": "Demo Filesystem MCP Server",
"url": "http://localhost:9001/mcp",
"tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"transport": "http-sse",
"rotation_mode": "key-pinned"
},
"approved_definition": {
"description": "Write content to a file in the demo workspace",
"input_schema": {
"type": "object",
"required": [
"path",
"content"
],
"properties": {
"path": {
"type": "string"
},
"content": {
"type": "string"
}
}
},
"output_schema": {
"type": "object",
"properties": {
"result": {
"type": "string"
}
}
}
},
"compliance_domain": "finance",
"requires_baa": false,
"sensitivity_level": "confidential",
"added_at": "2026-07-20T00:00:00Z",
"approved_by": "demo-setup"
},
{
"tool_name": "read_file",
"server": {
"display_name": "Demo Filesystem MCP Server",
"url": "http://localhost:9001/mcp",
"tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"transport": "http-sse",
"rotation_mode": "key-pinned"
},
"approved_definition": {
"description": "Read a file from the demo workspace",
"input_schema": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string"
}
}
},
"output_schema": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
}
},
"compliance_domain": "finance",
"requires_baa": false,
"sensitivity_level": "confidential",
"added_at": "2026-07-20T00:00:00Z",
"approved_by": "demo-setup"
}
]
9 changes: 9 additions & 0 deletions demo-04-context-enforcement/cmcp-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
attestation:
provider: auto # falls back to software-only when CMCP_DEV_MODE=1
enforcement_mode: enforcing
validity_seconds: 3600

policy_bundle_path: ./policies/
catalog_path: ./catalog.json
listen_addr: "127.0.0.1:8443"
audit_db_path: ./audit.db
7 changes: 7 additions & 0 deletions demo-04-context-enforcement/policies/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"version": "1.0.0",
"authored_at": "2026-07-20T00:00:00Z",
"author_identity": "demo-setup",
"commit_sha": "demo-do-not-use-in-production",
"approval_chain": []
}
Loading