WARDEN-WP-0044-T01: warden case check-type; revised oidc-rp 1.0 type conforms
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Mechanical follow-through check (lane, warden_executes, value_handling,
attended justification, read-free verification, rollback coverage). The
revised type passes schema; lanes identity-provisioner-groups and
keycape-client-secret-generate are not yet in the catalog (gate T02).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 327203@bnt-lap001
Assistant-Session: 55028ae3-4914-4bd1-8f5c-815f3527aef0
This commit is contained in:
tegwick 2026-10-09 00:25:11 +02:00
parent 98cb0bc24a
commit 0e7ceb8c06
5 changed files with 349 additions and 1 deletions

170
src/warden/case_type.py Normal file
View file

@ -0,0 +1,170 @@
"""SecurityCaseType follow-through check (WARDEN-WP-0044-T01).
Checks the ``follow_through`` section of a NetKingdom SecurityCaseType against
the rules ops-warden needs before it can plan a run (canon
``security-case-types_v0.1`` section on follow_through; ops-warden review
``history/2026-10-08-oidc-rp-follow-through-review.md``, R1-R3, R5-R7).
Two kinds of finding:
* ``schema`` — the type itself is wrong; net-kingdom fixes the type.
* ``catalog`` — the type is right but a lane it names is not yet a reviewed,
active catalog entry; the lane's owner supplies it. ``warden case plan``
(T02) refuses to plan while any catalog finding remains.
Read-only: it parses YAML and the catalog, executes nothing, and never handles a
secret value.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Optional
import yaml
from warden.routing.catalog import Catalog
VALUE_HANDLING = ("none", "generated_in_custody")
REQUIRED_STEP_FIELDS = ("id", "owner", "entry_point", "auth", "warden_executes", "attended", "value_handling")
@dataclass(frozen=True)
class Finding:
kind: str # "schema" | "catalog"
code: str
where: str
message: str
def as_dict(self) -> dict:
return {"kind": self.kind, "code": self.code, "where": self.where, "message": self.message}
@dataclass(frozen=True)
class FollowThroughReport:
type_id: str
version: str
findings: List[Finding]
attended_lanes: List[str] # one attended session per lane, in step order
warden_steps: List[str]
@property
def schema_ok(self) -> bool:
return not any(f.kind == "schema" for f in self.findings)
@property
def plannable(self) -> bool:
return not self.findings
def as_dict(self) -> dict:
return {
"type": self.type_id,
"version": self.version,
"schema_ok": self.schema_ok,
"plannable": self.plannable,
"warden_steps": self.warden_steps,
"attended_sessions": self.attended_lanes,
"findings": [f.as_dict() for f in self.findings],
}
def load_type(path: Path) -> dict:
with open(path) as fh:
doc = yaml.safe_load(fh)
if not isinstance(doc, dict) or doc.get("kind") != "SecurityCaseType":
raise ValueError(f"{path}: not a SecurityCaseType document")
return doc
def _bool(step: dict, key: str) -> Optional[bool]:
value = step.get(key)
return value if isinstance(value, bool) else None
def check_follow_through(doc: dict, catalog: Optional[Catalog] = None) -> FollowThroughReport:
spec = doc.get("spec") or {}
steps = spec.get("follow_through") or []
findings: List[Finding] = []
attended_lanes: List[str] = []
warden_steps: List[str] = []
def schema(code: str, where: str, message: str) -> None:
findings.append(Finding("schema", code, where, message))
if not steps:
schema("FT-EMPTY", "follow_through", "type declares no follow-through steps")
seen: set = set()
for index, step in enumerate(steps):
sid = step.get("id") if isinstance(step, dict) else None
where = f"follow_through[{sid or index}]"
if not isinstance(step, dict):
schema("FT-SHAPE", where, "step is not a mapping")
continue
for key in REQUIRED_STEP_FIELDS:
if key not in step or step[key] in (None, ""):
schema("FT-FIELD", where, f"missing required field '{key}'")
if sid in seen:
schema("FT-DUP", where, "duplicate step id")
seen.add(sid)
executes = _bool(step, "warden_executes")
attended = _bool(step, "attended")
for key, value in (("warden_executes", executes), ("attended", attended)):
if key in step and value is None:
schema("FT-BOOL", where, f"'{key}' must be a boolean")
# R3: values never pass through the run.
handling = step.get("value_handling")
if handling is not None and handling not in VALUE_HANDLING:
schema("FT-VALUE", where, f"value_handling '{handling}' not in {list(VALUE_HANDLING)}")
if handling == "generated_in_custody" and attended:
schema("FT-VALUE", where, "a generated-in-custody step may not run in an attended session")
# Attended steps: justified, and never executed by warden itself.
if attended:
if not step.get("attended_justification"):
schema("FT-ATTEND", where, "attended step carries no attended_justification")
if executes:
schema("FT-ATTEND", where, "a step is either warden_executes or attended, not both")
# R1: a lane for every step warden executes or groups into a session.
lane = step.get("lane")
if (executes or attended) and not lane:
schema("FT-LANE", where, "warden_executes or attended step names no catalog lane")
if lane and catalog is not None:
entry = catalog.get(lane)
if entry is None:
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 != "login":
findings.append(Finding("catalog", "LANE-KIND", where, f"attended step's lane '{lane}' is not a login lane"))
if executes:
warden_steps.append(sid)
if attended and lane and lane not in attended_lanes:
attended_lanes.append(lane)
# R7: verification never reads a value.
for index, item in enumerate(spec.get("verification") or []):
if not isinstance(item, dict) or item.get("reads_secret_value") is not False:
vid = item.get("id", index) if isinstance(item, dict) else index
schema("VER-VALUE", f"verification[{vid}]", "reads_secret_value must be explicitly false")
# Every step must be reversible (or explicitly the consumer's to undo).
reversed_ids = {r.get("reverses") for r in spec.get("rollback") or [] if isinstance(r, dict)}
for sid in sorted(s for s in seen if s and s not in reversed_ids):
schema("RB-MISSING", f"follow_through[{sid}]", "no rollback entry reverses this step")
return FollowThroughReport(
type_id=str(spec.get("id", "")),
version=str(spec.get("version", "")),
findings=findings,
attended_lanes=attended_lanes,
warden_steps=warden_steps,
)
def summarize(findings: Iterable[Finding]) -> str:
return "\n".join(f"[{f.kind}] {f.code} {f.where}: {f.message}" for f in findings)

View file

@ -7,6 +7,7 @@ from pathlib import Path
from typing import Annotated, List, Optional
import typer
import yaml
from rich.console import Console
from rich.table import Table
@ -50,6 +51,11 @@ memory_app = typer.Typer(
no_args_is_help=True,
)
app.add_typer(memory_app, name="memory")
case_app = typer.Typer(
help="SecurityCaseType follow-through (WARDEN-WP-0044; read-only check, no execution)",
no_args_is_help=True,
)
app.add_typer(case_app, name="case")
console = Console()
err = Console(stderr=True)
@ -2212,3 +2218,28 @@ def memory_activate(
print(json.dumps(payload, indent=2))
return
console.print(warden_memory.format_activation_summary(payload))
@case_app.command("check-type")
def case_check_type(
type_file: Annotated[Path, typer.Argument(help="SecurityCaseType type.yaml")],
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Check a type's follow_through against ops-warden's planning rules (exit 1 if not plannable)."""
from warden.case_type import check_follow_through, load_type, summarize
try:
doc = load_type(type_file)
except (OSError, ValueError, yaml.YAMLError) as exc:
err.print(f"[red]{exc}[/red]")
raise typer.Exit(2)
report = check_follow_through(doc, _load_catalog())
if output_json:
print(json.dumps(report.as_dict(), indent=2))
else:
console.print(f"{report.type_id} {report.version}: schema_ok={report.schema_ok} plannable={report.plannable}")
console.print(f"warden executes: {', '.join(report.warden_steps) or '-'}")
console.print(f"attended sessions: {', '.join(report.attended_lanes) or '-'}")
if report.findings:
console.print(summarize(report.findings), markup=False)
raise typer.Exit(0 if report.plannable else 1)