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.
This commit is contained in:
parent
5c6b71b83b
commit
5149946a4c
18 changed files with 1690 additions and 102 deletions
397
src/warden/desk.py
Normal file
397
src/warden/desk.py
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue