ops-warden/src/warden/desk.py
tegwick 5149946a4c
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
WARDEN-WP-0029: implement plan front door, org posture, desk, freshness
Ship posture-aware access planning: organization_posture=build (axis C),
catalog freshness warnings, warden plan verdicts, localhost founder desk,
and playbook/agent guidance that retire /tmp file-drop patterns.

Compose route catalog + handoff rather than a second routing layer.
2026-07-18 16:59:37 +02:00

397 lines
16 KiB
Python

"""Founder interaction surface — ``warden desk`` (WARDEN-WP-0029 T03).
Build-phase localhost page for the rare founder acts emitted by ``warden plan``:
approve/deny, OIDC login launch, paste-once provision into OpenBao.
Pattern: stdlib ``ThreadingHTTPServer`` on 127.0.0.1 only (see net-kingdom
security-bootstrap-console). No multi-user auth; OS session is trust boundary.
Never logs secret values.
"""
from __future__ import annotations
import html
import json
import secrets
import subprocess
import threading
import webbrowser
from dataclasses import dataclass, field as dc_field
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from urllib.parse import parse_qs, urlparse
# Acts the desk can render. Keep in sync with plan.FOUNDER_ACT_KINDS.
DESK_ACTS = ("approve", "oidc_login", "paste_once_provision")
class DeskError(Exception):
"""Raised when desk session setup fails."""
@dataclass
class DeskSession:
"""In-memory founder act session — metadata only (no secret values stored)."""
token: str
act: str
summary: str
lane_id: str = ""
path: str = ""
kv_field: str = "value"
oidc_command: str = ""
result: str = "pending" # pending | approved | denied | provisioned | launched | error
message: str = ""
extra: Dict[str, Any] = dc_field(default_factory=dict)
def new_session(
*,
act: str,
summary: str,
lane_id: str = "",
path: str = "",
kv_field: str = "value",
oidc_command: str = "",
extra: Optional[dict] = None,
) -> DeskSession:
if act not in DESK_ACTS:
raise DeskError(f"unknown desk act {act!r}; expected one of {DESK_ACTS}")
if act == "paste_once_provision" and not path:
raise DeskError("paste_once_provision requires --path (concrete OpenBao path)")
return DeskSession(
token=secrets.token_urlsafe(24),
act=act,
summary=summary,
lane_id=lane_id,
path=path,
kv_field=kv_field or "value",
oidc_command=oidc_command,
extra=dict(extra or {}),
)
def session_from_plan_dict(plan: dict) -> DeskSession:
"""Build a desk session from a ``warden plan --json`` payload."""
act_raw = plan.get("founder_act") or {}
if not act_raw or plan.get("verdict") != "founder_required":
raise DeskError(
"plan verdict is not founder_required or founder_act is missing — "
"nothing for the desk to render"
)
kind = str(act_raw.get("kind") or "")
details = act_raw.get("details") or {}
path = str(details.get("path_template") or details.get("path") or "")
if kind == "paste_once_provision" and ("<" in path or ">" in path):
raise DeskError(
f"path_template still has placeholders ({path!r}); pass a concrete "
"--path to warden desk"
)
return new_session(
act=kind,
summary=str(act_raw.get("summary") or plan.get("need") or "founder act"),
lane_id=str(details.get("lane_id") or plan.get("lane_id") or ""),
path=path if "<" not in path else "",
oidc_command=str(details.get("fetch_command") or ""),
extra={"need": plan.get("need"), "organization_posture": plan.get("organization_posture")},
)
def _page(title: str, body: str) -> bytes:
doc = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
:root {{ color-scheme: light dark; --ink: #1d2733; --muted: #536271;
--line: #d9e0e7; --paper: #f8fafc; --accent: #2066a8; --ok: #1a7f37; --bad: #b42318; }}
body {{ margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; color: var(--ink);
background: var(--paper); min-height: 100vh; display: grid; place-items: center; padding: 24px; }}
main {{ width: min(100%, 560px); background: #fff; border: 1px solid var(--line);
border-radius: 8px; padding: 28px 32px; box-shadow: 0 12px 32px rgba(29,39,51,.08); }}
h1 {{ margin: 0 0 8px; font-size: 1.35rem; }}
p, li {{ color: var(--muted); line-height: 1.55; }}
.eyebrow {{ text-transform: uppercase; font-size: .75rem; font-weight: 700;
color: var(--accent); letter-spacing: .04em; margin: 0 0 12px; }}
.box {{ background: var(--paper); border: 1px solid var(--line); border-radius: 6px;
padding: 12px 14px; margin: 16px 0; font-family: ui-monospace, monospace; font-size: .85rem;
word-break: break-all; }}
form {{ display: grid; gap: 12px; margin-top: 18px; }}
textarea {{ width: 100%; min-height: 96px; font-family: ui-monospace, monospace;
padding: 10px; border-radius: 6px; border: 1px solid var(--line); }}
.actions {{ display: flex; flex-wrap: wrap; gap: 10px; }}
button, .btn {{ appearance: none; border: 0; border-radius: 6px; padding: 10px 16px;
font-weight: 700; cursor: pointer; text-decoration: none; display: inline-flex;
align-items: center; background: var(--accent); color: #fff; }}
button.secondary, .btn.secondary {{ background: #e8eef4; color: var(--ink); }}
button.danger {{ background: var(--bad); }}
.ok {{ color: var(--ok); font-weight: 700; }}
.bad {{ color: var(--bad); font-weight: 700; }}
.note {{ font-size: .85rem; margin-top: 18px; }}
</style>
</head>
<body>
<main>
<p class="eyebrow">warden desk · build phase · localhost only</p>
{body}
</main>
</body>
</html>
"""
return doc.encode("utf-8")
def _render_home(session: DeskSession) -> bytes:
summary = html.escape(session.summary)
lane = html.escape(session.lane_id or "")
act = html.escape(session.act)
if session.result != "pending":
cls = "ok" if session.result in ("approved", "provisioned", "launched") else "bad"
return _page(
"Desk result",
f"<h1>Act complete</h1>"
f"<p class='{cls}'>Result: {html.escape(session.result)}</p>"
f"<p>{html.escape(session.message or '')}</p>"
f"<p class='note'>You can close this tab. Server will shut down shortly.</p>",
)
if session.act == "approve":
body = f"""
<h1>Founder approval</h1>
<p>{summary}</p>
<div class="box">lane: {lane}<br>act: {act}</div>
<form method="POST" action="/act">
<input type="hidden" name="token" value="{html.escape(session.token)}">
<div class="actions">
<button type="submit" name="decision" value="approve">Approve</button>
<button type="submit" name="decision" value="deny" class="danger">Deny</button>
</div>
</form>
<p class="note">Metadata-only — no secrets transit this form.</p>
"""
elif session.act == "oidc_login":
cmd = html.escape(session.oidc_command or "bao login -method=oidc")
body = f"""
<h1>OIDC / identity login</h1>
<p>{summary}</p>
<div class="box">{cmd}</div>
<p>Run the command in your own terminal (browser OIDC). ops-warden never
captures the token.</p>
<form method="POST" action="/act">
<input type="hidden" name="token" value="{html.escape(session.token)}">
<div class="actions">
<button type="submit" name="decision" value="launched">I completed login</button>
<button type="submit" name="decision" value="deny" class="secondary">Cancel</button>
</div>
</form>
"""
else: # paste_once_provision
path = html.escape(session.path)
kv_field = html.escape(session.kv_field)
body = f"""
<h1>Paste-once provision</h1>
<p>{summary}</p>
<div class="box">OpenBao path: {path}<br>field: {kv_field}<br>lane: {lane}</div>
<p>Paste the secret <strong>once</strong>. It is written to OpenBao via
<code>bao kv put</code> and never shown in the terminal or audit log.</p>
<form method="POST" action="/act" autocomplete="off">
<input type="hidden" name="token" value="{html.escape(session.token)}">
<label for="secret">Secret value</label>
<textarea id="secret" name="secret" required placeholder="paste value here"></textarea>
<div class="actions">
<button type="submit" name="decision" value="provision">Write to OpenBao</button>
<button type="submit" name="decision" value="deny" class="secondary">Cancel</button>
</div>
</form>
<p class="note">Build-phase desk: localhost only, OS session trust.</p>
"""
return _page("warden desk", body)
def _provision_to_openbao(path: str, field: str, value: str) -> None:
"""Write one field to OpenBao without putting the value on argv."""
# bao kv put path field=- reads value from stdin
proc = subprocess.run(
["bao", "kv", "put", path, f"{field}=-"],
input=value.encode("utf-8"),
capture_output=True,
timeout=60,
check=False,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or b"").decode("utf-8", errors="replace")
# scrub accidental value echo
if value and value in err:
err = err.replace(value, "<redacted>")
raise DeskError(f"bao kv put failed (exit {proc.returncode}): {err[:300]}")
def make_handler(
session: DeskSession,
*,
on_done: Optional[Callable[[DeskSession], None]] = None,
dry_run: bool = False,
) -> type:
"""Build a request handler class closed over *session*."""
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
# Avoid logging POST bodies / query secrets
line = f"[desk] {self.address_string()} {fmt % args}"
if session.token in line:
line = line.replace(session.token, "<token>")
print(line, flush=True)
def _deny(self, code: int = 404) -> None:
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"not found\n")
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
token = (qs.get("t") or [""])[0]
if parsed.path not in ("/", "/index.html") or token != session.token:
self._deny()
return
body = _render_home(session)
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path != "/act":
self._deny()
return
length = int(self.headers.get("Content-Length") or "0")
raw = self.rfile.read(length) if length else b""
form = parse_qs(raw.decode("utf-8", errors="replace"), keep_blank_values=True)
token = (form.get("token") or [""])[0]
if token != session.token:
self._deny(403)
return
decision = (form.get("decision") or [""])[0]
try:
if session.act == "approve":
if decision == "approve":
session.result = "approved"
session.message = "Approved (metadata only)."
else:
session.result = "denied"
session.message = "Denied."
elif session.act == "oidc_login":
if decision == "launched":
session.result = "launched"
session.message = "Operator confirmed OIDC login completed."
else:
session.result = "denied"
session.message = "Cancelled."
elif session.act == "paste_once_provision":
if decision == "deny":
session.result = "denied"
session.message = "Cancelled — nothing written."
else:
secret = (form.get("secret") or [""])[0]
if not secret:
raise DeskError("empty secret value")
if dry_run:
session.result = "provisioned"
session.message = (
f"dry-run: would write field {session.kv_field!r} "
f"to {session.path}"
)
else:
_provision_to_openbao(session.path, session.kv_field, secret)
session.result = "provisioned"
session.message = (
f"Wrote field {session.kv_field!r} to {session.path} "
"(value not logged)."
)
# drop reference promptly
secret = ""
form.pop("secret", None)
else:
raise DeskError(f"unhandled act {session.act}")
except DeskError as e:
session.result = "error"
session.message = str(e)
body = _render_home(session)
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if on_done and session.result != "pending":
on_done(session)
return Handler
def run_desk(
session: DeskSession,
*,
host: str = "127.0.0.1",
port: int = 0,
open_browser: bool = True,
dry_run: bool = False,
shutdown_after_done: bool = True,
) -> DeskSession:
"""Serve the desk until the act completes (or the process is interrupted).
Binds *host* (default loopback only). *port* 0 picks an ephemeral port.
"""
if host not in ("127.0.0.1", "localhost", "::1"):
raise DeskError(
f"desk refuses non-loopback bind {host!r} in build phase "
"(set host only for tests with 127.0.0.1)"
)
done = threading.Event()
def _on_done(_s: DeskSession) -> None:
if shutdown_after_done:
done.set()
handler = make_handler(session, on_done=_on_done, dry_run=dry_run)
server = ThreadingHTTPServer((host, port), handler)
bound_port = server.server_address[1]
url = f"http://{host}:{bound_port}/?t={session.token}"
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print(f"warden desk listening on {url}", flush=True)
print(f"act={session.act} lane={session.lane_id or ''} (token not for logs elsewhere)", flush=True)
if open_browser:
try:
webbrowser.open(url)
except Exception: # noqa: BLE001
pass
try:
done.wait()
except KeyboardInterrupt:
session.result = session.result if session.result != "pending" else "denied"
session.message = session.message or "interrupted"
finally:
server.shutdown()
thread.join(timeout=2)
return session
def load_plan_json(path: Path) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise DeskError("plan JSON must be an object")
return data