feat(WP-0011): warden route lookup CLI over the pointer catalog

Add a read-only `warden route` command group (list/show/find) that reads
registry/routing/catalog.yaml and tells a worker which subsystem owns a need
and which wiki/canon doc to follow. ops-warden still executes exactly one lane
(SSH); routed entries return a pointer and never call any subsystem.

- src/warden/routing/: models.py + catalog.py loader; enforces the
  no-double-source rule (non-SSH entries with steps/cert_command fail validation),
  dup-id and schema checks.
- route list (active-only unless --all, --tag), route show (SSH appends steps +
  cert pattern; routed ends with "next action on <owner> — see <wiki_ref>"),
  route find (keyword ranking, --json).
- tests/test_routing.py: load/validation, find ranking, CLI JSON shapes, plus a
  drift guard (every wiki_ref anchor resolves; every entry has a reviewed date).
- Docs: wiki/AccessRouting.md CLI section, README quick reference, SCOPE A3 -> A4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:03:24 +02:00
parent 407cd2e1f4
commit ac2efa1262
10 changed files with 690 additions and 32 deletions

View File

@@ -23,6 +23,11 @@ app = typer.Typer(
)
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")
console = Console()
err = Console(stderr=True)
@@ -512,3 +517,143 @@ def log(
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,
"wiki_ref": entry.wiki_ref,
"canon_ref": entry.canon_ref,
"reviewed": entry.reviewed,
"status": entry.status,
}
def _print_entry_table(entries, title: str) -> None:
table = Table(title=title)
table.add_column("ID")
table.add_column("Need")
table.add_column("Owner")
table.add_column("warden")
table.add_column("Status")
for e in entries:
executes = "[green]issue[/green]" if e.warden_executes else "route"
status_styled = e.status if e.status == "active" else f"[yellow]{e.status}[/yellow]"
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,
) -> None:
"""List routing scenarios. Active-only unless --all."""
catalog = _load_catalog()
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]]
if output_json:
print(json.dumps([_entry_summary(e) for e in entries], indent=2))
return
if not entries:
console.print("No matching routing entries.")
return
_print_entry_table(entries, "Routing scenarios")
@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
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}."
)
@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."""
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))
return
if not matches:
console.print(
f"No routing match for {query!r}. "
"Try `warden route list --all` to browse all scenarios."
)
return
_print_entry_table(matches, f"Matches for {query!r}")

View File

@@ -0,0 +1,17 @@
"""Routing lookup — read-only pointer layer over registry/routing/catalog.yaml.
This package never calls OpenBao, flex-auth, key-cape, ops-bridge, or any other
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.models import RouteEntry
__all__ = [
"Catalog",
"CatalogError",
"RouteEntry",
"find_catalog_path",
"load_catalog",
]

View File

@@ -0,0 +1,171 @@
"""Load and validate the routing pointer catalog.
The catalog lives at ``registry/routing/catalog.yaml`` in the repo root. Resolution
order:
1. ``WARDEN_ROUTING_CATALOG`` env var, if set (used by tests / overrides).
2. Walk upward from this module looking for ``registry/routing/catalog.yaml``.
Validation enforces the **no-double-source rule**: only ``warden_executes: true``
entries may carry an authored ``steps`` block or a ``cert_command``. Any non-SSH
entry that does so is a validation error — ops-warden points at the owner's doc, it
never restates another subsystem's procedure.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
import yaml
from warden.routing.models import RouteEntry
_REQUIRED_FIELDS = (
"id",
"title",
"need_keywords",
"owner_repo",
"subsystem",
"warden_executes",
"wiki_ref",
"canon_ref",
"reviewed",
"status",
)
_VALID_STATUS = ("active", "draft")
class CatalogError(Exception):
"""Raised when the routing catalog is missing or invalid."""
def find_catalog_path(start: Optional[Path] = None) -> Path:
"""Locate registry/routing/catalog.yaml.
Honors WARDEN_ROUTING_CATALOG first; otherwise walks up from `start`
(default: this module) until a repo root containing the catalog is found.
"""
override = os.environ.get("WARDEN_ROUTING_CATALOG")
if override:
return Path(os.path.expanduser(override))
rel = Path("registry") / "routing" / "catalog.yaml"
here = (start or Path(__file__)).resolve()
for parent in [here, *here.parents]:
candidate = parent / rel
if candidate.exists():
return candidate
raise CatalogError(
f"Routing catalog not found ({rel}). Set WARDEN_ROUTING_CATALOG to override."
)
@dataclass
class Catalog:
path: Path
entries: List[RouteEntry]
# --- lookup helpers ---------------------------------------------------
def get(self, entry_id: str) -> Optional[RouteEntry]:
for e in self.entries:
if e.id == entry_id:
return e
return None
def listed(self, include_draft: bool = False) -> List[RouteEntry]:
if include_draft:
return list(self.entries)
return [e for e in self.entries if e.is_active]
def find(self, query: str, include_draft: bool = False, limit: int = 5) -> List[RouteEntry]:
"""Rank entries by keyword overlap with the query. Highest first."""
tokens = [t for t in query.lower().replace("-", " ").split() if t]
pool = self.listed(include_draft=include_draft)
scored = [(e.match_score(tokens), e) for e in pool]
scored = [(s, e) for s, e in scored if s > 0]
scored.sort(key=lambda pair: (-pair[0], pair[1].id))
return [e for _, e in scored[:limit]]
def _parse_entry(raw: dict, index: int) -> RouteEntry:
if not isinstance(raw, dict):
raise CatalogError(f"entry #{index} is not a mapping")
missing = [f for f in _REQUIRED_FIELDS if f not in raw]
if missing:
ident = raw.get("id", f"#{index}")
raise CatalogError(f"entry {ident!r} missing required field(s): {', '.join(missing)}")
warden_executes = bool(raw["warden_executes"])
steps = raw.get("steps") or []
cert_command = raw.get("cert_command")
status = str(raw["status"])
if status not in _VALID_STATUS:
raise CatalogError(
f"entry {raw['id']!r} has invalid status {status!r} (expected one of {_VALID_STATUS})"
)
# No-double-source rule: authored procedure only on the SSH lane.
if not warden_executes and steps:
raise CatalogError(
f"entry {raw['id']!r} is not warden_executes but carries a `steps` block "
"— routed needs point at the owner's doc; they must not restate procedure "
"(no-double-source rule)."
)
if not warden_executes and cert_command:
raise CatalogError(
f"entry {raw['id']!r} is not warden_executes but carries a `cert_command`."
)
if not isinstance(raw["need_keywords"], list):
raise CatalogError(f"entry {raw['id']!r} need_keywords must be a list")
return RouteEntry(
id=str(raw["id"]),
title=str(raw["title"]),
need_keywords=[str(k) for k in raw["need_keywords"]],
owner_repo=str(raw["owner_repo"]),
subsystem=str(raw["subsystem"]),
warden_executes=warden_executes,
wiki_ref=str(raw["wiki_ref"]),
canon_ref=str(raw["canon_ref"]),
reviewed=str(raw["reviewed"]),
status=status,
steps=[str(s) for s in steps],
cert_command=str(cert_command) if cert_command else None,
)
def load_catalog(path: Optional[Path] = None) -> Catalog:
"""Load, parse, and validate the routing catalog."""
catalog_path = path or find_catalog_path()
if not catalog_path.exists():
raise CatalogError(f"Routing catalog not found: {catalog_path}")
try:
with catalog_path.open() as f:
raw = yaml.safe_load(f)
except yaml.YAMLError as e:
raise CatalogError(f"Invalid YAML in {catalog_path}: {e}") from e
if not isinstance(raw, dict):
raise CatalogError("Catalog must be a YAML mapping")
raw_entries = raw.get("entries")
if not isinstance(raw_entries, list) or not raw_entries:
raise CatalogError("Catalog has no `entries` list")
entries: List[RouteEntry] = []
seen: set[str] = set()
for i, raw_entry in enumerate(raw_entries):
entry = _parse_entry(raw_entry, i)
if entry.id in seen:
raise CatalogError(f"duplicate entry id: {entry.id!r}")
seen.add(entry.id)
entries.append(entry)
return Catalog(path=catalog_path, entries=entries)

View File

@@ -0,0 +1,49 @@
"""Data model for routing catalog entries.
A `RouteEntry` is a pointer: it names the owner and the authoritative doc for a
credential need. Only the SSH lane (`warden_executes: true`) may carry an authored
`steps` block and a `cert_command` pattern — every other entry is identifiers and
pointers only (the no-double-source rule, enforced in `catalog.py`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class RouteEntry:
id: str
title: str
need_keywords: List[str]
owner_repo: str
subsystem: str
warden_executes: bool
wiki_ref: str
canon_ref: str
reviewed: str
status: str # "active" | "draft"
# SSH lane only — None/empty for routed (non-executed) needs.
steps: List[str] = field(default_factory=list)
cert_command: Optional[str] = None
@property
def is_active(self) -> bool:
return self.status == "active"
def match_score(self, tokens: List[str]) -> int:
"""Keyword-overlap score against need_keywords, title, and id.
Pure ranking helper — no I/O, no external calls.
"""
haystack = set(k.lower() for k in self.need_keywords)
haystack.update(self.id.lower().replace("-", " ").split())
haystack.update(self.title.lower().replace("-", " ").split())
score = 0
for tok in tokens:
t = tok.lower()
if t in haystack:
score += 2
elif any(t in h or h in t for h in haystack):
score += 1
return score