diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..70b8dea --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "web-console/vendor/examples"] + path = web-console/vendor/examples + url = https://github.com/agentrust-io/examples diff --git a/README.md b/README.md index 1b1e03f..988a3ef 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,14 @@ Each demo has a `run.py` (works on any OS) and a `run.sh` (bash only). Server an ## Browser console -A point-and-click version of the same enforcement, for showing on a screen instead of a terminal. +A point-and-click version for showing on a screen instead of a terminal. It runs the real `financial-services` example (an EU corporate credit-risk agent, pulled in as a git submodule -- not copied) behind a small web UI. ``` +pip install cmcp-runtime httpx python web-console/run.py ``` -Opens `http://localhost:8000`. Send tool calls and watch Cedar allow and deny them, close the session into a signed TRACE record, then verify it offline -- every result comes from the real gateway. See [`web-console/README.md`](web-console/README.md). +Opens `http://localhost:8000`. Pick an obligor and run a six-step credit assessment: watch Cedar allow each step and block the write when the result breaches a control (CRR concentration, EBA delegated authority, IFRS 9, EU AML), each deny citing its regulation. Close the session into a signed TRACE record and verify it offline -- every result comes from the real gateway. See [`web-console/README.md`](web-console/README.md). --- diff --git a/web-console/README.md b/web-console/README.md index 21853ae..3e76f6b 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -1,67 +1,65 @@ -# Web console -- cMCP in the browser +# Web console -- cMCP credit-risk assessment -The same enforcement the other demos show on the command line, in a small web UI -you can click through in front of an audience. The scenario is a bank support -agent: it can look up accounts and customers, but policy forbids it from moving -money or exporting records. Every number on the page comes from the real -gateway -- real tool calls, real Cedar decisions, the real signed TRACE record, -and the real `cmcp verify` output. +A browser console for showing cMCP on a screen. It runs the **real +financial-services example** from `agentrust-io/examples` (an EU corporate +credit-risk agent) unmodified, behind a small web UI. Nothing here is a copy: +the tool server, Cedar policy, catalog, and agent all come from that repo, +pulled in as a git submodule under `vendor/`. ``` -pip install cmcp-runtime -python web-console/run.py +pip install cmcp-runtime httpx +git clone --recurse-submodules https://github.com/agentrust-io/demos.git # or run.py inits the submodule +python demos/web-console/run.py # opens http://localhost:8000 ``` -`run.py` starts three local processes and opens the console at -`http://localhost:8000`: +`run.py` starts three local processes and opens the console: -- a mock core-banking MCP server on `:9001` (`tools_server.py`) +- the example's mock EU credit-risk MCP server on `:8080` - the cMCP gateway on `:8443` (`CMCP_DEV_MODE=1`, software-only TEE) - this demo's web server on `:8000` -Press Ctrl+C in the terminal to stop all three. +Ctrl+C stops all three. (If the submodule is empty, `run.py` runs +`git submodule update --init` for you.) -## What to click +## What it shows -1. **`get_balance`** and **`get_customer`** -- Cedar permits them. The call is - forwarded to the tool and you see the real record (PII fields come back - masked). -2. **`transfer_funds`** and **`export_records`** -- Cedar forbids them. The - gateway returns HTTP 403 (`POLICY_DENY`) and the tool is never contacted. - The agent can read, but it cannot move money or bulk-export data. -3. **Close session -> TRACE record** -- the gateway signs a `GatewayClaim`: - which tools ran, which were denied, the policy bundle hash, an Ed25519 - signature. Saved to `workspace/web-console-claim.json`. -4. **Verify record offline** -- runs `cmcp verify` against the saved record. - In software-only mode the result is `partially_verified`: every - cryptographic field checks out, only the hardware root is absent. On real - TDX / SEV-SNP that last check passes too and it reads `verified`. +An AI agent runs a six-step credit assessment through the gateway. It reads the +client's filings, screens for sanctions, pulls a credit-bureau report, +aggregates group exposure, and runs the PD/LGD model -- then passes that outcome +into the write. Cedar enforces the bank's controls **on the result**, so the +write is only recorded if the assessment passes them. Three obligors: -The **Policy** tab shows the exact Cedar bundle the gateway loaded. Its hash is -measured into the attestation at startup, so the policy that ran is provably the -one you approved. +| Scenario | Obligor | Write | Blocked by | +|---|---|---|---| +| Performing | Rheintal Präzisionstechnik GmbH, €250k | allowed | -- | +| Concentration breach | Nordwind Logistik AG, €750k | blocked | CRR Art. 395 / EBA/GL/2020/06 | +| Sanctions / impaired | Meridian Trading DMCC, €200k | blocked | IFRS 9 (stage 3) | + +Each deny carries the regulation and reason back as structured advice. Close the +session for the signed `RuntimeClaim`, then verify it offline with `cmcp verify` +(reads `partially_verified` in software-only mode; `verified` on real TDX / +SEV-SNP). + +The **Policy** tab shows the Cedar bundle the gateway loaded and maps each +guardrail to the control it enforces. **What this shows** explains the four +properties the demo proves. ## How it fits together The browser never talks to the gateway directly. `webserver.py` serves the UI -and forwards to the gateway server-side, holding the bearer token so it stays -out of the browser and there is no CORS to configure. It is a thin proxy over -the same HTTP endpoints the CLI demos use (`POST /mcp`, `GET /audit/export`, -`POST /sessions/{id}/close`). - -The Cedar policy uses the action dialect the runtime evaluates -(`get_balance -> Action::"GetBalance"`). Nothing here is mocked except the tool -backend, and even that returns realistic records. +and, on **Run**, executes the example's own agent +(`vendor/examples/financial-services/agent/credit_risk_agent.py`) as a +subprocess against the gateway, then parses its output into the step timeline. +`run.py` supplies only a loopback gateway config that points at the example's +policy and catalog where they live -- it does not copy them. Bumping the +submodule pointer updates the example. ## Files ``` web-console/ - run.py launcher (tool server + gateway + web server) - webserver.py static UI + /api proxy to the gateway - tools_server.py mock core-banking MCP server (read + write/export tools) - cmcp-config.yaml gateway config (enforcing, software-only) - catalog.json approved tools - policies/ Cedar bundle + run.py launcher (submodule init + example server + gateway + web server) + webserver.py static UI + /api (runs the example agent, runs cmcp verify) web/ index.html, app.js, styles.css + vendor/examples/ git submodule -> agentrust-io/examples (the real scenario) ``` diff --git a/web-console/catalog.json b/web-console/catalog.json deleted file mode 100644 index 1dd1c90..0000000 --- a/web-console/catalog.json +++ /dev/null @@ -1,158 +0,0 @@ -[ - { - "tool_name": "get_balance", - "server": { - "display_name": "Core Banking MCP Server (mock)", - "url": "http://localhost:9001/mcp", - "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "transport": "http-sse" - }, - "approved_definition": { - "description": "Look up the balance for a bank account", - "input_schema": { - "type": "object", - "required": [ - "account_id" - ], - "properties": { - "account_id": { - "type": "string" - } - } - }, - "output_schema": { - "type": "object", - "properties": { - "result": { - "type": "string" - } - } - } - }, - "definition_hash": "sha256:7affd4526385567bbce7c86df1b15dd614c0559aaf12213a063e123af9ef8a7d", - "compliance_domain": "internal", - "requires_baa": false, - "sensitivity_level": "confidential", - "added_at": "2026-07-01T00:00:00Z", - "approved_by": "security-team" - }, - { - "tool_name": "get_customer", - "server": { - "display_name": "Core Banking MCP Server (mock)", - "url": "http://localhost:9001/mcp", - "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "transport": "http-sse" - }, - "approved_definition": { - "description": "Look up a customer profile (PII fields returned masked)", - "input_schema": { - "type": "object", - "required": [ - "customer_id" - ], - "properties": { - "customer_id": { - "type": "string" - } - } - }, - "output_schema": { - "type": "object", - "properties": { - "result": { - "type": "string" - } - } - } - }, - "definition_hash": "sha256:b424e55dce58f35ff97127904df9a7f837279ede56c604a99988078198fec33e", - "compliance_domain": "pii", - "requires_baa": false, - "sensitivity_level": "pii", - "added_at": "2026-07-01T00:00:00Z", - "approved_by": "security-team" - }, - { - "tool_name": "transfer_funds", - "server": { - "display_name": "Core Banking MCP Server (mock)", - "url": "http://localhost:9001/mcp", - "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "transport": "http-sse" - }, - "approved_definition": { - "description": "Move money between accounts", - "input_schema": { - "type": "object", - "required": [ - "from_account", - "to_account", - "amount" - ], - "properties": { - "from_account": { - "type": "string" - }, - "to_account": { - "type": "string" - }, - "amount": { - "type": "number" - } - } - }, - "output_schema": { - "type": "object", - "properties": { - "result": { - "type": "string" - } - } - } - }, - "definition_hash": "sha256:b594a0959a0fe472d219844ff47812a174014d12ae8dd1cd078c8e6446311d48", - "compliance_domain": "pci_data", - "requires_baa": false, - "sensitivity_level": "confidential", - "added_at": "2026-07-01T00:00:00Z", - "approved_by": "security-team" - }, - { - "tool_name": "export_records", - "server": { - "display_name": "Core Banking MCP Server (mock)", - "url": "http://localhost:9001/mcp", - "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "transport": "http-sse" - }, - "approved_definition": { - "description": "Bulk-export records from a dataset", - "input_schema": { - "type": "object", - "required": [ - "dataset" - ], - "properties": { - "dataset": { - "type": "string" - } - } - }, - "output_schema": { - "type": "object", - "properties": { - "result": { - "type": "string" - } - } - } - }, - "definition_hash": "sha256:4e09969b2f6543bcd2cb89a31ff2d05ec80c2f4323b0ff00bf666c307532ca10", - "compliance_domain": "pii", - "requires_baa": false, - "sensitivity_level": "confidential", - "added_at": "2026-07-01T00:00:00Z", - "approved_by": "security-team" - } -] diff --git a/web-console/cmcp-config.yaml b/web-console/cmcp-config.yaml deleted file mode 100644 index 7590dd0..0000000 --- a/web-console/cmcp-config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -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 diff --git a/web-console/policies/manifest.json b/web-console/policies/manifest.json deleted file mode 100644 index b27f449..0000000 --- a/web-console/policies/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": "1.0.0", - "authored_at": "2026-06-22T00:00:00Z", - "author_identity": "demo-setup", - "commit_sha": "demo-do-not-use-in-production", - "approval_chain": [] -} diff --git a/web-console/policies/schema.cedarschema b/web-console/policies/schema.cedarschema deleted file mode 100644 index a3b37be..0000000 --- a/web-console/policies/schema.cedarschema +++ /dev/null @@ -1,67 +0,0 @@ -{ - "cMCP": { - "entityTypes": { - "Principal": { - "memberOfTypes": [], - "shape": { - "type": "Record", - "attributes": { - "session_id": { - "type": "String", - "required": true - }, - "workflow_id": { - "type": "String", - "required": true - } - } - } - }, - "Resource": { - "memberOfTypes": [], - "shape": { - "type": "Record", - "attributes": { - "tool_name": { - "type": "String", - "required": true - }, - "compliance_domain": { - "type": "String", - "required": true - }, - "baa_covered": { - "type": "Bool", - "required": true - } - } - } - } - }, - "actions": { - "call_tool": { - "appliesTo": { - "principalTypes": [ - "cMCP::Principal" - ], - "resourceTypes": [ - "cMCP::Resource" - ], - "context": { - "type": "Record", - "attributes": { - "session_max_sensitivity": { - "type": "String", - "required": true - }, - "workflow_id": { - "type": "String", - "required": true - } - } - } - } - } - } - } -} diff --git a/web-console/policies/support-agent.cedar b/web-console/policies/support-agent.cedar deleted file mode 100644 index d5d19df..0000000 --- a/web-console/policies/support-agent.cedar +++ /dev/null @@ -1,22 +0,0 @@ -// cMCP Cedar policy -- bank support agent -// -// The runtime maps each tool name to a Cedar action (PascalCase): -// get_balance -> Action::"GetBalance" -// get_customer -> Action::"GetCustomer" -// transfer_funds -> Action::"TransferFunds" -// export_records -> Action::"ExportRecords" -// -// The agent may read account and customer data. -permit ( - principal, - action in [Action::"GetBalance", Action::"GetCustomer"], - resource -); - -// It may not move money or bulk-export records. forbid overrides permit, -// so these are denied at the gateway (HTTP 403) and never reach the tool. -forbid ( - principal, - action in [Action::"TransferFunds", Action::"ExportRecords"], - resource -); diff --git a/web-console/run.py b/web-console/run.py index 5bc1892..9c85421 100644 --- a/web-console/run.py +++ b/web-console/run.py @@ -1,14 +1,19 @@ """cMCP browser console -- one-command launcher (cross-platform). -Starts three local processes and opens the console in your browser: - - the demo MCP filesystem server on :9001 (shared server/server.py) +Runs the real financial-services example from the agentrust-io/examples repo +(pulled in as a git submodule under vendor/) behind a small web console. Nothing +is copied: the tool server, Cedar policy, catalog, and agent all come from the +submodule and run unmodified. This launcher only supplies a loopback gateway +config (tokenless dev mode binds to loopback) and starts: + + - the example's mock EU credit-risk MCP server on :8080 - the cMCP gateway on :8443 (CMCP_DEV_MODE=1, software-only TEE) - this demo's web server on :8000 -Everything the browser shows comes from the real gateway. Ctrl+C stops it all. +Then it opens http://localhost:8000. Ctrl+C stops everything. - python web-console/run.py # from repo root - python run.py # from the web-console directory + python web-console/run.py # from repo root + python run.py # from the web-console directory """ import os import pathlib @@ -22,6 +27,9 @@ HERE = pathlib.Path(__file__).parent.resolve() REPO_ROOT = HERE.parent +EXAMPLE = HERE / "vendor" / "examples" / "financial-services" +WORKSPACE = REPO_ROOT / "workspace" +GATEWAY_CFG = WORKSPACE / "fs-gateway.yaml" PORT = os.environ.get("WEB_CONSOLE_PORT", "8000") @@ -35,18 +43,45 @@ def _find_cmcp() -> str: for name in ("cmcp.exe", "cmcp"): if (scripts / name).exists(): return str(scripts / name) - sys.exit("cmcp not found. Run: pip install cmcp-runtime") + sys.exit("cmcp not found. Run: pip install cmcp-runtime httpx") + + +def _ensure_submodule() -> None: + if (EXAMPLE / "agent" / "credit_risk_agent.py").exists(): + return + print("-- fetching the examples submodule (first run)", flush=True) + subprocess.run(["git", "submodule", "update", "--init", "--depth", "1", + "web-console/vendor/examples"], cwd=REPO_ROOT, check=True) + if not (EXAMPLE / "agent" / "credit_risk_agent.py").exists(): + sys.exit("submodule missing. Run: git submodule update --init") + + +def _write_gateway_config() -> None: + # A loopback config pointing at the submodule example's own policy and + # catalog. We do not copy them -- we reference them where they live. + WORKSPACE.mkdir(exist_ok=True) + GATEWAY_CFG.write_text( + f"policy_bundle_path: {(EXAMPLE / 'policy').as_posix()}\n" + f"catalog_path: {(EXAMPLE / 'catalog.json').as_posix()}\n" + f"listen_addr: 127.0.0.1:8443\n" + f"max_response_size_bytes: 2097152\n" + f"audit_db_path: {(WORKSPACE / 'fs-audit.db').as_posix()}\n" + f"attestation:\n" + f" provider: auto\n" + f" enforcement_mode: enforcing\n" + ) -def main(): - os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") +def main() -> None: + _ensure_submodule() + _write_gateway_config() server_log = open(HERE / "server.log", "w") cmcp_log = open(HERE / "cmcp.log", "w") procs = [] try: - print("-- Core banking MCP server on :9001", flush=True) + print("-- EU credit-risk MCP server on :8080", flush=True) procs.append(subprocess.Popen( - [sys.executable, str(HERE / "tools_server.py")], + [sys.executable, str(EXAMPLE / "server" / "mock_mcp_server.py")], stdout=server_log, stderr=server_log)) time.sleep(1) @@ -54,8 +89,8 @@ def main(): env = os.environ.copy() env["CMCP_DEV_MODE"] = "1" procs.append(subprocess.Popen( - [_find_cmcp(), "start", "--config", str(HERE / "cmcp-config.yaml")], - stdout=cmcp_log, stderr=cmcp_log, cwd=HERE, env=env)) + [_find_cmcp(), "start", "--config", str(GATEWAY_CFG)], + stdout=cmcp_log, stderr=cmcp_log, env=env)) time.sleep(2) print(f"-- web console on http://localhost:{PORT}", flush=True) diff --git a/web-console/tools_server.py b/web-console/tools_server.py deleted file mode 100644 index 615c10b..0000000 --- a/web-console/tools_server.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Mock business tools for the cMCP browser console. - -Plain HTTP JSON-RPC 2.0, same shape the CLI demos' filesystem server uses. These -stand in for a bank's internal MCP tools: two read tools the support agent is -allowed to use, and two write/export tools policy forbids (so they never reach -this server -- they are here only so the "allowed" path has a real upstream). - - python tools_server.py # serves on :9001 -""" -import json -import pathlib - -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.responses import JSONResponse -from starlette.routing import Route - -# Canned records, keyed the way a real lookup would be. -ACCOUNTS = { - "AC-4821": {"account": "AC-4821", "type": "Checking", "balance": "$4,182.55", - "currency": "USD", "status": "active"}, - "AC-7730": {"account": "AC-7730", "type": "Savings", "balance": "$28,940.10", - "currency": "USD", "status": "active"}, -} -CUSTOMERS = { - "C-10293": {"customer_id": "C-10293", "name": "Jordan Rivera", "tier": "Premier", - "email": "j****@example.com", "ssn": "***-**-4821", "kyc": "verified"}, - "C-55817": {"customer_id": "C-55817", "name": "Priya Nair", "tier": "Standard", - "email": "p****@example.com", "ssn": "***-**-9037", "kyc": "verified"}, -} - - -def _ok(id_, obj): - text = json.dumps(obj, indent=2) if not isinstance(obj, str) else obj - return {"jsonrpc": "2.0", "id": id_, "result": {"content": [{"type": "text", "text": text}]}} - - -def _err(id_, code, msg): - return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": msg}} - - -async def handle(request: Request) -> JSONResponse: - if request.method == "GET": - return JSONResponse({"status": "ok"}) - try: - body = await request.json() - except Exception: - return JSONResponse(_err(None, -32700, "parse error"), status_code=400) - - id_ = body.get("id") - params = body.get("params", {}) - name = params.get("name", "") - args = params.get("arguments", {}) - - if body.get("method") != "tools/call": - return JSONResponse(_err(id_, -32601, "method not found"), status_code=404) - - if name == "get_balance": - rec = ACCOUNTS.get(args.get("account_id", "")) - if not rec: - return JSONResponse(_ok(id_, {"error": "account not found"})) - return JSONResponse(_ok(id_, rec)) - if name == "get_customer": - rec = CUSTOMERS.get(args.get("customer_id", "")) - if not rec: - return JSONResponse(_ok(id_, {"error": "customer not found"})) - return JSONResponse(_ok(id_, rec)) - # These are policy-forbidden, so the gateway blocks them before they arrive. - # Implemented anyway so the demo is honest about what the tool would do. - if name == "transfer_funds": - return JSONResponse(_ok(id_, {"status": "transferred", "from": args.get("from_account"), - "to": args.get("to_account"), "amount": args.get("amount")})) - if name == "export_records": - return JSONResponse(_ok(id_, {"status": "exported", "dataset": args.get("dataset"), - "rows": 128412})) - return JSONResponse(_err(id_, -32601, f"unknown tool: {name}"), status_code=404) - - -app = Starlette(routes=[ - Route("/mcp", handle, methods=["GET", "POST"]), - Route("/health", handle, methods=["GET"]), -]) - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="localhost", port=9001) diff --git a/web-console/vendor/examples b/web-console/vendor/examples new file mode 160000 index 0000000..c07e9f5 --- /dev/null +++ b/web-console/vendor/examples @@ -0,0 +1 @@ +Subproject commit c07e9f5d936f5690e3052a1bfb81fd31294148bb diff --git a/web-console/web/app.js b/web-console/web/app.js index d87ab6a..af95a73 100644 --- a/web-console/web/app.js +++ b/web-console/web/app.js @@ -1,132 +1,114 @@ "use strict"; - const $ = (s) => document.querySelector(s); -const api = async (path, body) => { - const res = await fetch(path, body ? { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - } : {}); - return res.json(); -}; +const api = async (path, body) => (await fetch(path, body + ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } + : {})).json(); +const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); -function setStatus(up, label) { - const el = $("#status"); - el.className = "status " + (up ? "up" : "down"); - el.innerHTML = ` ${label}`; -} +let CTX = null; +let selected = "clean"; +let reasonMap = {}; -async function loadPolicy() { - try { - const p = await api("/api/policy"); - $("#policy").textContent = p.policy.trim(); - $("#workspace").textContent = p.workspace; - $("#catalog").innerHTML = p.tools.map((t) => - `
${t.tool_name}${t.description}result
${escapeHtml(String(summary))}
- request
${escapeHtml(JSON.stringify(request, null, 2))}
- gateway response
${escapeHtml(JSON.stringify(response, null, 2))}
- ${esc(t.tool_name)}${esc(t.compliance_domain)}${esc(t.description)}