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
|
|
@ -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
|
||||
|
|
|
|||
25
AGENTS.md
25
AGENTS.md
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# (M0–M3). 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]
|
||||
|
|
|
|||
99
tests/test_desk.py
Normal file
99
tests/test_desk.py
Normal 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
79
tests/test_plan.py
Normal 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"
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
M0–M3 — 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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue