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.
397 lines
16 KiB
Python
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
|