Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions demo-02-policy-swap/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
26 changes: 26 additions & 0 deletions web-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions web-console/policy_variants.py
Original file line number Diff line number Diff line change
@@ -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()
62 changes: 57 additions & 5 deletions web-console/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt

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));
Expand All @@ -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) => `<li>${esc(e)}</li>`).join("");

// guardrails + policy
$("#guardrails").innerHTML = CTX.guardrails.map((g) =>
`<div class="gr"><span class="reg">${esc(g.regulation)}</span><span class="plain">${esc(g.plain)}</span></div>`).join("");
Expand All @@ -43,12 +54,21 @@ async function boot() {
`<div class="trow"><code>${esc(t.tool_name)}</code><span class="dom ${t.compliance_domain}">${esc(t.compliance_domain)}</span><span class="desc">${esc(t.description)}</span></div>`).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?";
Expand All @@ -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 = `<span class="st">Run failed.</span> ${esc(r.error.split("\n")[0])}
<pre class="mut" style="margin-top:6px;white-space:pre-wrap">${esc(r.error.split("\n").slice(1).join("\n"))}</pre>`;
$("#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 = `<span class="st">Write blocked.</span> ${esc(g ? g.plain : reason)}
<span class="reg">${esc(write.advice.regulation || (g && g.regulation) || "")}</span>
<div class="mut" style="margin-top:6px">The risk report was not written to core banking.</div>`;
} else if (r.variant === "tampered") {
sum.innerHTML = `<span class="st">Write allowed — under the tampered bundle.</span>
The same facility that the approved policy refused was just written to core banking.
<div class="mut" style="margin-top:6px">The enforcement worked exactly as the loaded policy told it to. That is why the record has to prove <em>which</em> policy was loaded.</div>`;
} else {
sum.innerHTML = `<span class="st">Assessment recorded.</span> All six steps passed policy; the report was written to core banking under Cedar enforcement.`;
}
Expand Down Expand Up @@ -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 = `<div class="rslt">${esc(v.error)}</div>`; return; }
$("#verify-checks").innerHTML = (v.checks || []).map((c) => {
const p = c.status === "PASS";
return `<div class="check ${p ? "pass" : "fail"}"><span class="box">${p ? "✓" : "✗"}</span><span class="name">${esc(c.name)}</span><span class="st">${c.status}</span></div>`;
}).join("");

const tampered = lastRun && lastRun.variant === "tampered";
let html = "";
if (tampered) {
html += `<div class="rslt tampered"><b>policy_bundle.hash &mdash; FAIL</b><br>
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.</div>`;
}
if (v.result) {
const ok = v.result.detail === "verified";
$("#verify-result").innerHTML = `<div class="rslt ${ok ? "verified" : "partial"}"><b>${esc(v.result.detail)}</b> &mdash; software-only run; on real TDX / SEV-SNP the hardware check verifies too.</div>`;
html += `<div class="rslt ${ok ? "verified" : "partial"}"><b>${esc(v.result.detail)}</b> &mdash; software-only run; on real TDX / SEV-SNP the hardware check verifies too.</div>`;
}
if (v.pinned_hash) {
html += `<div class="rslt pinned">pinned to approved bundle<br><code>${esc(v.pinned_hash)}</code></div>`;
}
$("#verify-result").innerHTML = html;
}

$("#run").addEventListener("click", run);
Expand Down
20 changes: 20 additions & 0 deletions web-console/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ <h1>Corporate credit-risk assessment</h1>

<div class="scenarios" id="scenarios"></div>

<div class="variants">
<span class="vlabel">Policy bundle loaded by the gateway</span>
<div class="vrow">
<button class="var sel" data-variant="approved">
<span class="vname">Approved</span>
<span class="vdesc">signed off by compliance</span>
</button>
<button class="var" data-variant="tampered">
<span class="vname">Tampered</span>
<span class="vdesc">an operator edited two rules</span>
</button>
<code class="vhash" id="variant-hash"></code>
</div>
<div class="tamper" id="tamper-note">
<span class="tt">What the operator changed</span>
<ul id="tamper-edits"></ul>
<span class="mut">Everything else in the bundle is byte-identical &mdash; and the hash still moves.</span>
</div>
</div>

<div class="runbar">
<button id="run" class="primary">Run assessment</button>
<span id="run-hint" class="mut">Pick an obligor, then run.</span>
Expand Down
Loading