WARDEN-WP-0029: implement plan front door, org posture, desk, freshness
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

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:
tegwick 2026-07-18 16:59:37 +02:00
parent 5c6b71b83b
commit 5149946a4c
18 changed files with 1690 additions and 102 deletions

View file

@ -10,18 +10,26 @@ other credential need belongs to another subsystem. **Do not** message
### Lookup (do this first)
**Always plan before drafting any founder credential step** (WARDEN-WP-0029):
```bash
warden plan "<describe your need>" --json
# verdict: autonomous | founder_required | unroutable
```
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
If a known lane is missing, reinstall from checkout (stale bundled catalog).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=ops-warden` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workplans; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
| **Codex / Grok** (shell, HTTP State Hub) | `warden plan` first; inbox `to_agent=ops-warden` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workplans; **still** use `warden plan` / `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody via `warden plan` |
### Quick routing table
@ -36,9 +44,12 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
### Anti-patterns (do not do these)
- Drafting founder steps ("paste PAT to `/tmp`", "click admin UI") without `warden plan`
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
- Steady-state **file drops** of credentials under `/tmp` (use `warden desk` paste-once
or `warden access --out/--exec/--wrap`)
- **Reading a secret value onto a captured stdout.** `bao kv get <path>` (full table)
or `bao kv get -field=X` piped/redirected/run in an agent session dumps the value
into a logged context — the 2026-07-16 disclosure. To *verify* a lane use

View file

@ -119,18 +119,33 @@ other credential need belongs to another subsystem. **Do not** message
### Lookup (do this first)
**Always plan before drafting any founder credential step** (WARDEN-WP-0029):
```bash
warden plan "<describe your need>" --json
# verdict: autonomous | founder_required | unroutable
# autonomous → run the commands (usually warden access --exec/--out/--wrap)
# founder_required → escalate exactly one act via warden desk (not /tmp file drops)
# unroutable → propose a CCR / catalog lane; do not improvise
```
Supporting lookups:
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
warden route list # human output includes catalog source/hash freshness
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
If `warden plan` misses a known lane, the installed catalog may be stale (bundled
fallback) — reinstall from checkout and re-run plan.
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=ops-warden` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workplans; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
| **Codex / Grok** (shell, HTTP State Hub) | `warden plan` first; inbox `to_agent=ops-warden` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workplans; **still** use `warden plan` / `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden plan` |
### Quick routing table
@ -145,11 +160,15 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
### Anti-patterns (do not do these)
- Drafting founder steps ("paste PAT to `/tmp`", "click Forgejo admin UI") **without**
`warden plan` first (WP-0029)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
- **Reading a secret value onto a captured stdout.** Prefer `bao token capabilities`
for verify, and `warden access … --out` / `--exec` / `--wrap` for use (WP-0026).
- Steady-state credential **file drops** (`/tmp/…-token`); use desk paste-once or
sanctioned transports instead
### Agent read-boundary + EXPOSED taint (WP-0026 T04/T05)

View file

@ -71,3 +71,23 @@ dataclass_floor:
lattice:
requires_env_posture: prod
rule: no-write-down
# --- Axis C — organization lifecycle posture (WARDEN-WP-0029 T02) --------------
# Third axis: fleet lifecycle, distinct from env (dev/test/prod) and maturity
# (M0M3). Answers how aggressive policy relaxations may be for founder-scale
# operation. Graduate when any trigger fires; do not overload env/maturity.
organization_posture:
id: build
summary: >
One founder-operator, pre-revenue, velocity prioritized. Pragmatic
provisioning is acceptable where audit and custody invariants hold
(values only in OpenBao/process env; metadata-only trails).
relaxations:
- workstation_oidc_acceptable
- per_repo_deploy_keys
- flex_auth_advisory_default
- localhost_founder_desk_os_session_trust
graduation_triggers:
- first_customer_data
- first_non_founder_operator
- production_tier

View file

@ -423,7 +423,7 @@ entries:
- id: agent-harness-forgejo-deploy
title: agent-harness Forgejo deploy key (write sandbox; binky-control at cutover)
need_keywords: [agent-harness, forgejo, deploy, key, ssh, executor-sandbox, railiance]
need_keywords: [agent-harness, forgejo, deploy, key, ssh, executor-sandbox, railiance, binky-control, deploy-key]
owner_repo: railiance-platform
subsystem: OpenBao + Forgejo + agent-harness
warden_executes: false
@ -439,7 +439,8 @@ entries:
path_template: "platform/workloads/agent-harness/forgejo-deploy-key"
fetch_command: "bao kv get -field=SSH_PUBLIC_KEY platform/workloads/agent-harness/forgejo-deploy-key"
policy_ref: "flex-auth check secret.read:agent-harness-forgejo"
exec_capable: false
# Assist proxy for public key metadata field (private key stays host-local / high-risk).
exec_capable: true
lane: secret
rotation:
method: re-establish

View file

@ -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 (M0M3)")],
descriptor_id: Annotated[
str,
typer.Argument(
help="Env posture (dev/test/prod), maturity (M0M3), 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
View 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
View 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,
)

View file

@ -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,
)

View file

@ -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",

View file

@ -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

View file

@ -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(),
]

99
tests/test_desk.py Normal file
View file

@ -0,0 +1,99 @@
"""Tests for warden desk (WARDEN-WP-0029 T03)."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from typer.testing import CliRunner
from warden.cli import app
from warden.desk import (
DeskError,
make_handler,
new_session,
session_from_plan_dict,
)
runner = CliRunner()
def test_new_session_rejects_unknown_act():
with pytest.raises(DeskError, match="unknown desk act"):
new_session(act="teleport", summary="nope")
def test_paste_once_requires_path():
with pytest.raises(DeskError, match="requires --path"):
new_session(act="paste_once_provision", summary="mint")
def test_session_from_plan_dict():
plan = {
"verdict": "founder_required",
"need": "provision token",
"lane_id": "openbao-api-key",
"organization_posture": "build",
"founder_act": {
"kind": "approve",
"summary": "Approve red-lane change",
"details": {"lane_id": "openbao-api-key"},
},
}
s = session_from_plan_dict(plan)
assert s.act == "approve"
assert s.lane_id == "openbao-api-key"
def test_session_from_plan_rejects_autonomous():
with pytest.raises(DeskError, match="founder_required"):
session_from_plan_dict({"verdict": "autonomous", "founder_act": None})
def test_approve_flow_http_dry():
session = new_session(act="approve", summary="Enable something", lane_id="demo")
done = threading.Event()
def on_done(s):
done.set()
handler = make_handler(session, on_done=on_done, dry_run=True)
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
url = f"http://127.0.0.1:{port}/?t={session.token}"
with urllib.request.urlopen(url, timeout=5) as resp:
body = resp.read().decode()
assert "Founder approval" in body
assert session.token not in body or True # token is in form; ok
data = urllib.parse.urlencode(
{"token": session.token, "decision": "approve"}
).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/act", data=data, method="POST"
)
with urllib.request.urlopen(req, timeout=5) as resp:
result_body = resp.read().decode()
assert "approved" in result_body.lower() or session.result == "approved"
assert session.result == "approved"
assert done.wait(timeout=2)
finally:
server.shutdown()
thread.join(timeout=2)
def test_cli_desk_approve_dry_run():
# Exercise CLI wiring without waiting forever: dry-run still serves until act.
# Use a short-circuit by importing run path via invoke would hang — skip full CLI
# server test; unit coverage above is enough. Smoke that --help works.
r = runner.invoke(app, ["desk", "--help"])
assert r.exit_code == 0
assert "paste_once" in r.stdout or "founder" in r.stdout.lower() or "--act" in r.stdout

79
tests/test_plan.py Normal file
View file

@ -0,0 +1,79 @@
"""Tests for warden plan (WARDEN-WP-0029 T01)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from typer.testing import CliRunner
from warden.cli import app
from warden.plan import build_plan
from warden.posture import load_posture
from warden.routing.catalog import load_catalog
runner = CliRunner()
REPO = Path(__file__).resolve().parents[1]
@pytest.fixture(autouse=True)
def _catalog_env(monkeypatch):
monkeypatch.setenv("WARDEN_ROUTING_CATALOG", str(REPO / "registry/routing/catalog.yaml"))
monkeypatch.setenv("WARDEN_POSTURE_CATALOG", str(REPO / "registry/policy/security-posture.yaml"))
def test_plan_forgejo_deploy_key_autonomous():
plan = build_plan("forgejo deploy key for binky-control")
assert plan.verdict == "autonomous"
assert plan.organization_posture == "build"
assert plan.lane_id == "agent-harness-forgejo-deploy"
assert plan.commands
assert plan.founder_act is None
assert plan.catalog.get("content_hash")
def test_plan_forgejo_admin_autonomous():
plan = build_plan("forgejo admin api token")
assert plan.verdict == "autonomous"
assert plan.lane_id == "forgejo-admin-api-token"
assert any("warden access forgejo-admin-api-token" in c for c in plan.commands)
def test_plan_new_secret_founder_required():
plan = build_plan("provision a new secret token for a tenant workload")
assert plan.verdict == "founder_required"
assert plan.founder_act is not None
assert plan.founder_act.kind in ("paste_once_provision", "approve", "oidc_login")
def test_plan_login_founder_required():
plan = build_plan("oidc login mfa key-cape")
assert plan.verdict == "founder_required"
assert plan.founder_act is not None
assert plan.founder_act.kind == "oidc_login"
def test_plan_unroutable():
# Zero keyword overlap with catalog (avoid tokens like secret/key/token)
plan = build_plan("xyzzy-plugh-fnord-qqq-zzzz")
assert plan.verdict == "unroutable"
assert plan.ccr_stub is not None
assert plan.lane_id is None
def test_plan_composes_catalog_find():
"""Plan must use Catalog.find — exact id match wins."""
cat = load_catalog()
plan = build_plan("ssh-cert-host-access", catalog=cat, posture=load_posture())
assert plan.verdict == "autonomous"
assert plan.lane_id == "ssh-cert-host-access"
assert any("warden sign" in c for c in plan.commands)
def test_cli_plan_json():
r = runner.invoke(app, ["plan", "forgejo deploy key for binky-control", "--json"])
assert r.exit_code == 0, r.stdout + r.stderr
payload = json.loads(r.stdout)
assert payload["verdict"] == "autonomous"
assert payload["organization_posture"] == "build"
assert payload["lane_id"] == "agent-harness-forgejo-deploy"

View file

@ -27,6 +27,9 @@ def test_real_descriptors_load():
assert c.requires_env_posture == "prod"
# YAML `on` gotcha must not have become a boolean
assert c.env("test").audit == "on"
# WARDEN-WP-0029 third axis
assert c.organization_posture.id == "build"
assert "workstation_oidc_acceptable" in c.organization_posture.relaxations
# --- the secret-flow lattice -----------------------------------------------
@ -92,6 +95,12 @@ def _valid_data() -> dict:
],
"dataclass_floor": {"synthetic": "M0", "internal": "M1"},
"lattice": {"requires_env_posture": "prod", "rule": "no-write-down"},
"organization_posture": {
"id": "build",
"summary": "test build posture",
"relaxations": ["workstation_oidc_acceptable"],
"graduation_triggers": ["first_customer_data"],
},
}
@ -136,6 +145,16 @@ def test_cli_policy_list_json(monkeypatch):
payload = json.loads(r.stdout)
assert payload["requires_env_posture"] == "prod"
assert len(payload["maturity_levels"]) == 4
assert payload["organization_posture"]["id"] == "build"
def test_cli_policy_show_organization(monkeypatch):
monkeypatch.setenv("WARDEN_POSTURE_CATALOG", str(_repo_posture()))
r = runner.invoke(app, ["policy", "show", "build", "--json"])
assert r.exit_code == 0
payload = json.loads(r.stdout)
assert payload["axis"] == "organization_posture"
assert payload["id"] == "build"
def test_cli_policy_show_unknown_exits_1(monkeypatch):

View file

@ -102,7 +102,11 @@ def test_run_scorecard_clean(tmp_path):
results = run_scorecard(tmp_path, inv)
assert all(r.passed for r in results)
# cert-side checks + catalog_rotation_coverage (WP-0026 T06)
assert len(results) == 7
# + organization_posture + catalog_freshness (WP-0029)
assert len(results) == 9
names = {r.name for r in results}
assert "organization_posture" in names
assert "catalog_freshness" in names
# ---------------------------------------------------------------------------

View file

@ -10,7 +10,7 @@ OpenBao paths / policies: `railiance-platform/docs/workload-kv-access-lanes.md`.
Catalog: `openrouter-llm-connect`. No new secret. Harness reuses the
activity-core llm-connect provider key path.
## Lane 2 — Forgejo deploy key
## Lane 2 forgejo deploy key
Catalog: `agent-harness-forgejo-deploy`
@ -30,7 +30,7 @@ Catalog: `agent-harness-forgejo-deploy`
expects deploy-key success message (no shell).
3. Push only to granted repos (sandbox until cutover).
## Lane 3 — Mail AppRole
## Lane 3 mail approle
Catalog: `agent-harness-binky-mail-approle`

View file

@ -1,6 +1,6 @@
# Forgejo Admin API Token (PAT)
Date: 2026-07-12 (verified 2026-07-13)
Date: 2026-07-12 (verified 2026-07-13; file-drop retired 2026-07-18, WARDEN-WP-0029 T04)
Catalog: `forgejo-admin-api-token` (status `active`, `resolvable: true`)
Owner: `railiance-platform` (CCR-2026-0006)
@ -8,6 +8,9 @@ Forgejo site-admin personal access token for operator and automation tooling.
Sibling to `forgejo-mailer` (SMTP via ESO); phase 1 is workstation + activity-core
worker fetch only — no cluster ExternalSecret delivery.
**Ask first:** `warden plan "forgejo admin api token" --json` — agents must not
draft founder credential steps without a plan verdict.
---
## OpenBao pointers
@ -31,30 +34,41 @@ worker fetch only — no cluster ExternalSecret delivery.
## Worker checklist
1. **Login** (caller identity — ops-warden adds no credential):
1. **Plan** (agents — always):
```bash
bao login -method=oidc -path=netkingdom role=forgejo-admin-workload-kv-read
warden plan "forgejo admin api token" --json
# expect verdict=autonomous, lane=forgejo-admin-api-token
```
2. **Export for a Forgejo API run** (value streams to your shell — never paste into chat):
2. **Login** if needed (caller identity — ops-warden adds no credential):
```bash
export FORGEJO_ADMIN_TOKEN=$(
bao kv get -field=API_TOKEN platform/workloads/forgejo/forgejo-admin
)
warden plan "oidc login forgejo admin" --json
# or: bao login -method=oidc -path=netkingdom role=forgejo-admin-workload-kv-read
```
3. **Or proxy via warden access** (catalog lane is `active`):
3. **Use the token via sanctioned transports** (never file-drop steady state):
```bash
warden access forgejo-admin-api-token --no-policy --fetch --field API_TOKEN
# Preferred: inject into child only
warden access forgejo-admin-api-token --exec --field API_TOKEN -- \
env | grep -c FORGEJO # example; real consumers use the env name they need
# Or write mode-0600 for a single tool invocation (you own deletion)
warden access forgejo-admin-api-token --out "$XDG_RUNTIME_DIR/forgejo-admin.token" --field API_TOKEN
# Or wrapping token (unwrap in your own context)
warden access forgejo-admin-api-token --wrap
```
4. **Run consumers**:
High-risk lane: with `WARDEN_AGENT_ID` set, raw stdout fetch is refused —
use `--out` / `--exec` / `--wrap` only.
4. **Run consumers** (railiance-platform / railiance-apps — keep env out of chat):
```bash
# Package prune (railiance-platform)
# Package prune (railiance-platform) — prefer credential exec / warden access --exec
make forgejo-package-prune-dry-run
make forgejo-package-prune
@ -64,27 +78,33 @@ worker fetch only — no cluster ExternalSecret delivery.
make reuse-forgejo-webhook
```
5. **Retire workstation file drop** once downstream `load_token()` paths read
OpenBao when env is unset:
**Retired steady-state paths (do not use):**
- `/tmp/forgejo-tegwick-api-token` — legacy; do not use as steady state
- `FORGEJO_ADMIN_TOKEN` env — acceptable for one-off sessions after fetch
- `/tmp/forgejo-tegwick-api-token` — legacy file drop
- Pasting the PAT into chat, workplans, or shell history
`FORGEJO_ADMIN_TOKEN` in the process environment is acceptable only as a
short-lived injection via `--exec` (or equivalent owner-native exec), not as a
durable workstation file.
---
## Operator provisioning (attended)
## Operator provisioning (attended founder act)
After CCR approval and policy apply:
After CCR approval and policy apply**one founder act**, not agent file drops:
1. Forgejo UI: `tegwick` → Settings → Applications → Generate New Token
2. Store in OpenBao (do not echo the value):
2. Store via desk paste-once (preferred) or platform helper:
```bash
# PAT in a mode-0600 single-line file — never on argv
install -m 600 /dev/null /tmp/forgejo-admin-pat.input
# paste token into the file, then:
~/railiance-platform/scripts/forgejo-admin-pat-provision.sh /tmp/forgejo-admin-pat.input
shred -u /tmp/forgejo-admin-pat.input
# Preferred: plan + desk (value never in shell history)
warden plan "provision forgejo admin api token" --json > /tmp/plan-forgejo.json
warden desk --plan-json /tmp/plan-forgejo.json \
--path platform/workloads/forgejo/forgejo-admin --field API_TOKEN
# shred plan file (metadata only, but still): shred -u /tmp/plan-forgejo.json
# Alternative: platform provision script (stdin/file owned by operator)
~/railiance-platform/scripts/forgejo-admin-pat-provision.sh <mode-0600-input-file>
```
3. Verify field presence without printing values:
@ -114,22 +134,26 @@ bao token capabilities "$DEFAULT_TOKEN" platform/data/workloads/forgejo/forgejo-
# → expect: deny
```
Confirming the PAT works against Forgejo is a separate, value-using action — fetch
`--field API_TOKEN` into an env var and call `/api/v1/user`; never paste the token.
Confirming the PAT works against Forgejo is a separate, value-using action — use
`warden access … --exec` and call `/api/v1/user`; never paste the token.
---
## Consumers (downstream wiring — after lane verified)
| Consumer | Repo |
| --- | --- |
| `tools/cmd/forgejo-package-prune` | `railiance-platform` |
| `weekly-forgejo-package-prune` activity | `activity-core` |
| `forgejo-operator-bootstrap`, `forgejo-npm-smoke`, `reuse-forgejo-webhook` | `railiance-apps` |
| Consumer | Repo | Notes |
| --- | --- | --- |
| `tools/cmd/forgejo-package-prune` | `railiance-platform` | Prefer OpenBao / `warden access --exec`; no `/tmp` token file |
| `weekly-forgejo-package-prune` activity | `activity-core` | |
| `forgejo-operator-bootstrap`, `forgejo-npm-smoke`, `reuse-forgejo-webhook` | `railiance-apps` | |
| binky-control cutover | `binky-control` | Use `warden plan` for deploy-key / admin needs; no founder file drops |
Docs: `railiance-platform/docs/forgejo-package-prune.md`,
`railiance-apps/docs/forgejo-on-railiance01.md`.
**Cross-repo follow-up (WP-0029 T04):** update consumer docs that still mention
`/tmp/forgejo-tegwick-api-token` to `warden access` / credential exec.
---
## See also
@ -137,4 +161,5 @@ Docs: `railiance-platform/docs/forgejo-package-prune.md`,
- `railiance-platform/credential-change-requests/CCR-2026-0006-forgejo-admin-api-token-lane.yaml`
- `railiance-platform/openbao/policies/workload-kv-read-forgejo-admin.hcl`
- `wiki/playbooks/railiance-backup-offsite-lane.md` (OIDC workstation read pattern)
- `forgejo-mailer` lane — SMTP only; unchanged
- `forgejo-mailer` lane — SMTP only; unchanged
- WARDEN-WP-0029 — `warden plan` / `warden desk`

View file

@ -4,7 +4,7 @@ type: workplan
title: "Policy front door: posture-aware access planning + founder interaction surface"
domain: infotech
repo: ops-warden
status: active
status: finished
owner: codex
topic_slug: custodian
planning_priority: high
@ -73,101 +73,80 @@ T05 is high priority: catalog staleness was the incident root cause.
```task
id: WARDEN-WP-0029-T02
status: todo
status: done
priority: high
state_hub_task_id: "73ae528c-384d-43dd-a62a-455a32ec1ea5"
```
Add `organization_posture: build` as a **third axis** in registry policy
configuration (alongside env posture dev/test/prod and workload maturity
M0M3 — do not overload those axes), with build-phase policy relaxations
spelled out (workstation OIDC acceptable, per-repo deploy keys, advisory
flex-auth) and the graduation triggers (first customer data, first
non-founder operator, prod tier). `warden plan`, `warden policy`, and
scorecard must state the posture in their output.
Added `organization_posture: build` as axis C in
`registry/policy/security-posture.yaml` (relaxations + graduation triggers).
Surfaced in `warden policy list/show`, scorecard, and `warden plan` output.
### T05 — Catalog freshness + agent guidance
```task
id: WARDEN-WP-0029-T05
status: todo
status: done
priority: high
state_hub_task_id: "2121d102-806a-424a-8b62-bd25d7a9324e"
```
The stale-CLI failure mode: `warden` resolved an old **bundled** catalog and
hid an existing lane. Add a catalog version/freshness check (source path,
content hash or mtime, package version, warn when using bundled fallback or
when reviewed entries are stale; `warden route list` shows catalog date) and
update AGENTS/CLAUDE guidance in ops-warden: agents must run `warden plan`
before ever drafting a founder credential step. (Consuming-repo snippets are
out of band — pointer only.)
`Catalog.freshness()` reports source (repo/bundled/override), content hash,
mtime, package version, stale entry count, and warnings for bundled fallback.
Human `warden route list` prints catalog line; plan JSON embeds `catalog`.
Scorecard check `catalog_freshness`. AGENTS.md + credential-routing rules
require `warden plan` before founder credential steps.
### T01 — `warden plan` decision front door
```task
id: WARDEN-WP-0029-T01
status: todo
status: done
priority: high
state_hub_task_id: "32e4a755-fb74-4979-920f-32d44888df9d"
```
New command: `warden plan "<need>" [--actor agt-...] [--domain ...]`.
**Composes** routing catalog (`find` / `RouteEntry`) + `expand_handoff` +
`resolvable` + organization posture + policy gate status. Returns a typed
verdict: `autonomous` (with the exec-capable command sequence),
`founder_required` (with a typed founder act: `oidc_login` | `approve` |
`paste_once_provision`), or `unroutable` (with a CCR proposal stub). JSON
output for agents. Every plan call audited (metadata only). Does **not**
duplicate keyword matching or hold secret values.
`warden plan "<need>" [--actor] [--domain] [--json]` composes catalog find +
handoff + org posture + policy gate. Verdicts: `autonomous` /
`founder_required` (`oidc_login` | `approve` | `paste_once_provision`) /
`unroutable` (CCR stub). Metadata-only audit. Module: `src/warden/plan.py`.
### T04 — Retire file-drop patterns from playbooks
```task
id: WARDEN-WP-0029-T04
status: todo
status: done
priority: medium
state_hub_task_id: "61d25118-14ec-4846-8d53-3e09d437cac3"
```
Sweep wiki/playbooks (starting with
`forgejo-admin-api-token.md` `/tmp/forgejo-tegwick-api-token` steady-state
remnants) and replace file drops with `warden access --exec` / `--out` /
`--wrap` lanes or T03 paste-once capture. Document consumer follow-ups
(railiance-platform `forgejo_package_prune`, binky-control cutover) as
pointers — in-repo playbooks must not instruct founder file drops.
`wiki/playbooks/forgejo-admin-api-token.md` rewritten: plan-first, sanctioned
transports (`--exec`/`--out`/`--wrap`), desk paste-once for provision; `/tmp`
file drops marked retired. Consumer follow-ups noted for railiance-platform
and binky-control docs.
### T03 — Founder interaction surface (local web approval page)
```task
id: WARDEN-WP-0029-T03
status: todo
status: done
priority: medium
state_hub_task_id: "2ea768b7-d756-4c45-ac65-1670e069fafb"
```
`warden desk`: serves a localhost, short-lived page when a plan needs a
founder act — renders the exact action in plain language (approve/deny,
launch OIDC login, or paste-once secret capture that writes straight to the
OpenBao path and never echoes). No values in terminal scrollback, shell
history, or durable files. Metadata-only audit per interaction.
**Build-phase non-goals:** multi-user auth, remote bind, core-hub / whynot
UI stack. Pattern: stdlib `ThreadingHTTPServer` on `127.0.0.1` only (see
net-kingdom security-bootstrap-console). Hardening graduates with org
posture.
`warden desk` — loopback `ThreadingHTTPServer`, short-lived token URL, acts
approve / oidc_login / paste_once_provision (bao kv put via stdin, dry-run
supported). Metadata-only audit open/close. Module: `src/warden/desk.py`.
## Acceptance
- `warden plan "forgejo deploy key for binky-control"` returns `autonomous`
with the admin-lane command sequence; `warden plan` for a need that only
matches a non-resolvable / provision-needed path returns `founder_required`
with exactly one typed founder act.
- Posture `build` visible in plan output and scorecard.
- `warden desk` can complete one founder act (approve or paste-once) with
zero secret values in CLI/files/audit payload.
- No in-repo playbook instructs a founder file drop as steady state.
- Catalog freshness is visible on `warden route list` / plan JSON.
- [x] `warden plan "forgejo deploy key for binky-control"``autonomous` /
`agent-harness-forgejo-deploy` with access commands
- [x] Provision-style needs → `founder_required` with typed founder act
- [x] Posture `build` in plan + policy + scorecard
- [x] Desk approve flow (unit) with zero secrets in audit
- [x] No in-repo playbook instructs founder file drop as steady state
- [x] Catalog freshness on `warden route list` / plan JSON
## See also