From f891ca040ab8ba5a1105f9b00a2a1a931021f27a Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Mon, 27 Jul 2026 09:39:42 -0700 Subject: [PATCH] Put the policy-swap story in the web console, and stop masking failed runs The console showed enforcement and proof but not tampering, which is the argument for why the proof has to exist. That story lived in a separate terminal demo, so nobody saw both together. Add a policy bundle selector to the Assessment tab: - Approved: the example's bundle, unchanged - Tampered: a copy generated at runtime with two edits an operator who wanted a loan booked would actually make -- delegated-authority ceiling raised from EUR 500k to EUR 5m, concentration guardrail inverted Nordwind Logistik (EUR 750k) is denied at the write under the approved bundle and allowed under the tampered one. cmcp verify is now always pinned to the approved bundle hash, so the tampered record fails policy_bundle.hash and no edit to the record can make the two agree. The gateway measures its bundle at startup, so the tampered bundle gets its own process on :8444, started lazily and stopped with the console. The approved gateway on :8443 is untouched. Also fixed: - A crashed or partial agent run rendered as a green "Assessment recorded" banner, because the UI inferred success from the absence of a denied write. An empty step list is not a pass. The API now reports the exit code and output tail, and the UI shows it as a failure. - A write that only succeeded because the bundle was edited was styled with the same green as a clean pass. It is now an amber warning. - demo-02-policy-swap printed two different hashes both labelled "v1" three lines apart -- step 0 showed the demo's own policies-v1 bundle, step 1 read the hash off demo-01's claim, which is a different bundle. Step 1 now computes it from the same bundle step 5 pins. Co-Authored-By: Claude Opus 5 (1M context) --- demo-02-policy-swap/run.py | 11 ++- web-console/README.md | 26 +++++ web-console/policy_variants.py | 171 +++++++++++++++++++++++++++++++++ web-console/web/app.js | 62 +++++++++++- web-console/web/index.html | 20 ++++ web-console/web/styles.css | 26 +++++ web-console/webserver.py | 70 ++++++++++++-- 7 files changed, 370 insertions(+), 16 deletions(-) create mode 100644 web-console/policy_variants.py 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__":