From c49f0f7ef8781e9524d76cff2de5624e60229798 Mon Sep 17 00:00:00 2001 From: Rui He Date: Sat, 25 Jul 2026 19:21:07 -0400 Subject: [PATCH 1/2] feat(fleetdash): approve button + POST /approve to answer a govd push_back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet monitor showed the approval queue and could not answer it: `needs_approve` was rendered as a value-free token list, and the only way to act was to re-submit the claim by hand. Meanwhile the agent-side human gate is unreachable in a caged deployment — every caller of resolve_gateway_approval() is a chat-platform adapter (Discord/Telegram/Feishu/Teams) or a TTY, and a caged agent can reach neither. Measured across the fleet: 45 push_backs on maria-dev-mac, 9 approvals, ALL from a test harness. The human gate has never once been answered by a person. This dashboard has NO app-auth and its monitor tokens are read-only by contract, so the write is gated deliberately, every branch fail-closed: - a SEPARATE credential — `approve_token_file` per node (or GOVD_APPROVE_TOKEN_), a principal token the operator provisions on purpose. The monitor token is never reused. Absent (the default) => no button is rendered AND the route 403s, so the read-only posture is unchanged; - CSRF — requires X-Fleetdash-Approve, a custom header a cross-origin page cannot set without a preflight this server never answers, so a hostile site cannot drive-by POST at 127.0.0.1; - never approve blind — the run must exist in the mirror and actually be a push_back, and the claim's own skill/perk/var_keys are REPLAYED from the mirrored record so the UI cannot widen a claim into something the agent never made; - --no-mirror refuses outright (no record to verify against), fixing a crash where mirror_dir was None; - the loopback-only default and its FLEETDASH_ALLOW_OPEN acknowledgement are untouched. HONEST LIMITS. This authorizes the CLAIM, not the tool call: the agent's gate fails closed in ~0.3s and is not resumed by a later approval, so pressing approve creates the audit record and the `superseded` marker without delivering the action. And the monitor token is one shared secret per node, not a person, so the chain still cannot name WHO approved. Both wait on per-person principals + an acl.approve axis; this is a stopgap that makes the ask answerable at all. Co-Authored-By: Claude Opus 5 (1M context) --- infra/tool/fleetdash.py | 182 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 175 insertions(+), 7 deletions(-) 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 From b920ec79300742cf1a4bd62eaacb3f8e4798d50c Mon Sep 17 00:00:00 2001 From: Rui He Date: Sat, 25 Jul 2026 19:21:07 -0400 Subject: [PATCH 2/2] test(e2e): mutation-verified Playwright suite for the approve path, + testing docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render-level checks prove the button APPEARS under the right conditions; they cannot prove the integrated path works, because the interesting behaviour is in the browser — the confirm() gate, the custom CSRF header the fetch must set, and the claim the node actually receives. This drives real Chromium against a real fleetdash and asserts on what the STUB NODE was handed, not on what the page says. Hermetic: own fleetdash on a free loopback port, a stub govd that records the claim it is given, and an on-disk mirror fixture in tmp_path. Never the live fleet; no credential outside the temp dir. Every guard is mutation-verified — each was individually disabled and the suite had to go red: CSRF header check ....... caught (test_csrf_header_required) push_back guard ......... caught (test_cannot_approve_a_non_pushback) run_id regex ............ caught (test_path_traversal_run_id_refused) button render gate ...... caught (test_no_button_without_operator_credential) credential gate ......... caught (test_credential_gate_is_enforced_SERVER_side) Two defects only mutation exposed, both of which would have shipped a green-but-hollow suite: 1. test_cannot_approve_a_non_pushback passed for the WRONG REASON — the fixture's allow-run had an empty needs_approve, so deleting the push_back guard still produced a 409 from the next check down. The fixture now carries approve tokens, making the guard the only thing that can refuse it. (Its perk also had to differ from the push_back's: the same skill/perk tuple makes mark_superseded hide the push_back's button — correct behaviour that would have silently gutted the happy-path test.) 2. There was NO server-side coverage of the credential gate — only "the button is not rendered". Withholding a button is cosmetic; anyone can POST /approve directly. That check holds the entire read-only posture and was untested. README documents the setup, because two INDEPENDENT steps are needed (`pip install playwright`, then `playwright install chromium`) and the failure when only the first has run — "Executable doesn't exist" — does not say so. Records the shared browser cache paths (outside the venv), the env overrides, that the suite importorskips (so `pytest tests` and CI are unaffected — these currently SKIP in CI), and the mutation discipline plus its two traps: a str.replace(pattern, repl, 1) mutation can patch an EARLIER identical line and wrongly report a test gap (mutate by line number, assert the line's content), and a fixture change can neuter an unrelated test (re-run the whole file). Both bit during this work. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 42 +++++ tests/test_fleetdash_approve_e2e.py | 280 ++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 tests/test_fleetdash_approve_e2e.py 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/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()