diff --git a/README.md b/README.md index a1bb8e4..3822d8c 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,48 @@ pytest tests # ~1,200 tests; the unit + integration core is seconds, th CI runs this and **gates on it** (`.github/workflows/codeqc.yml`, regenerated by the `ci-codeqc` skill itself — the ouroboros). +### Browser E2E (Playwright) — optional, opt-in + +A few behaviours only exist in a browser and cannot be pinned any other way: a `confirm()` gate, a custom +CSRF header a `fetch` must set, and *what the server actually received* when a button is clicked. Those live +in [`tests/test_fleetdash_approve_e2e.py`](tests/test_fleetdash_approve_e2e.py), which drives real Chromium +against a real `fleetdash` process. + +**Not required to run the suite.** The file `importorskip`s, so `pytest tests` passes untouched without +Playwright installed — including in CI, where these currently **skip**. Install it only when touching +dashboard behaviour. + +```sh +pip install playwright pytest +playwright install chromium # ~94 MB download, ~190 MB on disk; one time, shared +pytest tests/test_fleetdash_approve_e2e.py +``` + +**Where things land.** The Python package goes wherever `pip` points; the *browser* does **not** — it is +downloaded once into a shared OS cache, outside the venv, so a second venv reuses it: + +| | | +|---|---| +| browser cache (macOS) | `~/Library/Caches/ms-playwright/` | +| browser cache (Linux) | `~/.cache/ms-playwright/` | +| override | `PLAYWRIGHT_BROWSERS_PATH=` (set it for a pinned/offline install) | +| skip the browser download | `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` (the suite then skips) | + +If `pip install playwright` succeeds but tests error with *"Executable doesn't exist"*, the package is +present and the browser is not — run `playwright install chromium`. The two steps are independent. + +**Hermetic by construction.** The suite starts its own `fleetdash` on a free loopback port against a stub +govd and an on-disk mirror fixture. It never contacts the live fleet, needs no node, and no credential +outside `tmp_path` — so it is safe to run on any machine. + +**These are guard tests, so they are mutation-verified.** Each guard was individually disabled and the suite +had to go red; that pass found two defects a green run had hidden — one test passing for the wrong reason, +and a security check with no server-side coverage at all. **Add a guard, disable it, watch the suite fail** — +a guard test that has never been seen failing is not yet evidence. Two traps worth knowing when you do it: +a `str.replace(pattern, repl, 1)` mutation can silently patch an *earlier* identical line (mutate by line +number and assert the line's content first), and a fixture change can neuter an unrelated test — re-run the +whole file after touching one. + ## The agent economy A vendor's **skillChip** is a third product surface — past the UI (for humans) and the API (for diff --git a/infra/tool/fleetdash.py b/infra/tool/fleetdash.py index 2679e17..2291e0a 100644 --- a/infra/tool/fleetdash.py +++ b/infra/tool/fleetdash.py @@ -97,6 +97,39 @@ def _token(node): return os.environ.get("GOVD_MONITOR_TOKEN_" + str(node.get("name", "")).replace("-", "_").upper(), "") +def _approve_token(node): + """PRINCIPAL credential used ONLY to answer a push_back — deliberately NOT the monitor token. + + The monitor token is read-only by contract (`X-Govd-Monitor`, no govd write), and the dashboard has no + app-auth. Approving is a WRITE, so it needs a credential the operator provisions per node, on purpose: + `approve_token_file` in fleet.json, or GOVD_APPROVE_TOKEN_. Absent (the default) => the node simply + has no approve affordance, and the read-only posture is unchanged. Never reuse the monitor token here. + """ + f = _expand(node.get("approve_token_file")) + if f and os.path.isfile(f): + return open(f).read().strip() + return os.environ.get("GOVD_APPROVE_TOKEN_" + str(node.get("name", "")).replace("-", "_").upper(), "") + + +def _post_json(url, body, token=None, timeout=10): + """POST JSON to a node with a principal Bearer. Returns (status, parsed). govd answers a verdict on + non-2xx (403 reject / 409 push_back), so a 4xx carrying a `decision` is a RESULT, not a transport error.""" + data = json.dumps(body).encode() + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + if token: + req.add_header("Authorization", "Bearer " + token) + opener = urllib.request.build_opener(_NoRedirect) + try: + with opener.open(req, timeout=timeout) as r: + return r.status, json.loads(r.read(_MAX_BODY).decode() or "{}") + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read(_MAX_BODY).decode() or "{}") + except Exception: + return e.code, {"error": "upstream"} + + _MAX_BODY = 8 * 1024 * 1024 # cap a node response we read into memory / mirror to disk @@ -573,6 +606,10 @@ def render_text(results, feed, risk, limit=40): .badge.b-reject{color:var(--reject);border-color:var(--reject)} .tag-dest{color:var(--destructive);border:1px solid var(--destructive);border-radius:6px;padding:0 6px;font-size:10px;white-space:nowrap} .tag-fail{color:var(--reject);border:1px solid var(--reject);border-radius:6px;padding:0 6px;font-size:10px} + button.approve{margin-left:8px;font:inherit;font-size:11px;cursor:pointer;color:var(--push); + background:transparent;border:1px solid var(--push);border-radius:6px;padding:1px 8px} + button.approve:hover{background:var(--push);color:var(--bg)} + button.approve[disabled]{opacity:.5;cursor:default} .banner{display:flex;gap:10px;margin:0 0 14px;flex-wrap:wrap} .bn{border-radius:8px;padding:9px 14px;font-weight:600;border:1px solid} .bn.approval{background:rgba(251,189,35,.08);border-color:var(--push);color:var(--push)} @@ -951,10 +988,14 @@ def render_html(results, feed, risk, refresh=5, as_of=None): risk_n=_risk_pending(risk), as_of=as_of) -def render_risk(feed, risk, refresh=5, as_of=None): +def render_risk(feed, risk, refresh=5, as_of=None, can_approve=None): """The /risk drill-down: every needs-approval / high-risk / rejected run across the fleet, grouped. Approval rows show their age and the approve-token list (value-free ids — WHAT to approve); entries a - later approved run answered sink to the bottom of their section, dimmed as `superseded`.""" + later approved run answered sink to the bottom of their section, dimmed as `superseded`. + + `can_approve(node_name) -> bool` gates the ANSWER affordance: a node with no operator-provisioned + approve credential renders the token list read-only, exactly as before. See `_approve_token`.""" + can_approve = can_approve or (lambda _n: False) def row(x, extra=""): rid, node = _esc(x.get("run_id") or ""), _esc(x["node"]) sup = " superseded" if x.get("_superseded") else "" @@ -978,15 +1019,25 @@ def section(key, title, hint, extra=lambda x: ""): def approve_of(x): toks = _as_list(x.get("needs_approve") or x.get("approved")) - return f'{_esc(", ".join(toks))}' if toks else "—" + if not toks: + return "—" + label = f'{_esc(", ".join(toks))}' + if x.get("_superseded") or not can_approve(x.get("node")): + return label # no credential for this node => display only + rid, node = _esc(x.get("run_id") or ""), _esc(x.get("node") or "") + return (f'{label} ') content = ('

high-risk & approval queue

' + _banner(risk) + section("approval", "needs approval", "destructive claims govd PUSHED BACK — re-submit the claim with the listed approve " "tokens to proceed (govd never auto-approves). `superseded` = a later approved run " - "already answered this claim.", approve_of) + "already answered this claim. approve re-submits the claim from THIS node's " + "operator credential and is recorded in the signed chain — it authorizes the CLAIM; " + "an agent that already gave up waiting is not resumed by it.", approve_of) + section("high", "high-risk (ran)", "destructive operations that were approved and executed — audit them.") + section("reject", "rejected", "claims govd refused (structural problems).")) + content += _approve_script() return _page("fleet — risk queue", content, refresh, nav="risk", risk_n=_risk_pending(risk), as_of=as_of) @@ -1141,6 +1192,28 @@ def _run_live(detail): return len(done) < len(detail.get("seq") or []) +def _approve_script(): + """The approve button's client script. Sends `X-Fleetdash-Approve: 1` — a custom header a cross-origin + page cannot set without a CORS preflight the server never answers, so a hostile site cannot drive-by POST + an approval at 127.0.0.1. Confirms first; every node-supplied string is written via textContent.""" + return ('') + + def _values_reveal_script(): """The reveal button's client script: fetch the node-proxied /monitor/values/ and render each step's decrypted inputs. EVERY node-supplied string is written via textContent (never innerHTML) — the @@ -1313,7 +1386,7 @@ def _is_loopback(host): return host in ("localhost", "") -def serve(nodes, port, refresh, mirror_dir, mirror_interval, bind="127.0.0.1"): +def serve(nodes, port, refresh, mirror_dir, mirror_interval, bind="127.0.0.1", config_path=None): # FAIL CLOSED on a non-loopback bind — the dashboard has NO app-auth and carries a monitor-token-injecting # read proxy (/proxy, /embed, /flow), so binding a routable/tailnet/0.0.0.0 interface publishes every node's # ledger + that proxy to anyone who reaches :PORT. Mirror govd's require_closed_auth / fleetd's 0.0.0.0 gate: @@ -1352,9 +1425,45 @@ def _fleet(): # the request path: the with _snap_lock: return list(_snap["results"]), list(_snap["feed"]), _snap["as_of"] + # The ROSTER was previously frozen at process start: load_nodes() ran once in + # main() and _mirror_loop closed over the result, so a node added to + # fleet.json stayed invisible until the process was restarted (the + # mirror-interval only refreshed LEDGERS for already-known nodes, which made + # the dashboard look live while silently ignoring the edit). Re-stat the + # config each sweep and reload when it changes. + _cfg_mtime = [None] + if config_path: + try: + _cfg_mtime[0] = os.stat(_expand(config_path)).st_mtime + except OSError: + pass + + def _reload_nodes_if_changed(): + """Adopt an edited roster in place. Fail SOFT: a config that is missing or + mid-write (truncated JSON) keeps the last good roster rather than emptying + the dashboard.""" + nonlocal nodes + if not config_path: + return + try: + m = os.stat(_expand(config_path)).st_mtime + except OSError: + return + if m == _cfg_mtime[0]: + return + try: + fresh = load_nodes(config_path) + except Exception: + return # keep the last good roster; retry next sweep + if not fresh: + return # never let a bad edit blank the fleet + _cfg_mtime[0] = m + nodes = fresh + def _mirror_loop(): # keep the durable copy + the snapshot fresh in the background while True: try: + _reload_nodes_if_changed() sums = mirror_all(nodes, mirror_dir) # the slow live /health probe happens HERE, off the hot path _refresh(live=True, sweeps={s.get("node"): s for s in sums}) @@ -1422,6 +1531,63 @@ def _embed(self, up): return self._bytes(200, "image/svg+xml; charset=utf-8", _sanitize_svg(data)) return self._bytes(200, "application/json; charset=utf-8", data) + def _json(self, code, obj): + self._bytes(code, "application/json; charset=utf-8", json.dumps(obj).encode()) + + def do_POST(self): + """POST /approve {node, run_id} — answer a govd push_back. + + The ONLY write this dashboard performs. Four gates, all fail-closed: + 1. the custom X-Fleetdash-Approve header (a cross-origin page cannot set it without a CORS + preflight we never answer) — blocks drive-by POSTs at 127.0.0.1; + 2. an operator-provisioned `approve_token_file` for THAT node (absent => 403, and the button is + never rendered either); + 3. the run must exist in the mirror AND actually be a push_back — never approve blind; + 4. govd re-checks everything anyway; this only re-submits the SAME claim carrying the token. + The claim's own skill/perk/var_keys are replayed from the mirrored record, so this cannot widen a + claim into something the agent never made. + """ + up = urllib.parse.urlparse(self.path) + if up.path.rstrip("/") != "/approve": + return self._json(404, {"error": "not found"}) + if self.headers.get("X-Fleetdash-Approve") != "1": + return self._json(403, {"error": "missing X-Fleetdash-Approve"}) + try: + n = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(min(n, 64 * 1024)).decode() or "{}") + except Exception: + return self._json(400, {"error": "bad body"}) + node = by_name.get(str(body.get("node") or "")) + run_id = str(body.get("run_id") or "") + if not node or not re.fullmatch(r"[0-9a-f]{6,64}", run_id): + return self._json(400, {"error": "unknown node or malformed run_id"}) + tok = _approve_token(node) + if not tok: + return self._json(403, {"error": "no approve credential provisioned for this node"}) + if not mirror_dir: + # --no-mirror: there is no local record to replay the claim from, and approving a claim we + # cannot read would be approving blind. Refuse rather than reconstruct from the feed. + return self._json(409, {"error": "approval requires the ledger mirror (--no-mirror is set)"}) + rec = _read_json(os.path.join(mirror_dir, _safe(node["name"]), "runs", _safe(run_id) + ".json"), None) + if not isinstance(rec, dict): + return self._json(404, {"error": "run not in mirror"}) + if rec.get("decision") != "push_back": + return self._json(409, {"error": f"run is {rec.get('decision')!r}, not push_back"}) + perks = _as_list(rec.get("needs_approve")) + if not perks: + return self._json(409, {"error": "no approve tokens on this run"}) + claim = {"skill": rec.get("skill"), "perk": rec.get("perk"), + "var_keys": _as_list(rec.get("var_keys")), "approve": perks} + try: + status, verdict = _post_json(node["url"].rstrip("/") + "/govern", claim, token=tok) + except Exception as e: + return self._json(502, {"error": f"node unreachable: {type(e).__name__}"}) + ok = str(verdict.get("decision", "")).lower() == "allow" + return self._json(200 if ok else 409, + {"ok": ok, "status": status, "decision": verdict.get("decision"), + "run_id": verdict.get("run_id"), "problems": verdict.get("problems") or [], + "approved": perks}) + def do_GET(self): up = urllib.parse.urlparse(self.path) if up.path.startswith("/embed/"): # the individual-monitor iframe + its proxy @@ -1438,7 +1604,9 @@ def do_GET(self): if parts[0] == "risk": # fleet-wide risk / approval queue results, feed, as_of = _fleet() risk = mark_superseded(feed, risk_summary(feed)) - return self._send(200, render_risk(feed, risk, refresh, as_of=as_of)) + return self._send(200, render_risk( + feed, risk, refresh, as_of=as_of, + can_approve=lambda n: bool(_approve_token(by_name.get(n) or {})))) if parts[0] == "accounting": # fleet CREDIT accounting (spend by actor) _, feed, as_of = _fleet() return self._send(200, render_accounting(feed, refresh, as_of=as_of)) @@ -1537,7 +1705,7 @@ def main(): nodes = load_nodes(a.config) mirror_dir = None if a.no_mirror else a.mirror_dir if a.serve: - serve(nodes, a.serve, a.refresh, mirror_dir, a.mirror_interval, a.bind) + serve(nodes, a.serve, a.refresh, mirror_dir, a.mirror_interval, a.bind, config_path=a.config) else: if mirror_dir: mirror_all(nodes, mirror_dir) # mirror once, then render the durable view diff --git a/tests/test_fleetdash_approve_e2e.py b/tests/test_fleetdash_approve_e2e.py new file mode 100644 index 0000000..7ae0340 --- /dev/null +++ b/tests/test_fleetdash_approve_e2e.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""End-to-end browser test for the fleetdash approve button (real Chromium, real JS, real HTTP). + +The unit tests around `render_risk` prove the button is *rendered* under the right conditions; they cannot +prove the integrated path works, because the interesting behaviour lives in the browser: the confirm() +gate, the custom CSRF header the fetch must set, and the claim govd actually receives. Tonight's recurring +failure mode was exactly this — something reporting success while achieving nothing — so this drives the +whole loop and asserts on what the SERVER received, not on what the page says. + +Hermetic: a stub govd stands in for the node (records the claim it is handed) and the ledger mirror is a +fixture on disk. Nothing here touches the live fleet. + + pip install playwright pytest && playwright install chromium + pytest tests/test_fleetdash_approve_e2e.py +""" +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +pytest.importorskip("playwright", reason="pip install playwright && playwright install chromium") +from playwright.sync_api import sync_playwright # noqa: E402 + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +NODE = "test-node" +RUN_PUSHBACK = "aa11bb22cc33dd44" +RUN_ALLOW = "ee55ff66aa77bb88" +APPROVE_TOKEN = "operator-secret-token" + + +def _free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _StubGovd(BaseHTTPRequestHandler): + """Minimal node: enough /monitor/state for the mirror sweep, and a /govern that records the claim.""" + received: list = [] + + def log_message(self, *a): + pass + + def _json(self, code, obj): + b = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(b))) + self.end_headers() + self.wfile.write(b) + + def do_GET(self): + if self.path.startswith("/monitor/state"): + return self._json(200, {"decisions": [], "decisions_page": {"pages": 1}, + "now": "2026-07-25T00:00:00Z"}) + if self.path.startswith("/health"): + return self._json(200, {"ok": True, "mode": "remote", "exec_mode": "delegated"}) + return self._json(404, {"error": "nope"}) + + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(n) or b"{}") + type(self).received.append({"path": self.path, "body": body, + "auth": self.headers.get("Authorization")}) + # what govd answers to a claim carrying the approve token + return self._json(200, {"decision": "allow", "run_id": "newly1234allowed", + "plan_sha": "p" * 64, "approved": body.get("approve") or []}) + + +@pytest.fixture() +def stub_govd(): + _StubGovd.received = [] + port = _free_port() + srv = HTTPServer(("127.0.0.1", port), _StubGovd) + threading.Thread(target=srv.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{port}", _StubGovd + srv.shutdown() + + +def _mirror(tmp_path, node_url): + """A ledger mirror fixture: one push_back awaiting approval, and one allow that must be refused.""" + base = tmp_path / "mirror" / NODE + (base / "runs").mkdir(parents=True) + pushback = {"run_id": RUN_PUSHBACK, "ts": "2026-07-25T19:34:43Z", "principal": "agent-1", + "skill": "hermes:toolgate", "perk": "exec", "decision": "push_back", "destructive": True, + "needs_approve": ["exec"], "approved": [], + "var_keys": ["TOOL", "ARGS_DIGEST", "TARGET"], "_node": NODE} + # An ALREADY-APPROVED run — the realistic `superseded` shape. It deliberately still carries + # needs_approve, so the push_back guard is the ONLY thing that can refuse it: with an empty + # needs_approve the later "no approve tokens" check would refuse it anyway and the test would pass + # for the wrong reason (verified by mutation — dropping the guard did not fail the suite). + allow = {"run_id": RUN_ALLOW, "ts": "2026-07-25T19:30:00Z", "principal": "agent-1", + # a DIFFERENT perk from the push_back: same tuple would make mark_superseded hide the + # push_back's button (a later approved run answered that claim), which is correct behaviour + # but would silently gut the happy-path test. + "skill": "hermes:toolgate", "perk": "write", "decision": "allow", "destructive": True, + "needs_approve": ["write"], "approved": ["write"], + "var_keys": ["TOOL", "ARGS_DIGEST", "TARGET"], "_node": NODE} + for r in (pushback, allow): + (base / "runs" / f"{r['run_id']}.json").write_text(json.dumps(r)) + (base / "index.json").write_text(json.dumps({RUN_PUSHBACK: pushback, RUN_ALLOW: allow})) + return str(tmp_path / "mirror") + + +def _fleetdash(tmp_path, node_url, mirror_dir, *, with_approver): + """Start a real fleetdash on a free loopback port. `with_approver` decides whether this node has an + operator credential at all — the whole point of the gate.""" + node = {"name": NODE, "role": "body", "url": node_url} + if with_approver: + tok = tmp_path / "approve.token" + tok.write_text(APPROVE_TOKEN) + node["approve_token_file"] = str(tok) + cfg = tmp_path / "fleet.json" + cfg.write_text(json.dumps({"nodes": [node]})) + port = _free_port() + p = subprocess.Popen( + [sys.executable, "-m", "infra.tool.fleetdash", "--config", str(cfg), "--serve", str(port), + "--bind", "127.0.0.1", "--mirror-dir", mirror_dir, "--mirror-interval", "3600"], + cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env={**os.environ, "PYTHONPATH": REPO}) + url = f"http://127.0.0.1:{port}" + for _ in range(100): # wait for bind + try: + import urllib.request + urllib.request.urlopen(url + "/risk", timeout=1).read() + break + except Exception: + time.sleep(0.1) + else: + p.kill() + pytest.fail("fleetdash did not start: " + (p.stdout.read().decode()[-2000:] if p.stdout else "")) + return p, url + + +@pytest.fixture() +def page_ctx(): + with sync_playwright() as pw: + browser = pw.chromium.launch() + yield browser + browser.close() + + +def test_no_button_without_operator_credential(tmp_path, stub_govd, page_ctx): + """A node with no approve credential must expose NO affordance — the read-only posture is the default.""" + node_url, _stub = stub_govd + proc, url = _fleetdash(tmp_path, node_url, _mirror(tmp_path, node_url), with_approver=False) + try: + page = page_ctx.new_page() + page.goto(url + "/risk") + assert page.locator("button.approve").count() == 0 + assert "exec" in page.content() # the row IS there — only the button is withheld + finally: + proc.kill() + + +def test_credential_gate_is_enforced_SERVER_side(tmp_path, stub_govd, page_ctx): + """Withholding the BUTTON is cosmetic — the route itself must refuse a node with no operator credential. + + Found by mutation: deleting the server-side `if not tok` check left the whole suite green, because the + only coverage was "the button is not rendered". Anyone can POST /approve directly, so the UI gate proves + nothing. This asserts the route refuses and that nothing reaches the node. + """ + node_url, stub = stub_govd + proc, url = _fleetdash(tmp_path, node_url, _mirror(tmp_path, node_url), with_approver=False) + try: + page = page_ctx.new_page() + page.goto(url + "/risk") + status = page.evaluate( + """async ([node, run]) => { + const r = await fetch('/approve', {method:'POST', + headers:{'Content-Type':'application/json','X-Fleetdash-Approve':'1'}, + body: JSON.stringify({node, run_id: run})}); + return r.status; + }""", [NODE, RUN_PUSHBACK]) + assert status == 403, "a node with no approve credential must refuse the route, not just hide the button" + assert stub.received == [], "no credential must mean no claim reaches the node" + finally: + proc.kill() + + +def test_approve_end_to_end(tmp_path, stub_govd, page_ctx): + """Click approve in a real browser and assert on what the NODE received — not on what the page claims.""" + node_url, stub = stub_govd + proc, url = _fleetdash(tmp_path, node_url, _mirror(tmp_path, node_url), with_approver=True) + try: + page = page_ctx.new_page() + page.goto(url + "/risk") + + btn = page.locator(f'button.approve[data-run="{RUN_PUSHBACK}"]') + assert btn.count() == 1, "approve button missing for the push_back run" + + # the confirm() gate is part of the guard — a click must not proceed without it + page.on("dialog", lambda d: d.accept()) + btn.click() + page.wait_for_function( + "() => !document.querySelector('button.approve[data-run=\"%s\"]')" + " || document.querySelector('button.approve[data-run=\"%s\"]').textContent.trim() === 'approved'" + % (RUN_PUSHBACK, RUN_PUSHBACK), timeout=8000) + + # THE assertion that matters: what did the node actually get? + assert len(stub.received) == 1, f"expected exactly one claim, got {stub.received}" + got = stub.received[0] + assert got["path"] == "/govern" + assert got["auth"] == f"Bearer {APPROVE_TOKEN}", "must use the OPERATOR credential, not a monitor token" + assert got["body"]["approve"] == ["exec"] + # the claim is REPLAYED from the mirrored record — the UI cannot widen it + assert got["body"]["skill"] == "hermes:toolgate" + assert got["body"]["perk"] == "exec" + assert got["body"]["var_keys"] == ["TOOL", "ARGS_DIGEST", "TARGET"] + finally: + proc.kill() + + +def test_csrf_header_required(tmp_path, stub_govd, page_ctx): + """A cross-origin page cannot set X-Fleetdash-Approve without a preflight we never answer. Simulate the + drive-by: POST from the browser WITHOUT the header must be refused, and must not reach the node.""" + node_url, stub = stub_govd + proc, url = _fleetdash(tmp_path, node_url, _mirror(tmp_path, node_url), with_approver=True) + try: + page = page_ctx.new_page() + page.goto(url + "/risk") + status = page.evaluate( + """async ([node, run]) => { + const r = await fetch('/approve', {method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({node, run_id: run})}); + return r.status; + }""", [NODE, RUN_PUSHBACK]) + assert status == 403 + assert stub.received == [], "a header-less POST must never reach the node" + finally: + proc.kill() + + +def test_cannot_approve_a_non_pushback(tmp_path, stub_govd, page_ctx): + """Approving an already-allowed run is refused — never approve something that was not pushed back.""" + node_url, stub = stub_govd + proc, url = _fleetdash(tmp_path, node_url, _mirror(tmp_path, node_url), with_approver=True) + try: + page = page_ctx.new_page() + page.goto(url + "/risk") + status = page.evaluate( + """async ([node, run]) => { + const r = await fetch('/approve', {method:'POST', + headers:{'Content-Type':'application/json','X-Fleetdash-Approve':'1'}, + body: JSON.stringify({node, run_id: run})}); + return r.status; + }""", [NODE, RUN_ALLOW]) + assert status == 409 + assert stub.received == [] + finally: + proc.kill() + + +def test_path_traversal_run_id_refused(tmp_path, stub_govd, page_ctx): + """run_id is matched against ^[0-9a-f]{6,64}$ before it ever reaches a filesystem join.""" + node_url, stub = stub_govd + proc, url = _fleetdash(tmp_path, node_url, _mirror(tmp_path, node_url), with_approver=True) + try: + page = page_ctx.new_page() + page.goto(url + "/risk") + status = page.evaluate( + """async (node) => { + const r = await fetch('/approve', {method:'POST', + headers:{'Content-Type':'application/json','X-Fleetdash-Approve':'1'}, + body: JSON.stringify({node, run_id: '../../etc/passwd'})}); + return r.status; + }""", NODE) + assert status == 400 + assert stub.received == [] + finally: + proc.kill()