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.
1995 lines
73 KiB
Python
1995 lines
73 KiB
Python
"""OpsWarden CLI."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Annotated, List, Optional
|
||
|
||
import typer
|
||
from rich.console import Console
|
||
from rich.table import Table
|
||
|
||
from warden.ca import CAError, LocalCA, parse_cert_metadata
|
||
from warden.config import ConfigError, WardenConfig, load_config
|
||
from warden.policy import check_sign_policy
|
||
from warden.inventory import ActorEntry, InventoryError, PrincipalsInventory, load_inventory, save_inventory
|
||
from warden.models import ActorType, CertSpec, DEFAULT_TTL_HOURS, validate_actor_name
|
||
from warden.scorecard import run_scorecard
|
||
|
||
app = typer.Typer(
|
||
help="OpsWarden — SSH CA and certificate lifecycle manager",
|
||
no_args_is_help=True,
|
||
)
|
||
inventory_app = typer.Typer(help="Manage principals inventory", no_args_is_help=True)
|
||
app.add_typer(inventory_app, name="inventory")
|
||
route_app = typer.Typer(
|
||
help="Look up which subsystem owns a credential need (read-only pointer layer)",
|
||
no_args_is_help=True,
|
||
)
|
||
app.add_typer(route_app, name="route")
|
||
policy_app = typer.Typer(
|
||
help="Look up Workload Security Posture descriptors (read-only; env posture + maturity)",
|
||
no_args_is_help=True,
|
||
)
|
||
app.add_typer(policy_app, name="policy")
|
||
|
||
worker_app = typer.Typer(
|
||
help="Autonomous coordination worker (WP-0020; dry-run only until executor lands)",
|
||
no_args_is_help=True,
|
||
)
|
||
app.add_typer(worker_app, name="worker")
|
||
activity_app = typer.Typer(
|
||
help="Unified metadata-only audit view (WARDEN-WP-0022)",
|
||
no_args_is_help=True,
|
||
)
|
||
app.add_typer(activity_app, name="activity")
|
||
memory_app = typer.Typer(
|
||
help="Cross-runtime experiential memory via phase-memory (WARDEN-WP-0024)",
|
||
no_args_is_help=True,
|
||
)
|
||
app.add_typer(memory_app, name="memory")
|
||
|
||
console = Console()
|
||
err = Console(stderr=True)
|
||
|
||
|
||
@app.callback()
|
||
def _bootstrap_memory(ctx: typer.Context) -> None:
|
||
"""Implicitly load phase-memory for every warden command (opt-out: WARDEN_MEMORY=0)."""
|
||
if ctx.invoked_subcommand is None:
|
||
return
|
||
try:
|
||
from warden import memory as warden_memory
|
||
|
||
warden_memory.ensure_memory_context(implicit=True)
|
||
except Exception: # noqa: BLE001 — memory must never block warden commands
|
||
return
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _record_memory_episode(
|
||
*,
|
||
command: str,
|
||
outcome: str,
|
||
need: str = "",
|
||
route_id: str = "",
|
||
) -> None:
|
||
try:
|
||
from warden import memory as warden_memory
|
||
except ImportError:
|
||
return
|
||
if not warden_memory.enabled() or not warden_memory.memory_available():
|
||
return
|
||
try:
|
||
warden_memory.record_command_episode(
|
||
command=command,
|
||
outcome=outcome,
|
||
need=need,
|
||
route_id=route_id,
|
||
)
|
||
except RuntimeError:
|
||
return
|
||
|
||
|
||
def _load_cfg() -> WardenConfig:
|
||
try:
|
||
return load_config()
|
||
except ConfigError as e:
|
||
err.print(f"[red]Config error:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
|
||
def _load_inventory(cfg: WardenConfig) -> PrincipalsInventory:
|
||
try:
|
||
return load_inventory(cfg.inventory_path)
|
||
except InventoryError as e:
|
||
err.print(f"[red]Inventory error:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
|
||
def _get_ca(cfg: WardenConfig):
|
||
if cfg.backend == "vault":
|
||
from warden.vault import VaultCA
|
||
return VaultCA(cfg.vault, cfg.state_dir)
|
||
return LocalCA(cfg.ca_key, cfg.state_dir)
|
||
|
||
|
||
def _apply_policy_gate(cfg: WardenConfig, spec: CertSpec) -> None:
|
||
"""Run flex-auth check when policy.enabled; sets spec.policy_decision_id."""
|
||
decision_id = check_sign_policy(cfg.policy, spec)
|
||
if decision_id:
|
||
spec.policy_decision_id = decision_id
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden sign
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.command()
|
||
def sign(
|
||
actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")],
|
||
pubkey: Annotated[Path, typer.Option("--pubkey", help="Path to actor's public key file")],
|
||
ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None,
|
||
) -> None:
|
||
"""Sign a public key for the given actor. Writes cert text to stdout.
|
||
|
||
This is the cert_command interface: ops-bridge calls this and uses stdout
|
||
as the certificate passed to SSH alongside the private key.
|
||
"""
|
||
cfg = _load_cfg()
|
||
inventory = _load_inventory(cfg)
|
||
|
||
entry = inventory.actors.get(actor_name)
|
||
if entry is None:
|
||
err.print(
|
||
f"[red]Actor {actor_name!r} not found in inventory.[/red] "
|
||
f"Add it with: warden inventory add"
|
||
)
|
||
raise typer.Exit(1)
|
||
|
||
spec = CertSpec(
|
||
actor_name=actor_name,
|
||
actor_type=entry.actor_type,
|
||
pubkey_path=pubkey,
|
||
ttl_hours=ttl or entry.ttl_hours,
|
||
principals=entry.principals,
|
||
identity=actor_name,
|
||
)
|
||
|
||
ca = _get_ca(cfg)
|
||
try:
|
||
_apply_policy_gate(cfg, spec)
|
||
record = ca.sign(spec)
|
||
except CAError as e:
|
||
err.print(f"[red]Signing failed:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
# cert_command interface: write cert text to stdout only
|
||
print(record.cert_path.read_text().strip())
|
||
_record_memory_episode(
|
||
command="sign",
|
||
outcome="resolved",
|
||
need=f"ssh cert {actor_name}",
|
||
route_id="ops-warden-ssh-cert",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden issue
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.command()
|
||
def issue(
|
||
actor_name: Annotated[str, typer.Argument(help="Actor name")],
|
||
ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None,
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Generate a new keypair and sign it for the given actor.
|
||
|
||
Only supported with the local backend. Outputs keypair + cert paths and metadata.
|
||
"""
|
||
cfg = _load_cfg()
|
||
|
||
if cfg.backend != "local":
|
||
err.print("[red]warden issue is only supported with the local backend.[/red]")
|
||
raise typer.Exit(1)
|
||
|
||
inventory = _load_inventory(cfg)
|
||
entry = inventory.actors.get(actor_name)
|
||
if entry is None:
|
||
err.print(f"[red]Actor {actor_name!r} not found in inventory.[/red]")
|
||
raise typer.Exit(1)
|
||
|
||
ca = LocalCA(cfg.ca_key, cfg.state_dir)
|
||
try:
|
||
privkey_path, pubkey_path = ca.generate_keypair(actor_name)
|
||
except CAError as e:
|
||
err.print(f"[red]Key generation failed:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
spec = CertSpec(
|
||
actor_name=actor_name,
|
||
actor_type=entry.actor_type,
|
||
pubkey_path=pubkey_path,
|
||
ttl_hours=ttl or entry.ttl_hours,
|
||
principals=entry.principals,
|
||
identity=actor_name,
|
||
)
|
||
try:
|
||
_apply_policy_gate(cfg, spec)
|
||
record = ca.sign(spec)
|
||
except CAError as e:
|
||
err.print(f"[red]Signing failed:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
result = {
|
||
"actor": actor_name,
|
||
"privkey": str(privkey_path),
|
||
"cert": str(record.cert_path),
|
||
"identity": record.identity,
|
||
"principals": record.principals,
|
||
"valid_before": record.valid_before.isoformat(),
|
||
"signed_at": record.signed_at.isoformat(),
|
||
}
|
||
|
||
if output_json:
|
||
print(json.dumps(result, indent=2))
|
||
else:
|
||
console.print(f"[green]Issued credentials for {actor_name}[/green]")
|
||
for k, v in result.items():
|
||
console.print(f" {k}: {v}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden status
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.command()
|
||
def status(
|
||
actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None,
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
state_dir_override: Annotated[Optional[Path], typer.Option("--state-dir", help="State dir path (bypasses config)")] = None,
|
||
) -> None:
|
||
"""Show certificate status. Exits 1 if any cert is expired."""
|
||
now = datetime.now(timezone.utc)
|
||
|
||
if state_dir_override is not None:
|
||
state_dir = state_dir_override
|
||
else:
|
||
cfg = _load_cfg()
|
||
state_dir = cfg.state_dir
|
||
|
||
if actor_name:
|
||
cert_path = state_dir / f"{actor_name}-cert.pub"
|
||
paths = [cert_path] if cert_path.exists() else []
|
||
else:
|
||
paths = sorted(state_dir.glob("*-cert.pub")) if state_dir.exists() else []
|
||
|
||
if not paths:
|
||
msg = (
|
||
f"No certificate found for {actor_name!r} (static key / no cert)"
|
||
if actor_name
|
||
else "No certificates in state dir."
|
||
)
|
||
console.print(msg)
|
||
return
|
||
|
||
rows = []
|
||
for cert_path in paths:
|
||
name = cert_path.stem.replace("-cert", "")
|
||
try:
|
||
meta = parse_cert_metadata(cert_path)
|
||
valid_before = meta["valid_before"]
|
||
remaining = valid_before - now
|
||
secs = remaining.total_seconds()
|
||
if secs > 0:
|
||
h, rem = divmod(int(secs), 3600)
|
||
m = rem // 60
|
||
remaining_str = f"{h}h {m}m"
|
||
expired = False
|
||
else:
|
||
remaining_str = "EXPIRED"
|
||
expired = True
|
||
rows.append({
|
||
"actor": name,
|
||
"identity": meta["identity"],
|
||
"principals": ", ".join(meta["principals"]),
|
||
"valid_before": valid_before.isoformat(),
|
||
"remaining": remaining_str,
|
||
"expired": expired,
|
||
})
|
||
except Exception as e:
|
||
rows.append({"actor": name, "error": str(e), "expired": False})
|
||
|
||
if output_json:
|
||
print(json.dumps(rows, indent=2))
|
||
else:
|
||
table = Table(title="Certificate Status")
|
||
table.add_column("Actor")
|
||
table.add_column("Identity")
|
||
table.add_column("Principals")
|
||
table.add_column("Valid Before (UTC)")
|
||
table.add_column("Remaining")
|
||
for row in rows:
|
||
if "error" in row:
|
||
table.add_row(row["actor"], "[red]parse error[/red]", "", "", row["error"])
|
||
else:
|
||
rem_styled = (
|
||
f"[red]{row['remaining']}[/red]" if row["expired"] else row["remaining"]
|
||
)
|
||
table.add_row(
|
||
row["actor"],
|
||
row["identity"],
|
||
row["principals"],
|
||
row["valid_before"],
|
||
rem_styled,
|
||
)
|
||
console.print(table)
|
||
|
||
if any(r.get("expired") for r in rows):
|
||
raise typer.Exit(1)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden scorecard
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.command()
|
||
def scorecard(
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Run compliance scorecard checks (AccessManagementDirective §5, cert-side)."""
|
||
cfg = _load_cfg()
|
||
inventory = _load_inventory(cfg)
|
||
|
||
results = run_scorecard(cfg.state_dir, inventory)
|
||
passed = sum(1 for r in results if r.passed)
|
||
total = len(results)
|
||
|
||
if output_json:
|
||
print(json.dumps(
|
||
[{"check": r.name, "passed": r.passed, "detail": r.detail} for r in results],
|
||
indent=2,
|
||
))
|
||
else:
|
||
table = Table(title=f"OpsWarden Scorecard ({passed}/{total})")
|
||
table.add_column("Check")
|
||
table.add_column("Status")
|
||
table.add_column("Detail")
|
||
for r in results:
|
||
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]")
|
||
)
|
||
|
||
if passed < total:
|
||
raise typer.Exit(1)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden inventory
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@inventory_app.command("list")
|
||
def inventory_list(
|
||
output_json: Annotated[bool, typer.Option("--json")] = False,
|
||
) -> None:
|
||
"""List all actors in the principals inventory."""
|
||
cfg = _load_cfg()
|
||
inventory = _load_inventory(cfg)
|
||
|
||
if not inventory.actors:
|
||
console.print("No actors in inventory.")
|
||
return
|
||
|
||
if output_json:
|
||
print(json.dumps({
|
||
name: {
|
||
"type": e.actor_type.value,
|
||
"principals": e.principals,
|
||
"ttl_hours": e.ttl_hours,
|
||
"description": e.description,
|
||
}
|
||
for name, e in inventory.actors.items()
|
||
}, indent=2))
|
||
return
|
||
|
||
table = Table(title=f"Principals Inventory ({cfg.inventory_path})")
|
||
table.add_column("Actor")
|
||
table.add_column("Type")
|
||
table.add_column("Principals")
|
||
table.add_column("TTL (h)")
|
||
table.add_column("Description")
|
||
for name, e in inventory.actors.items():
|
||
table.add_row(
|
||
name,
|
||
e.actor_type.value,
|
||
", ".join(e.principals),
|
||
str(e.ttl_hours),
|
||
e.description,
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
@inventory_app.command("add")
|
||
def inventory_add(
|
||
actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")],
|
||
actor_type: Annotated[ActorType, typer.Option("--type", "-t", help="adm | agt | atm")],
|
||
principals: Annotated[
|
||
Optional[List[str]],
|
||
typer.Option("--principal", "-p", help="Principal (repeat for multiple)"),
|
||
] = None,
|
||
ttl: Annotated[Optional[int], typer.Option("--ttl", help="TTL in hours")] = None,
|
||
description: Annotated[str, typer.Option("--description", "-d")] = "",
|
||
) -> None:
|
||
"""Add an actor to the principals inventory."""
|
||
cfg = _load_cfg()
|
||
|
||
try:
|
||
validate_actor_name(actor_name, actor_type)
|
||
except ValueError as e:
|
||
err.print(f"[red]{e}[/red]")
|
||
raise typer.Exit(1)
|
||
|
||
resolved_principals: List[str] = principals or [actor_name]
|
||
inventory = _load_inventory(cfg)
|
||
inventory.actors[actor_name] = ActorEntry(
|
||
name=actor_name,
|
||
actor_type=actor_type,
|
||
principals=resolved_principals,
|
||
ttl_hours=ttl or DEFAULT_TTL_HOURS[actor_type],
|
||
description=description,
|
||
)
|
||
try:
|
||
save_inventory(inventory, cfg.inventory_path)
|
||
except Exception as e:
|
||
err.print(f"[red]Failed to save inventory:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
console.print(
|
||
f"[green]Added[/green] {actor_name} "
|
||
f"(type={actor_type.value}, principals={resolved_principals}, ttl={ttl or DEFAULT_TTL_HOURS[actor_type]}h)"
|
||
)
|
||
|
||
|
||
@inventory_app.command("remove")
|
||
def inventory_remove(
|
||
actor_name: Annotated[str, typer.Argument(help="Actor name to remove")],
|
||
) -> None:
|
||
"""Remove an actor from the principals inventory."""
|
||
cfg = _load_cfg()
|
||
inventory = _load_inventory(cfg)
|
||
|
||
if actor_name not in inventory.actors:
|
||
err.print(f"[red]Actor {actor_name!r} not in inventory.[/red]")
|
||
raise typer.Exit(1)
|
||
|
||
del inventory.actors[actor_name]
|
||
try:
|
||
save_inventory(inventory, cfg.inventory_path)
|
||
except Exception as e:
|
||
err.print(f"[red]Failed to save inventory:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
console.print(f"[green]Removed[/green] {actor_name}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden cleanup
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.command()
|
||
def cleanup(
|
||
actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None,
|
||
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without deleting")] = False,
|
||
) -> None:
|
||
"""Remove stale (expired > 5 min) certificates from state dir."""
|
||
cfg = _load_cfg()
|
||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||
|
||
if actor_name:
|
||
cert_path = cfg.state_dir / f"{actor_name}-cert.pub"
|
||
paths = [cert_path] if cert_path.exists() else []
|
||
else:
|
||
paths = sorted(cfg.state_dir.glob("*-cert.pub")) if cfg.state_dir.exists() else []
|
||
|
||
removed = []
|
||
for cert_path in paths:
|
||
try:
|
||
meta = parse_cert_metadata(cert_path)
|
||
except Exception:
|
||
continue
|
||
if meta["valid_before"] < cutoff:
|
||
if dry_run:
|
||
console.print(f"would remove: {cert_path.name}")
|
||
else:
|
||
cert_path.unlink()
|
||
console.print(f"removed: {cert_path.name}")
|
||
removed.append(cert_path.name)
|
||
|
||
if not removed:
|
||
console.print("No stale certificates found.")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden log
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.command()
|
||
def log(
|
||
actor_name: Annotated[Optional[str], typer.Argument(help="Filter by actor name")] = None,
|
||
last: Annotated[int, typer.Option("--last", help="Show last N entries")] = 20,
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Show outgoing certificate signing history."""
|
||
cfg = _load_cfg()
|
||
log_path = cfg.state_dir / "signatures.log"
|
||
|
||
if not log_path.exists():
|
||
if output_json:
|
||
print("[]")
|
||
else:
|
||
console.print("No signatures log found.")
|
||
return
|
||
|
||
entries = []
|
||
for line in log_path.read_text().splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entry = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if actor_name and entry.get("actor") != actor_name:
|
||
continue
|
||
entries.append(entry)
|
||
|
||
entries = entries[-last:]
|
||
|
||
if output_json:
|
||
print(json.dumps(entries, indent=2))
|
||
return
|
||
|
||
if not entries:
|
||
console.print("No matching log entries.")
|
||
return
|
||
|
||
table = Table(title="Signatures Log")
|
||
table.add_column("Timestamp")
|
||
table.add_column("Actor")
|
||
table.add_column("Type")
|
||
table.add_column("Identity")
|
||
table.add_column("TTL (h)")
|
||
table.add_column("Valid Before (UTC)")
|
||
table.add_column("Backend")
|
||
for e in entries:
|
||
table.add_row(
|
||
e.get("timestamp", "")[:19],
|
||
e.get("actor", ""),
|
||
e.get("actor_type", ""),
|
||
e.get("identity", ""),
|
||
str(e.get("ttl_hours", "")),
|
||
e.get("valid_before", "")[:19],
|
||
e.get("backend", ""),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden route — read-only routing lookup over the pointer catalog
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _load_catalog():
|
||
from warden.routing import CatalogError, load_catalog
|
||
try:
|
||
return load_catalog()
|
||
except CatalogError as e:
|
||
err.print(f"[red]Routing catalog error:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
|
||
def _entry_summary(entry) -> dict:
|
||
"""Pointer-only summary. Never includes secret material."""
|
||
return {
|
||
"id": entry.id,
|
||
"title": entry.title,
|
||
"owner_repo": entry.owner_repo,
|
||
"subsystem": entry.subsystem,
|
||
"warden_executes": entry.warden_executes,
|
||
# warden_role tells an agent at a glance whether ops-warden runs this lane
|
||
# itself (issue), proxies the fetch as the caller (assist), or only points (route).
|
||
"warden_role": (
|
||
"issue" if entry.warden_executes
|
||
else "assist" if entry.exec_capable
|
||
else "route"
|
||
),
|
||
"exec_capable": entry.exec_capable,
|
||
# resolvable: can `warden access --fetch` run this now with no <…> to fill?
|
||
# Lets an automated caller gate on readiness before attempting a fetch.
|
||
"resolvable": entry.resolvable,
|
||
# Owner-native exec front door (WP-0019): when present, this subsystem's exec is
|
||
# the PRIMARY path; ops-warden's proxy is the transparent fallback.
|
||
**(
|
||
{
|
||
"exec_owner": entry.exec_owner,
|
||
"exec_command": entry.exec_command,
|
||
"pointer_command": entry.pointer_command,
|
||
}
|
||
if entry.has_native_exec
|
||
else {}
|
||
),
|
||
"wiki_ref": entry.wiki_ref,
|
||
"canon_ref": entry.canon_ref,
|
||
"reviewed": entry.reviewed,
|
||
"status": entry.status,
|
||
# Agent read-boundary (WP-0026 T04) — high-risk lanes deny raw agent data reads.
|
||
"risk": entry.risk,
|
||
"high_risk": entry.is_high_risk,
|
||
# Renewal guidance (WP-0026 T06) — advisory, no secret values. `has_rotation`
|
||
# lets a caller gate before asking for the full block via `warden rotate-guide`.
|
||
"has_rotation": entry.has_rotation,
|
||
**(
|
||
{
|
||
"rotation": {
|
||
"method": entry.rotation.method,
|
||
"owner": entry.rotation.owner,
|
||
"automatable": entry.rotation.automatable,
|
||
"steps": entry.rotation.steps,
|
||
}
|
||
}
|
||
if entry.has_rotation
|
||
else {}
|
||
),
|
||
}
|
||
|
||
|
||
def _print_entry_table(
|
||
entries, title: str, *, show_reviewed: bool = False, stale_threshold_days: int = 90
|
||
) -> None:
|
||
table = Table(title=title)
|
||
table.add_column("ID")
|
||
table.add_column("Need")
|
||
table.add_column("Owner")
|
||
table.add_column("warden")
|
||
if show_reviewed:
|
||
table.add_column("Reviewed")
|
||
table.add_column("Days")
|
||
table.add_column("Status")
|
||
from warden.routing.catalog import days_since_review
|
||
|
||
for e in entries:
|
||
if e.warden_executes:
|
||
executes = "[green]issue[/green]"
|
||
elif e.exec_capable:
|
||
executes = "[cyan]assist[/cyan]" # warden access --fetch/--exec proxies it
|
||
else:
|
||
executes = "route"
|
||
status_styled = e.status if e.status == "active" else f"[yellow]{e.status}[/yellow]"
|
||
if show_reviewed:
|
||
days = days_since_review(e.reviewed)
|
||
reviewed_styled = (
|
||
f"[yellow]{e.reviewed}[/yellow]"
|
||
if days > stale_threshold_days
|
||
else e.reviewed
|
||
)
|
||
table.add_row(
|
||
e.id, e.title, e.owner_repo, executes, reviewed_styled, str(days), status_styled
|
||
)
|
||
else:
|
||
table.add_row(e.id, e.title, e.owner_repo, executes, status_styled)
|
||
console.print(table)
|
||
|
||
|
||
@route_app.command("list")
|
||
def route_list(
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False,
|
||
tag: Annotated[Optional[str], typer.Option("--tag", help="Filter by need keyword")] = None,
|
||
stale_only: Annotated[
|
||
bool, typer.Option("--stale", help="Show entries past review cadence (see --stale-days)")
|
||
] = False,
|
||
stale_days: Annotated[
|
||
int,
|
||
typer.Option(
|
||
"--stale-days",
|
||
help="Days since reviewed before an entry is stale (default 90)",
|
||
min=1,
|
||
),
|
||
] = 90,
|
||
) -> None:
|
||
"""List routing scenarios. Active-only unless --all."""
|
||
from warden.routing.catalog import days_since_review
|
||
|
||
catalog = _load_catalog()
|
||
if stale_only:
|
||
entries = catalog.stale(include_draft=all_entries, threshold_days=stale_days)
|
||
else:
|
||
entries = catalog.listed(include_draft=all_entries)
|
||
if tag:
|
||
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)
|
||
if stale_only:
|
||
row["days_since_review"] = days_since_review(e.reviewed)
|
||
row["stale_threshold_days"] = stale_days
|
||
payload.append(row)
|
||
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).")
|
||
else:
|
||
console.print("No matching routing entries.")
|
||
return
|
||
title = (
|
||
f"Stale routing scenarios (>{stale_days}d since reviewed)"
|
||
if stale_only
|
||
else "Routing scenarios"
|
||
)
|
||
_print_entry_table(
|
||
entries, title, show_reviewed=stale_only, stale_threshold_days=stale_days
|
||
)
|
||
|
||
|
||
@route_app.command("show")
|
||
def route_show(
|
||
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Show owner, pointers, and (SSH only) the authored steps for one scenario."""
|
||
catalog = _load_catalog()
|
||
entry = catalog.get(entry_id)
|
||
if entry is None:
|
||
err.print(
|
||
f"[red]Unknown routing id {entry_id!r}.[/red] "
|
||
f"Try: warden route find {entry_id!r}"
|
||
)
|
||
raise typer.Exit(1)
|
||
|
||
if output_json:
|
||
summary = _entry_summary(entry)
|
||
summary["need_keywords"] = entry.need_keywords
|
||
if entry.warden_executes:
|
||
summary["steps"] = entry.steps
|
||
summary["cert_command"] = entry.cert_command
|
||
elif entry.has_native_exec:
|
||
summary["next_action"] = (
|
||
f"primary: run via {entry.exec_owner} — `{entry.exec_command}`; ops-warden "
|
||
f"routes to the owner (fallback: `warden access <need> --exec`). See `{entry.wiki_ref}`."
|
||
)
|
||
elif entry.exec_capable:
|
||
summary["next_action"] = (
|
||
f"ops-warden can proxy this as the caller: `warden access <need> --fetch`"
|
||
f" (or `--exec -- <cmd>`); runs {entry.owner_repo}'s tool with your "
|
||
f"identity. See `{entry.wiki_ref}`."
|
||
)
|
||
else:
|
||
summary["next_action"] = (
|
||
f"next action on `{entry.owner_repo}` — see `{entry.wiki_ref}`"
|
||
)
|
||
print(json.dumps(summary, indent=2))
|
||
return
|
||
|
||
console.print(f"[bold]{entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
||
console.print(f" owner : {entry.owner_repo} ({entry.subsystem})")
|
||
console.print(f" wiki : {entry.wiki_ref}")
|
||
console.print(f" canon : {entry.canon_ref}")
|
||
console.print(f" reviewed : {entry.reviewed} status: {entry.status}")
|
||
|
||
if entry.warden_executes:
|
||
console.print("\n[green]ops-warden issues this directly.[/green]")
|
||
console.print(f" cert_command: [bold]{entry.cert_command}[/bold]")
|
||
if entry.steps:
|
||
console.print(" steps:")
|
||
for i, step in enumerate(entry.steps, 1):
|
||
console.print(f" {i}. {step}")
|
||
console.print(
|
||
" precondition: actor in inventory? backend configured? run `warden status`."
|
||
)
|
||
else:
|
||
console.print(
|
||
f"\n[yellow]ops-warden does not issue this.[/yellow] "
|
||
f"Next action on [bold]{entry.owner_repo}[/bold] — see {entry.wiki_ref}."
|
||
)
|
||
|
||
|
||
@app.command("taint")
|
||
def taint_show(
|
||
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Report whether a lane's OpenBao secret is marked EXPOSED (WP-0026 T05).
|
||
|
||
Reads KV v2 *metadata only* (custom_metadata: exposed_at, exposed_version, …).
|
||
Never reads secret data. Advisory — does not rotate or clear taint.
|
||
"""
|
||
from warden.taint import TaintError, fetch_taint_status
|
||
|
||
catalog = _load_catalog()
|
||
entry = catalog.get(entry_id)
|
||
if entry is None:
|
||
# Drafts are findable by exact id via get even when not listed.
|
||
err.print(
|
||
f"[red]Unknown routing id {entry_id!r}.[/red] Try: warden route find {entry_id!r} --all"
|
||
)
|
||
raise typer.Exit(1)
|
||
|
||
try:
|
||
status = fetch_taint_status(entry)
|
||
except TaintError as e:
|
||
err.print(f"[red]taint status unavailable:[/red] {e}")
|
||
raise typer.Exit(2)
|
||
|
||
if output_json:
|
||
print(json.dumps(status.to_dict(), indent=2))
|
||
return
|
||
|
||
console.print(f"[bold]Taint status — {entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
||
console.print(f" path : {status.path}")
|
||
if status.error:
|
||
console.print(f" [yellow]query error[/yellow] : {status.error}")
|
||
console.print(
|
||
" [dim]Need caller OpenBao auth with metadata-read on the path "
|
||
"(agent-high-risk-boundary allows metadata; workload-kv-read allows both).[/dim]"
|
||
)
|
||
raise typer.Exit(3)
|
||
if status.tainted:
|
||
console.print(" tainted : [red]yes (EXPOSED)[/red]")
|
||
console.print(f" exposed_at : {status.exposed_at}")
|
||
console.print(f" exposed_version : {status.exposed_version}")
|
||
console.print(f" exposed_reason : {status.exposed_reason}")
|
||
console.print(f" exposed_ref : {status.exposed_ref}")
|
||
console.print(f" current_version : {status.current_version}")
|
||
console.print(
|
||
"\n[yellow]Advisory:[/yellow] rotate/re-establish per "
|
||
f"`warden rotate-guide {entry.id}` then clear custom_metadata keys "
|
||
"(exposed_at, exposed_version, …). No auto-rotation (Strand B)."
|
||
)
|
||
else:
|
||
console.print(" tainted : [green]no[/green]")
|
||
console.print(f" current_version : {status.current_version}")
|
||
|
||
|
||
@app.command("rotate-guide")
|
||
def rotate_guide(
|
||
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Show how to rotate or re-establish a lane's credential (WP-0026 T06).
|
||
|
||
Advisory renewal guidance held in the ops-warden registry — never a secret
|
||
value, and ops-warden does not execute it (that is Strand B, WARDEN-WP-0027).
|
||
"""
|
||
catalog = _load_catalog()
|
||
entry = catalog.get(entry_id)
|
||
if entry is None:
|
||
err.print(
|
||
f"[red]Unknown routing id {entry_id!r}.[/red] Try: warden route find {entry_id!r}"
|
||
)
|
||
raise typer.Exit(1)
|
||
|
||
if not entry.has_rotation:
|
||
if output_json:
|
||
print(json.dumps({"id": entry.id, "has_rotation": False}, indent=2))
|
||
else:
|
||
err.print(
|
||
f"[yellow]No rotation guidance for {entry.id!r}.[/yellow] "
|
||
+ (
|
||
"This is the SSH lane — renewal is re-issuance (`warden sign`)."
|
||
if entry.warden_executes
|
||
else "Add a `rotation:` block to the catalog entry (WP-0026 T06)."
|
||
)
|
||
)
|
||
raise typer.Exit(0 if entry.warden_executes else 1)
|
||
|
||
rot = entry.rotation
|
||
if output_json:
|
||
print(json.dumps(
|
||
{
|
||
"id": entry.id,
|
||
"method": rot.method,
|
||
"owner": rot.owner,
|
||
"automatable": rot.automatable,
|
||
"steps": rot.steps,
|
||
},
|
||
indent=2,
|
||
))
|
||
return
|
||
|
||
console.print(f"[bold]Rotation guidance — {entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
||
console.print(f" method : {rot.method} owner: {rot.owner} automatable: {rot.automatable}")
|
||
console.print(" steps:")
|
||
for i, step in enumerate(rot.steps, 1):
|
||
console.print(f" {i}. {step}")
|
||
console.print(
|
||
"\n[dim]Advisory only — ops-warden holds no value and does not execute this "
|
||
"(one-command rotation is Strand B, WARDEN-WP-0027).[/dim]"
|
||
)
|
||
|
||
|
||
@route_app.command("find")
|
||
def route_find(
|
||
query: Annotated[str, typer.Argument(help="Free-text need, e.g. 'issue core api key'")],
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False,
|
||
limit: Annotated[int, typer.Option("--limit", help="Max matches")] = 5,
|
||
) -> None:
|
||
"""Rank routing scenarios by keyword overlap with the query."""
|
||
try:
|
||
from warden import memory as warden_memory
|
||
|
||
warden_memory.ensure_memory_context(need=query, implicit=True)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
catalog = _load_catalog()
|
||
matches = catalog.find(query, include_draft=all_entries, limit=limit)
|
||
|
||
if output_json:
|
||
print(json.dumps([_entry_summary(e) for e in matches], indent=2))
|
||
if matches:
|
||
_record_memory_episode(
|
||
command="route find",
|
||
outcome="resolved",
|
||
need=query,
|
||
route_id=matches[0].id,
|
||
)
|
||
else:
|
||
_record_memory_episode(command="route find", outcome="skipped", need=query)
|
||
return
|
||
|
||
if not matches:
|
||
_record_memory_episode(command="route find", outcome="skipped", need=query)
|
||
console.print(
|
||
f"No routing match for {query!r}. "
|
||
"Try `warden route list --all` to browse all scenarios."
|
||
)
|
||
return
|
||
_record_memory_episode(
|
||
command="route find",
|
||
outcome="resolved",
|
||
need=query,
|
||
route_id=matches[0].id,
|
||
)
|
||
_print_entry_table(matches, f"Matches for {query!r}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden access — operator front door (advisory; proxy lands in T3)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _access_json(entry, expanded, gate: str, domain: Optional[str]) -> dict:
|
||
"""Stable, secret-free JSON shape for agentic operators. WP-0014 T2."""
|
||
payload = _entry_summary(entry)
|
||
payload["domain"] = domain
|
||
payload["policy_gate"] = gate
|
||
payload["handoff"] = {
|
||
"auth_method": expanded.auth_method,
|
||
"path_template": expanded.path_template,
|
||
"fetch_command": expanded.fetch_command,
|
||
"policy_ref": expanded.policy_ref,
|
||
"exec_capable": expanded.exec_capable,
|
||
}
|
||
if entry.warden_executes:
|
||
payload["next_action"] = "ops-warden issues this directly — see cert_command"
|
||
payload["cert_command"] = entry.cert_command
|
||
elif entry.has_native_exec:
|
||
payload["next_action"] = (
|
||
f"primary: run via {entry.exec_owner} — `{entry.exec_command}`; "
|
||
"ops-warden routes to the owner (fallback: `warden access <need> --exec`). "
|
||
"ops-warden holds no token."
|
||
)
|
||
elif expanded.exec_capable:
|
||
verb = "fetch" if entry.lane != "login" else "login"
|
||
payload["next_action"] = (
|
||
f"ops-warden can proxy this {verb} as the caller: "
|
||
f"`warden access <need> --fetch`"
|
||
+ ("" if entry.lane == "login" else " (or `--exec -- <cmd>`)")
|
||
+ f". Runs {entry.owner_repo}'s tool with your identity; ops-warden holds no value."
|
||
)
|
||
else:
|
||
payload["next_action"] = (
|
||
f"obtain from {entry.owner_repo} ({entry.subsystem}); "
|
||
"ops-warden holds no value"
|
||
)
|
||
return payload
|
||
|
||
|
||
def _access_proxy(
|
||
entry,
|
||
*,
|
||
domain: Optional[str],
|
||
field: Optional[str],
|
||
path: Optional[str],
|
||
do_exec: bool,
|
||
child_argv: list,
|
||
no_policy: bool,
|
||
out_path: Optional[str] = None,
|
||
wrap: bool = False,
|
||
wrap_ttl: str = "5m",
|
||
unsafe_stdout: bool = False,
|
||
fingerprint: bool = False,
|
||
) -> None:
|
||
"""Proxy a non-SSH credential fetch as the caller (WP-0014 T3).
|
||
|
||
Enforces the three guardrails: caller identity (no warden token), policy gate
|
||
before fetch, and transit-only (no value persisted or logged). All warden chatter
|
||
goes to stderr so --fetch stdout carries only the secret.
|
||
"""
|
||
from warden.proxy import (
|
||
ProxyError,
|
||
build_wrapped_fetch,
|
||
caller_auth_present,
|
||
proxy_exec,
|
||
proxy_fetch,
|
||
proxy_fetch_fingerprint,
|
||
proxy_fetch_to_file,
|
||
proxy_fetch_wrapped,
|
||
resolve_fetch_command,
|
||
write_audit,
|
||
)
|
||
from warden.policy import check_fetch_policy
|
||
|
||
if not entry.exec_capable:
|
||
err.print(
|
||
f"[red]{entry.id!r} is not exec_capable.[/red] "
|
||
"Use `warden access` (advisory) and obtain it from the owner directly."
|
||
)
|
||
raise typer.Exit(2)
|
||
|
||
# Proxy is privileged — require a real config for policy posture + audit sink.
|
||
try:
|
||
cfg = load_config()
|
||
except ConfigError as e:
|
||
err.print(
|
||
f"[red]Proxy requires warden.yaml[/red] (policy gate + audit sink): {e}\n"
|
||
"Advisory mode works without it: drop --fetch/--exec."
|
||
)
|
||
raise typer.Exit(2)
|
||
|
||
is_login = entry.lane == "login"
|
||
decision_id = None
|
||
|
||
if is_login:
|
||
# Login lane: interactive auth bootstrap. No caller-auth precheck (you have no
|
||
# token yet — that's the point) and no secret-read gate (it needs an identity
|
||
# this flow establishes). --exec is meaningless here.
|
||
if do_exec:
|
||
err.print(
|
||
"[red]--exec is not valid for a login lane[/red] "
|
||
f"({entry.id!r} is interactive auth). Use --fetch."
|
||
)
|
||
raise typer.Exit(2)
|
||
err.print(
|
||
"[dim]login lane — interactive auth bootstrap; no secret-read gate, "
|
||
"token stays in the caller's own store.[/dim]"
|
||
)
|
||
else:
|
||
# G1 — caller identity. ops-warden adds no token of its own.
|
||
if not caller_auth_present():
|
||
err.print(
|
||
"[red]No caller credential found[/red] (VAULT_TOKEN/BAO_TOKEN or ~/.vault-token). "
|
||
f"Authenticate first: {entry.auth_method or 'see the owner auth path'}."
|
||
)
|
||
raise typer.Exit(3)
|
||
|
||
# G3 — policy gate before fetch.
|
||
if cfg.policy.enabled:
|
||
try:
|
||
decision_id = check_fetch_policy(
|
||
cfg.policy, need_id=entry.id, owner_repo=entry.owner_repo, domain=domain
|
||
)
|
||
except CAError as e:
|
||
err.print(f"[red]Policy gate denied the fetch:[/red] {e}")
|
||
raise typer.Exit(4)
|
||
err.print(f"[green]flex-auth allow[/green] (decision {decision_id}).")
|
||
elif not no_policy:
|
||
err.print(
|
||
"[yellow]flex-auth gate is not enforced[/yellow] (policy.enabled=false). "
|
||
"Re-run with [bold]--no-policy[/bold] to proxy ungated, or enable the gate."
|
||
)
|
||
raise typer.Exit(4)
|
||
else:
|
||
err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).")
|
||
|
||
# Wrapping (WP-0026 T02) uses its own command shape; the value-bearing transports
|
||
# share the resolved fetch command.
|
||
if wrap and not is_login:
|
||
try:
|
||
resolved = build_wrapped_fetch(entry, path=path, ttl=wrap_ttl)
|
||
except ProxyError as e:
|
||
err.print(f"[red]{e}[/red]")
|
||
raise typer.Exit(2)
|
||
else:
|
||
try:
|
||
resolved = resolve_fetch_command(entry, domain=domain, field=field, path=path)
|
||
except ProxyError as e:
|
||
err.print(f"[red]{e}[/red]")
|
||
raise typer.Exit(2)
|
||
|
||
# T04 — agent identity on a high-risk lane: never stream raw secret data.
|
||
# Agents may use sanctioned transports (--out / --exec / --wrap / --fingerprint).
|
||
agent_id = os.environ.get("WARDEN_AGENT_ID", "").strip()
|
||
raw_value_stream = (
|
||
not is_login and not do_exec and not wrap and not out_path and not fingerprint
|
||
)
|
||
if raw_value_stream and entry.is_high_risk and agent_id:
|
||
err.print(
|
||
f"[red]Agent read-boundary:[/red] {entry.id!r} is risk=high; "
|
||
f"agent identity {agent_id!r} must not stream raw secret data.\n"
|
||
"Use a sanctioned transport (value stays off the session transcript):\n"
|
||
" --out FILE write to a mode-0600 file\n"
|
||
" --exec -- CMD inject into a child process env only\n"
|
||
" --wrap single-use OpenBao wrapping token (unwrap out-of-band)\n"
|
||
" --fingerprint masked presence/length/hash only\n"
|
||
"OpenBao policy `agent-high-risk-boundary` also denies data-read for agents."
|
||
)
|
||
raise typer.Exit(7)
|
||
|
||
# T02 — the sanctioned fetch transports (file / env / wrapping token) never put a
|
||
# secret value on stdout. Streaming a value to stdout is the documented anti-pattern:
|
||
# allowed only to an interactive terminal, and only with an explicit acknowledgment
|
||
# when stdout is captured/piped (the logged-context disclosure risk).
|
||
if raw_value_stream:
|
||
import sys as _sys
|
||
|
||
if not _sys.stdout.isatty() and not unsafe_stdout:
|
||
err.print(
|
||
"[red]Refusing to stream a secret value to a non-terminal stdout[/red] "
|
||
"(captured/piped output is a disclosure risk). Use a sanctioned transport:\n"
|
||
" --out FILE write the value to a mode-0600 file\n"
|
||
" --exec -- CMD inject it into a child process env\n"
|
||
" --wrap return a single-use OpenBao wrapping token to unwrap yourself\n"
|
||
"Override only for an interactive human session: --unsafe-stdout."
|
||
)
|
||
raise typer.Exit(6)
|
||
|
||
action = "login" if is_login else ("exec" if do_exec else "fetch")
|
||
err.print(
|
||
f"[dim]proxy {action}: {entry.id} → {entry.owner_repo} "
|
||
f"(caller identity; value not persisted)[/dim]"
|
||
)
|
||
try:
|
||
if do_exec:
|
||
if not child_argv:
|
||
err.print("[red]--exec needs a command after `--`[/red], e.g. `-- npm publish`.")
|
||
raise typer.Exit(2)
|
||
rc = proxy_exec(resolved, env_var=field or "", child_argv=child_argv)
|
||
elif wrap:
|
||
token = proxy_fetch_wrapped(resolved)
|
||
# The wrapping token is not the secret value — safe to hand back on stdout.
|
||
print(token)
|
||
err.print(
|
||
f"[dim]wrapping token (single-use, ttl {wrap_ttl}) — unwrap in your own "
|
||
f"context: [bold]bao unwrap {'<token>'}[/bold][/dim]"
|
||
)
|
||
rc = 0
|
||
elif out_path:
|
||
rc = proxy_fetch_to_file(resolved, Path(out_path))
|
||
err.print(f"[dim]value written to {out_path} (mode 0600); not shown[/dim]")
|
||
elif fingerprint:
|
||
fp = proxy_fetch_fingerprint(resolved)
|
||
# Masked fingerprint only — presence, length, short hash; never the value.
|
||
print(fp.render())
|
||
err.print(
|
||
"[dim]masked fingerprint (defense-in-depth; not the value). Compare "
|
||
"sha256 prefixes to confirm two parties hold the same secret.[/dim]"
|
||
)
|
||
rc = 0
|
||
else:
|
||
rc = proxy_fetch(resolved)
|
||
except ProxyError as e:
|
||
err.print(f"[red]{e}[/red]")
|
||
raise typer.Exit(5)
|
||
finally:
|
||
try:
|
||
write_audit(
|
||
cfg.state_dir,
|
||
need_id=entry.id,
|
||
owner_repo=entry.owner_repo,
|
||
domain=domain,
|
||
action=action,
|
||
decision_id=decision_id,
|
||
)
|
||
except OSError as e:
|
||
err.print(f"[yellow]audit write failed:[/yellow] {e}")
|
||
|
||
raise typer.Exit(rc)
|
||
|
||
|
||
@app.command(
|
||
"access",
|
||
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
||
)
|
||
def access(
|
||
ctx: typer.Context,
|
||
need: Annotated[str, typer.Argument(help="Free-text need, e.g. 'npm token', 'db password'")],
|
||
domain: Annotated[
|
||
Optional[str],
|
||
typer.Option("--domain", help="Substitute <domain> in path/auth templates, e.g. coulomb_social"),
|
||
] = None,
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON (stable, secret-free)")] = False,
|
||
all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False,
|
||
do_fetch: Annotated[
|
||
bool, typer.Option("--fetch", help="Proxy the fetch as the caller (pair with --out/--wrap; raw stdout is guarded)")
|
||
] = False,
|
||
do_exec: Annotated[
|
||
bool,
|
||
typer.Option("--exec", help="Run the trailing command (after --) with the secret in its env"),
|
||
] = False,
|
||
field: Annotated[
|
||
Optional[str], typer.Option("--field", help="Secret field / env-var name, e.g. NPM_AUTH_TOKEN")
|
||
] = None,
|
||
path: Annotated[
|
||
Optional[str], typer.Option("--path", help="Override the owner-side path template")
|
||
] = None,
|
||
out_path: Annotated[
|
||
Optional[str],
|
||
typer.Option("--out", help="Sanctioned transport: write the value to this mode-0600 file, not stdout"),
|
||
] = None,
|
||
wrap: Annotated[
|
||
bool,
|
||
typer.Option("--wrap", help="Sanctioned transport: return a single-use OpenBao wrapping token (bao unwrap)"),
|
||
] = False,
|
||
wrap_ttl: Annotated[
|
||
str, typer.Option("--wrap-ttl", help="TTL for the --wrap response-wrapping token")
|
||
] = "5m",
|
||
unsafe_stdout: Annotated[
|
||
bool,
|
||
typer.Option("--unsafe-stdout", help="Acknowledge streaming a value to a captured/piped stdout (anti-pattern)"),
|
||
] = False,
|
||
fingerprint: Annotated[
|
||
bool,
|
||
typer.Option("--fingerprint", help="Show a masked fingerprint (presence, length, short hash) — never the value"),
|
||
] = False,
|
||
no_policy: Annotated[
|
||
bool,
|
||
typer.Option("--no-policy", help="Acknowledge proxying when the flex-auth gate is not enforced"),
|
||
] = False,
|
||
) -> None:
|
||
"""Operator front door: how to obtain any credential, gated and audited.
|
||
|
||
Advisory by default — renders the owner, auth method, path template, command
|
||
skeleton, and policy gate status for the best-matching need. ops-warden issues
|
||
the SSH lane directly and **routes every other need to its owner** — it never
|
||
holds or vends the secret value.
|
||
|
||
With --fetch / --exec it proxies the fetch *as the caller* for exec_capable lanes:
|
||
the flex-auth gate runs first, ops-warden adds no credential of its own, the value
|
||
is never persisted or logged, and only metadata is audited.
|
||
"""
|
||
from warden.access import expand_handoff, policy_gate_status
|
||
|
||
try:
|
||
from warden import memory as warden_memory
|
||
|
||
warden_memory.ensure_memory_context(need=need, implicit=True)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
catalog = _load_catalog()
|
||
matches = catalog.find(need, include_draft=all_entries, limit=1)
|
||
if not matches:
|
||
err.print(
|
||
f"[red]No access match for {need!r}.[/red] "
|
||
"Try `warden route list --all` to browse, or rephrase the need."
|
||
)
|
||
raise typer.Exit(1)
|
||
|
||
entry = matches[0]
|
||
|
||
if do_fetch or do_exec or out_path or wrap or fingerprint:
|
||
_access_proxy(
|
||
entry,
|
||
domain=domain,
|
||
field=field,
|
||
path=path,
|
||
do_exec=do_exec,
|
||
child_argv=list(ctx.args),
|
||
no_policy=no_policy,
|
||
out_path=out_path,
|
||
wrap=wrap,
|
||
wrap_ttl=wrap_ttl,
|
||
unsafe_stdout=unsafe_stdout,
|
||
fingerprint=fingerprint,
|
||
)
|
||
return
|
||
|
||
expanded = expand_handoff(entry, domain)
|
||
gate = policy_gate_status()
|
||
|
||
if output_json:
|
||
print(json.dumps(_access_json(entry, expanded, gate, domain), indent=2))
|
||
_record_memory_episode(
|
||
command="access",
|
||
outcome="resolved",
|
||
need=need,
|
||
route_id=entry.id,
|
||
)
|
||
return
|
||
|
||
_record_memory_episode(
|
||
command="access",
|
||
outcome="resolved",
|
||
need=need,
|
||
route_id=entry.id,
|
||
)
|
||
console.print(f"[bold]{entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
||
console.print(f" owner : {entry.owner_repo} ({entry.subsystem})")
|
||
|
||
if entry.warden_executes:
|
||
console.print("\n[green]ops-warden issues this directly.[/green]")
|
||
console.print(f" run : [bold]{entry.cert_command}[/bold]")
|
||
if entry.steps:
|
||
for i, step in enumerate(entry.steps, 1):
|
||
console.print(f" {i}. {step}")
|
||
return
|
||
|
||
if expanded.auth_method:
|
||
console.print(f" auth : {expanded.auth_method}")
|
||
if expanded.path_template:
|
||
console.print(f" path : {expanded.path_template}")
|
||
if expanded.fetch_command:
|
||
console.print(f" fetch : {expanded.fetch_command}")
|
||
if expanded.policy_ref:
|
||
console.print(f" policy : {expanded.policy_ref} [dim]({gate})[/dim]")
|
||
console.print(f" wiki : {entry.wiki_ref}")
|
||
console.print(f" canon : {entry.canon_ref}")
|
||
|
||
proxy = f"warden access {need!r}"
|
||
if domain:
|
||
proxy += f" --domain {domain}"
|
||
|
||
if entry.has_native_exec:
|
||
console.print(
|
||
f" exec : [bold]{entry.exec_command}[/bold] "
|
||
f"[cyan](via {entry.exec_owner} — primary)[/cyan]"
|
||
)
|
||
if entry.pointer_command:
|
||
console.print(f" pointer : [dim]{entry.pointer_command}[/dim]")
|
||
if expanded.exec_capable:
|
||
label = "fallback" if entry.has_native_exec else "proxy"
|
||
hint = (
|
||
"transparent conduit — fetches as you"
|
||
if entry.lane != "login"
|
||
else "runs the interactive login as you"
|
||
)
|
||
console.print(f" {label:<8} : [dim]{proxy} --fetch[/dim] [yellow]({hint})[/yellow]")
|
||
if expanded.path_template and "<" in expanded.path_template:
|
||
console.print(
|
||
" note : remaining <…> placeholders are owner-confirmed names "
|
||
f"(coordinate with {entry.owner_repo})."
|
||
)
|
||
|
||
if entry.has_native_exec:
|
||
console.print(
|
||
f"\n[green]Primary:[/green] run it via [bold]{entry.exec_owner}[/bold] — "
|
||
f"[bold]{entry.exec_command}[/bold]. ops-warden routes to the owner and holds no token.\n"
|
||
f"[dim]Fallback:[/dim] [bold]{proxy} --exec -- <cmd>[/bold] — ops-warden's transparent "
|
||
"conduit (runs the fetch as you, holds nothing)."
|
||
)
|
||
elif expanded.exec_capable:
|
||
verb = "fetch this for you" if entry.lane != "login" else "run this login for you"
|
||
console.print(
|
||
f"\n[green]ops-warden can {verb}[/green] as the caller — "
|
||
f"[bold]{proxy} --fetch[/bold]"
|
||
+ ("" if entry.lane == "login" else f" (or [bold]{proxy} --exec -- <cmd>[/bold])")
|
||
+ f". It runs {entry.owner_repo}'s tool with [bold]your[/bold] identity; the "
|
||
"value streams to you and ops-warden never holds, caches, or logs it."
|
||
)
|
||
else:
|
||
console.print(
|
||
f"\n[yellow]ops-warden does not hold this secret.[/yellow] "
|
||
f"Obtain it from [bold]{entry.owner_repo}[/bold] as shown — "
|
||
"warden advises, the owner vends."
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden policy — read-only Workload Security Posture lookup (WP-0015 T2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _load_posture():
|
||
from warden.posture import PostureError, load_posture
|
||
try:
|
||
return load_posture()
|
||
except PostureError as e:
|
||
err.print(f"[red]Posture descriptor error:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
|
||
@policy_app.command("list")
|
||
def policy_list(
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""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
|
||
|
||
env_table = Table(title="Axis A — environment posture")
|
||
for col in ("ID", "rank", "backend", "real values", "user data", "audit"):
|
||
env_table.add_column(col)
|
||
for e in sorted(cat.env_postures, key=lambda x: x.rank):
|
||
env_table.add_row(e.id, str(e.rank), e.backend, e.real_values, e.real_user_data, e.audit)
|
||
console.print(env_table)
|
||
|
||
mat_table = Table(title="Axis B — workload maturity")
|
||
for col in ("ID", "rank", "phase", "max dataclass", "promotion gate"):
|
||
mat_table.add_column(col)
|
||
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]"
|
||
)
|
||
|
||
|
||
@policy_app.command("show")
|
||
def policy_show(
|
||
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, maturity level, or organization posture."""
|
||
cat = _load_posture()
|
||
env = cat.env(descriptor_id)
|
||
mat = cat.maturity(descriptor_id)
|
||
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))
|
||
return
|
||
axis = "environment posture" if env else "workload maturity level"
|
||
console.print(f"[bold]{obj.id}[/bold] ([cyan]{axis}[/cyan])")
|
||
for k, v in vars(obj).items():
|
||
if k == "id":
|
||
continue
|
||
console.print(f" {k:14}: {', '.join(v) if isinstance(v, list) else v}")
|
||
if mat:
|
||
floor = [dc for dc, lvl in cat.dataclass_floor.items() if lvl == mat.id]
|
||
if floor:
|
||
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)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@worker_app.command("run")
|
||
def worker_run(
|
||
once: Annotated[bool, typer.Option("--once", help="Process the inbox once and exit")] = True,
|
||
dry_run: Annotated[
|
||
bool,
|
||
typer.Option("--dry-run/--execute", help="Plan only (default); --execute lands in WP-0020 T3"),
|
||
] = True,
|
||
brain: Annotated[
|
||
str,
|
||
typer.Option("--brain", help="Planner: 'rule' (deterministic, default) or 'llm' (llm-connect)"),
|
||
] = "rule",
|
||
full_auto: Annotated[
|
||
bool,
|
||
typer.Option("--full-auto", help="With --execute: auto-send replies + mark-read (default is conservative: triage + drafts only)"),
|
||
] = False,
|
||
) -> None:
|
||
"""Read ops-warden's unread coordination requests and act on them, guardrailed.
|
||
|
||
Default `--dry-run` previews. `--execute` runs the **conservative** tier: triage new
|
||
messages into a reviewed digest with drafted replies, post one progress note, and send
|
||
NOTHING to other agents (safe to schedule). `--execute --full-auto` auto-sends the safe
|
||
allowlisted actions. The allowlist + no-secret guardrails hold in every mode.
|
||
"""
|
||
from warden.worker import (
|
||
HubClient, LlmConnectBrain, RuleBrain, build_plans, execute_plans, render_plans,
|
||
run_conservative,
|
||
)
|
||
|
||
if brain not in ("rule", "llm"):
|
||
err.print(f"[red]Unknown --brain {brain!r}[/red] (expected 'rule' or 'llm').")
|
||
raise typer.Exit(2)
|
||
|
||
hub = HubClient()
|
||
try:
|
||
messages = hub.unread()
|
||
except Exception as e: # noqa: BLE001 — surface any transport error as a clean message
|
||
err.print(f"[red]Could not read the State Hub inbox:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
chosen = LlmConnectBrain() if brain == "llm" else RuleBrain()
|
||
plans = build_plans(messages, chosen)
|
||
auto = sum(1 for p in plans if not p.escalated)
|
||
|
||
if dry_run:
|
||
console.print(render_plans(plans))
|
||
console.print(
|
||
f"\n[dim]{len(plans)} request(s): {auto} auto-actionable, "
|
||
f"{len(plans) - auto} need a human. (dry-run — nothing executed)[/dim]"
|
||
)
|
||
return
|
||
|
||
# --execute. Topic for audit progress events.
|
||
topic_id = "cee7bedf-2b48-46ef-8601-006474f2ad7a"
|
||
if full_auto:
|
||
console.print("[yellow]Executing FULL-AUTO (in-scope only; escalations left for a human)…[/yellow]")
|
||
console.print(execute_plans(plans, hub, topic_id=topic_id))
|
||
else:
|
||
console.print("[green]Conservative triage[/green] — drafting; nothing sent to other agents.")
|
||
console.print(run_conservative(plans, hub, topic_id=topic_id))
|
||
|
||
|
||
@worker_app.command("drafts")
|
||
def worker_drafts() -> None:
|
||
"""List the worker's pending drafted replies (from the conservative tier)."""
|
||
from warden.worker import list_drafts
|
||
console.print(list_drafts())
|
||
|
||
|
||
@worker_app.command("approve")
|
||
def worker_approve(
|
||
message_id: Annotated[str, typer.Argument(help="Message id to send the drafted reply for")],
|
||
body: Annotated[
|
||
Optional[str], typer.Option("--body", help="Override the drafted reply text before sending")
|
||
] = None,
|
||
) -> None:
|
||
"""Send a reviewed draft as the reply and mark the message read."""
|
||
from warden.worker import HubClient, approve_draft
|
||
try:
|
||
console.print(approve_draft(message_id, HubClient(), body_override=body))
|
||
except Exception as e: # noqa: BLE001 — surface transport errors cleanly
|
||
err.print(f"[red]Approve failed:[/red] {e}")
|
||
raise typer.Exit(1)
|
||
|
||
|
||
@activity_app.callback(invoke_without_command=True)
|
||
def activity_show(
|
||
days: Annotated[int, typer.Option("--days", help="Look back N days")] = 7,
|
||
kind: Annotated[
|
||
Optional[str],
|
||
typer.Option("--kind", help="Filter: sign, access, worker, hub"),
|
||
] = None,
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
include_hub: Annotated[
|
||
bool, typer.Option("--hub", help="Include State Hub progress notes")
|
||
] = False,
|
||
) -> None:
|
||
"""Show what ops-warden did recently (metadata only — no secret values)."""
|
||
from warden.audit import collect_activity, fetch_hub_notes
|
||
|
||
cfg = _load_cfg()
|
||
kinds = {kind} if kind else None
|
||
events = collect_activity(cfg.state_dir, days=days, kinds=kinds)
|
||
if include_hub and (kinds is None or "hub" in kinds):
|
||
events.extend(fetch_hub_notes(days=days))
|
||
events.sort(key=lambda e: str(e.get("ts", "")))
|
||
|
||
if output_json:
|
||
print(json.dumps(events, indent=2))
|
||
return
|
||
|
||
if not events:
|
||
console.print(f"No activity in the last {days} day(s).")
|
||
return
|
||
|
||
table = Table(title=f"ops-warden activity (last {days} days)")
|
||
table.add_column("When", style="dim")
|
||
table.add_column("Kind")
|
||
table.add_column("Action")
|
||
table.add_column("Subject")
|
||
table.add_column("Target")
|
||
table.add_column("Outcome")
|
||
for event in events:
|
||
table.add_row(
|
||
str(event.get("ts", ""))[:19],
|
||
str(event.get("kind", "")),
|
||
str(event.get("action", "")),
|
||
str(event.get("subject", ""))[:24],
|
||
str(event.get("target", ""))[:28],
|
||
str(event.get("outcome", "")),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
@worker_app.command("status")
|
||
def worker_status_cmd() -> None:
|
||
"""Show worker state: pending drafts, triage count, last digest, timer status."""
|
||
import subprocess
|
||
from warden.worker import worker_status
|
||
console.print(worker_status())
|
||
try:
|
||
st = subprocess.run(
|
||
["systemctl", "--user", "is-active", "ops-warden-worker.timer"],
|
||
capture_output=True, text=True, timeout=5,
|
||
).stdout.strip()
|
||
console.print(f"timer : {st or 'unknown'}")
|
||
except Exception: # noqa: BLE001 — systemd may be absent (cron/other host)
|
||
console.print("timer : (systemd not available)")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# warden memory — cross-runtime experiential memory (WARDEN-WP-0024)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@memory_app.command("status")
|
||
def memory_status(
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Show canonical phase-memory store status (metadata only)."""
|
||
from warden import memory as warden_memory
|
||
|
||
if not warden_memory.memory_available():
|
||
err.print(f"[red]{warden_memory._PHASE_MEMORY_ERROR}[/red]")
|
||
raise typer.Exit(2)
|
||
try:
|
||
payload = warden_memory.status()
|
||
except RuntimeError as e:
|
||
err.print(f"[red]{e}[/red]")
|
||
raise typer.Exit(2)
|
||
if output_json:
|
||
print(json.dumps(payload, indent=2))
|
||
return
|
||
console.print(f"store_path : {payload.get('store_path', '')}")
|
||
console.print(f"profile_id : {payload.get('profile_id', '')}")
|
||
console.print(f"episode_count : {payload.get('episode_count', 0)}")
|
||
console.print(f"session_kinds : {payload.get('episode_counts_by_session_kind', {})}")
|
||
console.print(f"last_activation: {payload.get('last_activation_at') or '—'}")
|
||
|
||
|
||
@memory_app.command("activate")
|
||
def memory_activate(
|
||
need: Annotated[str, typer.Option("--need", help="Optional routing need fingerprint source")] = "",
|
||
agent: Annotated[
|
||
Optional[str],
|
||
typer.Option("--agent", help="Agent id for session_kind warden.agent.<id> (claude, codex, grok, …)"),
|
||
] = None,
|
||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||
) -> None:
|
||
"""Inspect or refresh coordination memory (optional — memory loads by default)."""
|
||
from warden import memory as warden_memory
|
||
|
||
if not warden_memory.memory_available():
|
||
err.print(f"[red]{warden_memory._PHASE_MEMORY_ERROR}[/red]")
|
||
raise typer.Exit(2)
|
||
try:
|
||
payload = warden_memory.activate(need=need, agent=agent, implicit=False)
|
||
except RuntimeError as e:
|
||
err.print(f"[red]{e}[/red]")
|
||
raise typer.Exit(2)
|
||
if output_json:
|
||
print(json.dumps(payload, indent=2))
|
||
return
|
||
console.print(warden_memory.format_activation_summary(payload))
|