From c86debc0a1b56c5dd2d1bf7aed2fb061562706e5 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 23 Jul 2026 17:32:26 -0700 Subject: [PATCH] Add browser console demo for cMCP A point-and-click version of the CLI demos for showing on a screen. Sends real tool calls through the gateway, shows Cedar allow/deny live, closes the session into a signed TRACE record, and runs cmcp verify offline. A small stdlib web server serves the UI and proxies to the gateway so the browser never holds the bearer token and there's no CORS to configure. Reuses the shared filesystem MCP server and the same action-dialect Cedar policy as demo-01. Catalog carries definition_hash and a schema-valid fingerprint so it loads on current cmcp-runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 12 ++ web-console/README.md | 62 ++++++ web-console/catalog.json | 106 ++++++++++ web-console/cmcp-config.yaml | 9 + .../policies/allow-filesystem-tools.cedar | 21 ++ web-console/policies/manifest.json | 7 + web-console/policies/schema.cedarschema | 67 ++++++ web-console/run.py | 88 ++++++++ web-console/web/app.js | 129 ++++++++++++ web-console/web/index.html | 90 ++++++++ web-console/web/styles.css | 106 ++++++++++ web-console/webserver.py | 195 ++++++++++++++++++ 12 files changed, 892 insertions(+) create mode 100644 web-console/README.md create mode 100644 web-console/catalog.json create mode 100644 web-console/cmcp-config.yaml create mode 100644 web-console/policies/allow-filesystem-tools.cedar create mode 100644 web-console/policies/manifest.json create mode 100644 web-console/policies/schema.cedarschema create mode 100644 web-console/run.py create mode 100644 web-console/web/app.js create mode 100644 web-console/web/index.html create mode 100644 web-console/web/styles.css create mode 100644 web-console/webserver.py diff --git a/README.md b/README.md index 673f242..1b1e03f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ 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. + +``` +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). + +--- + ## Demo 1 -- cMCP in action (~90 seconds) The agent calls three tools through the cMCP gateway. Cedar policy is enforced for every call. At session close, the gateway produces a signed TRACE claim carrying the policy bundle hash. diff --git a/web-console/README.md b/web-console/README.md new file mode 100644 index 0000000..8533672 --- /dev/null +++ b/web-console/README.md @@ -0,0 +1,62 @@ +# 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. + +``` +pip install cmcp-runtime +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`) +- 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. + +## 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. +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`. + +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. + +## 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 +(`write_file -> Action::"WriteFile"`), the same as `demo-01`. Nothing here is +mocked. + +## Files + +``` +web-console/ + run.py launcher (server + gateway + web server) + webserver.py static UI + /api proxy to the gateway + cmcp-config.yaml gateway config (enforcing, software-only) + catalog.json approved tools + policies/ Cedar bundle + web/ index.html, app.js, styles.css +``` diff --git a/web-console/catalog.json b/web-console/catalog.json new file mode 100644 index 0000000..5e35dd5 --- /dev/null +++ b/web-console/catalog.json @@ -0,0 +1,106 @@ +[ + { + "tool_name": "write_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write content to a file in the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + }, + "output_schema": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + }, + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup", + "definition_hash": "sha256:7b4738feb4a0c18b2870276996a14d8c2241380c063815cb98b54cb200e7e0d2" + }, + { + "tool_name": "read_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read a file from the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + }, + "output_schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + } + }, + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup", + "definition_hash": "sha256:3bdb3d438df2208aa5b293e0d71260f808365d6fcef4a9cf44702ebed1acc6cc" + }, + { + "tool_name": "list_dir", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "List files in the demo workspace", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + } + }, + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup", + "definition_hash": "sha256:215e8f06120f876cb19bb766a54042a31d521b54cf731f6893c975e4e0861e78" + } +] diff --git a/web-console/cmcp-config.yaml b/web-console/cmcp-config.yaml new file mode 100644 index 0000000..7590dd0 --- /dev/null +++ b/web-console/cmcp-config.yaml @@ -0,0 +1,9 @@ +attestation: + provider: auto # falls back to software-only when CMCP_DEV_MODE=1 + enforcement_mode: enforcing + validity_seconds: 3600 + +policy_bundle_path: ./policies/ +catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8443" +audit_db_path: ./audit.db diff --git a/web-console/policies/allow-filesystem-tools.cedar b/web-console/policies/allow-filesystem-tools.cedar new file mode 100644 index 0000000..98a7e17 --- /dev/null +++ b/web-console/policies/allow-filesystem-tools.cedar @@ -0,0 +1,21 @@ +// 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/manifest.json b/web-console/policies/manifest.json new file mode 100644 index 0000000..b27f449 --- /dev/null +++ b/web-console/policies/manifest.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000..a3b37be --- /dev/null +++ b/web-console/policies/schema.cedarschema @@ -0,0 +1,67 @@ +{ + "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/run.py b/web-console/run.py new file mode 100644 index 0000000..c181db2 --- /dev/null +++ b/web-console/run.py @@ -0,0 +1,88 @@ +"""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) + - 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. + + python web-console/run.py # from repo root + python run.py # from the web-console directory +""" +import os +import pathlib +import shutil +import subprocess +import sys +import time +import webbrowser + +sys.stdout.reconfigure(line_buffering=True) + +HERE = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = HERE.parent +PORT = os.environ.get("WEB_CONSOLE_PORT", "8000") + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + for scripts in (pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts"))): + for name in ("cmcp.exe", "cmcp"): + if (scripts / name).exists(): + return str(scripts / name) + sys.exit("cmcp not found. Run: pip install cmcp-runtime") + + +def main(): + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") + server_log = open(HERE / "server.log", "w") + cmcp_log = open(HERE / "cmcp.log", "w") + procs = [] + try: + print("-- MCP filesystem server on :9001", flush=True) + procs.append(subprocess.Popen( + [sys.executable, str(REPO_ROOT / "server" / "server.py")], + stdout=server_log, stderr=server_log)) + time.sleep(1) + + print("-- cMCP gateway on :8443 (CMCP_DEV_MODE=1)", flush=True) + 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)) + time.sleep(2) + + print(f"-- web console on http://localhost:{PORT}", flush=True) + web = subprocess.Popen([sys.executable, str(HERE / "webserver.py")], env=os.environ.copy()) + procs.append(web) + time.sleep(1) + + url = f"http://localhost:{PORT}" + print(f"\nOpen {url} (opening it for you now). Ctrl+C to stop.\n", flush=True) + try: + webbrowser.open(url) + except Exception: + pass + web.wait() + except KeyboardInterrupt: + print("\nstopping...", flush=True) + finally: + for p in reversed(procs): + if p and p.poll() is None: + p.terminate() + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + p.kill() + server_log.close() + cmcp_log.close() + + +if __name__ == "__main__": + main() diff --git a/web-console/web/app.js b/web-console/web/app.js new file mode 100644 index 0000000..3448a6e --- /dev/null +++ b/web-console/web/app.js @@ -0,0 +1,129 @@ +"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(); +}; + +function setStatus(up, label) { + const el = $("#status"); + el.className = "status " + (up ? "up" : "down"); + el.innerHTML = ` ${label}`; +} + +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"); + } +} + +const TOOL_ARGS = { + write_file: () => ({ path: $("#wf-path").value, content: $("#wf-content").value }), + read_file: () => ({ path: $("#rf-path").value }), + list_dir: () => ({}), +}; + +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); +} + +function escapeHtml(s) { + return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); +} + +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 closeSession() { + const claim = await api("/api/close", {}); + if (claim.error) { + $("#record").textContent = claim.error; + showTab("record"); + return; + } + $("#record").textContent = JSON.stringify(claim, null, 2); + $("#verify").disabled = false; + showTab("record"); +} + +async function verifyRecord() { + const v = await api("/api/verify", {}); + if (v.error) { $("#verify-raw").textContent = v.error; showTab("verify"); return; } + $("#verify-checks").innerHTML = (v.checks || []).map((c) => { + const pass = c.status === "PASS"; + return `
+ ${pass ? "✓" : "✗"} + ${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); + } + $("#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(); diff --git a/web-console/web/index.html b/web-console/web/index.html new file mode 100644 index 0000000..affd8ab --- /dev/null +++ b/web-console/web/index.html @@ -0,0 +1,90 @@ + + + + + + cMCP console + + + +
+
+ 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.

+

+        

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.
+
+
+
+ + + + diff --git a/web-console/web/styles.css b/web-console/web/styles.css new file mode 100644 index 0000000..ebd7311 --- /dev/null +++ b/web-console/web/styles.css @@ -0,0 +1,106 @@ +: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; + --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; +} +*{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)} +button{font-family:inherit;cursor:pointer} + +.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)} + +.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} + +.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)} +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} + +.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} + +.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} + +.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)} +.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.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)} + +@media (max-width:760px){ + .grid{grid-template-columns:1fr;height:auto} + .side{border-right:none;border-bottom:1px solid var(--line)} +} diff --git a/web-console/webserver.py b/web-console/webserver.py new file mode 100644 index 0000000..ad31d13 --- /dev/null +++ b/web-console/webserver.py @@ -0,0 +1,195 @@ +"""Local web server for the cMCP browser 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. + +Run indirectly via run.py, or standalone once the server and gateway are up: + python webserver.py # serves on http://localhost:8000 +""" +import json +import os +import pathlib +import re +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" +WORKSPACE = HERE.parent / "workspace" +POLICY_FILE = HERE / "policies" / "allow-filesystem-tools.cedar" +CATALOG_FILE = HERE / "catalog.json" +CLAIM_FILE = WORKSPACE / "web-console-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"} + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + for scripts in (pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts"))): + for name in ("cmcp.exe", "cmcp"): + if (scripts / name).exists(): + return str(scripts / name) + 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: + WORKSPACE.mkdir(exist_ok=True) + CLAIM_FILE.write_text(json.dumps(claim, indent=2)) + return claim, status + + +def _verify(): + if not CLAIM_FILE.exists(): + return {"error": "no claim yet -- close the session first"}, 409 + env = os.environ.copy() + env["CMCP_DEV_MODE"] = "1" + proc = subprocess.run([_find_cmcp(), "verify", str(CLAIM_FILE)], + capture_output=True, text=True, env=env) + raw = (proc.stdout or "") + (proc.stderr or "") + checks = [{"name": m.group(1).strip(), "status": m.group(2)} + for m in re.finditer(r"\[cmcp verify\]\s+(.+?)\s{2,}(PASS|FAIL)", raw)] + result = None + m = re.search(r"RESULT:\s*(\w+)\s*\(([^)]+)\)", raw) + if m: + result = {"status": m.group(1), "detail": m.group(2)} + return {"raw": raw.strip(), "checks": checks, "result": result}, 200 + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): # keep the terminal quiet + pass + + def _send(self, status, body, content_type="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-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") + 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._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/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): + 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") + self._send(200, target.read_bytes(), _CT.get(target.suffix, "application/octet-stream")) + + +def main(): + WORKSPACE.mkdir(exist_ok=True) + srv = ThreadingHTTPServer(("localhost", PORT), Handler) + print(f"web console on http://localhost:{PORT} (gateway {GATEWAY})", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + + +if __name__ == "__main__": + main()