diff --git a/pep-stance.yaml b/pep-stance.yaml index e5d0d54..40d69ca 100644 --- a/pep-stance.yaml +++ b/pep-stance.yaml @@ -98,7 +98,7 @@ on_apply: # 2026-10-08: platform-supplier-credentials added as a not-applicable path pattern, # so routing not_applicable rose 16 -> 17. classification_coverage: - measured: "2026-10-10" + measured: "2026-10-17" attribution: self-measured signing_targets: resolved: 0 @@ -107,7 +107,7 @@ classification_coverage: routing_lanes: resolved: 4 unknown: 22 - not_applicable: 18 + not_applicable: 22 verdict_caching: none input_claim_caching: "registry/flex-auth/production_registry_snapshot.json, rebuilt by scripts/build_flex_auth_registry.py" diff --git a/registry/generated/high-risk-data-paths.yaml b/registry/generated/high-risk-data-paths.yaml index 031d5a5..854bd05 100644 --- a/registry/generated/high-risk-data-paths.yaml +++ b/registry/generated/high-risk-data-paths.yaml @@ -10,22 +10,24 @@ # declares it, and is null where the field set has not been established -- # null means unknown, never 'one field'. -generated_at: "2026-10-14T20:20:19Z" +generated_at: "2026-10-17T10:04:57Z" source: ops-warden/registry/routing/catalog.yaml catalog_revision: "402b008986c3a710c4f5b41bb1007a55c8d4363e" catalog_revision_date: "2026-10-14T22:20:15+02:00" -catalog_dirty: false -high_risk_lane_count: 35 +catalog_dirty: true +high_risk_lane_count: 38 concrete_path_count: 17 # Graded high but not a single KV address -- a routing pattern, a broker # grant, or a non-KV lane. Nothing here for a policy to deny. no_concrete_path: + - consumer-owner-deployment - database-dynamic-credentials - flex-auth-decision-envelope-signing-key - fluid-telegram-operator - informed-decision-sitting-requester-login - inter-hub-bootstrap-ssh + - keycape-attended-case-change - keycape-client-secret-generate - net-kingdom-lldap-bind-credential - net-kingdom-privacyidea-admin-token @@ -35,6 +37,7 @@ no_concrete_path: - openbao-shamir-recovery-ceremony - openbao-structure-survey - ops-warden-warden-sign-token + - platform-secret-delivery - platform-supplier-credentials - secrets-engine-approval-client-login - secrets-engine-requester-login diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 525fc79..87fde9e 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -1604,3 +1604,89 @@ entries: automatable: false steps: - "This lane covers first generation only and refuses an existing path (exit 3). Rotation is a separate railiance-platform task that does not exist yet; route rotation needs to railiance-platform." + + # CATALOG-1 (nk48 1.3 owner review, 2026-10-17): four lanes for the 1.3 + # SecurityCaseType oidc-browser-relying-party. Pointer only, draft. Each + # stays draft until its owner confirms the pointer + # (wiki/playbooks/catalog-lane-promotion.md); the kernel refuses draft lanes. + - id: keycape-attended-case-change + title: SecurityCase attended KeyCape change (ensure-groups, bind-env, register-client) + # OW-1.3-C1: dedicated attended change authority replacing key-cape-oidc-login + # 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. + risk: high + workload_ref: + applicability: not-applicable + reason: "Per-case attended change lane; the consuming service is a case parameter." + need_keywords: [keycape-attended-case-change, ensure-groups, bind-env, register-client, attended keycape change] + owner_repo: key-cape/net-kingdom + subsystem: key-cape registry and Deployment env, net-kingdom groups + warden_executes: false + wiki_ref: wiki/playbooks/catalog-lane-promotion.md + canon_ref: net-kingdom/security-case-types/oidc-browser-relying-party/type.yaml + reviewed: "2026-10-17" + status: draft + delegation: + mode: interim + intended_owner: key-cape + blocked_on: "key-cape and net-kingdom confirm the pointer and the re-frozen 1.3 descriptors (OW-1.3-C1)." + reviewed: "2026-10-17" + verified: source-read + - id: platform-secret-delivery + title: SecurityCase deliver-secret, KeyCape client secret delivered by Platform + risk: high + workload_ref: + applicability: not-applicable + reason: "Per-case delivery lane; each case's consuming service gets its own client entry." + need_keywords: [platform-secret-delivery, deliver-secret, keycape client secret delivery] + owner_repo: railiance-platform + subsystem: railiance-platform External Secrets delivery + warden_executes: false + wiki_ref: wiki/playbooks/catalog-lane-promotion.md + canon_ref: railiance-platform/AGENTS.md + reviewed: "2026-10-17" + status: draft + delegation: + mode: native + intended_owner: railiance-platform + reviewed: "2026-10-17" + - id: consumer-owner-deployment + title: SecurityCase deliver-consumer-secret, consumer deployment by its owner + # 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. + risk: high + workload_ref: + applicability: not-applicable + reason: "Per-case consumer deployment; the consumer is a case parameter." + need_keywords: [consumer-owner-deployment, deliver-consumer-secret] + owner_repo: railiance-apps + subsystem: railiance-apps consumer deployment + warden_executes: false + wiki_ref: wiki/playbooks/catalog-lane-promotion.md + canon_ref: railiance-apps/AGENTS.md + reviewed: "2026-10-17" + status: draft + delegation: + mode: native + intended_owner: railiance-apps + reviewed: "2026-10-17" + - id: apps-consumer-handover + title: SecurityCase hand-over to the consuming service owner + risk: standard + workload_ref: + applicability: not-applicable + reason: "Per-case hand-over; the consumer owner is a case parameter." + need_keywords: [apps-consumer-handover, hand-over, consumer handover] + owner_repo: railiance-apps + subsystem: railiance-apps consumer hand-over + warden_executes: false + wiki_ref: wiki/playbooks/catalog-lane-promotion.md + canon_ref: railiance-apps/AGENTS.md + reviewed: "2026-10-17" + status: draft + delegation: + mode: native + intended_owner: railiance-apps + reviewed: "2026-10-17" diff --git a/src/warden/case_run.py b/src/warden/case_run.py index 552c33d..2f66583 100644 --- a/src/warden/case_run.py +++ b/src/warden/case_run.py @@ -553,6 +553,67 @@ class Journal: raise +def _catalog_lane(catalog_source: bytes, descriptor: Descriptor): + """KERNEL-2: the descriptor's lane must be an active, fitting catalog entry. + + catalog-routing-acceptance still covers the whole catalog digest; this + ties the one lane the descriptor names to an entry inside that digest. + """ + from .routing.catalog import CatalogError, _parse_entry + + try: + raw = yaml.safe_load(catalog_source) + items = raw["entries"] + if not isinstance(items, list): + raise TypeError + entries = {} + for index, item in enumerate(items): + parsed = _parse_entry(item, index) + if parsed.id in entries: + raise ValueError + entries[parsed.id] = parsed + except (yaml.YAMLError, CatalogError, KeyError, TypeError, ValueError): + raise Refused("invalid catalog source shape") from None + entry = entries.get(descriptor.lane) + if entry is None: + raise Refused("descriptor lane is not in the catalog") + if not entry.is_active: + raise Refused("descriptor lane is not active in the catalog") + if entry.delegation is None: + raise Refused("descriptor lane has no catalog delegation record") + if not entry.is_graded: + raise Refused("descriptor lane carries no risk grade") + if descriptor.owner not in entry.owner_repo.split("/"): + raise Refused("descriptor owner does not fit catalog lane owner_repo") + if descriptor.effect in ("mint", "change") and entry.risk != "high": + raise Refused("catalog lane risk does not cover the descriptor effect") + return entry + + +def _step_mode(case_type: dict, case: dict, descriptor: Descriptor) -> str: + """KERNEL-3: warden, attended or owner, from the type step's own text.""" + steps = [ + step + for step in case_type.get("follow_through") or [] + if isinstance(step, dict) and step.get("id") == descriptor.step + ] + step = steps[0] if len(steps) == 1 else {} + executes, attended = step.get("warden_executes"), step.get("attended") + if ( + type(executes) is not bool + or type(attended) is not bool + or (executes and attended) + or not isinstance(step.get("lane"), str) + ): + raise Refused("type step does not declare how the kernel dispatches it") + owner = step.get("owner") + if isinstance(owner, str) and re.fullmatch(r"\{[a-z_]+\}", owner): + owner = (case.get("parameters") or {}).get(owner[1:-1]) + if step["lane"] != descriptor.lane or owner != descriptor.owner: + raise Refused("type step lane or owner disagrees with descriptor") + return "warden" if executes else "attended" if attended else "owner" + + class Executor: """Injected fixed owner adapters and provenance verifier; none ship by default. @@ -650,6 +711,21 @@ class Executor: actor != "human" or os.getenv("WARDEN_AGENT_ID") ): raise Refused("agent mint is forbidden") + entry = _catalog_lane(catalog_source, descriptor) + mode = _step_mode(case_type, case, descriptor) + if mode == "attended" and ( + actor != "human" + or os.getenv("WARDEN_AGENT_ID") + or "parent-session" not in descriptor.revocation_scopes + ): + raise Refused("attended step requires a human parent session") + if mode == "owner" and ( + entry.effective_delegation.mode != "native" + or entry.owner_repo != descriptor.owner + or entry.warden_executes + or descriptor.effect == "mint" + ): + raise Refused("owner step lane is not the owner's native path") parameters = case.get("parameters", {}) declared = { item["name"]: item diff --git a/tests/test_case_admission_modes.py b/tests/test_case_admission_modes.py new file mode 100644 index 0000000..e425020 --- /dev/null +++ b/tests/test_case_admission_modes.py @@ -0,0 +1,135 @@ +"""KERNEL-2 lane resolution and KERNEL-3 mode-aware admission (fixture only).""" +from dataclasses import asdict, replace +import json + +import pytest +import yaml + +from warden.case_receipt_state import Refused +from warden.case_run import Descriptor, digest +from tests.test_case_run import MINT_STEP, fixture_catalog, lane_entry, make_rig + + +@pytest.fixture +def rig(tmp_path): + return make_rig(tmp_path) + +EVIDENCE = {"acceptance", "materialization", "descriptor", "session"} + + +def admit(rig, *, step=None, entries=None, catalog=None, desc=None, **over): + executor, _, _, sources, kwargs = rig + case, typ, descriptor, _ = sources + if desc is not None: + base = Descriptor.decode(descriptor) + descriptor = json.dumps(asdict(replace(base, **desc))).encode() + doc = yaml.safe_load(typ) + doc["spec"]["execution_descriptors"] = {"mint-secret": digest(descriptor)} + if step is not None: + doc["spec"]["follow_through"] = step + typ = yaml.safe_dump(doc).encode() + if catalog is None: + catalog = fixture_catalog(*(entries or [lane_entry()])) + executor.evidence_verifier = lambda purpose, source, evidence: evidence in EVIDENCE + return executor.admit(case, typ, descriptor, catalog, **{**kwargs, **over}) + + +def mode_step(executes, attended): + return [dict(MINT_STEP, warden_executes=executes, attended=attended)] + + +def refused(message, rig, **kw): + with pytest.raises(Refused, match=message): + admit(rig, **kw) + + +# Positive modes +def test_warden_mode_admits(rig): + assert admit(rig)[1].lane == "issuer-lane" + + +def test_attended_mode_admits_human_parent_session(rig, monkeypatch): + monkeypatch.delenv("WARDEN_AGENT_ID", raising=False) + admitted = admit( + rig, + step=mode_step(False, True), + desc=dict(effect="change", revocation_scopes=("parent-session",)), + ) + assert admitted[1].actor == "human" + + +def test_owner_mode_admits_native_owner_lane(rig): + admitted = admit(rig, step=mode_step(False, False), desc=dict(effect="change")) + assert admitted[1].owner == "owner" + + +# KERNEL-2 +@pytest.mark.parametrize( + "entries, catalog, message", + [ + (None, b"not: [a catalog", "invalid catalog source shape"), + ([lane_entry("other-lane")], None, "descriptor lane is not in the catalog"), + ([lane_entry(status="draft")], None, "descriptor lane is not active in the catalog"), + ([{k: v for k, v in lane_entry().items() if k != "delegation"}], None, + "descriptor lane has no catalog delegation record"), + ([{k: v for k, v in lane_entry().items() if k != "risk"}], None, + "descriptor lane carries no risk grade"), + ([lane_entry(owner="someone-else")], None, + "descriptor owner does not fit catalog lane owner_repo"), + ([lane_entry(risk="standard")], None, + "catalog lane risk does not cover the descriptor effect"), + ], +) +def test_catalog_lane_refusals(rig, entries, catalog, message): + refused(message, rig, entries=entries, catalog=catalog) + + +# KERNEL-3: the step must declare dispatch and agree with the descriptor +@pytest.mark.parametrize( + "step, message", + [ + ([], "type step does not declare how the kernel dispatches it"), + (mode_step(True, True), "type step does not declare how the kernel dispatches it"), + ([{k: v for k, v in MINT_STEP.items() if k != "lane"}], + "type step does not declare how the kernel dispatches it"), + ([dict(MINT_STEP, warden_executes="yes")], + "type step does not declare how the kernel dispatches it"), + ([dict(MINT_STEP, lane="other-lane")], "type step lane or owner disagrees with descriptor"), + ([dict(MINT_STEP, owner="other")], "type step lane or owner disagrees with descriptor"), + ], +) +def test_step_declaration_refusals(rig, step, message): + refused(message, rig, step=step) + + +def test_attended_refuses_agent_session(rig, monkeypatch): + monkeypatch.setenv("WARDEN_AGENT_ID", "agent-1") + refused("attended step requires a human parent session", rig, + step=mode_step(False, True), desc=dict(effect="change")) + + +def test_attended_refuses_agent_actor(rig): + refused("attended step requires a human parent session", rig, + step=mode_step(False, True), desc=dict(effect="change", actor="agent"), + actor="agent") + + +def test_attended_refuses_caller_lane_other_than_step_lane(rig): + refused("principal actor or lane mismatch", rig, + step=mode_step(False, True), desc=dict(effect="change"), lane="login-lane") + + +@pytest.mark.parametrize( + "entry, desc", + [ + (lane_entry(delegation={"mode": "interim", "intended_owner": "owner", + "blocked_on": "fixture", "reviewed": "2026-10-17"}), + dict(effect="change")), + (lane_entry(owner="owner/other"), dict(effect="change")), + (lane_entry(warden_executes=True), dict(effect="change")), + (lane_entry(), {}), # owner step may not be a Warden-brokered mint + ], +) +def test_owner_mode_refusals(rig, entry, desc): + refused("owner step lane is not the owner's native path", rig, + step=mode_step(False, False), entries=[entry], desc=desc) diff --git a/tests/test_case_inputs.py b/tests/test_case_inputs.py index 50a132f..c8d1b70 100644 --- a/tests/test_case_inputs.py +++ b/tests/test_case_inputs.py @@ -44,6 +44,9 @@ def browser(rig, version="1.1", mutate=None): case["spec"]["parameters"]["consumer_namespace"] = "forgejo" # This extension and all native provenance are TEST ONLY, never source assent. typ["spec"]["execution_descriptors"] = {"mint-secret": digest(sources[2])} + for step in typ["spec"]["follow_through"]: + if step["id"] == "mint-secret": # TEST ONLY: route to the fixture lane + step.update(owner="owner", lane="issuer-lane") if mutate: mutate(case, typ) changed = ( diff --git a/tests/test_case_run.py b/tests/test_case_run.py index 0032e5e..b7664a5 100644 --- a/tests/test_case_run.py +++ b/tests/test_case_run.py @@ -12,8 +12,47 @@ from warden.case_receipt_state import Outcome, Refused from warden.case_run import Adapter, Descriptor, Executor, Journal, OwnerResult, digest -@pytest.fixture -def rig(tmp_path): +def lane_entry(lane="issuer-lane", owner="owner", **over): + """Fixture catalog entry (TEST ONLY; never a real routing pointer).""" + entry = { + "id": lane, + "title": "fixture lane", + "need_keywords": [lane], + "owner_repo": owner, + "subsystem": "fixture", + "warden_executes": False, + "wiki_ref": "wiki/fixture.md", + "canon_ref": "fixture/canon.md", + "reviewed": "2026-10-17", + "status": "active", + "risk": "high", + "workload_ref": {"applicability": "not-applicable", "reason": "fixture"}, + "delegation": { + "mode": "native", + "intended_owner": owner, + "reviewed": "2026-10-17", + }, + } + entry.update(over) + return entry + + +def fixture_catalog(*entries): + return yaml.safe_dump({"entries": list(entries) or [lane_entry()]}).encode() + + +CATALOG = fixture_catalog() + +MINT_STEP = { + "id": "mint-secret", + "owner": "owner", + "lane": "issuer-lane", + "warden_executes": True, + "attended": False, +} + + +def make_rig(tmp_path): desc = Descriptor( "warden.case.execution.v1", "mint-secret", @@ -43,6 +82,7 @@ def rig(tmp_path): } ], "execution_descriptors": {"mint-secret": digest(descriptor)}, + "follow_through": [dict(MINT_STEP)], } } ).encode() @@ -106,7 +146,7 @@ def rig(tmp_path): ("ops-mason-materialization", digest(case), "materialization"), ("owner-descriptor-acceptance:owner", digest(descriptor), "descriptor"), ("type-machine-seam-acceptance", digest(typ), "descriptor"), - ("catalog-routing-acceptance", digest(b"catalog"), "descriptor"), + ("catalog-routing-acceptance", digest(CATALOG), "descriptor"), ("caller-session:human:operator:issuer-lane", digest(case), "session"), ("session-revocation:issuer-lane", digest(case), "session-revoked"), ("grant-revocation:issuer-lane", digest(case), "grant-revoked"), @@ -136,8 +176,13 @@ def rig(tmp_path): descriptor_evidence="descriptor", session_evidence="session", ) - admitted = executor.admit(case, typ, descriptor, b"catalog", **kwargs) - return (executor, admitted, calls, (case, typ, descriptor, b"catalog"), kwargs) + admitted = executor.admit(case, typ, descriptor, CATALOG, **kwargs) + return (executor, admitted, calls, (case, typ, descriptor, CATALOG), kwargs) + + +@pytest.fixture +def rig(tmp_path): + return make_rig(tmp_path) def test_commit_running_is_visible_from_separate_connection_before_dispatch(rig): @@ -387,7 +432,7 @@ def test_changed_catalog_pins_cannot_inherit_old_success(rig): executor, args, calls, sources, kwargs = rig executor.run(*args) with pytest.raises(Refused): - executor.admit(*sources[:3], b"changed-catalog", **kwargs) + executor.admit(*sources[:3], fixture_catalog(lane_entry(title="changed")), **kwargs) assert len(calls) == 1 @@ -601,7 +646,7 @@ def test_attempt_identity_changes_with_exact_catalog_revision_and_new_journal( executor, args, calls, sources, kwargs = rig executor.run(*args) original_attempt = calls[-1].attempt_id - new_catalog = b"independently-reviewed-catalog-v2" + new_catalog = fixture_catalog(lane_entry(title="independently reviewed v2")) old_verifier = executor.evidence_verifier def verifier(purpose, source, evidence): diff --git a/tests/test_workload_join.py b/tests/test_workload_join.py index e6363e8..2439c22 100644 --- a/tests/test_workload_join.py +++ b/tests/test_workload_join.py @@ -28,7 +28,9 @@ def test_repo_catalog_uses_only_explicit_workload_references(): # names fluid-telegram, which has published no workload declaration. # not_applicable 18 since 2026-10-10: keycape-client-secret-generate is a # per-case generation lane; each case's consumer gets its own entry. - assert len(report["not_applicable"]) == 18 + # not_applicable 22 since 2026-10-17: the four CATALOG-1 draft lanes + # (nk48 1.3 owner review) are per-case lanes like the one above. + assert len(report["not_applicable"]) == 22 assert {row["lane"] for row in report["resolved"]} == { "ops-warden-warden-sign-token", "issue-core-ingestion-api-key",