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) => `
`).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 = `
`;
}).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.