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()