WARDEN-WP-0044-T01: warden case check-type; revised oidc-rp 1.0 type conforms
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:
parent
98cb0bc24a
commit
0e7ceb8c06
5 changed files with 349 additions and 1 deletions
170
src/warden/case_type.py
Normal file
170
src/warden/case_type.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue