diff --git a/history/2026-09-09-layer-model-v08-review.md b/history/2026-09-09-layer-model-v08-review.md new file mode 100644 index 0000000..bc04aa6 --- /dev/null +++ b/history/2026-09-09-layer-model-v08-review.md @@ -0,0 +1,119 @@ +# security-layer-model v0.8 — ops-warden review + +**Date:** 2026-09-09 +**Reviewing:** `net-kingdom/canon/standards/security-layer-model_v0.8.md` (status +`proposed`, net-kingdom@66eeaba), circulated for assent by gate-house +**Prior reviews:** `2026-08-29-layer-model-v04-review.md`, `-v06-review.md`, +`-v07-scope-intent-assessment.md` + +## The cell in question + +v0.8 §6.4 obligation 3 adds: *unknown is not a zone and MUST resolve to +`fail_closed`.* `pep-stance.yaml` declares `unknown: fail_open` under `ADR-0009`. +§13.1 records the cell as non-conformant. + +## Finding 1 — assent. The rule is right and our falsifier check fails. + +gate-house wrote the falsifier into GH-DEC-2026-009's reversal: a scope that is +genuinely unknown **and** genuinely low-consequence, where failing closed costs +availability with no security gain. Their expectation is that any such path is a +§5.1 read-only diagnostic carrying no PEP obligation at all. + +We looked for one on our side and do not have it. The stance map governs exactly +one protected action — `warden sign`, SSH certificate issuance. That is a +credential-issuing side effect, not a diagnostic; §5.1 does not reach it. So the +predicted escape hatch is absent here, and the argument stands unrebutted on its +own terms: + +- §9.3 licenses trading availability for openness **per zone**, knowingly, for a + named scope. Where the scope is unknown that trade was never made for the + request in hand, so resolving permissively does not extend a considered + decision — it invents one. +- Unknown is the cheapest state to induce. Our own `unknown` reasons are + `workload_resolution_absent` and `catalog_applicability_absent` — an + unregistered workload or a missing declaration, neither of which requires a + credential to arrange. + +We accept that being unclassifiable must not buy permissiveness. The cell is +wrong as doctrine. + +## Finding 2 — the rule is right and adopting it today would be a global fail-closed flag in all but name + +This is the finding we would like recorded, and it is about adoption cost, not +about the rule. + +The stance map applies only when the engine is unreachable, so the cell's bite is +proportional to how much of the target population sits in `unknown`. Measured +today: + +| Population | Resolved to a zone | `unknown` | `not-applicable` | +| --- | --- | --- | --- | +| Signing targets (actor resources in the flex-auth snapshot) | **0** | **3** | 1 | +| Routing catalog lanes | 3 | 18 | 12 | + +Every signing target that resolves at all resolves to `unknown`. So +`unknown: fail_closed` adopted today does not fail closed on an edge case; it +fails closed on **essentially every certificate ops-warden issues** whenever +flex-auth is unreachable. + +That is the precise configuration `ADR-0006` rejected and `ADR-0009` replaced: one +value making the decision engine a uniform dependency of every signing path, +*including the continuity paths needed to repair that dependency*. If flex-auth is +unreachable and the operator needs an SSH certificate to reach the host and +restore it, `unknown: fail_closed` denies the certificate — and the target is +`unknown` precisely because nobody has classified the repair path. + +The zone model already answers this: classify continuity paths `z2-continuity`, +whose stance is `fail_open`, and the deadlock does not arise. We accept that as +the right answer. The point is that it is *work not yet done*, and the reason it +is not done is not ours to fix — 18 of our 18 unknown lanes are unknown because +another repo has not published an authoritative workload identity declaration. +`ADR-0009` rule 3 forbids us from closing that gap with inference, correctly. + +So as written, v0.8 §6.4 obligation 3 transfers an availability cost onto the +consumer for a classification gap the consumer cannot close alone, and it does so +at the moment of assent rather than at the moment the gap closes. + +**What we ask for**, in preference order: + +1. **§6.4 obligation 3 names a transition.** A consumer may declare + `unknown: fail_open` as a dated, published transitional state with a + classification-coverage figure attached, converting to `fail_closed` on + coverage rather than on calendar. This keeps the doctrine and prices the + adoption honestly. +2. Failing that, **§13.1 records classification coverage alongside stance.** A + register row reading `unknown: fail_closed` while 100% of that consumer's + targets are unknown is conformant and materially misleading; a reader cannot + tell a strict consumer from an unclassified one. This is the same defect + §11's *published map equals shipped behaviour* rule exists to prevent, one + level up: the map is now accurate about itself and inaccurate about its + effect. + +## Finding 3 — §12's derived-artifact rule, small and in agreement + +Six instances in one week matches our experience. `registry/generated/high-risk-data-paths.yaml` +carries `catalog_revision` and a `catalog_dirty` flag for exactly the reason §12 +now generalises: a derived artifact whose revision does not describe the file it +came from is worse than one with no revision, because the stale one is trusted. +No change requested; recording the agreement since the rule is being generalised +from other repos' incidents. + +## What ops-warden is doing about it + +Not flipping the cell on a `proposed` standard, and not flipping it before +coverage exists — that ordering is `ADR-0006`'s whole holding. `WARDEN-WP-0040` +records the adoption sequence: raise signing-target classification coverage +first, then convert the cell, with the deadlock case classified before rather +than after. If v0.8 is accepted with obligation 3 unchanged, ops-warden's cell +remains a **declared gap** under §11 — tracked non-conformance with a named +owner and a route, which is the mark §9.1 exists for and which we argued for in +the v0.6 round. + +## Axis declaration (second change touching us) + +`pep-stance.yaml` already carries `scope: security-zone`. No change needed. We +agree with rejecting a forced migration onto zones — secrets-engine scoping by +catalog stage is an accurate statement about their control, and making them +assert a zone they cannot know would trade a real fact for a conformant fiction. +The unavailability of cross-axis aggregation is better recorded than papered +over, and §13.1 now says so. diff --git a/registry/generated/high-risk-data-paths.yaml b/registry/generated/high-risk-data-paths.yaml index aa3dd0e..2ad306a 100644 --- a/registry/generated/high-risk-data-paths.yaml +++ b/registry/generated/high-risk-data-paths.yaml @@ -10,10 +10,10 @@ # declares it, and is null where the field set has not been established -- # null means unknown, never 'one field'. -generated_at: "2026-09-08T12:55:03Z" +generated_at: "2026-09-09T12:43:48Z" source: ops-warden/registry/routing/catalog.yaml -catalog_revision: "c1330049d9535f701676aa955d56f2138bb70d19" -catalog_revision_date: "2026-09-08T14:55:02+02:00" +catalog_revision: "83fdd08f881ce6a5bd9c3fbcb4dc79d1ab62b4d5" +catalog_revision_date: "2026-09-09T14:43:44+02:00" catalog_dirty: false high_risk_lane_count: 24 concrete_path_count: 15 diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index fdd76e0..7b9914a 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -241,7 +241,15 @@ entries: # form is superseded; do not reintroduce it. auth_method: "bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read" path_template: "platform/workloads/coulomb/whynot-design/npm-publish" - fetch_command: "bao kv get -field=NPM_AUTH_TOKEN platform/workloads/coulomb/whynot-design/npm-publish" + # Field corrected 2026-09-09 on the owner's statement (secrets-engine msg + # 15f0c0ca): `npm_token` is the KV FIELD; `NPM_AUTH_TOKEN` is the environment + # variable the publication-scope policy injects, and ops-warden had copied the + # env var in as the field name. That is ADR-0001's failure mode exactly — a + # pointer layer restating an owner's procedure and getting it wrong — so it is + # corrected from the owner's doc (secrets-engine docs/whynot-design-real-publish-closeout.md), + # never re-derived here. The path is a separate question, open with + # railiance-platform; do not change it on this correction. + fetch_command: "bao kv get -field=npm_token platform/workloads/coulomb/whynot-design/npm-publish" policy_ref: "flex-auth check secret.read:whynot-design" exec_capable: true lane: secret @@ -257,7 +265,7 @@ entries: automatable: false steps: - "In the coulomb Forgejo registry, revoke the current @whynot/design publish token and generate a new one (scope: package read/write) for the whynot-design publish identity." - - "Write it back: `bao kv put platform/workloads/coulomb/whynot-design/npm-publish NPM_AUTH_TOKEN=@file` (value from a mode-0600 file)." + - "Write it back: `bao kv put platform/workloads/coulomb/whynot-design/npm-publish npm_token=@file` (value from a mode-0600 file). The field is `npm_token`; `NPM_AUTH_TOKEN` is the injected env var, not a KV key." - "Verify capabilities-safe, then publish a fresh version and confirm it with Forgejo-supported `npm view @` through the governed execution lane (value used, not printed)." - id: policy-nexus-forgejo-source-read diff --git a/scripts/check_flex_auth_manifest_coverage.py b/scripts/check_flex_auth_manifest_coverage.py new file mode 100644 index 0000000..755ba02 --- /dev/null +++ b/scripts/check_flex_auth_manifest_coverage.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Assert every ssh-certificate resource declares its ceilings in the manifest. + +Read-only. Reads `inventory.yaml` and the flex-auth registry snapshot; touches +no network, no OpenBao, and no secret material. + +Why this exists (FLEX-DEC-2026-012, 2026-09-07). flex-auth's enrichment used to +overlay registry facts additive-if-absent, so a caller-supplied value for a key +won and the registry's ceiling never applied. That is fixed on their side: +registry facts now win. But they win only where the registry HAS a value — + + "A resource whose manifest omits max_ttl_hours hands that ceiling back to + the caller, and registering the resource is not sufficient; the specific + key must be present." + +So there are two ways to hand a ceiling back, and this check covers both: + + 1. An actor with no manifest resource at all. `warden sign` names + `ssh-cert:actor/` for any actor in inventory, whether or not the + snapshot was regenerated. Adding an actor and forgetting to rebuild is an + honour-system step in `SCOPE.md`, and honour-system steps are what + `ADR-0004`'s WARDEN_AGENT_ID marker taught us to stop relying on. + 2. A manifest resource missing one of the ceiling or allowlist keys. The + builder emits all of them today; this asserts it stays true, including for + resources added by hand or by a future code path. + +ops-warden sends no `resource.attributes` on a CheckRequest (`src/warden/policy.py`), +which is why FLEX-DEC-2026-012 was defence-in-depth rather than a live hole for +this repo. That property is asserted separately in `tests/test_policy.py`; this +check covers the half that survives it — if anything ever does reach a request +field, the ceiling it would have to beat must actually exist. + +Usage: + python scripts/check_flex_auth_manifest_coverage.py \ + --inventory examples/inventory.seed.yaml \ + --registry registry/flex-auth/production_registry_snapshot.json [--json] + +Exit: 0 covered, 2 coverage gap, 1 usage/IO error. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +import yaml + +#: Every key ops-warden's shipped policy package branches on as a ceiling or an +#: allowlist. Named by flex-auth in FLEX-DEC-2026-012; kept here rather than +#: derived from the package so a package edit that drops a branch does not +#: silently shrink what this check requires. +REQUIRED_CEILING_KEYS = ( + "actor_id", + "actor_type", + "allowed_principals", + "allowed_subjects", + "max_ttl_hours", + "security_zone", + "security_zone_admission", +) + +RESOURCE_TYPE = "ssh-certificate" + + +def _resources(registry: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for manifest in registry.get("resource_manifests") or []: + out.extend(manifest.get("resources") or []) + return out + + +def audit(inventory: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]: + actors = sorted((inventory.get("actors") or {}).keys()) + resources = _resources(registry) + by_id = {str(r.get("id")): r for r in resources} + + unregistered: list[str] = [] + missing_keys: list[dict[str, Any]] = [] + + for name in actors: + if f"ssh-cert:actor/{name}" not in by_id: + unregistered.append(name) + + # Check every ssh-certificate resource in the manifest, not only the ones an + # inventory actor maps to: a resource flex-auth can be asked about is one it + # holds, regardless of where it came from. + for resource in resources: + if str(resource.get("type")) != RESOURCE_TYPE: + continue + attributes = resource.get("attributes") or {} + absent = [ + key for key in REQUIRED_CEILING_KEYS + # A null is not a declaration: flex-auth overlays a registry value + # only where one exists, so `key: null` hands the ceiling back + # exactly as an absent key does. + if attributes.get(key) is None + ] + if absent: + missing_keys.append({"resource": str(resource.get("id")), "missing": absent}) + + # Not a security defect — a stale resource has ceilings, it just has no + # actor. Reported so drift is visible rather than accumulating silently. + orphaned = sorted( + rid for rid, r in by_id.items() + if str(r.get("type")) == RESOURCE_TYPE + and rid.removeprefix("ssh-cert:actor/") not in actors + ) + + return { + "actors": len(actors), + "resources": sum(1 for r in resources if str(r.get("type")) == RESOURCE_TYPE), + "required_keys": list(REQUIRED_CEILING_KEYS), + "unregistered_actors": unregistered, + "resources_missing_keys": missing_keys, + "orphaned_resources": orphaned, + "covered": not unregistered and not missing_keys, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inventory", default="examples/inventory.seed.yaml", type=Path) + parser.add_argument( + "--registry", + default="registry/flex-auth/production_registry_snapshot.json", + type=Path, + ) + parser.add_argument("--json", action="store_true", dest="as_json") + args = parser.parse_args(argv) + + try: + inventory = yaml.safe_load(args.inventory.read_text()) or {} + registry = json.loads(args.registry.read_text()) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + report = audit(inventory, registry) + report["inventory"] = str(args.inventory) + report["registry"] = str(args.registry) + + if args.as_json: + print(json.dumps(report, indent=2)) + else: + print(f"inventory : {args.inventory} ({report['actors']} actors)") + print(f"registry : {args.registry} ({report['resources']} ssh-certificate resources)") + for name in report["unregistered_actors"]: + print(f" MISSING {name} — no manifest resource; every ceiling is caller-supplied") + for row in report["resources_missing_keys"]: + print(f" UNDECLARED {row['resource']} — {', '.join(row['missing'])}") + for rid in report["orphaned_resources"]: + print(f" orphaned {rid} — manifest resource with no inventory actor") + print("covered" if report["covered"] else "NOT COVERED") + + return 0 if report["covered"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_flex_auth_registry.py b/tests/test_flex_auth_registry.py index c329d27..70dd26f 100644 --- a/tests/test_flex_auth_registry.py +++ b/tests/test_flex_auth_registry.py @@ -81,3 +81,86 @@ def test_compiler_joins_explicit_workload_reference_to_resolved_zone(tmp_path): assert attrs["security_zone"] == "z2-continuity" assert attrs["security_zone_admission"] == "satisfied" assert attrs["security_zone_revision"] == "sha256:zone-revision" + + +# --- manifest ceiling coverage (FLEX-DEC-2026-012) ---------------------------- +# +# flex-auth fixed enrichment so registry facts beat caller-supplied ones, then +# asked every consumer to confirm the ceiling keys are actually declared: the +# fix wins only where the registry HAS a value. These assert the two ways a +# ceiling gets handed back to the caller. + +import importlib.util # noqa: E402 + +import pytest # noqa: E402 + +_REPO = ROOT +_spec = importlib.util.spec_from_file_location( + "check_flex_auth_manifest_coverage", + _REPO / "scripts" / "check_flex_auth_manifest_coverage.py", +) +_coverage = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_coverage) +REQUIRED_CEILING_KEYS = _coverage.REQUIRED_CEILING_KEYS +audit = _coverage.audit + +_build_spec = importlib.util.spec_from_file_location("build_flex_auth_registry", SCRIPT) +_builder = importlib.util.module_from_spec(_build_spec) +_build_spec.loader.exec_module(_builder) +build_registry = _builder.build_registry + + +def _repo_inventory_and_registry(): + inventory = yaml.safe_load((_REPO / "examples" / "inventory.seed.yaml").read_text()) + registry = json.loads( + (_REPO / "registry" / "flex-auth" / "production_registry_snapshot.json").read_text() + ) + return inventory, registry + + +def test_shipped_snapshot_declares_every_ceiling_key(): + """The confirmation flex-auth asked for, as a test rather than a reading.""" + report = audit(*_repo_inventory_and_registry()) + assert report["unregistered_actors"] == [] + assert report["resources_missing_keys"] == [] + assert report["covered"] is True + + +def test_actor_with_no_manifest_resource_is_a_coverage_gap(): + """`warden sign` names ssh-cert:actor/ whether or not the snapshot was rebuilt.""" + inventory, registry = _repo_inventory_and_registry() + inventory["actors"]["adm-added-after-the-last-build"] = { + "type": "adm", "principals": ["adm-full"], "ttl_hours": 48, + } + report = audit(inventory, registry) + assert report["unregistered_actors"] == ["adm-added-after-the-last-build"] + assert report["covered"] is False + + +@pytest.mark.parametrize("key", REQUIRED_CEILING_KEYS) +def test_dropping_any_single_ceiling_key_is_a_coverage_gap(key): + inventory, registry = _repo_inventory_and_registry() + resource = registry["resource_manifests"][0]["resources"][0] + resource["attributes"].pop(key) + report = audit(inventory, registry) + assert report["resources_missing_keys"] == [ + {"resource": resource["id"], "missing": [key]} + ] + assert report["covered"] is False + + +@pytest.mark.parametrize("key", REQUIRED_CEILING_KEYS) +def test_a_null_ceiling_is_not_a_declaration(key): + """flex-auth overlays a registry value only where one exists.""" + inventory, registry = _repo_inventory_and_registry() + resource = registry["resource_manifests"][0]["resources"][0] + resource["attributes"][key] = None + report = audit(inventory, registry) + assert report["covered"] is False + + +def test_builder_output_is_covered_by_construction(tmp_path): + """Every resource the builder emits carries the full ceiling set.""" + inventory = yaml.safe_load((_REPO / "examples" / "inventory.seed.yaml").read_text()) + report = audit(inventory, build_registry(inventory)) + assert report["covered"] is True diff --git a/tests/test_policy.py b/tests/test_policy.py index 0e2f948..8257e23 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -307,3 +307,47 @@ def test_advisory_decision_is_recorded_and_does_not_block(tmp_path): assert check_sign_policy(cfg, spec) == "decision:advisory" assert spec.policy_zone == "unknown" assert spec.policy_outcome == "audit_only" + + +def test_check_request_asserts_no_resource_attributes(tmp_path, monkeypatch): + """The property FLEX-DEC-2026-012 turned on, asserted rather than assumed. + + flex-auth's enrichment used to overlay registry facts additive-if-absent, so + a caller-supplied `resource.attributes` value won and the registry ceiling + never applied. Their exploitability assessment rested on ops-warden sending + no `resource.attributes` at all -- true when they read it, and nothing here + held it true. secrets-engine sends them on every request, so this is a + property of this code rather than of the protocol. + + Ceilings must arrive from the registry. If a future change ever needs to send + an attribute, this test is the place that argument gets made. + """ + from warden import policy as policy_mod + + pubkey = tmp_path / "id.pub" + pubkey.write_text("ssh-ed25519 AAAA test\n") + cfg = PolicyConfig(flex_auth_url="http://flex-auth.test") + seen = {} + + class _Response: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return {"effect": "allow", "id": "decision:49350f1064f674d7"} + + def fake_post(url, json=None, headers=None, timeout=None): + seen["body"] = json + return _Response() + + monkeypatch.setattr(policy_mod.httpx, "post", fake_post) + policy_mod.check_sign_policy(cfg, _spec(pubkey)) + + assert "attributes" not in seen["body"]["resource"] + # The requested TTL is a policy input (ttl_out_of_bounds is denied against + # the registry ceiling), so it must travel as context, never as a resource + # attribute that would be compared against itself. + assert seen["body"]["context"]["ttl_hours"] == 24 + assert set(seen["body"]["resource"]) == {"id", "type", "system", "tenant"} diff --git a/wiki/playbooks/whynot-design-npm-publish.md b/wiki/playbooks/whynot-design-npm-publish.md index 4f26d05..4ffc934 100644 --- a/wiki/playbooks/whynot-design-npm-publish.md +++ b/wiki/playbooks/whynot-design-npm-publish.md @@ -10,7 +10,8 @@ Owner: `railiance-platform` (OpenBao) · provisioning CCR-2026-0001 (commit 8f61 > plan-authorized Forgejo admin recovery lane. Treat this dedicated lane as > unverified for writes until its package token is rotated and re-proven. -The `NPM_AUTH_TOKEN` that publishes `@whynot/design` to the coulomb Forgejo npm registry +The npm publish token for `@whynot/design` on the coulomb Forgejo npm registry +(KV field `npm_token`; reaching the publish command as `NPM_AUTH_TOKEN`) (`https://forgejo.coulomb.social/api/packages/coulomb/npm/`). ops-warden **does not hold this token** — it is the access front door: `warden access` proxies the read from OpenBao **as the caller** and never persists, caches, or logs the value. @@ -22,7 +23,8 @@ this token** — it is the access front door: `warden access` proxies the read f | Field | Value | | --- | --- | | OpenBao path | `platform/workloads/coulomb/whynot-design/npm-publish` | -| Field | `NPM_AUTH_TOKEN` | +| KV field | `npm_token` | +| Injected env var | `NPM_AUTH_TOKEN` (set by the publication-scope policy; not a KV key) | | KV mount | `platform` | | Read policy | `workload-kv-read-whynot-design-npm-publish` | | OIDC login | `bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read` | @@ -59,12 +61,12 @@ this token** — it is the access front door: `warden access` proxies the read f token fragment at `forgejo.coulomb.social`: ```bash # --exec needs the env-var name. The zone-aware policy gate always runs first. - warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN \ + warden access whynot-design-npm-publish --field npm_token \ --exec -- npm view @whynot/design@ version \ --registry=https://forgejo.coulomb.social/api/packages/coulomb/npm/ - warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN \ + warden access whynot-design-npm-publish --field npm_token \ --exec -- npm publish - warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN --fetch + warden access whynot-design-npm-publish --field npm_token --fetch ``` On either path the value transits to you (or the child env) and never enters ops-warden's memory, disk, or audit log. diff --git a/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md b/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md index 7d779ab..81a8f19 100644 --- a/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md +++ b/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md @@ -198,6 +198,36 @@ current access-engine implementation, received the decision-lifetime semantics question (message `02c99a74`). No implementation or policy decision was assigned to another owner by these messages. +**One of three answered, 2026-09-09.** `flex-auth` answered the decision-lifetime +question as `FLEX-DEC-2026-004` (flex-auth commit `6a6464f`), and answered it +rather than declining it: a decision lifetime shorter than the certificate TTL is +meaningful, but only as authority to **issue**, never as authority to **use** an +already-issued certificate. The action is `sign` and it completes at issuance, so +the decision is consumed once the certificate exists and no field in the decision +record reaches the artifact. 15m against a 48h `adm` certificate was never an +inconsistency to reconcile — the question mistook a decision lifetime for a +credential lifetime. + +Two obligations land on this side and are accepted: no replay of an allow past +`expires_at`, and a fresh Check per sign. The second is where the value is, and it +is real rather than nominal because claims are not cached PDP-side (deadline 0 for +the claim class), so a revoked principal stops obtaining *new* certificates +immediately. + +`pep-stance.yaml` §9.7.2 is unchanged and stays here: flex-auth explicitly declined +to move the residue to their side, on the ground that a PDP recalling an issued +credential would be "the same shape of lie as fail-open". Declining it was the +right call even though absorbing it would have made their record look tidier. + +The TTL lever they named — capping requested TTL per actor class or zone, already a +policy input since `ttl_out_of_bounds` is denied before OpenBao is reached — is +noted and deliberately not requested. Shortening `adm` from 48h has an operator +cost, and issuance on this lane is too sparse (3 signature records across 79 days, +`pep-stance.yaml` `emission_cadence`) to price it from intuition. + +T05 stays `wait` on the remaining two: `ops-mason` (no stance map published) and +`railiance-infra` (KRL distribution versus TTL expiry). Neither has replied. + ## Related Review 2026-09-05: reviewed the pending implementation and fixed the identity diff --git a/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md b/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md index a1db405..38a4d5b 100644 --- a/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md +++ b/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md @@ -79,3 +79,33 @@ authenticated exact-version lookup proved that Forgejo's remote integrity matches the local dry-run. T03 waits only on rotating the dedicated package token and proving the next fresh version through `whynot-design-npm-publish`; the package migration itself is complete. + +**2026-09-09 — field claim corrected, path routed.** `secrets-engine` (msg +`15f0c0ca`) corrected two things and declined a third, all correctly. + +The KV field is `npm_token`. `NPM_AUTH_TOKEN` is the environment variable their +publication-scope policy injects, and ops-warden had copied the env var in as the +field name — so the catalog's `fetch_command` named a field that does not exist +and could only ever have failed. Corrected in `registry/routing/catalog.yaml` and +`wiki/playbooks/whynot-design-npm-publish.md` from the owner's statement rather +than re-derived here. This was `ADR-0001`'s failure mode rather than a typo: a +pointer layer restating an owner's procedure and getting a detail wrong. + +The endpoint claim already agreed; `delivery_config.npm.registry` has been the +Forgejo URL throughout. + +The **path** is routed to `railiance-platform` and the catalog is unchanged pending +their answer. Which location backs the lane for reads is custody state they own; +`secrets-engine` has no lane read authority to confirm it and refused to rewrite a +production lane pointer from a coordination message (`SECRETS-WP-0006-T06`). Their +reasoning is right, and asserting our own pointer is authoritative because it is +ours would route around it. The ask names a location only and flags that a +`bao kv get` answer would be the 2026-07-16 disclosure vector on a `risk: high` +lane. + +`ready: false` is not being read as path evidence: a source checkout with no +production authority reports not-ready regardless of which path the catalog names. + +T03 still waits on the human rotation of the dedicated Forgejo package token; the +path question does not block that, it determines whether the pointer is correct +once it rotates. diff --git a/workplans/WARDEN-WP-0039-explicit-policy-refusal.md b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md index bda2c13..5476108 100644 --- a/workplans/WARDEN-WP-0039-explicit-policy-refusal.md +++ b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md @@ -74,3 +74,31 @@ authorized by this workplan alone. HFACT-WP-0001-T03 consumes this return. Validation: 429 tests passed (4 integration tests deselected by the repository default); Ruff passed for changed Python files. Full tests used the declared phase-memory source and an isolated temporary memory store. The focused policy/proxy suite passed 69 tests. The existing authenticated SSH policy probe still returns HTTP 200/ALLOW, decision:f3f7c88f9585582a; the credential-owner request returns 403. No CA issue or credential read was performed by these probes. Installed verification: source guard present after refreshed owner installation (`19bb75c`). `warden access forgejo-admin-api-token --exec --field API_TOKEN -- true` exits 4 with explicit HTTP 403 before credential transport or child execution. The previous fail-open warning is absent. The probe requested no output value; no credential was fetched. T01/T02 are complete; T03 owns the remaining exact policy-binding admission. + +**T03 routed 2026-09-09.** Asked `flex-auth` whether an admitted contract exists +for a delegated credential read where the caller (ops-warden) and the resource +owner differ, since that is exactly the shape drawing the live 403. Three outcomes +were named as equally acceptable answers, with no preference stated between the +first two: an existing contract to conform to; no contract yet, making this a +design question they should raise rather than one ops-warden invents a shape for; +or that there should be **no** such contract — the interim proxy transport is +itself the defect and the answer is to finish the native handoff +(`WARDEN-WP-0033` / `SECRETS-WP-0006`). + +The third is a real possibility and was not argued against. Eleven catalog lanes +are ops-warden proxies with a named intended owner precisely because no owner +front door exists; if delegated reads should not be made policy-admissible, that +shortens WP-0033 rather than blocking it. + +Two fixes were explicitly ruled out in the message rather than left unmentioned: +broadening the ops-warden caller binding so it may read other systems' resources, +and relabelling `resource.system` as ops-warden so the existing binding matches. +The second is worse — it would make the audit trail assert ops-warden owns +credentials it deliberately does not, which is the claim `ADR-0002` exists to +prevent, achieved by editing a field instead of making an argument. + +The refusal stays in place until an answer yields positive evidence plus +wrong-caller, wrong-owner and wrong-tenant negatives. No grant was requested. +`secrets-engine` was told this bears on SECRETS-WP-0007-T04 and on how many lanes +stay proxied; `gate-house` was told it may reach how the signing lane's +approval-consume is built (GH-DEC-2026-005). diff --git a/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md b/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md new file mode 100644 index 0000000..c20c5e8 --- /dev/null +++ b/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md @@ -0,0 +1,122 @@ +--- +id: WARDEN-WP-0040 +type: workplan +title: "Adopt unknown -> fail_closed behind signing-target classification coverage" +domain: infotech +repo: ops-warden +status: proposed +owner: ops-warden +topic_slug: netkingdom +planning_priority: P1 +depends_on_workplans: + - WARDEN-WP-0032 + - WARDEN-WP-0034 +created: "2026-09-09" +updated: "2026-09-09" +--- + +# WARDEN-WP-0040 — `unknown` → `fail_closed`, in the right order + +security-layer-model v0.8 §6.4 obligation 3 (proposed, 2026-09-06) rules that +`unknown` is not a zone and must resolve to `fail_closed`. ops-warden's +`pep-stance.yaml` declares `unknown: fail_open` under `ADR-0009`. + +**We agree with the rule.** `history/2026-09-09-layer-model-v08-review.md` records +the assent and why our falsifier check failed: the cell governs certificate +issuance, not a §5.1 read-only diagnostic, so nothing rescues it. + +**We disagree with adopting it first.** Measured 2026-09-09: of four signing +targets, zero resolve to a zone, three are `unknown`, one is `not-applicable`. +Flipping the cell today makes flex-auth a hard dependency of essentially every +certificate — `ADR-0006`'s rejected configuration, reached by a different route, +including the continuity path needed to repair flex-auth itself. + +So: coverage first, then the cell. That ordering is the whole holding of +`ADR-0006` and the reason `ADR-0009` replaced a global switch with a zone map. + +## Tasks + +```task +id: WARDEN-WP-0040-T01 +status: todo +priority: high +``` + +**Classify the continuity path before anything else changes.** + +The deadlock is specific and worth naming before it is designed around: flex-auth +unreachable → operator needs an SSH certificate to reach the host → target is +`unknown` because nobody classified the repair path → `fail_closed` denies it. + +`z2-continuity` exists for this and its stance is `fail_open`, so the fix is +classification, not an exception. Determine which actor(s) constitute the +repair path, establish whether the declaration is ops-warden's to make (our own +`tenancy.yaml` covers ops-warden as a workload; the actors' target workloads may +not be ours), and route what is not. + +Blocks T03. Converting the cell with the repair path unclassified is the one +outcome this workplan exists to prevent. + +```task +id: WARDEN-WP-0040-T02 +status: todo +priority: high +``` + +**Raise signing-target coverage, by asking owners — never by inferring.** + +Three actor resources report `workload_resolution_absent`: +`agt-codex-interhub-bootstrap`, `agt-state-hub-bridge`, `atm-backup-daily`. +Their owners (ops-bridge and the backup execution unit among them) have not +published an authoritative workload identity declaration. + +`ADR-0009` rule 3 forbids closing this with path or repository-name inference, +and that prohibition holds under pressure from this workplan specifically — +adopting a stricter stance is not a licence to manufacture the membership that +makes it survivable. + +Route to each owner with the concrete consequence: while the declaration is +absent their actor cannot be issued a certificate during a flex-auth outage once +the cell converts. That is a better ask than a generic request to publish a +declaration, and it is true. + +Report coverage with `scripts/report_workload_join.py`; this task is done when +coverage is stated, not when it reaches a threshold — the threshold is T03's +gate, and owners may legitimately decline. + +```task +id: WARDEN-WP-0040-T03 +status: wait +priority: high +``` + +**Convert the cell and supersede `ADR-0009`'s unknown row.** + +Gated on T01, and on v0.8 (or its successor) reaching `accepted`. Do not convert +against a `proposed` standard. + +`ADR-0009` is `owner: ops-warden`, so changing it means a superseding ADR, never +an in-place edit (`.claude/rules/architecture.md`). The superseding record should +carry the v0.8 argument rather than restate it: unknown is not a zone, so a §9.3 +per-zone trade was never made for that request. + +`pep-stance.yaml` is asserted equal to `PolicyConfig.failure_modes` by +`tests/test_layer_conformance.py`, so the map and the code convert in one commit +or the test fails — which is the property that makes the map worth publishing. + +```task +id: WARDEN-WP-0040-T04 +status: todo +priority: medium +``` + +**Return findings to gate-house and track the transition ask.** + +Sent 2026-09-09 with the review. Two asks, in preference order: obligation 3 +names a dated transitional state gated on coverage; or failing that, §13.1 +records classification coverage alongside stance, so a register row cannot read +`unknown: fail_closed` while every target is unknown. + +If both are declined, that is an answer and this workplan proceeds unchanged — +the cell stays a **declared gap** under §11 with this workplan as its route, +which is what the `declared-gap` mark (our own v0.6 finding) exists to express.