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}
`).join(""); - setStatus(true, "gateway " + p.gateway.replace(/^https?:\/\//, "")); - } catch (e) { - setStatus(false, "web server unreachable"); - } +function showView(name) { + document.querySelectorAll(".navitem").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); + document.querySelectorAll(".view").forEach((v) => v.classList.toggle("active", v.id === "view-" + name)); } +document.querySelectorAll(".navitem").forEach((b) => b.addEventListener("click", () => showView(b.dataset.view))); -const TOOL_ARGS = { - 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 }), -}; +async function boot() { + CTX = await api("/api/context"); + $("#gw-status").textContent = "gateway " + CTX.gateway.replace(/^https?:\/\//, ""); + CTX.guardrails.forEach((g) => (reasonMap[g.reason] = g)); -function activityEntry({ tool, http_status, decision, text, request, response }) { - $("#activity-empty").style.display = "none"; - const entry = document.createElement("div"); - entry.className = "entry " + decision; - const pill = decision === "allow" ? `${http_status} allow` - : decision === "deny" ? `${http_status} denied` : `${http_status} error`; - const summary = decision === "allow" && text != null - ? text : decision === "deny" - ? (response.error && response.error.data && response.error.data.error_code) || "POLICY_DENY" - : (response.error && response.error.message) || ""; - entry.innerHTML = ` -
- tools/call${tool} - ${pill} -
-
-

result

${escapeHtml(String(summary))}
-

request

${escapeHtml(JSON.stringify(request, null, 2))}
-

gateway response

${escapeHtml(JSON.stringify(response, null, 2))}
-
`; - entry.querySelector(".entry-head").addEventListener("click", () => entry.classList.toggle("open")); - $("#activity").prepend(entry); -} + // scenarios + $("#scenarios").innerHTML = CTX.scenarios.map((s) => ` + `).join(""); + document.querySelectorAll(".scn").forEach((c) => c.addEventListener("click", () => { + selected = c.dataset.id; + document.querySelectorAll(".scn").forEach((x) => x.classList.toggle("sel", x === c)); + })); + + // guardrails + policy + $("#guardrails").innerHTML = CTX.guardrails.map((g) => + `
${esc(g.regulation)}${esc(g.plain)}
`).join(""); + $("#policy").textContent = CTX.policy.trim(); -function escapeHtml(s) { - return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); + // tools + $("#tools").innerHTML = CTX.tools.map((t) => + `
${esc(t.tool_name)}${esc(t.compliance_domain)}${esc(t.description)}
`).join(""); } -async function sendCall(tool) { - const args = TOOL_ARGS[tool](); - const request = { - jsonrpc: "2.0", id: 1, method: "tools/call", - params: { name: tool, arguments: args, _cmcp: { workflow_id: "web-console" } }, - }; - const r = await api("/api/call", { tool, arguments: args }); - activityEntry({ tool, http_status: r.http_status, decision: r.decision, text: r.text, request, response: r.response }); - showTab("activity"); +async function run() { + const btn = $("#run"); + btn.disabled = true; btn.textContent = "Running…"; $("#run-hint").textContent = ""; + $("#timeline").innerHTML = ""; $("#summary").className = "summary hide"; + try { + const r = await api("/api/run", { scenario: selected }); + renderRun(r); + } catch (e) { + $("#run-hint").textContent = "run failed -- is the gateway up?"; + } finally { + btn.disabled = false; btn.textContent = "Run assessment"; + } } -async function closeSession() { - const claim = await api("/api/close", {}); - if (claim.error) { - $("#record").textContent = claim.error; - showTab("record"); - return; +function renderRun(r) { + const steps = r.steps || []; + const write = steps.find((s) => s.tool && s.tool.endsWith("risk_report_writer")); + const denied = write && write.decision === "deny"; + const sum = $("#summary"); + sum.className = "summary " + (denied ? "deny" : "allow"); + if (denied) { + const reason = write.advice.reason || ""; + const g = reasonMap[reason]; + sum.innerHTML = `Write blocked. ${esc(g ? g.plain : reason)} + ${esc(write.advice.regulation || (g && g.regulation) || "")} +
The risk report was not written to core banking.
`; + } else { + sum.innerHTML = `Assessment recorded. All six steps passed policy; the report was written to core banking under Cedar enforcement.`; + } + + $("#timeline").innerHTML = steps.map((s) => { + const dec = s.decision || "allow"; + const adv = (dec === "deny" && (s.advice.regulation || s.advice.reason)) ? ` +
${esc((reasonMap[s.advice.reason] || {}).plain || s.advice.reason || "denied by policy")} + ${s.advice.regulation ? `${esc(s.advice.regulation)}` : ""}
` : ""; + return `
  • + ${s.n}/6 + ${esc(s.tool)} + ${s.note ? `
    ${esc(s.note)}
    ` : ""}${adv}
    + ${dec === "deny" ? "403 denied" : "200 allow"} +
  • `; + }).join(""); + // staggered reveal + document.querySelectorAll("#timeline .tl").forEach((el, i) => setTimeout(() => el.classList.add("in"), 90 + i * 130)); + + // record view + if (r.claim) { + $("#record").textContent = JSON.stringify(r.claim, null, 2); + $("#verify").disabled = false; + $("#rec-hint").textContent = ""; } - $("#record").textContent = JSON.stringify(claim, null, 2); - $("#verify").disabled = false; - showTab("record"); } -async function verifyRecord() { +async function verify() { const v = await api("/api/verify", {}); - if (v.error) { $("#verify-raw").textContent = v.error; showTab("verify"); return; } + if (v.error) { $("#verify-result").innerHTML = `
    ${esc(v.error)}
    `; return; } $("#verify-checks").innerHTML = (v.checks || []).map((c) => { - const pass = c.status === "PASS"; - return `
    - ${pass ? "✓" : "✗"} - ${c.name}${c.status}
    `; + const p = c.status === "PASS"; + return `
    ${p ? "✓" : "✗"}${esc(c.name)}${c.status}
    `; }).join(""); if (v.result) { - const verified = v.result.status.toLowerCase() === "pass" || v.result.detail === "verified"; - const div = document.createElement("div"); - div.className = "result " + (verified ? "verified" : "partial"); - div.innerHTML = `${v.result.detail} — software-only run; on real TDX / SEV-SNP the hardware check verifies too.`; - $("#verify-checks").appendChild(div); + const ok = v.result.detail === "verified"; + $("#verify-result").innerHTML = `
    ${esc(v.result.detail)} — software-only run; on real TDX / SEV-SNP the hardware check verifies too.
    `; } - $("#verify-raw").textContent = v.raw || ""; - showTab("verify"); } -async function reset() { - await api("/api/reset", {}); - $("#activity").innerHTML = ""; - $("#activity-empty").style.display = ""; - $("#record").textContent = "Close the session to produce a record."; - $("#verify-checks").innerHTML = ""; - $("#verify-raw").textContent = "Verify a record to see the result."; - $("#verify").disabled = true; - showTab("activity"); -} - -function showTab(name) { - document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.panel === name)); - document.querySelectorAll(".panel").forEach((p) => p.classList.toggle("active", p.id === "panel-" + name)); -} - -document.querySelectorAll(".run").forEach((b) => b.addEventListener("click", () => sendCall(b.dataset.tool))); -document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => showTab(t.dataset.panel))); -$("#close").addEventListener("click", closeSession); -$("#verify").addEventListener("click", verifyRecord); -$("#reset").addEventListener("click", reset); - -loadPolicy(); +$("#run").addEventListener("click", run); +$("#verify").addEventListener("click", verify); +boot(); diff --git a/web-console/web/index.html b/web-console/web/index.html index 3da115a..c5a1d54 100644 --- a/web-console/web/index.html +++ b/web-console/web/index.html @@ -1,97 +1,101 @@ - + - cMCP console + cMCP console -- credit-risk assessment -
    -
    - cMCP - console · local · software-only TEE -
    -
    - checking gateway… - -
    -
    - -
    - -
    -
    - - - - -
    - -
    -
    -

    No calls yet. Send one from the left.

    -
    - -
    -

    The 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.

    + +
    +
    +

    Policy

    +

    The Cedar bundle the gateway loaded. Its hash is measured into the hardware attestation at startup, so the policy that ran is provably the one that was approved. Each guardrail carries the control it enforces.

    +
    +

    Guardrails on the write

    +
    +

    allow.cedar

    
    -        

    Catalog

    -
    -
    - -
    -

    The signed GatewayClaim returned when the session closes: what ran, what was denied, the policy hash, and an Ed25519 signature.

    -
    Close the session to produce a record.
    -
    +
    -
    -

    Output of cmcp verify run against the saved record. No gateway, no network, no trust in the operator.

    -
    -
    Verify a record to see the result.
    -
    - -
    + +
    +
    +

    Trust record

    +

    The signed RuntimeClaim produced when the session closes: which tools ran, which were denied, the policy hash, and an Ed25519 signature. Verify it offline -- no gateway, no network, no trust in the operator.

    +
    +
    + + Run an assessment to produce a record. +
    +
    +
    +

    cmcp verify

    +
    +
    +
    +
    +

    RuntimeClaim

    +
    Run an assessment first.
    +
    +
    +
    + +
    +
    +

    What this shows

    +

    A real corporate credit-risk agent, running unmodified from the agentrust-io/examples repository, governed by cMCP.

    +
    +
    +

    Proof of which tools ran

    The gateway records every tool call in a hash-chained audit log and seals the session into a signed Trust Record. An auditor can verify exactly which tools ran, in what order, and what was denied -- without trusting the agent process.

    +

    Policy as machine-readable compliance

    The bank's controls are written directly in Cedar. Each block returns the regulation and the reason as structured advice, so the caller knows why a write was refused and who must review it.

    +

    Controls fire on the result

    The agent screens the client, pulls a bureau report, aggregates exposure, and runs the model, then passes that outcome into the write. The guardrails act on the real assessment -- CDD status, IFRS 9 stage, concentration, facility size -- not on the request.

    +

    Attestation-gated data

    A Cedar rule forbids confidential (MNPI) tools unless the runtime presents attestation evidence. On real TDX or SEV-SNP the Trust Record's hardware check verifies too; in dev mode it reads partially_verified.

    +
    +

    The six tools

    +
    +

    Source: vendor/examples/financial-services (git submodule). The obligors are fictional German Mittelstand companies with format-valid LEIs and IBANs; the guardrails and regulations are real.

    +
    + + diff --git a/web-console/web/styles.css b/web-console/web/styles.css index ebd7311..ace7c1b 100644 --- a/web-console/web/styles.css +++ b/web-console/web/styles.css @@ -1,106 +1,128 @@ :root{ - --bg:#0c1210; --panel:#121a17; --panel2:#0e1613; --line:rgba(255,255,255,.08); - --line2:rgba(255,255,255,.14); --ink:#e7efec; --ink2:#b7c4bf; --mut:#7f8f8a; + --bg:#0a0f0e; --panel:#101917; --panel2:#0d1513; --raise:#14201d; + --line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.15); + --ink:#e9f1ee; --ink2:#aebbb6; --mut:#788681; --green:#10ba87; --blue:#00b0ff; --umber:#e1765a; --sand:#e2c99e; --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; - --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; + --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; } *{box-sizing:border-box} html,body{margin:0;height:100%} -body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5} -code{font-family:var(--mono)} +body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.55} +code{font-family:var(--mono);font-size:.92em} button{font-family:inherit;cursor:pointer} +.hide{display:none!important} +.mut{color:var(--mut)} -.bar{display:flex;align-items:center;justify-content:space-between;gap:16px; - padding:12px 20px;border-bottom:1px solid var(--line);background:var(--panel2)} -.bar-left{display:flex;align-items:baseline;gap:12px} -.mark{font-weight:700;letter-spacing:.02em;color:var(--green);font-size:16px} -.sub{color:var(--mut);font-size:12px} -.bar-right{display:flex;align-items:center;gap:16px;font-size:12px;color:var(--mut)} -.status{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono)} -.status .d{width:8px;height:8px;border-radius:50%;background:var(--mut)} -.status.up .d{background:var(--green);box-shadow:0 0 0 3px rgba(16,186,135,.2)} -.status.down .d{background:var(--umber)} -.ws{font-family:var(--mono)} +.app{display:grid;grid-template-columns:236px 1fr;height:100%} -.grid{display:grid;grid-template-columns:330px 1fr;height:calc(100% - 49px)} -.side{border-right:1px solid var(--line);overflow-y:auto;padding:18px 16px;display:flex;flex-direction:column;gap:22px} -.block h2{font-size:11px;text-transform:uppercase;letter-spacing:.14em;color:var(--mut);margin:0 0 12px;font-weight:600} -.hint{color:var(--ink2);font-size:12.5px;margin:0 0 14px} -.hint.sm{margin:6px 0 10px} +/* sidebar */ +.nav{background:var(--panel2);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:18px 12px} +.brand{padding:6px 10px 20px} +.wordmark{display:block;font-weight:800;letter-spacing:.14em;font-size:18px;color:var(--green)} +.brand-sub{display:block;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mut);margin-top:3px} +.navitem{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:none; + color:var(--ink2);padding:10px 11px;border-radius:8px;font-size:13.5px;margin-bottom:2px} +.navitem .gi{width:7px;height:7px;border-radius:2px;background:var(--mut);flex:0 0 auto} +.navitem:hover{background:var(--raise);color:var(--ink)} +.navitem.active{background:var(--raise);color:var(--ink)} +.navitem.active .gi{background:var(--green)} +.nav-foot{margin-top:auto;padding:12px 11px 4px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px} +.stat{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink2)} +.stat .d{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px rgba(16,186,135,.2)} +.stat.mut{color:var(--mut)} -.tool{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 13px;margin-bottom:12px} -.tool-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px} -.tool-head code{font-size:13.5px;color:var(--ink)} -.exp{font-family:var(--mono);font-size:10px;letter-spacing:.05em;padding:2px 7px;border-radius:5px;text-transform:uppercase} -.exp.allow{background:rgba(16,186,135,.16);color:var(--green)} -.exp.deny{background:rgba(225,118,90,.16);color:var(--umber)} -.tool label{display:block;font-size:11px;color:var(--mut);margin-bottom:8px} -.tool input{width:100%;margin-top:3px;background:var(--bg);border:1px solid var(--line);border-radius:7px; - color:var(--ink);font-family:var(--mono);font-size:12.5px;padding:7px 9px} -.tool input:focus{outline:none;border-color:var(--line2)} -button.run{width:100%;margin-top:4px;background:var(--panel2);border:1px solid var(--line2);color:var(--ink); - border-radius:7px;padding:8px 10px;font-size:13px;transition:.15s} -button.run:hover{border-color:var(--green);color:var(--green)} -button.act{width:100%;margin-bottom:9px;background:var(--panel);border:1px solid var(--line2);color:var(--ink); - border-radius:8px;padding:10px 12px;font-size:13px;text-align:left;transition:.15s} -button.act:hover:not(:disabled){border-color:var(--green)} -button.act:disabled{opacity:.4;cursor:default} -button.act.ghost{border-style:dashed;color:var(--mut)} +/* main */ +.main{overflow-y:auto} +.view{display:none;padding:30px clamp(20px,4vw,56px) 60px;max-width:1100px} +.view.active{display:block} +.vhead{margin-bottom:24px} +.vhead h1{font-size:23px;font-weight:700;margin:0 0 8px;letter-spacing:-.01em} +.vhead p{margin:0;color:var(--ink2);max-width:74ch} +h2.sec{font-size:11px;text-transform:uppercase;letter-spacing:.13em;color:var(--mut);font-weight:600;margin:26px 0 12px} + +/* scenarios */ +.scenarios{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px} +.scn{text-align:left;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 15px;transition:.15s} +.scn:hover{border-color:var(--line2)} +.scn.sel{border-color:var(--green);box-shadow:0 0 0 1px var(--green) inset} +.scn .label{font-weight:600;font-size:13.5px;margin-bottom:8px} +.scn .obligor{font-size:12.5px;color:var(--ink2);line-height:1.4} +.scn .amt{font-family:var(--mono);font-size:12px;color:var(--ink);margin-top:6px} +.scn .exp{display:inline-block;margin-top:11px;font-size:10.5px;letter-spacing:.04em;padding:2px 8px;border-radius:5px;text-transform:uppercase} +.scn .exp.allow{background:rgba(16,186,135,.16);color:var(--green)} +.scn .exp.deny{background:rgba(225,118,90,.16);color:var(--umber)} + +.runbar{display:flex;align-items:center;gap:14px;margin-bottom:18px} +.primary{background:var(--green);color:#052a20;border:none;border-radius:9px;padding:10px 18px;font-size:13.5px;font-weight:700} +.primary:hover{filter:brightness(1.06)} +.primary:disabled{opacity:.45;cursor:default;filter:none} button:focus-visible{outline:2px solid var(--green);outline-offset:2px} -.content{display:flex;flex-direction:column;min-width:0} -.tabs{display:flex;gap:2px;padding:10px 18px 0;border-bottom:1px solid var(--line)} -.tab{background:none;border:none;color:var(--mut);padding:9px 14px;font-size:13px;border-bottom:2px solid transparent;margin-bottom:-1px} -.tab:hover{color:var(--ink2)} -.tab.active{color:var(--ink);border-bottom-color:var(--green)} -.panel{display:none;flex:1;overflow-y:auto;padding:18px 20px} -.panel.active{display:block} -.panel-note{color:var(--ink2);font-size:12.5px;max-width:70ch;margin:0 0 16px} -.empty{color:var(--mut);font-size:13px;font-style:italic} +/* summary banner */ +.summary{border-radius:12px;padding:15px 18px;margin-bottom:18px;border:1px solid var(--line);font-size:14px} +.summary.allow{background:rgba(16,186,135,.1);border-color:rgba(16,186,135,.4)} +.summary.deny{background:rgba(225,118,90,.1);border-color:rgba(225,118,90,.4)} +.summary .st{font-weight:700} +.summary.allow .st{color:var(--green)} .summary.deny .st{color:var(--umber)} +.summary .reg{font-family:var(--mono);font-size:11px;margin-left:8px;padding:2px 7px;border-radius:5px;background:rgba(0,0,0,.25)} -.activity{display:flex;flex-direction:column;gap:10px} -.entry{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--panel)} -.entry-head{display:flex;align-items:center;gap:12px;padding:11px 14px;cursor:pointer;font-family:var(--mono);font-size:13px} -.entry-head .arrow{color:var(--mut)} -.entry-head .tool{color:var(--ink);font-weight:500} -.entry-head .pill{margin-left:auto;font-size:11px;font-weight:600;padding:3px 9px;border-radius:6px;letter-spacing:.03em} -.entry.allow .pill{background:rgba(16,186,135,.16);color:var(--green)} -.entry.deny .pill{background:rgba(225,118,90,.16);color:var(--umber)} -.entry.error .pill{background:rgba(226,201,158,.16);color:var(--sand)} -.entry-body{display:none;border-top:1px solid var(--line);padding:12px 14px;background:var(--panel2)} -.entry.open .entry-body{display:block} -.entry-body .lbl{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin:0 0 5px} -.entry-body pre{margin:0 0 12px} +/* timeline */ +.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px} +.tl{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);border-radius:11px; + padding:13px 15px;opacity:0;transform:translateY(6px);transition:.3s} +.tl.in{opacity:1;transform:none} +.tl .num{font-family:var(--mono);font-size:11px;color:var(--mut);width:26px;flex:0 0 auto;padding-top:2px} +.tl .body{flex:1;min-width:0} +.tl .toolname{font-family:var(--mono);font-size:13px;color:var(--ink)} +.tl .note{font-size:12.5px;color:var(--ink2);margin-top:3px} +.tl .adv{margin-top:8px;font-size:12px;color:var(--umber);display:flex;flex-wrap:wrap;gap:6px 14px} +.tl .adv .reg{font-family:var(--mono);font-size:11px;background:rgba(225,118,90,.14);color:var(--umber);padding:2px 7px;border-radius:5px} +.tl .pill{font-size:11px;font-weight:700;letter-spacing:.04em;padding:4px 10px;border-radius:6px;flex:0 0 auto} +.tl.allow .pill{background:rgba(16,186,135,.16);color:var(--green)} +.tl.deny .pill{background:rgba(225,118,90,.16);color:var(--umber)} +.tl.deny{border-color:rgba(225,118,90,.3)} -.code{font-family:var(--mono);font-size:12.5px;line-height:1.6;color:var(--ink2);white-space:pre; - overflow-x:auto;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:0} -#policy{color:var(--ink2)} -.catalog{margin-top:12px;display:flex;flex-direction:column;gap:7px} -.crow{display:flex;gap:12px;align-items:baseline;font-family:var(--mono);font-size:12.5px; - padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel)} -.crow code{color:var(--ink)} -.crow .desc{color:var(--mut);font-family:var(--sans);font-size:12px} -h3{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--mut);margin:22px 0 4px} +/* policy */ +.code{font-family:var(--mono);font-size:12.5px;line-height:1.65;color:var(--ink2);white-space:pre;overflow-x:auto; + background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0;max-height:60vh} +.guardrails{display:flex;flex-direction:column;gap:8px} +.gr{display:flex;gap:14px;align-items:baseline;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 15px} +.gr .reg{font-family:var(--mono);font-size:11px;color:var(--sand);flex:0 0 auto;width:190px} +.gr .plain{color:var(--ink2);font-size:13px} -.checks{display:flex;flex-direction:column;gap:6px;margin-bottom:16px} -.check{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:13px; - padding:9px 13px;border:1px solid var(--line);border-radius:8px;background:var(--panel)} +/* trust record */ +.rec-actions{display:flex;align-items:center;gap:14px;margin-bottom:18px} +.rec-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px} +.checks{display:flex;flex-direction:column;gap:6px;margin-bottom:14px} +.check{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:12.5px;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel)} .check .box{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:12px;font-weight:700;flex:0 0 auto} .check.pass .box{background:rgba(16,186,135,.18);color:var(--green)} .check.fail .box{background:rgba(225,118,90,.18);color:var(--umber)} .check .name{flex:1;color:var(--ink2)} -.check .st{font-size:11px;font-weight:700;letter-spacing:.06em} +.check .st{font-size:10px;font-weight:700;letter-spacing:.06em} .check.pass .st{color:var(--green)} .check.fail .st{color:var(--umber)} -.result{margin:4px 0 16px;padding:12px 15px;border-radius:9px;font-family:var(--mono);font-size:13px; - border:1px solid var(--line2);background:var(--panel)} -.result .status{font-weight:700} -.result.partial{border-color:rgba(226,201,158,.4)} -.result.partial .status{color:var(--sand)} -.result.verified{border-color:rgba(16,186,135,.4)} -.result.verified .status{color:var(--green)} +.rslt{padding:12px 15px;border-radius:9px;font-family:var(--mono);font-size:12.5px;border:1px solid var(--line2);background:var(--panel)} +.rslt.partial{border-color:rgba(226,201,158,.4)} .rslt.partial b{color:var(--sand)} +.rslt.verified{border-color:rgba(16,186,135,.4)} .rslt.verified b{color:var(--green)} + +/* about */ +.cards{display:grid;grid-template-columns:1fr 1fr;gap:14px} +.pt{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px} +.pt h3{margin:0 0 7px;font-size:14.5px;font-weight:650} +.pt p{margin:0;color:var(--ink2);font-size:13px} +.tools{display:flex;flex-direction:column;gap:7px} +.trow{display:grid;grid-template-columns:230px 130px 1fr;gap:14px;align-items:baseline;background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:10px 14px;font-size:12.5px} +.trow code{color:var(--ink)} +.trow .dom{font-family:var(--mono);font-size:11px} +.trow .dom.mnpi,.trow .dom.pci_data{color:var(--umber)} .trow .dom.pii{color:var(--sand)} .trow .dom.internal{color:var(--blue)} +.trow .desc{color:var(--mut)} +.foot-note{margin-top:20px;color:var(--mut);font-size:12px} -@media (max-width:760px){ - .grid{grid-template-columns:1fr;height:auto} - .side{border-right:none;border-bottom:1px solid var(--line)} +@media (max-width:900px){ + .app{grid-template-columns:1fr} + .nav{flex-direction:row;flex-wrap:wrap;height:auto;align-items:center} + .nav nav{display:flex;gap:4px} + .nav-foot{margin:0 0 0 auto;border:none;padding:0} + .scenarios,.rec-grid,.cards{grid-template-columns:1fr} } diff --git a/web-console/webserver.py b/web-console/webserver.py index ba07e5a..bbca8e7 100644 --- a/web-console/webserver.py +++ b/web-console/webserver.py @@ -1,12 +1,11 @@ -"""Local web server for the cMCP browser console. +"""Web server for the cMCP financial-services console. -Serves the static UI in web/ and exposes a small JSON API that the browser -calls. The API forwards to the running cMCP gateway on :8443, holding the -bearer token here so the browser never sees it and there is no CORS to deal -with. Every response the UI shows is the gateway's real output. +Serves the UI in web/ and a small JSON API. The API runs the real example +agent (vendor/examples/financial-services) as a subprocess against the running +gateway and parses its output into structured steps; it also runs cmcp verify +on the signed record. The browser never talks to the gateway directly. -Run indirectly via run.py, or standalone once the server and gateway are up: - python webserver.py # serves on http://localhost:8000 + python webserver.py # serves on http://localhost:8000 """ import json import os @@ -15,26 +14,48 @@ import shutil import subprocess import sys -import urllib.error -import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = pathlib.Path(__file__).parent.resolve() WEB = HERE / "web" +EXAMPLE = HERE / "vendor" / "examples" / "financial-services" +AGENT = EXAMPLE / "agent" / "credit_risk_agent.py" WORKSPACE = HERE.parent / "workspace" -POLICY_FILE = HERE / "policies" / "support-agent.cedar" -CATALOG_FILE = HERE / "catalog.json" -CLAIM_FILE = WORKSPACE / "web-console-claim.json" +CLAIM_FILE = WORKSPACE / "fs-claim.json" GATEWAY = os.environ.get("CMCP_GATEWAY_URL", "http://localhost:8443") -TOKEN = os.environ.get("CMCP_BEARER_TOKEN", "demo-token") PORT = int(os.environ.get("WEB_CONSOLE_PORT", "8000")) -SESSION_LABEL = "web-console-session" -WORKFLOW_ID = "web-console" _CT = {".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".json": "application/json"} +SCENARIOS = [ + {"id": "clean", "label": "Performing obligor", + "obligor": "Rheintal Präzisionstechnik GmbH", "amount": "€250,000", + "expect": "Write allowed", "outcome": "allow"}, + {"id": "large-exposure", "label": "Concentration breach", + "obligor": "Nordwind Logistik AG", "amount": "€750,000", + "expect": "Write blocked", "outcome": "deny"}, + {"id": "sanctions-hit", "label": "Sanctions / impaired", + "obligor": "Meridian Trading DMCC", "amount": "€200,000", + "expect": "Write blocked", "outcome": "deny"}, +] + +# Plain-English gloss for the guardrails in policy/allow.cedar, keyed by the +# @reason annotation the runtime returns on deny. +GUARDRAILS = [ + {"reason": "cdd-clearance-required", "regulation": "EU AML Regulation 2024/1624", + "plain": "No assessment is written until customer due diligence clears."}, + {"reason": "human-review-required", "regulation": "EBA/GL/2020/06", + "plain": "Facilities above the €500k delegated authority need a human decision-maker."}, + {"reason": "concentration-limit-breached", "regulation": "CRR Art. 395", + "plain": "The facility must not breach the single-obligor concentration limit."}, + {"reason": "ifrs9-stage-3-credit-impaired", "regulation": "IFRS 9", + "plain": "Credit-impaired (stage 3) obligors cannot be auto-approved."}, + {"reason": "attested-runtime-required", "regulation": "DORA Art. 9", + "plain": "Confidential financial data only flows through an attested runtime."}, +] + def _find_cmcp() -> str: found = shutil.which("cmcp") @@ -49,55 +70,59 @@ def _find_cmcp() -> str: return "cmcp" -def _gw(method: str, path: str, payload=None): - """Call the gateway. Returns (body_dict, status_code).""" - data = json.dumps(payload).encode() if payload is not None else None - req = urllib.request.Request( - GATEWAY + path, data=data, method=method, - headers={"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"}, - ) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - return json.loads(resp.read() or b"{}"), resp.status - except urllib.error.HTTPError as exc: - try: - return json.loads(exc.read() or b"{}"), exc.code - except json.JSONDecodeError: - return {"error": "non-JSON error body"}, exc.code - - -def _tool_call(tool: str, arguments: dict): - return _gw("POST", "/mcp", { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": tool, - "arguments": arguments, - "_cmcp": {"session_id": SESSION_LABEL, "workflow_id": WORKFLOW_ID}, - }, - }) - - -def _close_session(): - """Resolve the internal session id from the audit export, then close it.""" - export, status = _gw("GET", f"/audit/export?session_id={SESSION_LABEL}") - if status != 200: - return {"error": "no session yet -- make a tool call first"}, 409 - entries = export.get("entries", []) - if not entries: - return {"error": "no session yet -- make a tool call first"}, 409 - internal = entries[0]["session_id"] - claim, status = _gw("POST", f"/sessions/{internal}/close", {}) - if status == 200: +def _run_agent(scenario: str): + """Run the real example agent and parse its output into steps + claim.""" + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + proc = subprocess.run( + [sys.executable, str(AGENT), "--scenario", scenario, "--gateway", GATEWAY], + capture_output=True, text=True, encoding="utf-8", env=env, timeout=60) + out = (proc.stdout or "") + "\n" + (proc.stderr or "") + + claim = None + marker = out.find("TRACE Trust Record") + body = out + if marker != -1: + rest = out[marker:] + brace = rest.find("{") + if brace != -1: + try: + claim, _ = json.JSONDecoder().raw_decode(rest[brace:]) + except json.JSONDecodeError: + claim = None + body = out[:marker] + + steps, cur = [], None + for line in body.splitlines(): + m = re.match(r"\s*\[(\d)/6\]\s+(\S+)", line) + if m: + cur = {"n": m.group(1), "tool": m.group(2), "decision": None, "note": "", "advice": {}} + steps.append(cur) + continue + if cur is None: + continue + m = re.search(r"->\s*decision:\s*(allow|deny)(?:\s*\(([^)]+)\))?(.*)", line) + if m: + cur["decision"] = m.group(1) + if m.group(2): + cur["deny_code"] = m.group(2) + note = (m.group(3) or "").strip() + if note: + cur["note"] = note + continue + m = re.match(r"\s+(reason|regulation|delegated_authority_limit_eur|error_code):\s*(.+)", line) + if m: + cur["advice"][m.group(1)] = m.group(2).strip() + + if claim: WORKSPACE.mkdir(exist_ok=True) CLAIM_FILE.write_text(json.dumps(claim, indent=2)) - return claim, status + return {"scenario": scenario, "steps": steps, "claim": claim} def _verify(): if not CLAIM_FILE.exists(): - return {"error": "no claim yet -- close the session first"}, 409 + return {"error": "run an assessment first"}, 409 env = os.environ.copy() env["CMCP_DEV_MODE"] = "1" proc = subprocess.run([_find_cmcp(), "verify", str(CLAIM_FILE)], @@ -113,68 +138,54 @@ def _verify(): class Handler(BaseHTTPRequestHandler): - def log_message(self, *a): # keep the terminal quiet + def log_message(self, *a): pass - def _send(self, status, body, content_type="application/json"): + def _send(self, status, body, ct="application/json"): if isinstance(body, (dict, list)): body = json.dumps(body).encode() elif isinstance(body, str): body = body.encode() self.send_response(status) - self.send_header("Content-Type", content_type) + self.send_header("Content-Type", ct) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path in ("/", "/index.html"): - return self._serve_static("index.html") + return self._serve("index.html") if self.path.startswith("/web/"): - return self._serve_static(self.path[len("/web/"):]) - if self.path == "/api/policy": - catalog = json.loads(CATALOG_FILE.read_text()) - tools = [{"tool_name": c["tool_name"], - "description": c["approved_definition"]["description"]} - for c in catalog] - return self._send(200, {"policy": POLICY_FILE.read_text(), "tools": tools, - "gateway": GATEWAY, "workspace": str(WORKSPACE)}) + return self._serve(self.path[len("/web/"):]) + if self.path == "/api/context": + catalog = json.loads((EXAMPLE / "catalog.json").read_text(encoding="utf-8")) + tools = [{"tool_name": c["tool_name"], "compliance_domain": c.get("compliance_domain"), + "sensitivity_level": c.get("sensitivity_level"), + "description": c["approved_definition"]["description"]} for c in catalog] + return self._send(200, { + "scenarios": SCENARIOS, "tools": tools, "guardrails": GUARDRAILS, + "policy": (EXAMPLE / "policy" / "allow.cedar").read_text(encoding="utf-8"), + "gateway": GATEWAY, + }) return self._send(404, {"error": "not found"}) def do_POST(self): length = int(self.headers.get("Content-Length", 0)) payload = json.loads(self.rfile.read(length) or b"{}") if length else {} - - if self.path == "/api/call": - tool = payload.get("tool") - args = payload.get("arguments", {}) - body, status = _tool_call(tool, args) - allowed = status == 200 - decision = "allow" if allowed else ( - "deny" if body.get("error", {}).get("data", {}).get("error_code") == "POLICY_DENY" - else "error") - text = None - if allowed: - try: - text = body["result"]["content"][0]["text"] - except (KeyError, IndexError, TypeError): - text = None - return self._send(200, {"tool": tool, "http_status": status, "decision": decision, - "text": text, "response": body}) - if self.path == "/api/close": - body, status = _close_session() - return self._send(200 if status == 200 else status, body) + if self.path == "/api/run": + scenario = payload.get("scenario", "clean") + if scenario not in {s["id"] for s in SCENARIOS}: + return self._send(400, {"error": "unknown scenario"}) + try: + return self._send(200, _run_agent(scenario)) + except subprocess.TimeoutExpired: + return self._send(504, {"error": "agent run timed out"}) if self.path == "/api/verify": body, status = _verify() return self._send(status, body) - if self.path == "/api/reset": - # the gateway rotates its session on close; here we just clear the saved claim - if CLAIM_FILE.exists(): - CLAIM_FILE.unlink() - return self._send(200, {"ok": True}) return self._send(404, {"error": "not found"}) - def _serve_static(self, rel): + def _serve(self, rel): target = (WEB / rel).resolve() if not str(target).startswith(str(WEB.resolve())) or not target.is_file(): return self._send(404, "not found", "text/plain")