From d0d4f9d8fcbc0f6daafcb5c835610e73f2f57239 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 20 Aug 2026 01:13:35 +0200 Subject: [PATCH] Make the risk grade fail safe, and gate CI on absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism behind RISK-F-0003 was sharper than the finding described. is_high_risk was risk == "high", but risk was never absent at the model layer: RouteEntry.risk carried a dataclass default of "standard". An omitted grade was not unhandled, it was actively resolved to the permissive value — fail-open by construction, which is why nothing ever warned. The default is now "ungraded" and is_high_risk returns true for anything outside an explicit low-risk vocabulary (standard / low / accepted). An omitted grade and an unrecognised grade from a newer catalog both resolve to high, so the boundary fails safe in both directions rather than reading an unknown value as permission. test_every_repo_catalog_lane_is_explicitly_graded is the CI gate that stops an ungraded lane being committed, per ADR-0007: absence is not a grade. "accepted" is in the low-risk vocabulary deliberately, ready for the maturity-derived default — an experimental-context lane may be explicitly accepted, which is a graded decision rather than an omission. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 2 +- src/warden/routing/models.py | 27 ++++++++-- tests/test_routing.py | 59 ++++++++++++++++++++++ workplans/WARDEN-WP-0032-security-zones.md | 27 +++++++++- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 6867475..07d7174 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -119,5 +119,5 @@ | task | WARDEN-WP-0032-T02 | wait | — | workplans/WARDEN-WP-0032-security-zones.md | | task | WARDEN-WP-0032-T03 | wait | — | workplans/WARDEN-WP-0032-security-zones.md | | task | WARDEN-WP-0032-T04 | wait | — | workplans/WARDEN-WP-0032-security-zones.md | -| task | WARDEN-WP-0032-T05 | todo | — | workplans/WARDEN-WP-0032-security-zones.md | +| task | WARDEN-WP-0032-T05 | done | — | workplans/WARDEN-WP-0032-security-zones.md | | task | WARDEN-WP-0032-T06 | wait | — | workplans/WARDEN-WP-0032-security-zones.md | diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index a264576..86695b8 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -11,6 +11,13 @@ from dataclasses import dataclass, field from typing import List, Optional +# Risk grade vocabulary (ADR-0007). Grades outside LOW_RISK_GRADES — including +# the "ungraded" default and any value from a newer catalog — are treated as +# high by is_high_risk, so the read-boundary fails safe in both directions. +LOW_RISK_GRADES = frozenset({"standard", "low", "accepted"}) +GRADED_RISK = frozenset({"standard", "low", "accepted", "high", "critical"}) + + @dataclass class RotationGuide: """Structured-but-advisory renewal guidance for a lane (WARDEN-WP-0026 T06). @@ -111,7 +118,11 @@ class RouteEntry: # Rotation / re-establishment guidance (WP-0026 T06) — advisory, no secret values. rotation: Optional[RotationGuide] = None # Agent read-boundary risk class (WP-0026 T04). high → agents use wrap/out/exec only. - risk: str = "standard" # "standard" | "high" + # Default is "ungraded", which FAILS SAFE: it is treated as high. Before + # ADR-0007 this defaulted to "standard", so a lane that simply omitted the + # field was silently placed outside the read-boundary (RISK-F-0003) — the + # control was never relaxed by decision, it was never reached. + risk: str = "ungraded" # "standard" | "high" | "ungraded" # Delegation register (WP-0030). None → implicit interim with unknown owner. delegation: Optional[Delegation] = None @@ -119,10 +130,20 @@ class RouteEntry: def is_active(self) -> bool: return self.status == "active" + @property + def is_graded(self) -> bool: + """False when this lane carries no explicit risk grade (ADR-0007).""" + return self.risk in GRADED_RISK + @property def is_high_risk(self) -> bool: - """True when this lane is on the agent raw-read deny list (WP-0026 T04).""" - return self.risk == "high" + """True when this lane is on the agent raw-read deny list (WP-0026 T04). + + Anything not explicitly graded low is high. An ungraded lane, or one + carrying a grade this version does not recognise, is treated as high + rather than waved through — ADR-0007: absence is not a grade. + """ + return self.risk not in LOW_RISK_GRADES @property def has_rotation(self) -> bool: diff --git a/tests/test_routing.py b/tests/test_routing.py index 7bef7d1..e0d5d33 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -709,3 +709,62 @@ def test_cli_route_show_includes_delegation(repo_catalog_env): assert data["delegation"]["mode"] == "interim" assert data["delegation"]["intended_owner"] == "tenant-engine" assert data["delegation"]["implicit"] is False + + +# --- ADR-0007: absence is not a grade (WARDEN-WP-0032-T06) ------------------ + + +def _bare_entry(**overrides): + """A minimal RouteEntry, so these tests exercise defaults and nothing else.""" + from warden.routing.models import RouteEntry + + fields = dict( + id="x", + title="t", + need_keywords=[], + owner_repo="r", + subsystem="s", + warden_executes=False, + wiki_ref="w", + canon_ref="c", + reviewed="2026-08-20", + status="active", + ) + fields.update(overrides) + return RouteEntry(**fields) + + +def test_every_repo_catalog_lane_is_explicitly_graded(): + """The CI gate. A lane added without a `risk` grade is a defect (ADR-0007).""" + catalog = load_catalog(_repo_catalog()) + ungraded = sorted(e.id for e in catalog.entries if not e.is_graded) + assert ungraded == [], ( + f"{len(ungraded)} catalog lane(s) carry no explicit risk grade: {ungraded}. " + "ADR-0007: absence is not a grade — grade the lane on merit." + ) + + +def test_ungraded_lane_fails_safe_to_high_risk(): + """RISK-F-0003 regression: an omitted grade must not wave a lane through. + + Before ADR-0007 the dataclass default was "standard", so a lane that simply + omitted the field landed outside the agent read-boundary silently. + """ + entry = _bare_entry() + assert entry.risk == "ungraded" + assert entry.is_graded is False + assert entry.is_high_risk is True + + +def test_unrecognised_grade_is_treated_as_high(): + """A grade from a newer catalog must not be read as permission.""" + entry = _bare_entry(risk="spicy") + assert entry.is_high_risk is True + assert entry.is_graded is False + + +def test_low_risk_vocabulary_is_explicit(): + for grade in ("standard", "low", "accepted"): + entry = _bare_entry(risk=grade) + assert entry.is_high_risk is False, grade + assert entry.is_graded is True, grade diff --git a/workplans/WARDEN-WP-0032-security-zones.md b/workplans/WARDEN-WP-0032-security-zones.md index 9160e44..1e4a836 100644 --- a/workplans/WARDEN-WP-0032-security-zones.md +++ b/workplans/WARDEN-WP-0032-security-zones.md @@ -219,7 +219,7 @@ policy covers these paths (T06). ```task id: WARDEN-WP-0032-T06 -status: wait +status: progress priority: medium state_hub_task_id: "8c082416-0ff9-45dc-8cbd-e9ca7913a6ef" ``` @@ -235,6 +235,31 @@ Feed back to `ZONE-WP-0001-T03` whether ops-warden can supply the join it needs today no lane references a workload or an environment, so the `M0`–`M3` ladder has nothing to attach to from this side. +**Enforcement half done 2026-08-20.** It did not need the zone model: `ADR-0007` +already decided absence is a defect, which is enough to make the code fail safe +and to gate CI. + +The real mechanism turned out to be sharper than `RISK-F-0003` described. +`is_high_risk` was `risk == "high"`, but `risk` was **not** absent at the model +layer — `RouteEntry.risk` carried a dataclass default of `"standard"`. So an +omitted grade was not unhandled; it was actively resolved to the permissive +value. Fail-open by construction, which is why nothing warned. + +Now: the default is `"ungraded"`, and `is_high_risk` returns true for anything +not in an explicit low-risk vocabulary (`standard` / `low` / `accepted`). An +omitted grade **and** a grade from a newer catalog both resolve to high, so the +boundary fails safe in both directions. `is_graded` exposes the distinction, and +`test_every_repo_catalog_lane_is_explicitly_graded` is the CI gate that stops an +ungraded lane being committed. Four regression tests cover it. + +`accepted` is in the low-risk vocabulary deliberately, ready for the +maturity-derived default: an experimental-context lane may be explicitly +accepted, which is a graded decision rather than an omission. + +**Still open on this task:** whether the maturity-derived default replaces the +`ungraded` sentinel entirely (waits on `ZONE-WP-0001-T03`), and verifying +OpenBao's `agent-high-risk-boundary` policy covers these paths. + ## Related - `zone-engine` `ZONE-WP-0001` — the model, and where this work is led from