From f6d81eb2aeb4e84689617c37ac073f42bcbdc7ae Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Fri, 24 Jul 2026 10:17:45 -0700 Subject: [PATCH] Make the web console a realistic bank scenario Replace the filesystem tools (write/read/list) with a bank support agent: it may look up account balances and customer profiles, but policy forbids it from moving money or bulk-exporting records. The forbidden calls are denied at the gateway (403) and never reach the tool. Customer PII comes back masked. Adds a small mock core-banking MCP server with realistic canned records. Same real gateway, signed TRACE record, and offline verify as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-console/README.md | 31 +++-- web-console/catalog.json | 118 +++++++++++++----- .../policies/allow-filesystem-tools.cedar | 21 ---- web-console/policies/support-agent.cedar | 22 ++++ web-console/run.py | 4 +- web-console/tools_server.py | 86 +++++++++++++ web-console/web/app.js | 9 +- web-console/web/index.html | 29 +++-- web-console/webserver.py | 2 +- 9 files changed, 238 insertions(+), 84 deletions(-) delete mode 100644 web-console/policies/allow-filesystem-tools.cedar create mode 100644 web-console/policies/support-agent.cedar create mode 100644 web-console/tools_server.py diff --git a/web-console/README.md b/web-console/README.md index 8533672..21853ae 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -1,9 +1,11 @@ # Web console -- cMCP in the browser 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. 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. +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. ``` pip install cmcp-runtime @@ -13,7 +15,7 @@ python web-console/run.py `run.py` starts three local processes and opens the console at `http://localhost:8000`: -- the shared MCP filesystem server on `:9001` (`server/server.py`) +- a mock core-banking MCP server on `:9001` (`tools_server.py`) - the cMCP gateway on `:8443` (`CMCP_DEV_MODE=1`, software-only TEE) - this demo's web server on `:8000` @@ -21,10 +23,12 @@ Press Ctrl+C in the terminal to stop all three. ## What to click -1. **`write_file`** and **`read_file`** -- Cedar permits them. The call is - forwarded to the tool and you see the real result. -2. **`list_dir`** -- Cedar forbids it. The gateway returns HTTP 403 - (`POLICY_DENY`) and the tool is never contacted. +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`. @@ -42,19 +46,20 @@ one you approved. 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 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 -(`write_file -> Action::"WriteFile"`), the same as `demo-01`. Nothing here is -mocked. +(`get_balance -> Action::"GetBalance"`). Nothing here is mocked except the tool +backend, and even that returns realistic records. ## Files ``` web-console/ - run.py launcher (server + gateway + web server) + 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 diff --git a/web-console/catalog.json b/web-console/catalog.json index 5e35dd5..1dd1c90 100644 --- a/web-console/catalog.json +++ b/web-console/catalog.json @@ -1,25 +1,58 @@ [ { - "tool_name": "write_file", + "tool_name": "get_balance", "server": { - "display_name": "Demo Filesystem MCP Server", + "display_name": "Core Banking MCP Server (mock)", "url": "http://localhost:9001/mcp", "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "transport": "http-sse" }, "approved_definition": { - "description": "Write content to a file in the demo workspace", + "description": "Look up the balance for a bank account", "input_schema": { "type": "object", "required": [ - "path", - "content" + "account_id" ], "properties": { - "path": { + "account_id": { "type": "string" - }, - "content": { + } + } + }, + "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" } } @@ -33,74 +66,93 @@ } } }, - "compliance_domain": "public", + "definition_hash": "sha256:b424e55dce58f35ff97127904df9a7f837279ede56c604a99988078198fec33e", + "compliance_domain": "pii", "requires_baa": false, - "sensitivity_level": "public", - "added_at": "2026-06-22T00:00:00Z", - "approved_by": "demo-setup", - "definition_hash": "sha256:7b4738feb4a0c18b2870276996a14d8c2241380c063815cb98b54cb200e7e0d2" + "sensitivity_level": "pii", + "added_at": "2026-07-01T00:00:00Z", + "approved_by": "security-team" }, { - "tool_name": "read_file", + "tool_name": "transfer_funds", "server": { - "display_name": "Demo Filesystem MCP Server", + "display_name": "Core Banking MCP Server (mock)", "url": "http://localhost:9001/mcp", "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "transport": "http-sse" }, "approved_definition": { - "description": "Read a file from the demo workspace", + "description": "Move money between accounts", "input_schema": { "type": "object", "required": [ - "path" + "from_account", + "to_account", + "amount" ], "properties": { - "path": { + "from_account": { + "type": "string" + }, + "to_account": { "type": "string" + }, + "amount": { + "type": "number" } } }, "output_schema": { "type": "object", "properties": { - "content": { + "result": { "type": "string" } } } }, - "compliance_domain": "public", + "definition_hash": "sha256:b594a0959a0fe472d219844ff47812a174014d12ae8dd1cd078c8e6446311d48", + "compliance_domain": "pci_data", "requires_baa": false, - "sensitivity_level": "public", - "added_at": "2026-06-22T00:00:00Z", - "approved_by": "demo-setup", - "definition_hash": "sha256:3bdb3d438df2208aa5b293e0d71260f808365d6fcef4a9cf44702ebed1acc6cc" + "sensitivity_level": "confidential", + "added_at": "2026-07-01T00:00:00Z", + "approved_by": "security-team" }, { - "tool_name": "list_dir", + "tool_name": "export_records", "server": { - "display_name": "Demo Filesystem MCP Server", + "display_name": "Core Banking MCP Server (mock)", "url": "http://localhost:9001/mcp", "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "transport": "http-sse" }, "approved_definition": { - "description": "List files in the demo workspace", + "description": "Bulk-export records from a dataset", "input_schema": { + "type": "object", + "required": [ + "dataset" + ], + "properties": { + "dataset": { + "type": "string" + } + } + }, + "output_schema": { "type": "object", "properties": { - "path": { + "result": { "type": "string" } } } }, - "compliance_domain": "public", + "definition_hash": "sha256:4e09969b2f6543bcd2cb89a31ff2d05ec80c2f4323b0ff00bf666c307532ca10", + "compliance_domain": "pii", "requires_baa": false, - "sensitivity_level": "public", - "added_at": "2026-06-22T00:00:00Z", - "approved_by": "demo-setup", - "definition_hash": "sha256:215e8f06120f876cb19bb766a54042a31d521b54cf731f6893c975e4e0861e78" + "sensitivity_level": "confidential", + "added_at": "2026-07-01T00:00:00Z", + "approved_by": "security-team" } ] diff --git a/web-console/policies/allow-filesystem-tools.cedar b/web-console/policies/allow-filesystem-tools.cedar deleted file mode 100644 index 98a7e17..0000000 --- a/web-console/policies/allow-filesystem-tools.cedar +++ /dev/null @@ -1,21 +0,0 @@ -// cMCP Cedar policy for demo-01 -// -// AGT's CedarBackend maps tool_name to a Cedar action (PascalCase): -// write_file -> Action::"WriteFile" -// read_file -> Action::"ReadFile" -// list_dir -> Action::"ListDir" -// -// write_file and read_file: ALLOWED -// list_dir: DENIED (demonstrates Cedar enforcement) - -permit ( - principal, - action in [Action::"WriteFile", Action::"ReadFile"], - resource -); - -forbid ( - principal, - action == Action::"ListDir", - resource -); diff --git a/web-console/policies/support-agent.cedar b/web-console/policies/support-agent.cedar new file mode 100644 index 0000000..d5d19df --- /dev/null +++ b/web-console/policies/support-agent.cedar @@ -0,0 +1,22 @@ +// 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 c181db2..5bc1892 100644 --- a/web-console/run.py +++ b/web-console/run.py @@ -44,9 +44,9 @@ def main(): cmcp_log = open(HERE / "cmcp.log", "w") procs = [] try: - print("-- MCP filesystem server on :9001", flush=True) + print("-- Core banking MCP server on :9001", flush=True) procs.append(subprocess.Popen( - [sys.executable, str(REPO_ROOT / "server" / "server.py")], + [sys.executable, str(HERE / "tools_server.py")], stdout=server_log, stderr=server_log)) time.sleep(1) diff --git a/web-console/tools_server.py b/web-console/tools_server.py new file mode 100644 index 0000000..615c10b --- /dev/null +++ b/web-console/tools_server.py @@ -0,0 +1,86 @@ +"""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/web/app.js b/web-console/web/app.js index 3448a6e..d87ab6a 100644 --- a/web-console/web/app.js +++ b/web-console/web/app.js @@ -28,9 +28,12 @@ async function loadPolicy() { } const TOOL_ARGS = { - write_file: () => ({ path: $("#wf-path").value, content: $("#wf-content").value }), - read_file: () => ({ path: $("#rf-path").value }), - list_dir: () => ({}), + get_balance: () => ({ account_id: $("#gb-account").value }), + get_customer: () => ({ customer_id: $("#gc-customer").value }), + transfer_funds: () => ({ + from_account: $("#tf-from").value, to_account: $("#tf-to").value, amount: Number($("#tf-amount").value), + }), + export_records: () => ({ dataset: $("#er-dataset").value }), }; function activityEntry({ tool, http_status, decision, text, request, response }) { diff --git a/web-console/web/index.html b/web-console/web/index.html index affd8ab..3da115a 100644 --- a/web-console/web/index.html +++ b/web-console/web/index.html @@ -22,25 +22,32 @@