From 03c0569820437d83c012aebba442330057e97844 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 7 Sep 2026 00:22:12 +0200 Subject: [PATCH] feat: adopt the owner-documented PDP access path, and prove it live Adoption asked for by flex-auth (FLEX-WP-0021-T05) and glas-harness (GLAS-WP-0015), plus the first real decision this engine has obtained from the deployed pin -- which found a defect the fixtures could not. ACCESS PATH. require_supported_pdp_address refuses in-cluster Service names and any non-loopback host. This is no longer a unilateral call: the owner path is documented as loopback kubectl port-forward over the authenticated Kubernetes API, which is what authenticates the responder transitively (FLEX-DEC-2026-010). A Service name from a workstation does not fail, it resolves through the DNS search suffix to an unrelated public host, and since decision records carry no signature, a responder knowing the published package and version can return an allow that passes every check we make. Fail-closed protects against a PDP that is absent, not one that lies. The guard runs before the token is read, so a misdirected request cannot leak it; a test pins that ordering. LIVE PROOF. Minted a 10-minute TokenRequest token (audience flex-auth, SA secrets-engine/secrets-engine, mode 0600 outside the worktree, shredded after), forwarded to the named pod, and sent a real CheckRequest for glas-claude-agent-dev-anthropic. Result: allow, catalog_lane_policy_matched, served by v2 (sha256:bd11c5fe...) -- so the redeploy flex-auth flagged as outstanding has landed and the pin no longer serves the tenant-blind v1. Our tenant fix is confirmed against the real service: binding.tenant is tenant:platform. THE DEFECT IT FOUND. The evaluator enriches from its registry before hashing -- subject gains attributes and tenant, resource gains tenant -- so binding.request_digest is over material we never sent and cannot reproduce. validate_decision_envelope rejects every real allow. Every replay test passes because _request_from() rebuilds the request out of the binding, i.e. the enriched form: a self-consistent fake agreeing with itself, which hid this through three rounds of digest work. Third time a real artifact has beaten a fake in this integration. NOT FIXED, DELIBERATELY. Rejecting a valid allow is wrong in the safe direction. Which fields may be enriched is flex-auth's contract to publish; inferring it means accepting a binding that differs from our proposal in a way we decided was benign -- the fail-open shape GH-DEC-2026-008 rejected for vocabularies and FLEX-DEC-2026-007 for digests. Raised with them. Tenant question closed by operator decision 5ed3fb35: tenant:platform exactly, and service_auth.TENANT stays tenant:coulomb because the two identity layers are to remain distinct. Declining to author that mapping was right -- the answer was neither reading offered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E4tNMAYcSQmZWUE4wqP4ij Assistant: claude-code Assistant-Model: opus Assistant-Process: 715726@bnt-lap001 Assistant-Session: 80a42b32-cba6-4b23-8be0-68819b1a6092 --- docs/pdp-access-path.md | 96 ++++++++++++++ docs/tenant-alignment.md | 10 +- src/secrets_engine/decision_check.py | 57 ++++++++ tests/fixtures/flex-auth-live/PROVENANCE.md | 31 +++++ .../decision_rotate_glas_live.json | 116 +++++++++++++++++ tests/test_decision_replay.py | 58 +++++++++ tests/test_live_decision_enrichment.py | 123 ++++++++++++++++++ 7 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 docs/pdp-access-path.md create mode 100644 tests/fixtures/flex-auth-live/PROVENANCE.md create mode 100644 tests/fixtures/flex-auth-live/decision_rotate_glas_live.json create mode 100644 tests/test_live_decision_enrichment.py diff --git a/docs/pdp-access-path.md b/docs/pdp-access-path.md new file mode 100644 index 0000000..a84fa90 --- /dev/null +++ b/docs/pdp-access-path.md @@ -0,0 +1,96 @@ +# Reaching the access-engine pin + +The supported way for this engine to reach `flex-auth-secrets-engine`, and the +reason the address is enforced in code rather than left to configuration. + +## Never the Service DNS name + +``` +SECRETS_ENGINE_PDP_URL=http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080 +``` + +is refused by `decision_check.require_supported_pdp_address`. From a workstation +that name does not fail — it **resolves, to the wrong host**: + +```text +$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local +80.158.43.29 ...svc.cluster.local.ad.binect.de +$ getent hosts this-service-does-not-exist.flex-auth.svc.cluster.local +80.158.43.29 (identical — proves suffix expansion, not a record) +``` + +`resolv.conf` carries `search ad.binect.de`, a wildcard zone, so every +`*.svc.cluster.local` name answers with one unrelated public address. Sending +there ships the CheckRequest body and our bearer token to a third party. + +`flex-auth.decision-record.v1` carries **no signature**, and every digest in it +is either sent by us or published in flex-auth's repo, so a forger reproduces +all three exactly. Digests establish integrity of the binding, never +authenticity of the source. As flex-auth put it in `FLEX-DEC-2026-010`: + +> Fail-closed protects against a PDP that is ABSENT, not against one that LIES. + +Until detached signatures ship (`FLEX-WP-0024`), the address shape is the only +thing standing in for responder authenticity. That is why it is enforced. + +## The supported path + +Loopback `kubectl port-forward` to the named pod. It resolves no DNS name, +targets one pod explicitly, and rides the Kubernetes API server's TLS with our +cluster credentials — so it authenticates the responder transitively. Note this +is the exact reverse of the caller direction, where port-forward bypasses the +NetworkPolicy (`FLEX-WP-0023`); the two properties point opposite ways and +summarising either as "the network protects it" gets one backwards. + +```bash +# 1. Target the pod by name, not the Service. +POD=$(kubectl -n flex-auth get pods \ + -l app.kubernetes.io/name=flex-auth-secrets-engine \ + -o jsonpath='{.items[0].metadata.name}') +kubectl -n flex-auth port-forward "pod/$POD" 18080:8080 --address 127.0.0.1 & + +# 2. Fresh bounded caller token per session. Audience MUST be flex-auth; +# caller auth is `enforce` as of Helm revision 3. +umask 077 +kubectl -n secrets-engine create token secrets-engine \ + --audience flex-auth --duration 10m > "$PDP_TOKEN" +chmod 600 "$PDP_TOKEN" # outside any Git worktree + +# 3. Point the engine at the forward. +export SECRETS_ENGINE_PDP_URL=http://127.0.0.1:18080 +export SECRETS_ENGINE_PDP_TOKEN_FILE="$PDP_TOKEN" +export SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE=secrets-engine.catalog-lane.lifecycle +export SECRETS_ENGINE_AUTHORIZATION_POLICY_VERSION=v2 +``` + +Shred the token file and stop the forward when the session ends. The token is +ten minutes; it is a caller credential, not an authorization. + +`ServiceAccount secrets-engine/secrets-engine` has `automountServiceAccountToken: +false` and no role bindings — it exists to be a caller identity and nothing else. + +## Pin v2, never v1 + +v1 shipped and was deployed with no `input.tenant` rule at all; a `rotate` under +`tenant:coulomb` returned **allow**. v2 supersedes rather than amends it so the +change is visible in the version string, and this engine refuses a v1 decision +outright. See `docs/tenant-alignment.md`. + +## Known gap: the digest join does not hold against a real request + +Proved live on 2026-09-07 and **not yet fixed**; the engine currently rejects +every real allow, which is fail-closed and therefore safe to leave standing +while the rule is published. + +The evaluator enriches the request from its registry before hashing — `subject` +gains `attributes` and `tenant`, `resource` gains `tenant` — so +`binding.request_digest` is a digest of material we did not send and cannot +reproduce. `validate_decision_envelope` compares the binding against our +unenriched request and fails with *"decision binding does not match request"*. + +Which fields the evaluator may enrich is flex-auth's contract to publish, not +ours to infer. Guessing it would mean accepting a binding that differs from what +we proposed in some way we decided was benign — the same fail-open shape as a +guessed digest exclusion or a guessed vocabulary mapping. Raised with flex-auth; +staying fail-closed and naming the missing rule is the correct posture until +they answer. diff --git a/docs/tenant-alignment.md b/docs/tenant-alignment.md index cd03596..0423d07 100644 --- a/docs/tenant-alignment.md +++ b/docs/tenant-alignment.md @@ -112,9 +112,13 @@ alone. - The owner-reviewed JWT/store/CheckRequest mapping, with a decision reference. Until it exists, no live client or policy subject changes here. -- A supported owner access path to `flex-auth-secrets-engine` for a workstation - CLI. Service DNS is not workstation connectivity, so the tenant fix above is - necessary but not sufficient for activation (`FLEX-WP-0021-T05`). +- ~~A supported owner access path~~ — **delivered**. Loopback `kubectl + port-forward` plus a bounded `TokenRequest` token; see + `docs/pdp-access-path.md`. Adopted and enforced in + `decision_check.require_supported_pdp_address`. +- The evaluator's **enrichment rule**: `binding` carries registry-enriched + `subject.attributes`, `subject.tenant` and `resource.tenant` that we did not + send, so the digest join fails against a real request. flex-auth's to publish. ## Hazard: the Service DNS name resolves here, to the wrong host diff --git a/src/secrets_engine/decision_check.py b/src/secrets_engine/decision_check.py index 316a86f..252d22b 100644 --- a/src/secrets_engine/decision_check.py +++ b/src/secrets_engine/decision_check.py @@ -10,8 +10,10 @@ production closed. """ from __future__ import annotations +import ipaddress import json from pathlib import Path +from urllib.parse import urlsplit from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @@ -21,6 +23,60 @@ from secrets_engine.openbao import read_strict_token_file _MAX_BODY = 512 * 1024 +#: Suffixes that name an in-cluster Service and must never be dialled from here. +_CLUSTER_SUFFIXES = (".svc.cluster.local", ".svc", ".cluster.local") + + +def _is_loopback(host: str) -> bool: + if host in ("localhost", "localhost."): + return True + try: + return ipaddress.ip_address(host.strip("[]")).is_loopback + except ValueError: + return False + + +def require_supported_pdp_address(base_url: str) -> None: + """Refuse address shapes the owner path excludes (FLEX-DEC-2026-010). + + The supported operator path is a loopback ``kubectl port-forward`` over the + authenticated Kubernetes API, which is the only shape that authenticates the + *responder*: it resolves no DNS name, targets one named pod, and rides the + API server's TLS with our cluster credentials. + + A cluster Service name must never be dialled from a workstation. It does not + merely fail -- it RESOLVES, to the wrong host. This workstation carries + ``search ad.binect.de``, a wildcard zone, so every ``*.svc.cluster.local`` + name (including services that do not exist) answers with one unrelated + public address. Sending there would ship the CheckRequest body and our + bearer token to a third party, and because ``flex-auth.decision-record.v1`` + carries no signature, a responder that knows the published package id and + version can return a well-formed allow that passes every check we make. + + Fail-closed protects against a PDP that is ABSENT, not one that LIES. Until + FLEX-WP-0024 ships detached signatures, the address shape is the only thing + standing in for responder authenticity, so it is enforced rather than + documented. + """ + host = (urlsplit(base_url).hostname or "").rstrip(".").lower() + if not host: + raise DecisionError("access-engine check URL has no host") + if any(host.endswith(suffix) for suffix in _CLUSTER_SUFFIXES): + raise DecisionError( + f"access-engine check URL '{host}' is an in-cluster Service name; " + "from a workstation it resolves through the DNS search suffix to an " + "unrelated public host. Use the owner-documented loopback " + "kubectl port-forward instead (docs/pdp-access-path.md)" + ) + if not _is_loopback(host): + raise DecisionError( + f"access-engine check URL host '{host}' is not loopback; the " + "supported path is a loopback kubectl port-forward over the " + "authenticated Kubernetes API, which is what authenticates the " + "responder (FLEX-DEC-2026-010). Set SECRETS_ENGINE_PDP_URL to the " + "forwarded 127.0.0.1 port (docs/pdp-access-path.md)" + ) + def _status_message(status: int) -> str: if status in (401, 403): @@ -47,6 +103,7 @@ def check_decision( """ if not base_url or not base_url.startswith(("http://", "https://")): raise DecisionError("access-engine check URL is missing or invalid") + require_supported_pdp_address(base_url) token = read_strict_token_file(Path(token_file), purpose="access-engine credential") encoded = json.dumps(request).encode("utf-8") http_request = Request( diff --git a/tests/fixtures/flex-auth-live/PROVENANCE.md b/tests/fixtures/flex-auth-live/PROVENANCE.md new file mode 100644 index 0000000..c485b9e --- /dev/null +++ b/tests/fixtures/flex-auth-live/PROVENANCE.md @@ -0,0 +1,31 @@ +# Live decision from the deployed pin + +Not a vendored example. This is a real `DecisionEnvelope` this engine obtained +on 2026-09-07 from the deployed `flex-auth-secrets-engine` pin, through the +owner-documented access path: + +- loopback `kubectl port-forward` to the named pod + `flex-auth-secrets-engine-65c74d4858-qvf2p`, over the authenticated + Kubernetes API (`FLEX-DEC-2026-010`: this is what authenticates the + *responder*); +- a 10-minute `TokenRequest` token for ServiceAccount + `secrets-engine/secrets-engine`, audience `flex-auth`, in a mode-0600 file + outside the worktree, shredded after use; +- request built by `build_action_request` for the real catalog lane + `glas-claude-agent-dev-anthropic`, action `rotate`. + +The decision is `allow`, `catalog_lane_policy_matched`, served by +`secrets-engine.catalog-lane.lifecycle` **v2** +(`sha256:bd11c5fe…`) — so the redeploy flex-auth flagged as outstanding has +landed and the pin no longer serves the tenant-blind v1. + +It contains no secret material: subject and resource metadata, digests, and +policy provenance only. + +**Why it is kept.** It is the artifact that proved the digest join does not hold +against a real request. The evaluator enriches `subject` (attributes, tenant) +and `resource` (tenant) from its registry before hashing, so +`binding.request_digest` cannot equal a digest computed over the unenriched +request we sent. Every replay fixture test passes because `_request_from()` +rebuilds the request *from the binding*, which is the enriched form — a +self-consistent fake agreeing with itself. Only a real request exposed it. diff --git a/tests/fixtures/flex-auth-live/decision_rotate_glas_live.json b/tests/fixtures/flex-auth-live/decision_rotate_glas_live.json new file mode 100644 index 0000000..d2c1672 --- /dev/null +++ b/tests/fixtures/flex-auth-live/decision_rotate_glas_live.json @@ -0,0 +1,116 @@ +{ + "id": "decision:0f9c98f14545c42d", + "contract_version": "flex-auth.decision-record.v1", + "request_id": "check:secrets-engine-adoption-proof", + "effect": "allow", + "reason": "catalog_lane_policy_matched", + "matched_policy_version": "v2", + "matched_rule": "catalog_lane_policy_matched", + "resource": { + "id": "catalog:glas-claude-agent-dev-anthropic", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "api_key" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "binding": { + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "action": "rotate", + "resource": { + "id": "catalog:glas-claude-agent-dev-anthropic", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "api_key" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "context": { + "purpose": "live-adoption-proof" + }, + "request_digest": "sha256:c37f2fe78205758b27d081e8fb90a9446aa4bd5a571b337d343621955705013c" + }, + "lifetime": { + "kind": "ttl", + "ttl": "15m", + "not_before": "2026-09-06T22:19:09Z", + "expires_at": "2026-09-06T22:34:09Z" + }, + "diagnostics": { + "action": "rotate", + "matched_relationship": "", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_status": "ready", + "registry_resource": false, + "registry_subject": true + }, + "provenance": { + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", + "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", + "input_claim_digests": { + "context": "sha256:098626d19cdacbc5abebada472e73b2fa328dfd5f8506cb50e63ac6767f87e3a" + }, + "decision_time": "2026-09-06T22:19:09Z" + }, + "caring": { + "profile": "caring-0.4.0-rc2", + "conformance_findings": [ + { + "code": "CARING-DESCRIPTOR-MISSING", + "severity": "warning", + "message": "no CARING descriptor matched the request", + "fields": [ + "caring_context" + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/test_decision_replay.py b/tests/test_decision_replay.py index b334ab3..e540fe5 100644 --- a/tests/test_decision_replay.py +++ b/tests/test_decision_replay.py @@ -27,6 +27,10 @@ from secrets_engine.authorization import ( request_digest, validate_decision_envelope, ) +from secrets_engine.decision_check import ( + check_decision, + require_supported_pdp_address, +) from secrets_engine.errors import DecisionError FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay" @@ -429,3 +433,57 @@ def test_the_superseded_v1_package_is_not_accepted(): accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v1"}, ) + + +# --- supported PDP address (FLEX-DEC-2026-010) ------------------------------- + + +@pytest.mark.parametrize( + "url,fragment", + [ + # The exact address flex-auth originally handed over. It does not fail + # to resolve from a workstation, it resolves to an unrelated public host. + ("http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080", + "in-cluster Service name"), + ("http://flex-auth-secrets-engine.flex-auth.svc.cluster.local.:8080", + "in-cluster Service name"), + ("http://flex-auth-secrets-engine.flex-auth.svc:8080", + "in-cluster Service name"), + # A public address is refused even though it would "work": nothing + # authenticates the responder, so reaching something is not reaching + # the pin. + ("http://80.158.43.29:8080", "is not loopback"), + ("https://flex-auth.example.com", "is not loopback"), + ], +) +def test_unsupported_pdp_addresses_are_refused(url, fragment): + with pytest.raises(DecisionError, match=fragment): + require_supported_pdp_address(url) + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1:18080", + "http://localhost:18080", + "http://[::1]:18080", + ], +) +def test_loopback_forward_addresses_are_accepted(url): + require_supported_pdp_address(url) + + +def test_the_guard_runs_before_the_token_is_read(tmp_path): + """A bad address must not cause the bearer token to be read, let alone sent. + + The token is the thing a misdirected request would leak, so the address + check has to come first rather than alongside. + """ + missing = tmp_path / "never-read.token" + with pytest.raises(DecisionError, match="in-cluster Service name"): + check_decision( + base_url="http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080", + token_file=missing, + request={}, + ) + assert not missing.exists() diff --git a/tests/test_live_decision_enrichment.py b/tests/test_live_decision_enrichment.py new file mode 100644 index 0000000..bb79280 --- /dev/null +++ b/tests/test_live_decision_enrichment.py @@ -0,0 +1,123 @@ +"""The digest join against a REAL request, not one rebuilt from the binding. + +tests/test_decision_replay.py passes every digest assertion because +``_request_from()`` reconstructs the request out of ``envelope["binding"]`` -- +which is the *enriched* form the evaluator hashed. That is a self-consistent +fake agreeing with itself, and it hid the gap below through three rounds of +digest work. + +This module builds the request the way the engine actually builds it and +compares it to a decision the engine actually received, so the gap is pinned +until flex-auth publishes the enrichment rule. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from secrets_engine.authorization import ( + build_action_request, + canonical_check_request, + request_digest, + validate_decision_envelope, +) +from secrets_engine.catalog import load_catalog +from secrets_engine.errors import DecisionError + +LIVE = Path(__file__).parent / "fixtures" / "flex-auth-live" / "decision_rotate_glas_live.json" +CATALOG = Path(__file__).resolve().parents[1] / "catalog" + +PACKAGE = "secrets-engine.catalog-lane.lifecycle" +VERSION = "v2" + + +def _live(): + return json.loads(LIVE.read_text()) + + +def _our_request(): + entry = load_catalog(CATALOG)["glas-claude-agent-dev-anthropic"] + return build_action_request( + entry, + "rotate", + subject_id="secrets-engine", + subject_type="service", + purpose="live-adoption-proof", + fields=["api_key"], + request_id="check:secrets-engine-adoption-proof", + ) + + +def test_the_live_decision_is_a_real_v2_allow(): + """Provenance of the artifact these assertions rest on.""" + env = _live() + assert env["effect"] == "allow" + assert env["reason"] == "catalog_lane_policy_matched" + provenance = env["provenance"] + assert provenance["policy_package"] == PACKAGE + assert provenance["policy_version"] == VERSION + assert env["binding"]["tenant"] == "tenant:platform" + + +def test_the_evaluator_enriches_subject_and_resource_before_hashing(): + """Names exactly which fields appeared that we never sent. + + If flex-auth publishes an enrichment rule that differs from this, this test + fails and tells us the shape moved -- which is the point. It asserts the + observed gap, not a rule we invented. + """ + sent = _our_request() + bound = _live()["binding"] + + assert "attributes" not in sent["subject"] + assert set(bound["subject"]["attributes"]) == { + "description", "display_name", "groups", "organization_relation", "roles", + } + assert "tenant" not in sent["subject"] + assert bound["subject"]["tenant"] == sent["tenant"] + + assert "tenant" not in sent["resource"] + assert bound["resource"]["tenant"] == sent["tenant"] + + # Everything we DID send survived unchanged. The enrichment is additive, so + # the decision is about the action we proposed -- which is why staying + # fail-closed here costs correctness nothing today. + assert bound["action"] == sent["action"] + assert bound["tenant"] == sent["tenant"] + assert bound["context"] == sent["context"] + assert bound["subject"]["id"] == sent["subject"]["id"] + assert bound["subject"]["type"] == sent["subject"]["type"] + for key in ("id", "type", "system", "attributes"): + assert bound["resource"][key] == sent["resource"][key] + + +def test_our_digest_cannot_match_a_real_binding(): + """The join is unsatisfiable against a real request, not merely mismatched. + + We hash what we sent; the evaluator hashed what it enriched. No amount of + care on our side closes that, because the registry material is not ours. + """ + sent = _our_request() + bound = _live()["binding"] + assert bound["request_digest"] != request_digest(sent) + assert canonical_check_request(bound) != canonical_check_request(sent) + + +def test_we_currently_reject_every_real_allow_and_that_is_fail_closed(): + """Pins today's behaviour honestly rather than asserting it is correct. + + Rejecting a valid allow is wrong, but it is wrong in the safe direction, so + it stays until flex-auth publishes which fields may be enriched. Guessing + the rule would mean accepting a binding that differs from our proposal in + some way we decided was benign -- the fail-open shape GH-DEC-2026-008 + rejected for vocabularies and FLEX-DEC-2026-007 rejected for digests. + """ + with pytest.raises(DecisionError, match="binding does not match request"): + validate_decision_envelope( + _live(), + _our_request(), + accepted_policy_packages={PACKAGE}, + accepted_policy_versions={VERSION}, + )