diff --git a/demo-02-policy-swap/run.py b/demo-02-policy-swap/run.py index 181706c..4ba7884 100644 --- a/demo-02-policy-swap/run.py +++ b/demo-02-policy-swap/run.py @@ -70,11 +70,16 @@ def main() -> None: sys.stdout.flush() subprocess.run([sys.executable, str(SCRIPT_DIR / "check_hash.py")], check=True) - # Step 1: show v1 claim's policy hash + # Step 1: the approved (v1) bundle is the baseline a verifier pins. + # Compute it from the bundle itself so the value shown here is the same one + # printed in step 0 and pinned in step 5 -- reading it off the demo-01 claim + # instead showed a hash from a different bundle under the same "v1" label. print() - print("-- Step 1: TRACE claim from demo-01 --") + print("-- Step 1: The approved policy bundle --") v1_claim = json.loads(CLAIM_PATH.read_text()) - v1_policy_hash = v1_claim["trace"]["policy"]["bundle_hash"] + v1_policy_hash = subprocess.check_output( + [sys.executable, str(SCRIPT_DIR / "check_hash.py"), "v1"], text=True + ).split()[1] catalog_hash = v1_claim["gateway"]["catalog"]["hash"] print(f" v1 policy.bundle_hash: {v1_policy_hash}") print(f" catalog.hash: {catalog_hash}") diff --git a/web-console/README.md b/web-console/README.md index 3e76f6b..15a627f 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -44,6 +44,32 @@ The **Policy** tab shows the Cedar bundle the gateway loaded and maps each guardrail to the control it enforces. **What this shows** explains the four properties the demo proves. +## Approved vs tampered policy + +The **Policy bundle** selector on the Assessment tab puts the policy-swap story +(previously the separate terminal `demo-02-policy-swap`) in the same view. Pick +**Tampered** and the console runs the agent against a second gateway holding an +edited copy of the bundle: the delegated-authority ceiling raised from €500k to +€5m, and the concentration guardrail inverted so it never fires. The tampered +copy is generated from the approved bundle at runtime, so it cannot drift from +the example. + +Run **Nordwind Logistik AG (€750k)** under each: + +| Bundle | Step 6, the write | Verify against the approved hash | +|---|---|---| +| Approved | `403 denied` — EBA/GL/2020/06 | `policy_bundle.hash` **PASS** | +| Tampered | `200 allow` — the write lands | `policy_bundle.hash` **FAIL** | + +That is the whole argument for the record: enforcement did exactly what the +loaded policy said, so the record has to prove *which* policy was loaded. +`cmcp verify` is always pinned to the approved bundle hash — a verifier does not +get to move the goalposts after the fact. + +The gateway measures the bundle at startup, so the tampered bundle needs its own +process. It starts lazily on `:8444` the first time you select **Tampered**, and +stops when the console exits. The approved gateway on `:8443` is untouched. + ## How it fits together The browser never talks to the gateway directly. `webserver.py` serves the UI diff --git a/web-console/policy_variants.py b/web-console/policy_variants.py new file mode 100644 index 0000000..9c99b6e --- /dev/null +++ b/web-console/policy_variants.py @@ -0,0 +1,171 @@ +"""Approved vs tampered Cedar bundles, and a second gateway to serve the latter. + +Demo 2 (policy swap) used to be a separate terminal demo. This puts it in the +same console, so one view tells the whole story: enforcement, then tampering, +then the record refusing to agree with the tamper. + +How it works +------------ +The gateway reads its policy bundle once, at startup, and measures the bundle +hash into the attestation report before any code runs -- so a policy swap means +a *different gateway process*. Rather than restarting the approved gateway +(which the launcher owns), this module starts a SECOND gateway on its own port +with a tampered copy of the bundle. Both stay up; the console picks which one +to run the agent against. + +The tampered bundle is generated from the approved one at runtime, so it can +never drift from the example. Two edits, both the kind an operator who wanted a +loan booked would actually make: + + 1. the delegated-authority ceiling is raised from EUR 500k to EUR 5m, so a + EUR 750k facility no longer needs a human decision-maker + 2. the single-obligor concentration guardrail is inverted so it never fires + +Everything else in the bundle is byte-identical. That is the point: two small +edits, a completely different decision, and a bundle hash that cannot be made +to match the approved one. +""" +from __future__ import annotations + +import json +import pathlib +import shutil +import subprocess +import sys +import time + +from cmcp_runtime.policy.bundle import _canonical_bundle_hash + +HERE = pathlib.Path(__file__).parent.resolve() +EXAMPLE = HERE / "vendor" / "examples" / "financial-services" +WORKSPACE = HERE.parent / "workspace" + +APPROVED_DIR = EXAMPLE / "policy" +TAMPERED_DIR = WORKSPACE / "policy-tampered" + +TAMPERED_PORT = 8444 +TAMPERED_URL = f"http://localhost:{TAMPERED_PORT}" + +# (what to find, what to replace it with, what to call it in the UI) +EDITS = [ + ("context.arguments.amount_eur > 500000", + "context.arguments.amount_eur > 5000000", + "delegated-authority ceiling raised from EUR 500k to EUR 5m"), + ("@delegated_authority_limit_eur(\"500000\")", + "@delegated_authority_limit_eur(\"5000000\")", + "the annotation was updated to match, so the edit looks deliberate"), + ("context.arguments.breaches_concentration_limit == true", + "context.arguments.breaches_concentration_limit == false", + "single-obligor concentration guardrail inverted so it never fires"), +] + + +def bundle_hash(policy_dir: pathlib.Path) -> str: + """Bundle hash exactly as the runtime computes it, so the value shown in the + console is the value that lands in the signed record.""" + manifest = json.loads((policy_dir / "manifest.json").read_text(encoding="utf-8")) + policy_files = { + p.relative_to(policy_dir).as_posix(): p.read_text(encoding="utf-8") + for p in sorted(policy_dir.glob("**/*.cedar")) + } + schema = (policy_dir / "schema.cedarschema").read_text(encoding="utf-8") + return _canonical_bundle_hash(manifest, policy_files, schema) + + +def build_tampered() -> tuple[pathlib.Path, list[str]]: + """Copy the approved bundle and apply the edits. Returns the dir and the + human-readable list of what was changed.""" + if TAMPERED_DIR.exists(): + shutil.rmtree(TAMPERED_DIR) + shutil.copytree(APPROVED_DIR, TAMPERED_DIR) + + target = TAMPERED_DIR / "allow.cedar" + text = target.read_text(encoding="utf-8") + applied = [] + for find, replace, label in EDITS: + if find not in text: + raise RuntimeError( + f"tamper edit no longer matches the example policy: {find!r}. " + "The submodule changed; update EDITS in policy_variants.py." + ) + text = text.replace(find, replace) + applied.append(label) + target.write_text(text, encoding="utf-8") + return TAMPERED_DIR, applied + + +def write_config(policy_dir: pathlib.Path, port: int, audit_name: str) -> pathlib.Path: + WORKSPACE.mkdir(exist_ok=True) + cfg = WORKSPACE / f"fs-gateway-{audit_name}.yaml" + cfg.write_text( + f"policy_bundle_path: {policy_dir.as_posix()}\n" + f"catalog_path: {(EXAMPLE / 'catalog.json').as_posix()}\n" + f"listen_addr: 127.0.0.1:{port}\n" + f"max_response_size_bytes: 2097152\n" + f"audit_db_path: {(WORKSPACE / f'fs-audit-{audit_name}.db').as_posix()}\n" + f"attestation:\n" + f" provider: auto\n" + f" enforcement_mode: enforcing\n" + ) + return cfg + + +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")), + pathlib.Path(sysconfig.get_path("scripts", "nt_user"))): + for name in ("cmcp.exe", "cmcp"): + if (scripts / name).exists(): + return str(scripts / name) + return "cmcp" + + +class TamperedGateway: + """Lazily-started second gateway serving the tampered bundle.""" + + def __init__(self) -> None: + self.proc: subprocess.Popen | None = None + self.applied: list[str] = [] + self.log = None + + def ensure(self, env: dict) -> str: + """Start it if it is not already up. Returns the gateway URL.""" + if self.proc is not None and self.proc.poll() is None: + return TAMPERED_URL + policy_dir, self.applied = build_tampered() + cfg = write_config(policy_dir, TAMPERED_PORT, "tampered") + self.log = open(HERE / "cmcp-tampered.log", "w") + run_env = dict(env) + run_env["CMCP_DEV_MODE"] = "1" + self.proc = subprocess.Popen( + [_find_cmcp(), "start", "--config", str(cfg)], + stdout=self.log, stderr=self.log, env=run_env, + ) + # the gateway measures the bundle before it binds; give it a moment + for _ in range(40): + time.sleep(0.25) + if self.proc.poll() is not None: + raise RuntimeError( + "tampered gateway exited on startup -- see cmcp-tampered.log" + ) + try: + import socket + with socket.create_connection(("127.0.0.1", TAMPERED_PORT), 0.25): + return TAMPERED_URL + except OSError: + continue + raise RuntimeError("tampered gateway did not come up on :%d" % TAMPERED_PORT) + + def stop(self) -> None: + if self.proc is not None and self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + if self.log is not None: + self.log.close() diff --git a/web-console/web/app.js b/web-console/web/app.js index af95a73..b89a23d 100644 --- a/web-console/web/app.js +++ b/web-console/web/app.js @@ -7,7 +7,9 @@ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "< let CTX = null; let selected = "clean"; +let variant = "approved"; // which Cedar bundle the gateway loaded let reasonMap = {}; +let lastRun = null; function showView(name) { document.querySelectorAll(".navitem").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); @@ -33,6 +35,15 @@ async function boot() { document.querySelectorAll(".scn").forEach((x) => x.classList.toggle("sel", x === c)); })); + // policy variant -- approved bundle, or the one an operator quietly edited + renderVariant(); + document.querySelectorAll(".var").forEach((b) => b.addEventListener("click", () => { + variant = b.dataset.variant; + renderVariant(); + })); + $("#tamper-edits").innerHTML = (CTX.tamper_edits || []) + .map((e) => `
  • ${esc(e)}
  • `).join(""); + // guardrails + policy $("#guardrails").innerHTML = CTX.guardrails.map((g) => `
    ${esc(g.regulation)}${esc(g.plain)}
    `).join(""); @@ -43,12 +54,21 @@ async function boot() { `
    ${esc(t.tool_name)}${esc(t.compliance_domain)}${esc(t.description)}
    `).join(""); } +function renderVariant() { + document.querySelectorAll(".var").forEach((b) => + b.classList.toggle("sel", b.dataset.variant === variant)); + const h = (CTX.policy_hashes || {})[variant] || ""; + $("#variant-hash").textContent = h; + $("#tamper-note").className = variant === "tampered" ? "tamper show" : "tamper"; +} + async function run() { const btn = $("#run"); btn.disabled = true; btn.textContent = "Running…"; $("#run-hint").textContent = ""; $("#timeline").innerHTML = ""; $("#summary").className = "summary hide"; try { - const r = await api("/api/run", { scenario: selected }); + const r = await api("/api/run", { scenario: selected, variant }); + lastRun = r; renderRun(r); } catch (e) { $("#run-hint").textContent = "run failed -- is the gateway up?"; @@ -59,16 +79,34 @@ async function run() { function renderRun(r) { const steps = r.steps || []; + const sum = $("#summary"); + + // A crashed or partial run is an error, not a pass. Inferring "allowed" from + // the absence of a denied write used to render a failure as a green success. + if (r.error) { + sum.className = "summary deny"; + sum.innerHTML = `Run failed. ${esc(r.error.split("\n")[0])} +
    ${esc(r.error.split("\n").slice(1).join("\n"))}
    `; + $("#timeline").innerHTML = ""; + return; + } + const write = steps.find((s) => s.tool && s.tool.endsWith("risk_report_writer")); const denied = write && write.decision === "deny"; - const sum = $("#summary"); - sum.className = "summary " + (denied ? "deny" : "allow"); + // A write that only succeeded because the bundle was edited is not a success. + // Colour it as the warning it is, not the green of a clean pass. + const tamperedAllow = !denied && r.variant === "tampered"; + sum.className = "summary " + (denied ? "deny" : tamperedAllow ? "tampered" : "allow"); if (denied) { const reason = write.advice.reason || ""; const g = reasonMap[reason]; sum.innerHTML = `Write blocked. ${esc(g ? g.plain : reason)} ${esc(write.advice.regulation || (g && g.regulation) || "")}
    The risk report was not written to core banking.
    `; + } else if (r.variant === "tampered") { + sum.innerHTML = `Write allowed — under the tampered bundle. + The same facility that the approved policy refused was just written to core banking. +
    The enforcement worked exactly as the loaded policy told it to. That is why the record has to prove which policy was loaded.
    `; } else { sum.innerHTML = `Assessment recorded. All six steps passed policy; the report was written to core banking under Cedar enforcement.`; } @@ -97,16 +135,30 @@ function renderRun(r) { } async function verify() { - const v = await api("/api/verify", {}); + // Always verify against the APPROVED bundle hash. A verifier that approved + // one policy does not get to move the goalposts afterwards. + const v = await api("/api/verify", { pinned: true }); if (v.error) { $("#verify-result").innerHTML = `
    ${esc(v.error)}
    `; return; } $("#verify-checks").innerHTML = (v.checks || []).map((c) => { const p = c.status === "PASS"; return `
    ${p ? "✓" : "✗"}${esc(c.name)}${c.status}
    `; }).join(""); + + const tampered = lastRun && lastRun.variant === "tampered"; + let html = ""; + if (tampered) { + html += `
    policy_bundle.hash — FAIL
    + This record came from the tampered bundle. A verifier holding the approved + hash rejects it, and no edit to the record can make the two agree.
    `; + } if (v.result) { const ok = v.result.detail === "verified"; - $("#verify-result").innerHTML = `
    ${esc(v.result.detail)} — software-only run; on real TDX / SEV-SNP the hardware check verifies too.
    `; + html += `
    ${esc(v.result.detail)} — software-only run; on real TDX / SEV-SNP the hardware check verifies too.
    `; + } + if (v.pinned_hash) { + html += `
    pinned to approved bundle
    ${esc(v.pinned_hash)}
    `; } + $("#verify-result").innerHTML = html; } $("#run").addEventListener("click", run); diff --git a/web-console/web/index.html b/web-console/web/index.html index c5a1d54..811c51a 100644 --- a/web-console/web/index.html +++ b/web-console/web/index.html @@ -34,6 +34,26 @@

    Corporate credit-risk assessment

    +
    + Policy bundle loaded by the gateway +
    + + + +
    +
    + What the operator changed + + Everything else in the bundle is byte-identical — and the hash still moves. +
    +
    +
    Pick an obligor, then run. diff --git a/web-console/web/styles.css b/web-console/web/styles.css index ace7c1b..b7968b3 100644 --- a/web-console/web/styles.css +++ b/web-console/web/styles.css @@ -64,7 +64,9 @@ button:focus-visible{outline:2px solid var(--green);outline-offset:2px} .summary.allow{background:rgba(16,186,135,.1);border-color:rgba(16,186,135,.4)} .summary.deny{background:rgba(225,118,90,.1);border-color:rgba(225,118,90,.4)} .summary .st{font-weight:700} +.summary.tampered{background:rgba(237,174,90,.1);border-color:rgba(237,174,90,.5)} .summary.allow .st{color:var(--green)} .summary.deny .st{color:var(--umber)} +.summary.tampered .st{color:#edae5a} .summary .reg{font-family:var(--mono);font-size:11px;margin-left:8px;padding:2px 7px;border-radius:5px;background:rgba(0,0,0,.25)} /* timeline */ @@ -106,6 +108,30 @@ button:focus-visible{outline:2px solid var(--green);outline-offset:2px} .rslt.partial{border-color:rgba(226,201,158,.4)} .rslt.partial b{color:var(--sand)} .rslt.verified{border-color:rgba(16,186,135,.4)} .rslt.verified b{color:var(--green)} +/* policy variant -- approved bundle vs the one an operator edited */ +.variants{margin:18px 0 6px} +.vlabel{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut);margin-bottom:8px} +.vrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.var{display:flex;flex-direction:column;gap:2px;text-align:left;cursor:pointer; + background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:9px 14px;color:var(--ink2)} +.var:hover{border-color:var(--line2)} +.var .vname{font-size:13.5px;font-weight:650;color:var(--ink)} +.var .vdesc{font-size:11.5px;color:var(--mut)} +.var.sel{border-color:var(--green)} +.var.sel .vname{color:var(--green)} +.var[data-variant="tampered"].sel{border-color:var(--umber)} +.var[data-variant="tampered"].sel .vname{color:var(--umber)} +.vhash{font-size:11.5px;color:var(--mut);word-break:break-all;flex:1 1 260px} +.tamper{display:none;margin-top:12px;background:var(--panel);border:1px solid rgba(225,118,90,.4); + border-radius:9px;padding:12px 15px} +.tamper.show{display:block} +.tamper .tt{display:block;font-size:12px;font-weight:650;color:var(--umber);margin-bottom:6px} +.tamper ul{margin:0 0 6px;padding-left:18px;font-size:12.5px;color:var(--ink2)} +.tamper li{margin:2px 0} +.tamper .mut{font-size:12px} +.rslt.tampered{border-color:rgba(225,118,90,.5)} .rslt.tampered b{color:var(--umber)} +.rslt.pinned{color:var(--mut)} .rslt.pinned code{color:var(--ink2);word-break:break-all} + /* about */ .cards{display:grid;grid-template-columns:1fr 1fr;gap:14px} .pt{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px} diff --git a/web-console/webserver.py b/web-console/webserver.py index bbca8e7..e510e7b 100644 --- a/web-console/webserver.py +++ b/web-console/webserver.py @@ -16,6 +16,8 @@ import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import policy_variants + HERE = pathlib.Path(__file__).parent.resolve() WEB = HERE / "web" EXAMPLE = HERE / "vendor" / "examples" / "financial-services" @@ -26,6 +28,20 @@ GATEWAY = os.environ.get("CMCP_GATEWAY_URL", "http://localhost:8443") PORT = int(os.environ.get("WEB_CONSOLE_PORT", "8000")) +TAMPERED = policy_variants.TamperedGateway() +_HASHES: dict[str, str] = {} + + +def _policy_hashes() -> dict[str, str]: + """Approved and tampered bundle hashes, computed the way the runtime does. + Cached: the approved bundle is read-only and the tampered one is generated + deterministically from it.""" + if not _HASHES: + _HASHES["approved"] = policy_variants.bundle_hash(policy_variants.APPROVED_DIR) + tampered_dir, _ = policy_variants.build_tampered() + _HASHES["tampered"] = policy_variants.bundle_hash(tampered_dir) + return _HASHES + _CT = {".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".json": "application/json"} @@ -70,12 +86,12 @@ def _find_cmcp() -> str: return "cmcp" -def _run_agent(scenario: str): +def _run_agent(scenario: str, gateway: str = GATEWAY): """Run the real example agent and parse its output into steps + claim.""" env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" proc = subprocess.run( - [sys.executable, str(AGENT), "--scenario", scenario, "--gateway", GATEWAY], + [sys.executable, str(AGENT), "--scenario", scenario, "--gateway", gateway], capture_output=True, text=True, encoding="utf-8", env=env, timeout=60) out = (proc.stdout or "") + "\n" + (proc.stderr or "") @@ -117,16 +133,30 @@ def _run_agent(scenario: str): if claim: WORKSPACE.mkdir(exist_ok=True) CLAIM_FILE.write_text(json.dumps(claim, indent=2)) - return {"scenario": scenario, "steps": steps, "claim": claim} + + # A run that crashed or produced no steps must not be reported as a clean + # pass. Previously the UI inferred "allowed" from the absence of a denied + # write, so a failed run rendered as a green success banner. + error = None + if proc.returncode != 0 or not steps: + tail = "\n".join(l for l in out.strip().splitlines()[-6:] if l.strip()) + error = (f"the example agent exited with code {proc.returncode} and " + f"produced {len(steps)} of 6 steps.\n{tail}") + return {"scenario": scenario, "steps": steps, "claim": claim, "error": error} -def _verify(): +def _verify(pinned_hash: str | None = None): + """Verify the last record. When pinned_hash is given, the verifier is held + to the policy bundle it approved -- which is what makes a tampered run + detectable rather than merely different.""" if not CLAIM_FILE.exists(): return {"error": "run an assessment first"}, 409 env = os.environ.copy() env["CMCP_DEV_MODE"] = "1" - proc = subprocess.run([_find_cmcp(), "verify", str(CLAIM_FILE)], - capture_output=True, text=True, env=env) + cmd = [_find_cmcp(), "verify", str(CLAIM_FILE)] + if pinned_hash: + cmd += ["--policy-hash", pinned_hash] + proc = subprocess.run(cmd, 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)] @@ -166,6 +196,8 @@ def do_GET(self): "scenarios": SCENARIOS, "tools": tools, "guardrails": GUARDRAILS, "policy": (EXAMPLE / "policy" / "allow.cedar").read_text(encoding="utf-8"), "gateway": GATEWAY, + "policy_hashes": _policy_hashes(), + "tamper_edits": [label for _, _, label in policy_variants.EDITS], }) return self._send(404, {"error": "not found"}) @@ -174,14 +206,32 @@ def do_POST(self): payload = json.loads(self.rfile.read(length) or b"{}") if length else {} if self.path == "/api/run": scenario = payload.get("scenario", "clean") + variant = payload.get("variant", "approved") if scenario not in {s["id"] for s in SCENARIOS}: return self._send(400, {"error": "unknown scenario"}) + if variant not in ("approved", "tampered"): + return self._send(400, {"error": "unknown policy variant"}) try: - return self._send(200, _run_agent(scenario)) + gateway, applied = GATEWAY, [] + if variant == "tampered": + gateway = TAMPERED.ensure(os.environ.copy()) + applied = TAMPERED.applied + body = _run_agent(scenario, gateway) + body["variant"] = variant + body["tamper_edits"] = applied + body["policy_hash"] = _policy_hashes()[variant] + return self._send(200, body) except subprocess.TimeoutExpired: return self._send(504, {"error": "agent run timed out"}) + except RuntimeError as exc: + return self._send(500, {"error": str(exc)}) if self.path == "/api/verify": - body, status = _verify() + # The verifier is pinned to the APPROVED bundle: that is the whole + # point. A record from the tampered gateway fails this check. + pinned = _policy_hashes()["approved"] if payload.get("pinned", True) else None + body, status = _verify(pinned) + if isinstance(body, dict): + body["pinned_hash"] = pinned return self._send(status, body) return self._send(404, {"error": "not found"}) @@ -200,6 +250,10 @@ def main(): srv.serve_forever() except KeyboardInterrupt: srv.shutdown() + finally: + # the tampered gateway is ours to clean up; the approved one belongs + # to run.py + TAMPERED.stop() if __name__ == "__main__":