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
|
|
@ -365,6 +365,16 @@ def scorecard(
|
|||
status_str = "[green]PASS[/green]" if r.passed else "[red]FAIL[/red]"
|
||||
table.add_row(r.name, status_str, r.detail)
|
||||
console.print(table)
|
||||
# Always surface org posture in human scorecard (WP-0029 T02)
|
||||
try:
|
||||
from warden.posture import load_posture
|
||||
|
||||
org = load_posture().organization_posture
|
||||
console.print(
|
||||
f"\n[dim]organization_posture:[/dim] [bold]{org.id}[/bold] — {org.summary[:160]}"
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
console.print(
|
||||
f"\nScore: {passed}/{total} "
|
||||
+ ("[green]Operational[/green]" if passed == total else "[yellow]Needs attention[/yellow]")
|
||||
|
|
@ -718,7 +728,11 @@ def route_list(
|
|||
t = tag.lower()
|
||||
entries = [e for e in entries if t in [k.lower() for k in e.need_keywords]]
|
||||
|
||||
freshness = catalog.freshness(stale_threshold_days=stale_days)
|
||||
|
||||
if output_json:
|
||||
# Stable array of entries for agents. Freshness lives on human output +
|
||||
# `warden plan --json` (`catalog` field); avoid breaking list parsers.
|
||||
payload = []
|
||||
for e in entries:
|
||||
row = _entry_summary(e)
|
||||
|
|
@ -729,6 +743,16 @@ def route_list(
|
|||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
# Human path: always show catalog freshness (WP-0029 T05)
|
||||
console.print(
|
||||
f"[dim]catalog[/dim] source={freshness.source} "
|
||||
f"hash={freshness.content_hash} "
|
||||
f"reviewed={freshness.newest_reviewed or '—'} "
|
||||
f"entries={freshness.active_count}/{freshness.entry_count}"
|
||||
)
|
||||
for w in freshness.warnings:
|
||||
console.print(f"[yellow]catalog warning:[/yellow] {w}")
|
||||
|
||||
if not entries:
|
||||
if stale_only:
|
||||
console.print(f"No stale routing entries (threshold: {stale_days} days since reviewed).")
|
||||
|
|
@ -1414,14 +1438,16 @@ def _load_posture():
|
|||
def policy_list(
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||
) -> None:
|
||||
"""List both posture axes: environment postures and workload maturity levels."""
|
||||
"""List posture axes: env, maturity, and organization lifecycle (WP-0029)."""
|
||||
cat = _load_posture()
|
||||
org = cat.organization_posture
|
||||
if output_json:
|
||||
print(json.dumps({
|
||||
"env_postures": [vars(e) for e in cat.env_postures],
|
||||
"maturity_levels": [vars(m) for m in cat.maturity_levels],
|
||||
"dataclass_floor": cat.dataclass_floor,
|
||||
"requires_env_posture": cat.requires_env_posture,
|
||||
"organization_posture": vars(org),
|
||||
}, indent=2))
|
||||
return
|
||||
|
||||
|
|
@ -1438,6 +1464,19 @@ def policy_list(
|
|||
for m in sorted(cat.maturity_levels, key=lambda x: x.rank):
|
||||
mat_table.add_row(m.id, str(m.rank), m.phase, m.max_dataclass, ", ".join(m.promotion_gate) or "—")
|
||||
console.print(mat_table)
|
||||
|
||||
org_table = Table(title="Axis C — organization lifecycle posture (WP-0029)")
|
||||
org_table.add_column("ID")
|
||||
org_table.add_column("Summary")
|
||||
org_table.add_column("Relaxations")
|
||||
org_table.add_column("Graduation triggers")
|
||||
org_table.add_row(
|
||||
org.id,
|
||||
org.summary[:80] + ("…" if len(org.summary) > 80 else ""),
|
||||
", ".join(org.relaxations) or "—",
|
||||
", ".join(org.graduation_triggers) or "—",
|
||||
)
|
||||
console.print(org_table)
|
||||
console.print(
|
||||
f"\n[dim]lattice: deliver iff env=={cat.requires_env_posture} and "
|
||||
"workload.maturity >= secret.required_maturity (and the dataclass floor).[/dim]"
|
||||
|
|
@ -1446,19 +1485,39 @@ def policy_list(
|
|||
|
||||
@policy_app.command("show")
|
||||
def policy_show(
|
||||
descriptor_id: Annotated[str, typer.Argument(help="An env posture (dev/test/prod) or maturity level (M0–M3)")],
|
||||
descriptor_id: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help="Env posture (dev/test/prod), maturity (M0–M3), or organization posture id (build)"
|
||||
),
|
||||
],
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||
) -> None:
|
||||
"""Show one environment posture or maturity level."""
|
||||
"""Show one environment posture, maturity level, or organization posture."""
|
||||
cat = _load_posture()
|
||||
env = cat.env(descriptor_id)
|
||||
mat = cat.maturity(descriptor_id)
|
||||
if env is None and mat is None:
|
||||
org = cat.organization_posture if cat.organization_posture.id == descriptor_id else None
|
||||
# Alias: `organization` always shows axis C
|
||||
if descriptor_id in ("organization", "organization_posture", "org"):
|
||||
org = cat.organization_posture
|
||||
if env is None and mat is None and org is None:
|
||||
err.print(
|
||||
f"[red]Unknown descriptor {descriptor_id!r}.[/red] "
|
||||
"Try `warden policy list`."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if org is not None and env is None and mat is None:
|
||||
if output_json:
|
||||
print(json.dumps({"axis": "organization_posture", **vars(org)}, indent=2))
|
||||
return
|
||||
console.print(f"[bold]{org.id}[/bold] ([cyan]organization lifecycle posture[/cyan])")
|
||||
console.print(f" {'summary':14}: {org.summary}")
|
||||
console.print(f" {'relaxations':14}: {', '.join(org.relaxations) or '—'}")
|
||||
console.print(
|
||||
f" {'graduation':14}: {', '.join(org.graduation_triggers) or '—'}"
|
||||
)
|
||||
return
|
||||
obj = env or mat
|
||||
if output_json:
|
||||
print(json.dumps({"axis": "env_posture" if env else "maturity_level", **vars(obj)}, indent=2))
|
||||
|
|
@ -1475,6 +1534,259 @@ def policy_show(
|
|||
console.print(f" {'dataclass floor':14}: {', '.join(floor)} require this level")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# warden plan — policy decision front door (WARDEN-WP-0029 T01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.command("plan")
|
||||
def plan_cmd(
|
||||
need: Annotated[str, typer.Argument(help="Free-text credential / access need")],
|
||||
actor: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--actor", help="Optional actor id for audit context (e.g. agt-...)"),
|
||||
] = None,
|
||||
domain: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--domain", help="Optional domain substitution for path templates"),
|
||||
] = None,
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Machine-readable plan")] = False,
|
||||
include_draft: Annotated[
|
||||
bool, typer.Option("--all", help="Include draft catalog lanes in matching")
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Policy decision front door: autonomous / founder_required / unroutable.
|
||||
|
||||
Composes the routing catalog + access handoff + organization posture. Never
|
||||
holds secret values. Agents must call this before drafting founder credential steps.
|
||||
"""
|
||||
from warden.plan import build_plan
|
||||
|
||||
access_plan = build_plan(
|
||||
need, actor=actor, domain=domain, include_draft=include_draft
|
||||
)
|
||||
|
||||
# Metadata-only audit
|
||||
try:
|
||||
cfg = _load_cfg()
|
||||
from warden.audit import record_event
|
||||
|
||||
record_event(
|
||||
cfg.state_dir,
|
||||
kind="plan",
|
||||
action="plan",
|
||||
subject=actor or "",
|
||||
target=access_plan.lane_id or "",
|
||||
outcome=access_plan.verdict,
|
||||
need=need[:200],
|
||||
organization_posture=access_plan.organization_posture,
|
||||
lane_id=access_plan.lane_id or "",
|
||||
)
|
||||
except Exception: # noqa: BLE001 — audit must never block plan
|
||||
pass
|
||||
|
||||
_record_memory_episode(
|
||||
command="plan",
|
||||
outcome=access_plan.verdict,
|
||||
need=need,
|
||||
route_id=access_plan.lane_id or "",
|
||||
)
|
||||
|
||||
if output_json:
|
||||
print(json.dumps(access_plan.to_dict(), indent=2))
|
||||
return
|
||||
|
||||
verdict_style = {
|
||||
"autonomous": "[green]autonomous[/green]",
|
||||
"founder_required": "[yellow]founder_required[/yellow]",
|
||||
"unroutable": "[red]unroutable[/red]",
|
||||
}.get(access_plan.verdict, access_plan.verdict)
|
||||
|
||||
console.print(f"[bold]verdict[/bold]: {verdict_style}")
|
||||
console.print(f"[bold]need[/bold]: {access_plan.need}")
|
||||
console.print(
|
||||
f"[bold]posture[/bold]: [cyan]{access_plan.organization_posture}[/cyan] "
|
||||
f"policy_gate={access_plan.policy_gate}"
|
||||
)
|
||||
if access_plan.lane_id:
|
||||
console.print(
|
||||
f"[bold]lane[/bold]: {access_plan.lane_id} — {access_plan.lane_title or ''}"
|
||||
)
|
||||
for reason in access_plan.reasons:
|
||||
console.print(f"[dim]reason:[/dim] {reason}")
|
||||
if access_plan.commands:
|
||||
console.print("\n[bold]commands[/bold]")
|
||||
for c in access_plan.commands:
|
||||
console.print(f" {c}")
|
||||
if access_plan.founder_act:
|
||||
fa = access_plan.founder_act
|
||||
console.print("\n[bold]founder act[/bold]")
|
||||
console.print(f" kind: {fa.kind}")
|
||||
console.print(f" summary: {fa.summary}")
|
||||
for k, v in fa.details.items():
|
||||
console.print(f" {k}: {v}")
|
||||
console.print(
|
||||
"\n[dim]Open the act surface:[/dim] "
|
||||
f"warden desk --act {fa.kind}"
|
||||
+ (f" --lane {access_plan.lane_id}" if access_plan.lane_id else "")
|
||||
)
|
||||
if access_plan.ccr_stub:
|
||||
console.print("\n[bold]CCR stub[/bold] (unroutable — propose a lane)")
|
||||
console.print(f" {access_plan.ccr_stub.get('title')}")
|
||||
for step in access_plan.ccr_stub.get("steps") or []:
|
||||
console.print(f" - {step}")
|
||||
cat = access_plan.catalog
|
||||
if cat:
|
||||
console.print(
|
||||
f"\n[dim]catalog source={cat.get('source')} hash={cat.get('content_hash')} "
|
||||
f"bundled={cat.get('using_bundled')}[/dim]"
|
||||
)
|
||||
for w in cat.get("warnings") or []:
|
||||
console.print(f"[yellow]catalog warning:[/yellow] {w}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# warden desk — founder interaction surface (WARDEN-WP-0029 T03)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.command("desk")
|
||||
def desk_cmd(
|
||||
act: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--act", help="Founder act: approve | oidc_login | paste_once_provision"),
|
||||
] = None,
|
||||
summary: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--summary", help="Plain-language description of the act"),
|
||||
] = None,
|
||||
lane: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--lane", help="Catalog lane id for context"),
|
||||
] = None,
|
||||
path: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--path", help="Concrete OpenBao path (paste_once_provision)"),
|
||||
] = None,
|
||||
field: Annotated[
|
||||
str,
|
||||
typer.Option("--field", help="KV field name for paste_once_provision"),
|
||||
] = "value",
|
||||
oidc_command: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--oidc-command", help="Login command to display (oidc_login)"),
|
||||
] = None,
|
||||
plan_json: Annotated[
|
||||
Optional[Path],
|
||||
typer.Option("--plan-json", help="Path to warden plan --json output"),
|
||||
] = None,
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option("--port", help="Port (0 = ephemeral)"),
|
||||
] = 0,
|
||||
no_browser: Annotated[
|
||||
bool,
|
||||
typer.Option("--no-browser", help="Do not open a browser"),
|
||||
] = False,
|
||||
dry_run: Annotated[
|
||||
bool,
|
||||
typer.Option("--dry-run", help="Do not call bao; simulate paste-once write"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Localhost founder interaction surface (build-phase: OS session trust).
|
||||
|
||||
Serves a short-lived page on 127.0.0.1 for approve / OIDC confirm / paste-once
|
||||
provision into OpenBao. Secret values never appear in audit or CLI history.
|
||||
"""
|
||||
from warden.desk import (
|
||||
DeskError,
|
||||
load_plan_json,
|
||||
new_session,
|
||||
run_desk,
|
||||
session_from_plan_dict,
|
||||
)
|
||||
|
||||
try:
|
||||
if plan_json is not None:
|
||||
session = session_from_plan_dict(load_plan_json(plan_json))
|
||||
# Allow CLI overrides on top of plan
|
||||
if path:
|
||||
session.path = path
|
||||
if field:
|
||||
session.kv_field = field
|
||||
else:
|
||||
if not act:
|
||||
err.print(
|
||||
"[red]desk requires --act or --plan-json.[/red] "
|
||||
"Example: warden desk --act approve --summary 'enable policy'"
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
session = new_session(
|
||||
act=act,
|
||||
summary=summary or act,
|
||||
lane_id=lane or "",
|
||||
path=path or "",
|
||||
kv_field=field,
|
||||
oidc_command=oidc_command or "",
|
||||
)
|
||||
except DeskError as e:
|
||||
err.print(f"[red]desk error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
cfg = _load_cfg()
|
||||
from warden.audit import record_event
|
||||
|
||||
record_event(
|
||||
cfg.state_dir,
|
||||
kind="desk",
|
||||
action="desk_open",
|
||||
subject="",
|
||||
target=session.lane_id or session.act,
|
||||
outcome="open",
|
||||
act=session.act,
|
||||
lane_id=session.lane_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
cfg = None
|
||||
|
||||
try:
|
||||
finished = run_desk(
|
||||
session,
|
||||
port=port,
|
||||
open_browser=not no_browser,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
except DeskError as e:
|
||||
err.print(f"[red]desk error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if cfg is not None:
|
||||
try:
|
||||
from warden.audit import record_event
|
||||
|
||||
record_event(
|
||||
cfg.state_dir,
|
||||
kind="desk",
|
||||
action="desk_close",
|
||||
subject="",
|
||||
target=finished.lane_id or finished.act,
|
||||
outcome=finished.result,
|
||||
act=finished.act,
|
||||
lane_id=finished.lane_id,
|
||||
# message is metadata-only by construction for approve/login;
|
||||
# for provision it must not include the secret (desk never puts it there)
|
||||
detail=finished.message[:200] if finished.message else "",
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
console.print(
|
||||
f"[bold]desk result:[/bold] {finished.result}"
|
||||
+ (f" — {finished.message}" if finished.message else "")
|
||||
)
|
||||
if finished.result in ("denied", "error"):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# warden worker — autonomous coordination worker (WP-0020 T1: dry-run scaffold)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
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
|
||||
406
src/warden/plan.py
Normal file
406
src/warden/plan.py
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
"""Policy decision front door — ``warden plan`` (WARDEN-WP-0029 T01).
|
||||
|
||||
Composes the routing catalog, access handoff expansion, organization posture,
|
||||
and flex-auth gate status into a typed verdict. Never holds secret values.
|
||||
Does not re-implement keyword matching — delegates to ``Catalog.find``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from warden.access import expand_handoff, policy_gate_status
|
||||
from warden.posture import PostureCatalog, load_posture
|
||||
from warden.routing.catalog import Catalog, load_catalog
|
||||
from warden.routing.models import RouteEntry
|
||||
|
||||
VERDICTS = ("autonomous", "founder_required", "unroutable")
|
||||
FOUNDER_ACT_KINDS = ("oidc_login", "approve", "paste_once_provision")
|
||||
|
||||
_PROVISION_SIGNS = re.compile(
|
||||
r"\b(provision|mint|onboard|paste|first[- ]time|rotate\s+into|put\s+into\s+openbao)\b"
|
||||
r"|\bnew\b.{0,40}\b(secret|token|pat|key|credential)\b"
|
||||
r"|\bstore\s+(?:the\s+)?(?:pat|token|key|secret)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_APPROVAL_SIGNS = re.compile(
|
||||
r"\b(approv|red[- ]lane|ccr|policy\s+enable|prod\s+flip|break[- ]glass)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FounderAct:
|
||||
kind: str # oidc_login | approve | paste_once_provision
|
||||
summary: str
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"kind": self.kind, "summary": self.summary, "details": dict(self.details)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessPlan:
|
||||
need: str
|
||||
verdict: str
|
||||
organization_posture: str
|
||||
policy_gate: str
|
||||
lane_id: Optional[str] = None
|
||||
lane_title: Optional[str] = None
|
||||
match_score: Optional[int] = None
|
||||
commands: List[str] = field(default_factory=list)
|
||||
founder_act: Optional[FounderAct] = None
|
||||
ccr_stub: Optional[dict] = None
|
||||
catalog: dict = field(default_factory=dict)
|
||||
candidates: List[dict] = field(default_factory=list)
|
||||
reasons: List[str] = field(default_factory=list)
|
||||
actor: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"need": self.need,
|
||||
"verdict": self.verdict,
|
||||
"organization_posture": self.organization_posture,
|
||||
"policy_gate": self.policy_gate,
|
||||
"lane_id": self.lane_id,
|
||||
"lane_title": self.lane_title,
|
||||
"match_score": self.match_score,
|
||||
"commands": list(self.commands),
|
||||
"founder_act": self.founder_act.to_dict() if self.founder_act else None,
|
||||
"ccr_stub": self.ccr_stub,
|
||||
"catalog": dict(self.catalog),
|
||||
"candidates": list(self.candidates),
|
||||
"reasons": list(self.reasons),
|
||||
"actor": self.actor,
|
||||
"domain": self.domain,
|
||||
}
|
||||
|
||||
|
||||
def _org_posture_id(posture: Optional[PostureCatalog]) -> str:
|
||||
if posture is None:
|
||||
return "unknown"
|
||||
return posture.organization_posture.id
|
||||
|
||||
|
||||
def _candidate_row(entry: RouteEntry, score: int) -> dict:
|
||||
return {
|
||||
"id": entry.id,
|
||||
"title": entry.title,
|
||||
"score": score,
|
||||
"status": entry.status,
|
||||
"resolvable": entry.resolvable,
|
||||
"exec_capable": entry.exec_capable,
|
||||
"warden_executes": entry.warden_executes,
|
||||
"lane": entry.lane,
|
||||
"risk": entry.risk,
|
||||
}
|
||||
|
||||
|
||||
def _score_for(catalog: Catalog, entry: RouteEntry, need: str) -> int:
|
||||
if entry.id == need.strip():
|
||||
return 100
|
||||
tokens = [t for t in need.lower().replace("-", " ").split() if t]
|
||||
return entry.match_score(tokens)
|
||||
|
||||
|
||||
def _concrete(value: Optional[str]) -> bool:
|
||||
"""True when a template has no ``<...>`` placeholders left."""
|
||||
if not value:
|
||||
return False
|
||||
return "<" not in value and ">" not in value
|
||||
|
||||
|
||||
def _lane_is_autonomous(entry: RouteEntry) -> bool:
|
||||
"""Whether an agent can proceed without a founder act for this lane."""
|
||||
if entry.warden_executes:
|
||||
return True
|
||||
if entry.lane == "login":
|
||||
return False
|
||||
if entry.resolvable:
|
||||
return True
|
||||
if entry.has_native_exec and _concrete(entry.exec_command):
|
||||
return True
|
||||
# Concrete owner fetch path (even if not exec_capable) — value already in custody
|
||||
if _concrete(entry.fetch_command):
|
||||
return True
|
||||
# Pure pointer — follow wiki, no secret mechanics for founder
|
||||
if not entry.has_handoff and not entry.exec_capable and not entry.has_native_exec:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _autonomous_commands(entry: RouteEntry, domain: Optional[str]) -> List[str]:
|
||||
cmds: List[str] = []
|
||||
if entry.warden_executes:
|
||||
if entry.cert_command:
|
||||
cmds.append(entry.cert_command)
|
||||
for step in entry.steps[:4]:
|
||||
cmds.append(f"# {step}")
|
||||
return cmds
|
||||
|
||||
expanded = expand_handoff(entry, domain=domain)
|
||||
if entry.has_native_exec and entry.exec_command:
|
||||
cmds.append(entry.exec_command)
|
||||
if entry.pointer_command:
|
||||
cmds.append(entry.pointer_command)
|
||||
if entry.exec_capable:
|
||||
base = f"warden access {entry.id}"
|
||||
if domain:
|
||||
base += f" --domain {domain}"
|
||||
if entry.is_high_risk:
|
||||
cmds.append(f"{base} --exec -- <cmd> # high-risk: no raw stdout")
|
||||
cmds.append(f"{base} --out FILE")
|
||||
cmds.append(f"{base} --wrap")
|
||||
else:
|
||||
cmds.append(f"{base} --fetch")
|
||||
cmds.append(f"{base} --exec -- <cmd>")
|
||||
if expanded.fetch_command:
|
||||
cmds.append(f"# owner fetch (as you): {expanded.fetch_command}")
|
||||
elif _concrete(expanded.fetch_command or entry.fetch_command):
|
||||
cmds.append(expanded.fetch_command or entry.fetch_command or "")
|
||||
if entry.wiki_ref:
|
||||
cmds.append(f"# playbook: {entry.wiki_ref}")
|
||||
elif entry.wiki_ref:
|
||||
cmds.append(f"# follow owner playbook: {entry.wiki_ref}")
|
||||
return [c for c in cmds if c]
|
||||
|
||||
|
||||
def _founder_for_entry(entry: RouteEntry, need: str, domain: Optional[str]) -> FounderAct:
|
||||
expanded = expand_handoff(entry, domain=domain)
|
||||
if entry.lane == "login":
|
||||
return FounderAct(
|
||||
kind="oidc_login",
|
||||
summary=f"Interactive OIDC/MFA login via {entry.owner_repo}",
|
||||
details={
|
||||
"lane_id": entry.id,
|
||||
"auth_method": expanded.auth_method,
|
||||
"fetch_command": expanded.fetch_command,
|
||||
"desk_hint": f"warden desk --from-plan (act=oidc_login) or: {expanded.fetch_command}",
|
||||
},
|
||||
)
|
||||
if _APPROVAL_SIGNS.search(need):
|
||||
return FounderAct(
|
||||
kind="approve",
|
||||
summary=f"Founder approval required for {entry.id}",
|
||||
details={
|
||||
"lane_id": entry.id,
|
||||
"wiki_ref": entry.wiki_ref,
|
||||
"desk_hint": "warden desk --act approve --lane " + entry.id,
|
||||
},
|
||||
)
|
||||
# Default founder path for non-resolvable secret lanes: paste-once provision
|
||||
path = expanded.path_template or entry.path_template or "<openbao-path>"
|
||||
return FounderAct(
|
||||
kind="paste_once_provision",
|
||||
summary=(
|
||||
f"Provision secret value once into OpenBao path for {entry.id} "
|
||||
"(no CLI paste; use warden desk)"
|
||||
),
|
||||
details={
|
||||
"lane_id": entry.id,
|
||||
"path_template": path,
|
||||
"auth_method": expanded.auth_method,
|
||||
"desk_hint": (
|
||||
f"warden desk --act paste_once_provision --lane {entry.id}"
|
||||
+ (f" --path {path}" if "<" not in (path or "") else "")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ccr_stub(need: str) -> dict:
|
||||
return {
|
||||
"title": f"CCR: new credential lane for {need[:80]}",
|
||||
"status": "proposed",
|
||||
"owner_hint": "railiance-platform (OpenBao) or owning subsystem",
|
||||
"steps": [
|
||||
"Draft CCR with path, policy, OIDC role, consumers",
|
||||
"Add ops-warden catalog entry (pointers only; no secret values)",
|
||||
"Playbook under wiki/playbooks/; promote status active when live",
|
||||
],
|
||||
"commands": [
|
||||
"warden route list --all",
|
||||
"# after CCR: edit registry/routing/catalog.yaml + playbook",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_plan(
|
||||
need: str,
|
||||
*,
|
||||
actor: Optional[str] = None,
|
||||
domain: Optional[str] = None,
|
||||
catalog: Optional[Catalog] = None,
|
||||
posture: Optional[PostureCatalog] = None,
|
||||
include_draft: bool = False,
|
||||
) -> AccessPlan:
|
||||
"""Resolve *need* to a typed access plan. Pure of secret values."""
|
||||
cat = catalog or load_catalog()
|
||||
try:
|
||||
post = posture if posture is not None else load_posture()
|
||||
except Exception: # noqa: BLE001 — plan still works without posture file
|
||||
post = None
|
||||
|
||||
gate = policy_gate_status()
|
||||
org = _org_posture_id(post)
|
||||
freshness = cat.freshness().to_dict()
|
||||
|
||||
raw_matches = cat.find(need, include_draft=include_draft, limit=8)
|
||||
# Require score >= 2 (at least one full keyword hit). Score-1 hits are usually
|
||||
# accidental substring overlaps (e.g. title word "or" inside an unrelated token).
|
||||
scored = [(e, _score_for(cat, e, need)) for e in raw_matches]
|
||||
matches = [(e, s) for e, s in scored if s >= 2]
|
||||
candidates = [_candidate_row(e, s) for e, s in scored[:5]]
|
||||
|
||||
if not matches:
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="unroutable",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
ccr_stub=_ccr_stub(need),
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=["no catalog match for need (score < 2)"],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
entry, score = matches[0]
|
||||
|
||||
# Draft-only top match without active alternatives → unroutable
|
||||
if entry.status == "draft" and not include_draft:
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="unroutable",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
lane_id=entry.id,
|
||||
lane_title=entry.title,
|
||||
match_score=score,
|
||||
ccr_stub=_ccr_stub(need),
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=[f"top match {entry.id!r} is draft — promote or request CCR"],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
# Login lanes always need a human identity act
|
||||
if entry.lane == "login":
|
||||
act = _founder_for_entry(entry, need, domain)
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="founder_required",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
lane_id=entry.id,
|
||||
lane_title=entry.title,
|
||||
match_score=score,
|
||||
commands=_autonomous_commands(entry, domain),
|
||||
founder_act=act,
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=["login lane requires interactive founder/operator identity act"],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
# Explicit approval language
|
||||
if _APPROVAL_SIGNS.search(need) and not entry.warden_executes:
|
||||
act = _founder_for_entry(entry, need, domain)
|
||||
act.kind = "approve"
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="founder_required",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
lane_id=entry.id,
|
||||
lane_title=entry.title,
|
||||
match_score=score,
|
||||
founder_act=act,
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=["need text requests founder approval"],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
# Explicit first-time provision language wins even if a concrete lane matched
|
||||
if _PROVISION_SIGNS.search(need) and entry.lane == "secret" and not entry.warden_executes:
|
||||
act = _founder_for_entry(entry, need, domain)
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="founder_required",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
lane_id=entry.id,
|
||||
lane_title=entry.title,
|
||||
match_score=score,
|
||||
commands=[],
|
||||
founder_act=act,
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=["need requires first-time provision — one founder act via warden desk"],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
if _lane_is_autonomous(entry):
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="autonomous",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
lane_id=entry.id,
|
||||
lane_title=entry.title,
|
||||
match_score=score,
|
||||
commands=_autonomous_commands(entry, domain),
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=["lane is usable under current catalog without founder mechanics"],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
# Template / non-concrete secret handoff → founder paste-once
|
||||
if entry.lane == "secret":
|
||||
act = _founder_for_entry(entry, need, domain)
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="founder_required",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
lane_id=entry.id,
|
||||
lane_title=entry.title,
|
||||
match_score=score,
|
||||
commands=[],
|
||||
founder_act=act,
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=[
|
||||
"lane handoff still has placeholders or needs provision — "
|
||||
"one founder act via warden desk"
|
||||
],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
# Fallback: autonomous with best-effort commands
|
||||
return AccessPlan(
|
||||
need=need,
|
||||
verdict="autonomous",
|
||||
organization_posture=org,
|
||||
policy_gate=gate,
|
||||
lane_id=entry.id,
|
||||
lane_title=entry.title,
|
||||
match_score=score,
|
||||
commands=_autonomous_commands(entry, domain),
|
||||
catalog=freshness,
|
||||
candidates=candidates,
|
||||
reasons=["matched lane; proceed via catalog handoff"],
|
||||
actor=actor,
|
||||
domain=domain,
|
||||
)
|
||||
|
|
@ -42,6 +42,16 @@ class MaturityLevel:
|
|||
promotion_gate: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrganizationPosture:
|
||||
"""Fleet lifecycle posture (WARDEN-WP-0029) — third axis, not env/maturity."""
|
||||
|
||||
id: str
|
||||
summary: str
|
||||
relaxations: List[str]
|
||||
graduation_triggers: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostureCatalog:
|
||||
path: Path
|
||||
|
|
@ -49,6 +59,7 @@ class PostureCatalog:
|
|||
maturity_levels: List[MaturityLevel]
|
||||
dataclass_floor: Dict[str, str] # dataclass -> maturity id
|
||||
requires_env_posture: str # lattice: posture a secret fetch requires
|
||||
organization_posture: OrganizationPosture
|
||||
|
||||
# --- lookups ----------------------------------------------------------
|
||||
def env(self, env_id: str) -> Optional[EnvPosture]:
|
||||
|
|
@ -184,10 +195,24 @@ def load_posture(path: Optional[Path] = None) -> PostureCatalog:
|
|||
if not any(e.id == requires_env for e in env_postures):
|
||||
raise PostureError(f"lattice requires_env_posture {requires_env!r} is not an env posture")
|
||||
|
||||
org_raw = raw.get("organization_posture") or {}
|
||||
if not isinstance(org_raw, dict) or not org_raw.get("id"):
|
||||
raise PostureError(
|
||||
"posture descriptors need organization_posture with at least an id "
|
||||
"(WARDEN-WP-0029 third axis)"
|
||||
)
|
||||
organization_posture = OrganizationPosture(
|
||||
id=str(org_raw["id"]),
|
||||
summary=str(org_raw.get("summary") or "").strip(),
|
||||
relaxations=[str(x) for x in (org_raw.get("relaxations") or [])],
|
||||
graduation_triggers=[str(x) for x in (org_raw.get("graduation_triggers") or [])],
|
||||
)
|
||||
|
||||
return PostureCatalog(
|
||||
path=posture_path,
|
||||
env_postures=env_postures,
|
||||
maturity_levels=maturity_levels,
|
||||
dataclass_floor=dataclass_floor,
|
||||
requires_env_posture=requires_env,
|
||||
organization_posture=organization_posture,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ subsystem. It loads the machine-readable routing catalog and answers "who owns
|
|||
this need and where is the authoritative doc". The one lane ops-warden executes
|
||||
(SSH certificate issuance) is the only entry that carries authored steps.
|
||||
"""
|
||||
from warden.routing.catalog import Catalog, CatalogError, find_catalog_path, load_catalog
|
||||
from warden.routing.catalog import (
|
||||
Catalog,
|
||||
CatalogError,
|
||||
CatalogFreshness,
|
||||
find_catalog_path,
|
||||
load_catalog,
|
||||
)
|
||||
from warden.routing.models import RouteEntry
|
||||
|
||||
__all__ = [
|
||||
"Catalog",
|
||||
"CatalogError",
|
||||
"CatalogFreshness",
|
||||
"RouteEntry",
|
||||
"find_catalog_path",
|
||||
"load_catalog",
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@ never restates another subsystem's procedure.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
|
@ -112,6 +113,45 @@ def find_catalog_path(start: Optional[Path] = None) -> Path:
|
|||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogFreshness:
|
||||
"""Install vs source freshness for the routing catalog (WARDEN-WP-0029 T05).
|
||||
|
||||
Surfaces the path that was loaded, whether it is the wheel-bundled fallback
|
||||
(the stale-CLI failure mode), a content hash, and entry review age. Never
|
||||
carries secret material.
|
||||
"""
|
||||
|
||||
path: str
|
||||
source: str # "override" | "repo" | "bundled"
|
||||
content_hash: str
|
||||
mtime_iso: str
|
||||
package_version: str
|
||||
entry_count: int
|
||||
active_count: int
|
||||
newest_reviewed: Optional[str]
|
||||
oldest_reviewed: Optional[str]
|
||||
stale_entry_count: int
|
||||
using_bundled: bool
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"path": self.path,
|
||||
"source": self.source,
|
||||
"content_hash": self.content_hash,
|
||||
"mtime_iso": self.mtime_iso,
|
||||
"package_version": self.package_version,
|
||||
"entry_count": self.entry_count,
|
||||
"active_count": self.active_count,
|
||||
"newest_reviewed": self.newest_reviewed,
|
||||
"oldest_reviewed": self.oldest_reviewed,
|
||||
"stale_entry_count": self.stale_entry_count,
|
||||
"using_bundled": self.using_bundled,
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Catalog:
|
||||
path: Path
|
||||
|
|
@ -161,6 +201,85 @@ class Catalog:
|
|||
if is_review_stale(e.reviewed, threshold_days=threshold_days, today=today)
|
||||
]
|
||||
|
||||
def freshness(
|
||||
self,
|
||||
*,
|
||||
stale_threshold_days: int = DEFAULT_STALE_DAYS,
|
||||
today: Optional[date] = None,
|
||||
) -> CatalogFreshness:
|
||||
"""Describe which catalog was loaded and how fresh it is (WP-0029 T05)."""
|
||||
path = self.path.resolve()
|
||||
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
||||
mtime_iso = ""
|
||||
if path.exists():
|
||||
mtime_iso = datetime.fromtimestamp(
|
||||
path.stat().st_mtime, tz=timezone.utc
|
||||
).isoformat()
|
||||
|
||||
source = _classify_catalog_source(path)
|
||||
using_bundled = source == "bundled"
|
||||
reviewed_dates = [e.reviewed for e in self.entries if e.reviewed]
|
||||
newest = max(reviewed_dates) if reviewed_dates else None
|
||||
oldest = min(reviewed_dates) if reviewed_dates else None
|
||||
stale_count = len(self.stale(include_draft=True, threshold_days=stale_threshold_days, today=today))
|
||||
|
||||
package_version = _package_version()
|
||||
warnings: List[str] = []
|
||||
if using_bundled:
|
||||
warnings.append(
|
||||
"using wheel-bundled catalog fallback — reinstall from checkout "
|
||||
"(`uv tool install -e .` or `pip install -e .`) if lanes look missing"
|
||||
)
|
||||
if stale_count:
|
||||
warnings.append(
|
||||
f"{stale_count} catalog entr{'y' if stale_count == 1 else 'ies'} "
|
||||
f"past {stale_threshold_days}d review cadence"
|
||||
)
|
||||
|
||||
return CatalogFreshness(
|
||||
path=str(path),
|
||||
source=source,
|
||||
content_hash=digest,
|
||||
mtime_iso=mtime_iso,
|
||||
package_version=package_version,
|
||||
entry_count=len(self.entries),
|
||||
active_count=len(self.listed(include_draft=False)),
|
||||
newest_reviewed=newest,
|
||||
oldest_reviewed=oldest,
|
||||
stale_entry_count=stale_count,
|
||||
using_bundled=using_bundled,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _package_version() -> str:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("ops-warden")
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
from warden import __version__
|
||||
|
||||
return str(__version__)
|
||||
except Exception: # noqa: BLE001
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _classify_catalog_source(path: Path) -> str:
|
||||
"""Classify catalog load path for freshness warnings."""
|
||||
if os.environ.get("WARDEN_ROUTING_CATALOG"):
|
||||
return "override"
|
||||
resolved = str(path.resolve())
|
||||
if "/_registry/" in resolved or resolved.endswith("/warden/_registry/routing/catalog.yaml"):
|
||||
return "bundled"
|
||||
# hatch force-include places registry at warden/_registry
|
||||
parts = path.resolve().parts
|
||||
if "_registry" in parts:
|
||||
return "bundled"
|
||||
return "repo"
|
||||
|
||||
|
||||
def _assert_no_secret_material(
|
||||
entry_id: str, field_name: str, value: str, *, prose: bool = False
|
||||
|
|
|
|||
|
|
@ -189,6 +189,70 @@ def check_catalog_rotation_coverage() -> CheckResult:
|
|||
)
|
||||
|
||||
|
||||
def check_organization_posture() -> CheckResult:
|
||||
"""Surface declared organization lifecycle posture (WARDEN-WP-0029 T02).
|
||||
|
||||
Always informational PASS when descriptors load -- the check exists so operators
|
||||
and agents see the posture in scorecard output without hunting config files.
|
||||
"""
|
||||
try:
|
||||
from warden.posture import load_posture
|
||||
|
||||
cat = load_posture()
|
||||
org = cat.organization_posture
|
||||
except Exception as e: # noqa: BLE001
|
||||
return CheckResult(
|
||||
name="organization_posture",
|
||||
passed=False,
|
||||
detail=f"could not load organization posture: {e}",
|
||||
)
|
||||
relax = ", ".join(org.relaxations[:3])
|
||||
if len(org.relaxations) > 3:
|
||||
relax += ", ..."
|
||||
summary = org.summary[:120]
|
||||
if len(org.summary) > 120:
|
||||
summary += "..."
|
||||
relax_part = relax or "no relaxations listed"
|
||||
return CheckResult(
|
||||
name="organization_posture",
|
||||
passed=True,
|
||||
detail=f"{org.id} -- {summary} [{relax_part}]",
|
||||
)
|
||||
|
||||
|
||||
def check_catalog_freshness() -> CheckResult:
|
||||
"""Warn when the CLI is using a bundled (stale-risk) catalog (WP-0029 T05)."""
|
||||
try:
|
||||
from warden.routing import load_catalog
|
||||
|
||||
fresh = load_catalog().freshness()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return CheckResult(
|
||||
name="catalog_freshness",
|
||||
passed=False,
|
||||
detail=f"could not load routing catalog: {e}",
|
||||
)
|
||||
if fresh.using_bundled:
|
||||
nwarn = len(fresh.warnings)
|
||||
return CheckResult(
|
||||
name="catalog_freshness",
|
||||
passed=False,
|
||||
detail=(
|
||||
f"bundled catalog hash={fresh.content_hash} - reinstall from checkout "
|
||||
f"if lanes look missing ({nwarn} warnings)"
|
||||
),
|
||||
)
|
||||
return CheckResult(
|
||||
name="catalog_freshness",
|
||||
passed=True,
|
||||
detail=(
|
||||
f"source={fresh.source} hash={fresh.content_hash} "
|
||||
f"entries={fresh.active_count}/{fresh.entry_count} active "
|
||||
f"newest_reviewed={fresh.newest_reviewed}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[CheckResult]:
|
||||
"""Run all cert-side scorecard checks. Returns list of CheckResult."""
|
||||
return [
|
||||
|
|
@ -199,4 +263,6 @@ def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[Check
|
|||
check_ttl_policy(state_dir, inventory),
|
||||
check_file_permissions(state_dir),
|
||||
check_catalog_rotation_coverage(),
|
||||
check_organization_posture(),
|
||||
check_catalog_freshness(),
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue