ops-warden/src/warden/routing/catalog.py
tegwick c3eb59ea04
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
WARDEN-WP-0026 T06: rotation guidance registry + warden rotate-guide
- routing model: RotationGuide (method rotate|re-establish, steps, owner,
  automatable), RouteEntry.rotation + has_rotation + vends_secret.
- catalog parser: validate rotation block; secret-material screen gains a
  prose-safe mode (high-entropy detector only) so authored steps aren't tripped
  by substrings like "s."/"exists.".
- CLI: `warden rotate-guide <id>` (human + --json); route show --json now
  carries has_rotation + rotation.
- scorecard: catalog_rotation_coverage — every active secret-vending lane must
  carry a rotation block (SSH/login/pointer lanes exempt). Promotion checklist
  criterion 9.
- data: rotation blocks for all 7 active vending lanes + the draft
  railiance-backup lane (re-establish: age keypair regen + re-encrypt).
- fix pre-existing collision: bare `npm` keyword on forgejo-admin -> forgejo-npm
  so "npm token" routes to the generic lane (restores test_access expectations).
- tests: rotation parse/coverage/prose-screen/CLI in tests/test_routing.py;
  scorecard count 6 -> 7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:40:30 +02:00

360 lines
14 KiB
Python

"""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
import re
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import List, Optional
import yaml
from warden.routing.models import RotationGuide, RouteEntry
# Structured handoff string fields (WP-0014) — templates and pointers only.
# Every one is scanned for accidental secret material; see _assert_no_secret_material.
_HANDOFF_STR_FIELDS = (
"auth_method", "path_template", "fetch_command", "policy_ref",
# Owner-native exec front door (WP-0019) — pointer commands, screened too.
"exec_command", "pointer_command",
)
# Known secret-bearing token prefixes — a literal here means a value leaked into
# the catalog (which is git-tracked and agent-visible). Templates use `<...>`.
_SECRET_PREFIXES = (
"ghp_", "gho_", "ghs_", "github_pat_", # GitHub
"sk-", "sk_live_", "sk_test_", # OpenAI / Stripe
"xoxb-", "xoxp-", # Slack
"AKIA", "ASIA", # AWS access key ids
"hvs.", "hvb.", "s.", # Vault/OpenBao service tokens
"AIza", # Google
"eyJ", # JWT
)
# A long unbroken high-entropy run that is not a placeholder — likely a raw value.
_HIGH_ENTROPY_RUN = re.compile(r"[A-Za-z0-9_\-]{32,}")
_REQUIRED_FIELDS = (
"id",
"title",
"need_keywords",
"owner_repo",
"subsystem",
"warden_executes",
"wiki_ref",
"canon_ref",
"reviewed",
"status",
)
_VALID_STATUS = ("active", "draft")
_VALID_LANES = ("secret", "login")
_VALID_ROTATION_METHODS = ("rotate", "re-establish")
# Default review cadence — see wiki/AccessRouting.md#drift-review-cadence
DEFAULT_STALE_DAYS = 90
def days_since_review(reviewed: str, *, today: Optional[date] = None) -> int:
"""Calendar days between reviewed date (YYYY-MM-DD) and today."""
reviewed_date = date.fromisoformat(reviewed)
ref = today or date.today()
return (ref - reviewed_date).days
def is_review_stale(
reviewed: str,
*,
threshold_days: int = DEFAULT_STALE_DAYS,
today: Optional[date] = None,
) -> bool:
"""True when reviewed date is older than the cadence threshold."""
return days_since_review(reviewed, today=today) > threshold_days
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
# Fallback: registry bundled into the installed wheel (warden/_registry/...).
bundled = Path(__file__).resolve().parent.parent / "_registry" / "routing" / "catalog.yaml"
if bundled.exists():
return bundled
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.
An exact catalog-id match wins outright — this is what makes a stable keyed
command (`warden access whynot-design-npm-publish`) resolve deterministically
regardless of keyword collisions with other lanes.
"""
exact = self.get(query.strip())
if exact is not None and (include_draft or exact.is_active):
return [exact]
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 stale(
self,
include_draft: bool = False,
threshold_days: int = DEFAULT_STALE_DAYS,
*,
today: Optional[date] = None,
) -> List[RouteEntry]:
"""Entries whose reviewed date is past the cadence threshold."""
return [
e
for e in self.listed(include_draft=include_draft)
if is_review_stale(e.reviewed, threshold_days=threshold_days, today=today)
]
def _assert_no_secret_material(
entry_id: str, field_name: str, value: str, *, prose: bool = False
) -> None:
"""Reject a handoff field that appears to embed a literal secret value.
The structured handoff fields are command/path *templates*: concrete values
must be placeholders (`<...>`) or field names, never a real credential. The
catalog is git-tracked and agent-visible, so a leaked value here is the exact
custody failure WP-0014 forbids. We screen for known token prefixes and for a
long high-entropy run that is not a placeholder.
``prose=True`` (rotation guidance steps, WP-0026 T06) skips the *substring*
prefix screen — short prefixes like ``s.`` or ``eyJ`` collide with ordinary
English ("exists.", "artifacts.") — and relies on the high-entropy-run detector,
which catches an actually-pasted token (a real ``hvs.``/``ghp_``/``sk-`` value
carries a long high-entropy tail) while allowing plain sentences.
"""
lowered = value.lower()
if not prose:
for prefix in _SECRET_PREFIXES:
if prefix.lower() in lowered:
raise CatalogError(
f"entry {entry_id!r} field {field_name!r} appears to contain a literal "
f"secret (matched {prefix!r}). Handoff fields are templates — use "
"placeholders like <FIELD>/<PATH>, never a real value."
)
for run in _HIGH_ENTROPY_RUN.findall(value):
# Allow long placeholder/path/identifier tokens; flag anything else.
if "<" in run or ">" in run:
continue
if run.replace("_", "").replace("-", "").isalpha():
continue # all-letters run (e.g. a long word) — not a credential
raise CatalogError(
f"entry {entry_id!r} field {field_name!r} contains a high-entropy token "
f"({run[:8]}…) that is not a placeholder — suspected leaked secret value."
)
def _parse_rotation(entry_id: str, raw: Optional[dict]) -> Optional[RotationGuide]:
"""Parse and validate an optional ``rotation:`` block (WP-0026 T06).
Advisory renewal guidance only — screened for secret material like every other
catalog string. ``method`` must be rotate | re-establish; ``steps`` a non-empty
list; ``owner`` required.
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise CatalogError(f"entry {entry_id!r} `rotation` must be a mapping")
method = str(raw.get("method", "")).strip()
if method not in _VALID_ROTATION_METHODS:
raise CatalogError(
f"entry {entry_id!r} rotation.method {method!r} invalid "
f"(expected one of {_VALID_ROTATION_METHODS})"
)
steps_raw = raw.get("steps")
if not isinstance(steps_raw, list) or not steps_raw:
raise CatalogError(
f"entry {entry_id!r} rotation.steps must be a non-empty list of steps"
)
steps = [str(s) for s in steps_raw]
owner = str(raw.get("owner", "")).strip()
if not owner:
raise CatalogError(f"entry {entry_id!r} rotation.owner is required")
# Screen advisory prose for accidental secret material (git-tracked, agent-visible).
for i, step in enumerate(steps):
_assert_no_secret_material(entry_id, f"rotation.steps[{i}]", step, prose=True)
_assert_no_secret_material(entry_id, "rotation.owner", owner, prose=True)
return RotationGuide(
method=method,
steps=steps,
owner=owner,
automatable=bool(raw.get("automatable", False)),
)
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")
# Structured handoff fields (WP-0014) — optional, screened for secret material.
entry_id = str(raw["id"])
handoff: dict[str, Optional[str]] = {}
for fname in _HANDOFF_STR_FIELDS:
val = raw.get(fname)
if val is None or val == "":
handoff[fname] = None
continue
sval = str(val)
_assert_no_secret_material(entry_id, fname, sval)
handoff[fname] = sval
exec_capable = bool(raw.get("exec_capable", False))
# A lane cannot be proxy-executable without a fetch_command to run.
if exec_capable and not handoff["fetch_command"]:
raise CatalogError(
f"entry {entry_id!r} sets exec_capable: true but has no fetch_command — "
"a proxyable lane must declare the command warden runs as the caller."
)
lane = str(raw.get("lane", "secret"))
if lane not in _VALID_LANES:
raise CatalogError(
f"entry {entry_id!r} has invalid lane {lane!r} (expected one of {_VALID_LANES})"
)
return RouteEntry(
id=entry_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,
auth_method=handoff["auth_method"],
path_template=handoff["path_template"],
fetch_command=handoff["fetch_command"],
exec_capable=exec_capable,
policy_ref=handoff["policy_ref"],
lane=lane,
exec_owner=str(raw["exec_owner"]) if raw.get("exec_owner") else None,
exec_command=handoff["exec_command"],
pointer_command=handoff["pointer_command"],
rotation=_parse_rotation(entry_id, raw.get("rotation")),
)
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)