diff --git a/src/warden/plan.py b/src/warden/plan.py index 730a43a..e709b9a 100644 --- a/src/warden/plan.py +++ b/src/warden/plan.py @@ -98,6 +98,48 @@ def _candidate_row(entry: RouteEntry, score: int) -> dict: } +#: Verbs that make a need a request to CHANGE custody rather than to read it +#: (WARDEN-WP-0038). Reported independently by key-cape (2026-09-08) and +#: railiance-platform (2026-09-09): a need saying "generate a successor secret and +#: CAS-write it to two custodians" scored well against the lane that READS that +#: path and inherited its `autonomous` verdict, answered with three read +#: transports. +#: +#: Deliberately not including "issue" or "sign". SSH certificate issuance is a +#: mutating act ops-warden owns outright, and the ownership test below is what +#: separates it from someone else's custody — not the absence of the verb. +MUTATE_TOKENS = frozenset({ + "rotate", "rotating", "rotation", "rerotate", + "generate", "regenerate", "mint", "reissue", + "write", "rewrite", "put", "patch", "cas", + "provision", "reprovision", "create", "install", + "revoke", "revoking", "disable", "delete", "remove", + "replace", "successor", "reset", "restart", "update", +}) + + +def _need_intent(need: str) -> str: + """``mutate`` when the need asks to change a credential, else ``read``. + + Token match, not substring: "update" must not fire on "updated docs" any + more than it already would, but "no-update" style hyphenation is normalised + the same way the scorer normalises it. + """ + tokens = {t.strip(".,;:()") for t in need.lower().replace("-", " ").split()} + return "mutate" if tokens & MUTATE_TOKENS else "read" + + +def _owns_write_authority(entry: RouteEntry) -> bool: + """Whether ops-warden may itself perform a mutating act on this lane. + + True only where ops-warden is the designed owner of the front door — the SSH + signing lane, `delegation.mode: permanent`. Everywhere else ops-warden is a + pointer or a caller-identity proxy (`ADR-0002`, `ADR-0005`), so a write is + another component's custody act and no plan verdict here can authorise it. + """ + return entry.effective_delegation.mode == "permanent" + + def _score_for(catalog: Catalog, entry: RouteEntry, need: str) -> int: if entry.id == need.strip(): return 100 @@ -294,6 +336,7 @@ def build_plan( ) entry, score = matches[0] + intent = _need_intent(need) # Draft-only top match without active alternatives → unroutable if entry.status == "draft" and not include_draft: @@ -378,6 +421,80 @@ def build_plan( domain=domain, ) + # --- mutating need on a lane ops-warden does not own (WARDEN-WP-0038) ------ + # + # Must run before any branch that can return `autonomous`. A write is a + # custody act belonging to the lane owner, and `autonomous` is documented as + # the signal to proceed without the founder — so returning it here would let + # this front door authorise a mutation on someone else's custody. It would + # also answer a write with `--out`/`--exec`/`--wrap`, which are reads. + if intent == "mutate" and not _owns_write_authority(entry): + rotation = entry.rotation + owner = (rotation.owner if rotation else None) or entry.owner_repo + reasons = [ + "need asks to change a credential, not read one", + f"write authority on this lane belongs to {owner}, not ops-warden", + ] + if rotation is not None: + act = FounderAct( + kind="approve", + summary=( + f"Attended owner act required to change {entry.id} — " + f"{owner} holds write authority" + ), + details={ + "lane_id": entry.id, + "write_owner": owner, + "rotation_method": rotation.method, + "rotation_automatable": rotation.automatable, + "wiki_ref": entry.wiki_ref, + "guidance_command": f"warden rotate-guide {entry.id}", + "desk_hint": f"warden desk --act approve --lane {entry.id}", + }, + ) + if not rotation.automatable: + reasons.append( + "lane records the rotation as not automatable — an executable " + "driver must not attempt it" + ) + return AccessPlan( + need=need, + verdict="founder_required", + organization_posture=org, + policy_gate=gate, + lane_id=entry.id, + lane_title=entry.title, + match_score=score, + # Deliberately empty: every command this lane offers is a READ, + # and offering one against a write need is the reported defect. + commands=[f"warden rotate-guide {entry.id} # guidance, not execution"], + founder_act=act, + catalog=freshness, + candidates=candidates, + reasons=reasons, + actor=actor, + domain=domain, + ) + stub = _ccr_stub(need) + stub["owner_hint"] = f"{owner} (write authority for {entry.id})" + reasons.append("lane records no rotation route — the act has no admitted transport") + return AccessPlan( + need=need, + verdict="unroutable", + organization_posture=org, + policy_gate=gate, + lane_id=entry.id, + lane_title=entry.title, + match_score=score, + commands=[], + ccr_stub=stub, + catalog=freshness, + candidates=candidates, + reasons=reasons, + actor=actor, + domain=domain, + ) + if _lane_is_autonomous(entry): return AccessPlan( need=need, diff --git a/tests/test_plan.py b/tests/test_plan.py index 73fc7e9..a3cf827 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -119,3 +119,87 @@ def test_cli_plan_json(): assert payload["verdict"] == "autonomous" assert payload["organization_posture"] == "build" assert payload["lane_id"] == "agent-harness-forgejo-deploy" + + +# --- mutate intent (WARDEN-WP-0038) ------------------------------------------- +# +# `warden plan` scored a need by keyword overlap with no notion of what the +# caller wanted to DO, so "generate a successor secret and CAS-write it to two +# custodians" matched the lane that READS that path and inherited its +# `autonomous` verdict, answered with --out/--exec/--wrap. Reported independently +# by key-cape (2026-09-08) and railiance-platform (2026-09-09), who added: "this +# is the same verdict your warden plan should have returned; until that is fixed, +# do not let a plan result stand in for this answer." + +QONTO_ROTATION_NEED = ( + "generate a successor client secret for rapp-qonto keycape client and " + "CAS-write it to OpenBao platform/workloads/rapp-qonto/keycape-client " + "and sso/keycape-rapp-qonto-client" +) + + +def test_reported_qonto_write_no_longer_returns_autonomous(): + """The exact need from the report. Regression, in the manner of WP-0033-T06.""" + plan = build_plan(QONTO_ROTATION_NEED) + assert plan.verdict == "founder_required" + assert plan.lane_id == "rapp-qonto-keycape-client" + assert plan.founder_act is not None + assert plan.founder_act.kind == "approve" + assert plan.founder_act.details["write_owner"] == "key-cape" + + +def test_a_write_need_is_never_answered_with_a_read_transport(): + """The half that made the wrong verdict actionable rather than merely wrong.""" + plan = build_plan(QONTO_ROTATION_NEED) + joined = " ".join(plan.commands) + for read_transport in ("--out", "--wrap", "--fetch", "bao kv get", "--exec"): + assert read_transport not in joined, read_transport + + +def test_mutating_need_names_why_it_escalated(): + """WP-0029's property: a verdict carries the reasons that produced it.""" + plan = build_plan(QONTO_ROTATION_NEED) + assert any("change a credential" in r for r in plan.reasons) + assert any("write authority" in r for r in plan.reasons) + # The lane records automatable: false; a driver must be told so. + assert any("not automatable" in r for r in plan.reasons) + + +def test_ops_warden_still_proceeds_autonomously_on_the_lane_it_owns(): + """Signing IS a mutating act. The test is ownership, not the absence of a verb. + + `delegation.mode: permanent` is the whole distinction — if this ever fails, + the guard has started refusing ops-warden's own front door. + """ + for need in ( + "sign an ssh certificate for agt-state-hub-bridge", + "issue a short-lived ssh cert for adm", + ): + plan = build_plan(need) + assert plan.verdict == "autonomous", need + assert plan.lane_id == "ssh-cert-host-access" + + +def test_reading_the_same_lane_is_unaffected(): + plan = build_plan("I need the npm token to publish whynot-design") + assert plan.verdict == "autonomous" + assert plan.lane_id == "whynot-design-npm-publish" + + +@pytest.mark.parametrize( + "verb", ["rotate", "revoke", "regenerate", "reset", "replace"] +) +def test_mutating_verbs_escalate_on_a_lane_ops_warden_does_not_own(verb): + plan = build_plan(f"{verb} the forgejo admin api token") + assert plan.verdict == "founder_required" + assert plan.founder_act.details["write_owner"] == "railiance-platform" + + +def test_intent_classifier_matches_tokens_not_substrings(): + from warden.plan import _need_intent + + assert _need_intent("rotate the forgejo token") == "mutate" + assert _need_intent("CAS-write to two custodians") == "mutate" + # "created" and "updated" are not the verbs; a need describing state is a read. + assert _need_intent("read the token created for whynot-design") == "read" + assert _need_intent("which subsystem owns the npm token") == "read" diff --git a/workplans/WARDEN-WP-0038-plan-mutation-intent.md b/workplans/WARDEN-WP-0038-plan-mutation-intent.md index a18a3f3..047148c 100644 --- a/workplans/WARDEN-WP-0038-plan-mutation-intent.md +++ b/workplans/WARDEN-WP-0038-plan-mutation-intent.md @@ -4,14 +4,14 @@ type: workplan title: "warden plan must distinguish reading a secret from mutating one" domain: infotech repo: ops-warden -status: proposed +status: finished owner: ops-warden topic_slug: netkingdom planning_priority: P1 depends_on_workplans: - WARDEN-WP-0029 created: "2026-09-08" -updated: "2026-09-08" +updated: "2026-09-09" state_hub_workstream_id: "751ad530-e44b-52c4-b70e-cb47568a0179" --- @@ -70,3 +70,57 @@ acts it admits (`read`, `rotate`, `provision`) and `plan` could refuse any act a lane does not declare — stricter, and it fails closed on lanes that say nothing, which is the majority today. That is a catalog schema change and needs its own argument. + +## Confirmed independently, then fixed — 2026-09-09 + +`railiance-platform` hit the same defect from the other side while answering the +Qonto custody routing question (msg `7c7228ac`). Having stated that the correct +verdict is `founder_required` — attended OIDC via `netkingdom` `role=platform-admin`, +through the governed `openbao-platform-admin-login` lane — they added: + +> Note this is the same verdict your `warden plan` should have returned; it +> returning `autonomous` and offering read transports for a write is a real +> defect and WARDEN-WP-0038 is the right place for it. Until that is fixed, do +> not let a plan result stand in for this answer. + +Two counterparties reporting the same wrong verdict in two days, one of whom is +the write authority being wrongly bypassed, moved this from proposed to shipped. + +### What shipped + +`_need_intent()` classifies a need as `mutate` or `read` by token match, and +`_owns_write_authority()` tests `delegation.mode == "permanent"`. A mutating need +on a lane ops-warden does not permanently own can no longer reach any branch that +returns `autonomous`: + +- lane declares a `rotation` → `founder_required`, with an `approve` act naming + the write owner, the rotation method, whether it is automatable, and + `warden rotate-guide` as guidance; +- lane declares none → `unroutable` with a CCR stub whose `owner_hint` names the + write authority. + +**Commands are empty of read transports either way.** That is the half that made +the wrong verdict actionable rather than merely wrong: `--out`, `--exec`, +`--wrap` and a bare `bao kv get` were being offered as the answer to a write, and +a test now asserts none of them appears. + +### The design question in the plan, answered by use + +The plan asked whether intent belongs in the matcher or in the lane. It went in +the matcher, with the *ownership* test doing the work that a verb list cannot: +SSH certificate issuance is itself a mutating act, and `warden sign` must stay +`autonomous`. `delegation.mode: permanent` is what separates ops-warden's own +front door from someone else's custody — not the absence of a verb from a list. +A regression asserts the SSH lane still proceeds, because a guard that refuses +our own lane would be a worse defect than the one it fixes. + +The stricter alternative — lanes declaring which acts they admit, `plan` refusing +anything undeclared — is not built. It is a catalog schema change that would fail +closed on the majority of lanes, which say nothing today, and it needs its own +argument rather than arriving as a side effect of this fix. + +### Not claimed + +This narrows what `warden plan` will assert; it does not make the tool an +authority on custody. railiance-platform's instruction stands and is the right +standing posture: a plan result does not substitute for the owner's answer.