diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 87fde9e..7f4735e 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -1615,6 +1615,8 @@ entries: # for kernel-dispatched KeyCape registry, group and Deployment env changes. # The session may be established by a key-cape OIDC login; that login lane # stays the identity step, not the change authority. + lane: attended-change + session_lane: key-cape-oidc-login risk: high workload_ref: applicability: not-applicable @@ -1656,6 +1658,8 @@ entries: # Descriptor actor is agent: ADR-0004 read boundary applies. Owner-side # delivery only; no raw value reaches the agent session. If that cannot be # shown, the actor changes to human and the railiance-apps digest changes. + lane: attended-change + session_lane: pending-owner-confirmation risk: high workload_ref: applicability: not-applicable @@ -1674,7 +1678,9 @@ entries: reviewed: "2026-10-17" - id: apps-consumer-handover title: SecurityCase hand-over to the consuming service owner - risk: standard + lane: attended-change + session_lane: pending-owner-confirmation + risk: high workload_ref: applicability: not-applicable reason: "Per-case hand-over; the consumer owner is a case parameter." diff --git a/src/warden/case_type.py b/src/warden/case_type.py index 46aae8e..986d74f 100644 --- a/src/warden/case_type.py +++ b/src/warden/case_type.py @@ -138,8 +138,13 @@ def check_follow_through(doc: dict, catalog: Optional[Catalog] = None) -> Follow findings.append(Finding("catalog", "LANE-MISSING", where, f"lane '{lane}' is not in the catalog")) elif not entry.is_active or not entry.reviewed: findings.append(Finding("catalog", "LANE-UNREVIEWED", where, f"lane '{lane}' is not active and reviewed")) + elif attended and entry.lane == "attended-change": + session = catalog.get(entry.session_lane or "") + if session is None or not session.is_active or not session.reviewed or session.lane != "login": + findings.append(Finding("catalog", "LANE-KIND", where, + f"attended-change lane '{lane}' session_lane '{entry.session_lane}' does not resolve to an active, reviewed login lane")) elif attended and entry.lane != "login": - findings.append(Finding("catalog", "LANE-KIND", where, f"attended step's lane '{lane}' is not a login lane")) + findings.append(Finding("catalog", "LANE-KIND", where, f"attended step's lane '{lane}' is not a login or attended-change lane")) if executes: warden_steps.append(sid) diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index a8a0236..b776d09 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -69,7 +69,7 @@ _REQUIRED_FIELDS = ( "workload_ref", ) _VALID_STATUS = ("active", "draft") -_VALID_LANES = ("secret", "login", "ceremony") +_VALID_LANES = ("secret", "login", "ceremony", "attended-change") _VALID_ROTATION_METHODS = ("rotate", "re-establish") # Default review cadence for a catalog pointer — "is this still the right owner @@ -697,6 +697,9 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: raise CatalogError( f"entry {entry_id!r} has invalid lane {lane!r} (expected one of {_VALID_LANES})" ) + session_lane = str(raw["session_lane"]).strip() if raw.get("session_lane") else None + if lane == "attended-change" and not session_lane: + raise CatalogError(f"entry {entry_id!r} is an attended-change lane and must name session_lane") risk_value = raw.get("risk") risk = str(risk_value).strip() if risk_value is not None else "ungraded" @@ -728,6 +731,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: exec_capable=exec_capable, policy_ref=handoff["policy_ref"], lane=lane, + session_lane=session_lane, exec_owner=str(raw["exec_owner"]) if raw.get("exec_owner") else None, exec_command=handoff["exec_command"], pointer_command=handoff["pointer_command"], diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index 2485c69..21abe64 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -177,6 +177,7 @@ class RouteEntry: # "ceremony" — attended owner operation such as Shamir seal/unseal. It is a # pointer plus approval boundary, never an executable access lane. lane: str = "secret" + session_lane: Optional[str] = None # attended-change: login lane whose session establishes it # Owner-native exec front door (WP-0019). When `exec_owner` is set, that subsystem # (e.g. secrets-engine) provides the PRIMARY way to run a secret-backed command; the # catalog routes to it and keeps ops-warden's own --fetch/--exec proxy as a transparent