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