From fe578c5864c2cb73d61411c2a014772345a685f4 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 16 Aug 2026 12:57:04 +0200 Subject: [PATCH 01/20] feat(778): name the check-and-use race, and verify the protection the merge gate rests on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #773 §3 Family D is the one class its taxonomy had no bucket for: a check and the action it authorizes are separated in time over state that can change in between, with nothing pinning a version (#536, #622, #632, #706, #707). The repo had already solved this twice without noticing it was one problem — a compare-exchange claim in-process (ffmpeg.work-ahead-slot-atomic) and RFC 7232 If-Match across /api/v1 (concurrency.ifmatch-rfc7232) — and then solved it a third time from scratch for the tooling at #706/#707. Hence a class-level record rather than a fourth per-instance one. What the enumeration actually found, which none of the five records predicted: the merge-consent hook's scheduled-auto-merge path is safe only because `review-verdict/h10` is a REQUIRED status check on main. That is branch-protection CONFIG, it lives outside this repo, nothing compared the two, and the hook asserted it in a comment AND in the grant reason a human reads. Switch the context off and every word of that sentence is false while the hook keeps printing it and keeps auto-granting. The hook now reads the branch protection and treats it as three outcomes: present proceeds, unreadable asks, absent denies. Two defects were caught by the new checks themselves rather than by review: - the population test found .gitea/workflows/dependency-scan.yml absent from the first draft of the inventory (a sixth workflow the recon slice never listed); - self-review found the guard denying with a confident wrong reason when jq errors one level down on a malformed contexts member, so the word is now matched exhaustively rather than compared against "yes". Same swallow that survived the first fix in the #632 base-change guard. Detector D has no plausible linter, so the detector is detector A applied to an enumerated inventory: docs/remote-state-inventory.md classifies every in-scope executable, and scripts/tests/test_remote_state_inventory.py derives the population from the filesystem and asserts set equality both ways. Deferred with reasons stated in the inventory: select-queue.sh (advisory, authorizes no write), ci-detect-already-validated.sh (skip elides re-validation only, the image still builds), review-verdict.yml's status POST (Gitea offers no conditional write; already fenced by #706's retarget counter). Mutation proofs witnessed for both new guards, clause-level, not whole-file. fixes #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 46 ++++ docs/README.md | 7 + docs/decisions/README.md | 1 + .../process/check-and-use-pins-a-version.md | 72 ++++++ docs/guard-inventory.md | 6 +- docs/remote-state-inventory.md | 106 +++++++++ .../test_merge_consent_required_check.py | 216 ++++++++++++++++++ scripts/tests/test_remote_state_inventory.py | 182 +++++++++++++++ 8 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 docs/decisions/records/process/check-and-use-pins-a-version.md create mode 100644 docs/remote-state-inventory.md create mode 100644 scripts/tests/test_merge_consent_required_check.py create mode 100644 scripts/tests/test_remote_state_inventory.py diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index bc72eb97b..bc5ba5fc1 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -318,6 +318,52 @@ else pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;; *) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;; esac + + # --- The mitigation this path RESTS on, verified instead of asserted (ersatztv#778). ----------- + # Everything above proves a property of the head that exists NOW. What makes that safe under + # merge_when_checks_succeed is stated in the paragraph opening this branch: `review-verdict/h10` + # is a REQUIRED status check on the base, a commit status belongs to exactly ONE sha, so a commit + # pushed after scheduling cannot inherit it and Gitea's own gate refuses the merge. + # + # That guarantee is branch-protection CONFIG. It lives outside this repo, no code here owned it, + # and until #778 nothing compared the two — so the grant reason handed to a human cited a + # protection that could have been switched off with no signal anywhere. The comment above and the + # grant string below are claims about the past; a dated claim is not a check. + # + # This is the hook's OWN defect class (#778 / `process.check-and-use-pins-a-version`): a check + # ("a later push clears the status") authorizes an action ("arm an auto-merge that Gitea completes + # later") over state that can change in between, with nothing pinning it. The read here does not + # pin anything either — branch protection can still be edited after this call — but it converts an + # ASSUMPTION that was never observed into a precondition that is, which is the honest ceiling for + # a config whose API offers no version, ETag or conditional read. + # + # Tri-state, matching this file's idiom throughout: unreadable -> ask (a human adjudicates), + # present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened, + # and the whole point of that issue is that the failure is silent from the merge caller's side. + [ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling." + bp=$(gq "repos/$owner/$repo/branch_protections/$base_ref") + if [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then + decide ask "H6/H10 merge gate: could not read branch protection for '$base_ref' (Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs). Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling." + fi + # jq's `index` yields the position (0 is TRUTHY in jq — only false/null are falsy), so the result + # is compared against null and mapped to a WORD rather than used as a condition. + # + # The word is then matched exhaustively, because "" is not a third synonym for "no". Validating + # `$bp` as an object above does not make its MEMBERS well-formed: a `status_check_contexts` that + # is a number makes jq error, `|| true` turns that into an empty string, and a bare + # `[ "$guarded" = "yes" ] || decide deny` would then emit "NOT a required status check" for a + # payload it never managed to read. That is the same one-level-down swallow that survived the + # first fix in the #632 base-change guard: the validation domain has to match the CONSUMPTION + # domain, not stop at the top-level type. + guarded=$(printf '%s' "$bp" \ + | jq -r 'if (.enable_status_check == true) + and (((.status_check_contexts // []) | index("review-verdict/h10")) != null) + then "yes" else "no" end' 2>/dev/null || true) + case "$guarded" in + yes) : ;; + no) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is NOT a required status check on '$base_ref' (branch protection reports enable_status_check/status_check_contexts without it). A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622); without it, arming merge_when_checks_succeed freezes consent at a head Gitea may not be the one to merge. Restore it in branch protection, or merge immediately (without merge_when_checks_succeed) once CI is green." ;; + *) decide ask "H6/H10 merge gate: branch protection for '$base_ref' came back in an unexpected shape, so the 'review-verdict/h10' required check that makes a scheduled auto-merge safe (ersatztv#622) could not be confirmed either way. Check it manually, or merge immediately instead of scheduling." ;; + esac fi # --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the diff --git a/docs/README.md b/docs/README.md index 69995870b..8870bb88a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ doc below, or that changes which sections a task signal points to.** | CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` | | Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the two rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md` and `…/guard-ships-with-mutation-proof.md` | | Adding / changing / deleting a guard file | `docs/guard-inventory.md` — every guard's row is machine-checked by `scripts/tests/test_guard_inventory.py`, so a new guard must acquire a row before the suite goes green | +| Writing code that reads live Gitea/remote state and then acts on it | `docs/decisions/records/process/check-and-use-pins-a-version.md`, then `docs/remote-state-inventory.md` — a new executable that talks to a remote service must acquire a row there before `scripts/tests/test_remote_state_inventory.py` goes green | | Finding every site that references a symbol (multi-site fix/sweep) | `docs/local-lsp-tooling.md` — which surface answers, and why a delegated agent must be pointed at the `csharp-lsp` MCP tools rather than the `LSP` tool | | Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.sh` | | Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` | @@ -112,6 +113,12 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K and an audit of which configured hooks/MCP servers/LSPs are actually invoked. Read it before proposing a new guard or CI check — §4 is the detector menu, and it argues against enumerating cases one incident at a time. +- **`docs/remote-state-inventory.md`** — every executable that reads live remote state and acts on + that read, classified `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window and what bounds it. + The population is derived from the filesystem and compared for set equality by + `scripts/tests/test_remote_state_inventory.py`, so a new script that talks to a remote service + cannot ship unclassified. Read it with `process.check-and-use-pins-a-version`; it is that record's + detector, since the class has no plausible linter (`docs/defect-shapes-773.md` §4 detector D). - **`docs/guard-inventory.md`** — every executable guard file, what it blocks, whether it is a `GUARD` or `TOOLING`, and whether it ships a mutation proof (`MUTATION` / `BEHAVIOUR-ONLY` / `NONE`) with a `file::function` ref. The population is derived from the filesystem and the diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d409fd4b1..939574815 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -106,6 +106,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `process.bom-format-detection-recipe` | Before any push touching `.cs`, detect BOMs with the `od -A n -t x1 -N 3` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh. NOT `xxd`: it ships with vim and is absent on plain Linux hosts including this repo's CI runner, where the substitution yields empty, never matches, and the check reports all-clean — the same all-clean-detector failure this record was written about, in the detector it prescribed. | 2026-07-21 | [link](records/process/bom-format-detection-recipe.md) | | `process.branch-off-feature-branch` | To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building. | 2026-07-21 | [link](records/process/branch-off-feature-branch.md) | | `process.build-concurrency-limits` | Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](records/process/build-concurrency-limits.md) | +| `process.check-and-use-pins-a-version` | Where a CHECK authorizes an ACTION over state that can change in between, the two are bound to ONE version of that state. Binding alone is not enough and is the half that keeps being skipped: a snapshot nothing re-validates is not pinned, it is a stale read wearing a version number. Three substrates, three mechanisms, and they are the SAME rule — in-process, a compare-exchange claim taken by the caller, never a `Volatile.Read` in one place and an `Interlocked` in another (`ffmpeg.work-ahead-slot-atomic`); over our own HTTP API, RFC 7232 `If-Match`/ETag, with the force-write path named explicitly rather than left implicit (`concurrency.ifmatch-rfc7232`, `concurrency.force-write-non-ifmatch`); against a remote service, a full commit sha, an image digest or a monotonic event count re-read immediately before the write. Prefer true compare-and-set where the server offers it. Where it does not — Gitea's commit-status API has no ETag, no If-Match and no expected-previous-state — the ceiling is READ-COMPARE-REFUSE: re-read the identifier immediately before the write and FAIL CLOSED on any movement, which narrows the window to one round trip and makes the loss observable instead of silent. A residual that cannot be closed is STATED in the code and carried in `docs/remote-state-inventory.md` as `UNSAFE-KNOWN` with the reason it is tolerable; "noticed" is not "accepted". Two identifier traps are load-bearing here: compare the FULL sha, never a 7-char prefix, and compare a base BRANCH REF rather than its tip sha, because the tip moves on every unrelated merge and comparing it deadlocks every open PR. Finally, and this is the failure #778 actually found: a mitigation that lives OUTSIDE the code relying on it — branch protection, a required status context, a server-side refusal — must be VERIFIED at the point of use, not asserted in a comment or in the reason string a human reads. A dated claim about configuration is not a check, and it is worse than no claim, because it talks the next reader out of looking. | 2026-08-16 | [link](records/process/check-and-use-pins-a-version.md) | | `process.codex-cheap-worker-launch` | For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings. | 2026-07-21 | [link](records/process/codex-cheap-worker-launch.md) | | `process.consistency-fix-new-code-scrutiny` | Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | [link](records/process/consistency-fix-new-code-scrutiny.md) | | `process.enumerate-workaround-behaviors-before-deleting` | When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | [link](records/process/enumerate-workaround-behaviors-before-deleting.md) | diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md new file mode 100644 index 000000000..815e4047c --- /dev/null +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -0,0 +1,72 @@ +--- +key: process.check-and-use-pins-a-version +title: '2026-08-16 — a check and the action it authorizes are bound to one version, or the gap is stated and fenced (#778)' +status: active +since: '2026-08-16' +supersedes: none +superseded-by: none +rule: 'Where a CHECK authorizes an ACTION over state that can change in between, the two are bound to ONE version of that state. Binding alone is not enough and is the half that keeps being skipped: a snapshot nothing re-validates is not pinned, it is a stale read wearing a version number. Three substrates, three mechanisms, and they are the SAME rule — in-process, a compare-exchange claim taken by the caller, never a `Volatile.Read` in one place and an `Interlocked` in another (`ffmpeg.work-ahead-slot-atomic`); over our own HTTP API, RFC 7232 `If-Match`/ETag, with the force-write path named explicitly rather than left implicit (`concurrency.ifmatch-rfc7232`, `concurrency.force-write-non-ifmatch`); against a remote service, a full commit sha, an image digest or a monotonic event count re-read immediately before the write. Prefer true compare-and-set where the server offers it. Where it does not — Gitea''s commit-status API has no ETag, no If-Match and no expected-previous-state — the ceiling is READ-COMPARE-REFUSE: re-read the identifier immediately before the write and FAIL CLOSED on any movement, which narrows the window to one round trip and makes the loss observable instead of silent. A residual that cannot be closed is STATED in the code and carried in `docs/remote-state-inventory.md` as `UNSAFE-KNOWN` with the reason it is tolerable; "noticed" is not "accepted". Two identifier traps are load-bearing here: compare the FULL sha, never a 7-char prefix, and compare a base BRANCH REF rather than its tip sha, because the tip moves on every unrelated merge and comparing it deadlocks every open PR. Finally, and this is the failure #778 actually found: a mitigation that lives OUTSIDE the code relying on it — branch protection, a required status context, a server-side refusal — must be VERIFIED at the point of use, not asserted in a comment or in the reason string a human reads. A dated claim about configuration is not a check, and it is worse than no claim, because it talks the next reader out of looking.' +signals: 'check-and-use race · TOCTOU over remote state · pin a version or compare-and-set · read-compare-refuse · fail closed on movement · snapshot that stops being true · full sha never a 7-char prefix · base ref not base tip sha · monotonic event count not a branch name · ABA · required status check verified not asserted · the mitigation lives outside the code that relies on it · `UNSAFE-KNOWN` with a stated reason · paths: `docs/remote-state-inventory.md`, `scripts/tests/test_remote_state_inventory.py`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/post-review-verdict.sh`, `scripts/pr-changed-files.sh` · issues: #778, #773, #707, #706, #632, #622, #536' +mechanics: 'The detector is not a linter — there is no way to spot "this code should have pinned a sha". It is `testing.guard-derives-population-from-source` applied to an enumerated inventory: `docs/remote-state-inventory.md` classifies every in-scope executable `PINNED`/`CAS`/`UNSAFE-KNOWN`/`N/A`, and `scripts/tests/test_remote_state_inventory.py` derives the population from the filesystem and asserts set equality both ways, so a new script that talks to a remote service cannot ship unclassified. Nothing checks that a `PINNED` claim is true; that stays with review.' +--- + +`docs/defect-shapes-773.md` §3 names this as **Family D**, the one class #773's taxonomy had no +bucket for at all. Three raters proposed it unprompted; a fourth, working blind, proposed it again +under its own name. Five records: #536, #622, #632, #706, #707. + +**The unifying property, and why the name matters.** A check and the action it authorizes are +separated in time over state that can change in between, with nothing pinning a version. #622's own +record states it exactly: *"The gate was never bypassed — it was satisfied against a snapshot that +stops being true."* That sentence is the whole class. Nothing is mis-scoped and no predicate is +wrong; the answer was simply computed about a different world than the one the action lands in. + +**The repo had already solved this twice without noticing it was one problem.** #536 was fixed with +a compare-exchange claim. The whole `/api/v1` write surface was given RFC 7232 `If-Match` a year +earlier. Both are this rule; neither pointed at the other, and the tooling — the third substrate, +where the state is somebody else's server — got the fix a third time from scratch at #706 and #707. +That is the same one-record-per-instance growth `defect-shapes-773.md` §4 criticises in this repo's +own knowledge base, and it is the reason this record is written at the class level. + +**Binding is the easy half; re-validation is the half that gets skipped.** A sha captured into a +variable and then used in a URL feels pinned and is not. `scripts/post-review-verdict.sh` is the +worked example of doing it properly: it re-reads the PR immediately before the status POST, compares +both `.head.sha` and `.base.ref`, and `die`s without writing anything on either mismatch. The +comparison, not the capture, is what makes it safe. + +**Where no compare-and-set exists, say so instead of implying one.** Gitea's status API offers no +conditional write, so `review-verdict.yml` cannot make its read and its POST one operation. It +narrows the window twice — a monotonic `change_target_branch` event-count fence, and a high-water-mark +re-read that repairs a `success` posted over a human verdict back to `pending` — and then states the +remaining gap in the file. The count is used rather than the branch NAME because a name is +ABA-vulnerable: `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a +forged exemption. An honest residual is a design output. A file claiming atomicity it does not have +is the thing that stops getting re-examined. + +**The failure this issue actually found, which none of the five records predicted.** The +merge-consent hook's scheduled-auto-merge path is safe only because `review-verdict/h10` is a +REQUIRED status check on `main` — a commit status belongs to one sha, so a commit pushed after +scheduling cannot inherit the verdict and Gitea refuses the merge. That is #622's fix and it works. +But it is *branch-protection configuration*. It lives outside this repo, nothing in the repo +compared the two, and the hook asserted it in a comment **and in the grant reason a human reads**: +*"because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it +and block the merge."* Switch that context off and every word of that sentence becomes false while +the hook keeps printing it and keeps auto-granting. + +So the class has a second face: not only "the state moved between the check and the action", but +"the thing that made the action safe was never observed at all". The hook now reads +`GET /repos/{owner}/{repo}/branch_protections/{base}` on that path and treats the result as three +outcomes, not two — present proceeds, unreadable **asks** (a transient failure or a credential +without repo-admin scope is not evidence of safety), absent **denies**. Denying on absence is the +point: that is #622's hole reopened, and its defining property is that it is silent from the merge +caller's side. + +This is the same gap `testing.guard-derives-population-from-source` already flagged one directory +over — `MARKED_JOBS` in `test_ci_dropped_step_guard.py` is a hand-written mirror of those same +required contexts, annotated with a date. A dated comment is a claim about the past. Two independent +guards now depend on that configuration; one of them checks it. + +**What this record does not claim.** The inventory grades *files*, so it cannot see an existing file +growing a second unpinned read — the sites-in-code limit tracked in #777. And nothing verifies that a +row marked `PINNED` is telling the truth. Both residues are stated in +`docs/remote-state-inventory.md` rather than papered over, because a guard described as sound stops +being re-examined, which is the failure mode this whole family is made of. diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index 87ba95f98..1d479d52c 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -83,7 +83,7 @@ all. | `.claude/hooks/pretooluse-agent-ram.sh` | an Agent dispatch under 10% free RAM | GUARD | NONE | — | | `.claude/hooks/pretooluse-bash-guard.sh` | a Bash call setting `ETV_UPDATE_GOLDENS` | GUARD | NONE | — | | `.claude/hooks/pretooluse-bom-guard.sh` | a commit/push carrying a BOM in a touched `.cs` | GUARD | MUTATION | `test_bom_guard_detection.py::test_DISARMING_the_BOM_comparison_stops_detection` | -| `.claude/hooks/pretooluse-merge-consent.sh` | a PR merge without derived consent | GUARD | BEHAVIOUR-ONLY | `test_merge_consent_exemption.py::test_protected_path_on_a_LATER_page_is_still_seen` | +| `.claude/hooks/pretooluse-merge-consent.sh` | a PR merge without derived consent | GUARD | MUTATION | `test_merge_consent_required_check.py::test_a_base_without_the_required_check_denies_a_SCHEDULED_merge` | | `.claude/hooks/pretooluse-nav-guard.sh` | a browser navigate to a streaming URL | GUARD | NONE | — | | `.claude/hooks/pretooluse-worktree-guard.sh` | a commit/merge in a foreign worktree | GUARD | NONE | — | | `.husky/commit-msg` | a commit with no `Co-Authored-By` trailer | GUARD | NONE | — | @@ -120,15 +120,17 @@ all. | `scripts/tests/test_jq_preflight.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_merge_consent_base_change.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_merge_consent_exemption.py` | the `script-tests` job | PROOF | NONE | — | +| `scripts/tests/test_merge_consent_required_check.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_migration_equivalence.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_post_review_verdict.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_pr_changed_files.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_prepush_rebase_check_tag_exemption.py` | the `script-tests` job | PROOF | NONE | — | +| `scripts/tests/test_remote_state_inventory.py` | the `script-tests` job, on an executable that talks to a remote service with no row in `docs/remote-state-inventory.md` | GUARD | MUTATION | `test_remote_state_inventory.py::test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected` | | `scripts/tests/test_prove_fix.py` | the `script-tests` job | PROOF | NONE | — | ## What the numbers say -33 guards, 5 tooling scripts, 14 proof files. **7 guards carry a mutation proof; 6 are +34 guards, 5 tooling scripts, 15 proof files. **9 guards carry a mutation proof; 5 are behaviour-only; 20 have none.** These figures are asserted against the table by `test_the_summary_counts_match_the_table` — they were wrong in the first draft (28/4/6/3/19 against a table holding 27/5/6/3/18), because a hand-maintained summary of a table is a second copy of it, diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md new file mode 100644 index 000000000..cd8690801 --- /dev/null +++ b/docs/remote-state-inventory.md @@ -0,0 +1,106 @@ +# Remote-state inventory (ersatztv#778) + +Every executable in this repo that reads live remote state and later acts on that read, and whether +the read is bound to something that cannot change underneath it. + +`scripts/tests/test_remote_state_inventory.py` derives the population from the filesystem and +asserts **set equality** against the `Site` column, so a new script that talks to a remote service +cannot ship without acquiring a row. + +**Read `docs/decisions/records/process/check-and-use-pins-a-version.md` before adding a row or +changing a classification.** + +## Why this file exists rather than a linter + +`docs/defect-shapes-773.md` §4 detector D is a **fix pattern, not a mechanical detector**: there is +no general lint for "this code should have pinned a sha." What makes the class actionable is that +the population is small and enumerable, so the detector is detector A — derive the population from +an authoritative source and assert set equality — applied to this inventory. The inventory is the +artifact; the test keeps it from rotting. + +## Columns + +- **Class** — + - `PINNED` — the read is bound to an immutable version identifier (a **full** commit sha, an + image digest, a monotonic event count) **and** the action re-validates against that identifier + before it commits. Binding alone is not enough; a snapshot nobody re-checks is not pinned. + - `CAS` — the write itself carries a compare-and-set condition the server enforces. + - `UNSAFE-KNOWN` — read-then-act with nothing pinning it, **accepted** with the reason stated in + the Note. Every row here must say why the residual is tolerable, not merely that it exists. + - `N/A` — reads no live remote state, or draws no authorization from what it reads. +- **Note** — the window, and what closes or bounds it. + +A row is about a *site*, not a file: a file with two independent reads gets two rows only where the +classifications differ; otherwise the strictest applies and the Note names the exception. + +## The inventory + +### Hooks + +| Site | Class | Note | +|---|---|---| +| `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `PINNED` | Every comparison uses the **full** 40-char `.head.sha`; `${sha:0:7}` appears only in human-readable strings. On the immediate-merge path there is no async window — the merge happens in the tool call this hook is gating. | +| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `PINNED` | The hook's proof is a snapshot; Gitea merges later. Safety comes from `review-verdict/h10` being a **required status check**, which a later commit cannot inherit (#622). Since #778 the hook **verifies** that protection instead of asserting it: absent → deny, unreadable → ask. Residual: a PreToolUse hook cannot add `head_commit_id` to the merge call, so it cannot convert its own grant into a CAS — it can only refuse. | +| `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `PINNED` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632). Deliberately compares the base **ref**, not `base.sha` — the tip moves on every unrelated merge, and comparing it would deadlock every open PR. | +| `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. | +| `.claude/hooks/prepush-donewhen.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. | +| `.claude/hooks/pretooluse-nav-guard.sh` | `N/A` | Reads only the proposed tool call's own parameters and decides synchronously. The `curl` token that puts it in the population appears in prose, not in an executed line. | + +### Scripts + +| Site | Class | Note | +|---|---|---| +| `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on any mismatch. Closes #706 and #632 for this path by read-compare-refuse, not by CAS — Gitea's status API offers no conditional write. Residual: the **comment** is posted *before* that re-read, so a head that moves in between leaves a verdict comment with no status. The comment is not the gate, but the mismatch is confusing; tracked in **#792**. | +| `scripts/pr-changed-files.sh` — paged file enumeration | `PINNED` | This is #707's fix. `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after; any movement fails the whole enumeration closed rather than emitting a short list. Pages are **not** diffed per-page against a re-read tip — the whole run is rejected instead, which is what makes a mid-paging `main` advance observable rather than silent. | +| `scripts/select-queue.sh` — issue list, then per-issue `/dependencies` | `UNSAFE-KNOWN` | The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed `in-progress` in that gap still appears on the shortlist. Accepted: the script authorizes **no write**. The real gate is the four-way claim check in `process.parallel-session-claim`, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. | +| `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. | +| `scripts/issue-qualification-audit.sh` | `N/A` | Pure read-and-report; exits 0/1 with a list. Authorizes no write and no merge. | +| `scripts/security-scan.sh` | `N/A` | `docker pull` of a mutable tag, then scans exactly what was pulled. Resolution and use are one step — no check-then-use split. | +| `scripts/migration-smoke.sh` | `N/A` | Same shape. The resolved image id is **reported** for the operator rather than compared against a prior read, which is an operator-trust gap (the script says so) and not a race. | +| `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir. In the population only because `curl` appears in a comment. | +| `scripts/e2e-local.sh` | `N/A` | No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. | +| `scripts/e2e-ui.sh` | `N/A` | Launches a local Chromium and runs specs against `http://localhost:$PORT`. | +| `scripts/e2e-functional.sh` | `N/A` | Every call targets `$BASE_URL`, defaulting to `http://localhost:8409`. The one non-local-looking address, `192.0.2.1`, is TEST-NET-1 (RFC 5737) — written into the DB as a connection row precisely so it is unroutable, never dialed by the script. | + +### Workflows + +| Site | Class | Note | +|---|---|---| +| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed twice rather than claimed closed: a monotonic `change_target_branch` **event-count** fence refuses to write if the count moved (`ci.verdict-write-retarget-fence`; the count is used because the branch *name* is ABA-vulnerable), and a high-water-mark re-read repairs a `success` posted over a human verdict back to `pending`. The file states the residual window explicitly rather than asserting safety. | +| `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. | +| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `PINNED` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base; inherits that row's pins. | +| `.gitea/workflows/docker-build.yml` — CI toolchain image | `PINNED` | Jobs consume `ersatztv-ci:`, never `:latest`, and `pr-checks.yml`'s `ci-image-pin` job fails the build if the pin drifts from the last commit touching `docker/ci/`. The pin is a **tag, not a digest**, so immutability is convention plus that guard rather than a registry property — the availability half of that gap is **#772**. | +| `.gitea/workflows/docker-build.yml` — release smoke pull | `PINNED` | Pulls `${IMAGE}:${SMOKE_SHORT_SHA}` — the tag the same job pushed moments earlier, serialized by the job's `concurrency` group. | +| `.gitea/workflows/docker-build.yml` — `api-docs` / `format` base fetch | `N/A` | Fetches the live base tip to diff generated artifacts. Advisory, non-required contexts; re-fetched fresh on every trigger with no state carried across runs. | +| `.gitea/workflows/ci-image.yml` — verify the pushed image | `PINNED` | Pulls back the immutable `:` it just pushed. `:latest` is moved for humans and documented as never consumed by a job. | +| `.gitea/workflows/pr-checks.yml` — `prove-fix` | `PINNED` | `base`/`head` are full shas from the event payload, immune to later PR mutation. | +| `.gitea/workflows/pr-checks.yml` — `ci-image-pin`, `docs-reminder`, `decisions-guard` | `N/A` | Local-tree comparisons or advisory base fetches; none authorize a merge or a write. | +| `.gitea/workflows/renovate.yml` — `renovate/renovate:43` | `N/A` | Out of this class, in scope for a different one — see the limits below. | +| `.gitea/workflows/dependency-scan.yml` — NuGet advisory query | `N/A` | `dotnet list package --vulnerable` queries a live advisory database and fails the job on the report marker in the same step, so there is no check-then-act split. Worth one line anyway: a green here means "no advisories **as of this run**", which is a dated claim about mutable remote data rather than a property of the tree — which is why the scan runs on a weekly schedule instead of only on PRs. | + +## Limits, stated rather than implied + +1. **Unpinned dependencies are a different class and are not graded here.** `actions/checkout@v4`, + `docker/build-push-action@v6`, `mysql:8.4` and `renovate/renovate:43` are floating tags, and + `renovate.yml` runs its one with a repo-writing token. But resolution and execution are the same + step — there is no earlier check whose answer a later action trusts — so they are a + supply-chain-pinning concern, not a check-and-use race. Listing them as `N/A` here records that + they were examined and classified, not that they are safe. + +2. **The population is files, not call sites.** The test derives which *files* are in scope; it + cannot tell that an existing file grew a second, unpinned read. That residue is the + sites-in-code limit named in `testing.guard-derives-population-from-source` — it needs + find-all-references tooling, tracked in #777 — and it is why the Note column is prose a reviewer + reads rather than a field a script checks. + +3. **Scope is hand-written; the population inside it is derived.** Scope is `scripts/`, + `.claude/hooks/`, `.husky/` (filtered to files containing an outbound-network token) plus **all** + of `.gitea/workflows/` unconditionally. The token filter over-includes — `hook-fire-log.sh` is + here because of a comment — which is the safe direction, since over-inclusion costs a row and + under-inclusion costs a blind spot. It under-includes only if someone reaches the network by a + mechanism none of those tokens name. + +4. **Nothing here checks that a `PINNED` claim is true.** The test asserts every in-scope file has a + row and that the classifications come from a closed vocabulary. Whether a row is honest stays a + review responsibility, and this table is what review reads — the same split, and the same + admitted residue, as `docs/guard-inventory.md`. diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py new file mode 100644 index 000000000..fffaf20f1 --- /dev/null +++ b/scripts/tests/test_merge_consent_required_check.py @@ -0,0 +1,216 @@ +"""The scheduled-auto-merge path verifies the protection it rests on (ersatztv#778). + +`merge_when_checks_succeed` hands the actual merge to Gitea, to be performed later against whatever +head is green at that moment. Everything the hook proves is therefore a SNAPSHOT. What makes that +safe is stated in the hook and in #622: `review-verdict/h10` is a REQUIRED status check on the base +branch, a commit status belongs to exactly one sha, so a commit pushed after scheduling cannot +inherit the verdict and Gitea's own gate refuses the merge. + +That guarantee is branch-protection CONFIG. It lives outside this repo, and before #778 nothing +compared the two — the hook asserted it in a comment and in the reason string a human reads, which +is a claim about the past, not a check. These tests pin the conversion of that assumption into a +precondition. + +The three-way outcome is the contract, and each arm is asserted separately because collapsing any +two of them is how this class of guard has failed here before: + + * required check PRESENT -> proceed (no opinion drawn from this check) + * branch protection UNREADABLE -> ask (a transient failure is not evidence of safety) + * required check ABSENT -> deny (this is #622's hole reopened, not a degraded read) + +Observable contract: the hook exits 0 with EMPTY stdout when it has no opinion, and emits a JSON +`permissionDecision` otherwise. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-merge-consent.sh" + +SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b" +SHORT = SHA[:7] + +# The PR is deliberately NOT docs-only: the docs-only exemption short-circuits the whole gate before +# the scheduled-merge branch is reached, so a docs PR would pass these tests without ever running +# the code under test. +CURL_SHIM = r'''#!/usr/bin/env python3 +import json, os, sys, pathlib, urllib.parse + +state = pathlib.Path(os.environ["STUB_DIR"]) +args = sys.argv[1:] +url = [a for a in args if a.startswith("http")][-1] + +if "/branch_protections/" in url: + mode = (state / "bp").read_text().strip() + if mode == "TRANSPORT-ERROR": + sys.exit(22) + if mode == "GARBAGE": + print('[]'); sys.exit(0) + if mode == "EMPTY": + print(''); sys.exit(0) + if mode == "MALFORMED-MEMBER": + # A valid JSON OBJECT whose contexts member is not a list — passes a top-level type check + # and then makes jq error one level down. + print('{"enable_status_check": true, "status_check_contexts": 7}'); sys.exit(0) + contexts = ["Build ErsatzTV Image / Build & test (.NET) (pull_request)"] + enabled = True + if mode == "GUARDED": + contexts.append("review-verdict/h10") + elif mode == "STATUS-CHECK-OFF": + contexts.append("review-verdict/h10") + enabled = False + # mode == "MISSING-CONTEXT" falls through with h10 absent + print(json.dumps({"branch_name": "main", "enable_status_check": enabled, + "status_check_contexts": contexts})) + sys.exit(0) + +if "/pulls/" in url and "/files" in url: + q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + page = int(q.get("page", ["1"])[0]) + if page == 1: + print(json.dumps([{"filename": "ErsatzTV/Program.cs", "status": "modified"}])) + else: + print("[]") + sys.exit(0) + +if "/status" in url: + print(json.dumps({"state": "success", "statuses": [ + {"context": "review-verdict/h10", "status": "success", + "description": "Review-verdict: MERGEABLE @ %s (base: main)" % os.environ["STUB_SHORT"]}]})) + sys.exit(0) + +if "/issues/" in url and "/comments" in url: + print(json.dumps([{"body": "Review-verdict: MERGEABLE @ %s" % os.environ["STUB_SHORT"]}])) + sys.exit(0) + +if "/issues/" in url: + print(json.dumps({"body": "## Done-when\n- [x] everything\n"})) + sys.exit(0) + +if "/pulls/" in url: + print(json.dumps({"head": {"sha": os.environ["STUB_SHA"]}, + "base": {"ref": "main", "sha": "b" * 40}, + "body": "fixes #1"})) + sys.exit(0) + +print("{}") +''' + + +@pytest.fixture +def hook(tmp_path): + bindir = tmp_path / "bin"; bindir.mkdir() + curl = bindir / "curl"; curl.write_text(CURL_SHIM); curl.chmod(0o755) + state = tmp_path / "state"; state.mkdir() + (state / "bp").write_text("GUARDED") + + env = dict(os.environ) + env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}" + env["STUB_DIR"] = str(state) + env["STUB_SHA"] = SHA + env["STUB_SHORT"] = SHORT + env["ETV_GITEA_TOKEN"] = "stub" + env["ETV_GITEA_URL"] = "http://gitea.example" + env["CLAUDE_PROJECT_DIR"] = str(REPO_ROOT) + env.pop("ETV_GITEA_BASICAUTH", None) + + class Handle: + def set_branch_protection(self, mode): + (state / "bp").write_text(mode) + + def decision(self, scheduled=True): + payload = {"tool_input": {"method": "merge", "owner": "timothy", + "repo": "ersatztv", "pull_number": 42, + "merge_when_checks_succeed": scheduled}} + r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), + env=env, capture_output=True, text=True) + assert r.returncode == 0, r.stderr + if not r.stdout.strip(): + return None + return json.loads(r.stdout) + + def reason(self, scheduled=True): + d = self.decision(scheduled=scheduled) + return "" if d is None else json.dumps(d) + + return Handle() + + +def test_a_base_without_the_required_check_denies_a_SCHEDULED_merge(hook): + """The defect #778 closes: arming an auto-merge while the per-sha gate that makes it safe is + absent. Nothing else in the flow notices, which is what makes it worth a guard.""" + hook.set_branch_protection("MISSING-CONTEXT") + reason = hook.reason() + assert "deny" in reason, ( + "a scheduled auto-merge was armed with no 'review-verdict/h10' required check on the base — " + "that is ersatztv#622's hole reopened") + assert "review-verdict/h10" in reason, ( + "the deny must name the missing context; a reader cannot act on 'branch protection is wrong'") + + +def test_status_checks_disabled_wholesale_also_denies(hook): + """The context can be listed while `enable_status_check` is false, in which case Gitea enforces + none of them. Reading only the list would report the protection as present — the same + check-the-label-not-the-capability shape (#697/#698) this repo has paid for twice.""" + hook.set_branch_protection("STATUS-CHECK-OFF") + reason = hook.reason() + assert "deny" in reason, ( + "status checks were disabled entirely and the listed context was read as protection anyway") + + +def test_positive_control_a_guarded_base_does_not_trigger_this_deny(hook): + """Without this, the two tests above pass if the hook denies on every path — which it very + nearly does, since this PR is non-docs and several later conditions are stubbed only loosely.""" + reason = hook.reason() + assert "NOT a required status check" not in reason, ( + "the required-check guard fired on a base that carries the context") + + +def test_a_malformed_contexts_MEMBER_asks_rather_than_denying_with_the_wrong_reason(hook): + """One level below the response-shape check, and it survives it. + + `{"status_check_contexts": 7}` is a perfectly good object, so the top-level type guard passes; + jq then errors on the member, `|| true` turns that into an empty string, and a two-way test + would report "NOT a required status check" — a confident, specific, wrong diagnosis of a payload + that was never read. The same swallow one level down as the #632 base-change guard's second fix. + """ + hook.set_branch_protection("MALFORMED-MEMBER") + reason = hook.reason() + assert "ask" in reason, "a malformed contexts member produced a decision instead of a question" + assert "NOT a required status check" not in reason, ( + "an unreadable payload was reported as a confirmed missing required check") + + +@pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE", "EMPTY"]) +def test_an_UNREADABLE_branch_protection_asks_rather_than_denying_or_passing(hook, failure): + """"Could not check" is a third outcome, not a synonym for either neighbour. + + Denying would strand every scheduled merge on a Gitea hiccup or on credentials without the + repo-admin scope this endpoint needs. Passing would be worse: it would restore the exact + unverified assumption #778 exists to remove, while now printing a reason string claiming the + protection was confirmed. + """ + hook.set_branch_protection(failure) + reason = hook.reason() + assert "ask" in reason, ( + f"an unreadable branch-protection response ({failure}) did not fall through to a human") + assert "branch protection" in reason or "base branch" in reason, ( + "the ask must name what could not be checked") + + +def test_an_IMMEDIATE_merge_is_not_subjected_to_this_check(hook): + """Scope, deliberately narrow. Without `merge_when_checks_succeed` there is no async window: + the merge happens now, against the head just verified. The required check is what protects the + SCHEDULED path specifically, so denying an immediate merge over branch-protection config would + block a safe operation and invite the guard being switched off wholesale.""" + hook.set_branch_protection("MISSING-CONTEXT") + reason = hook.reason(scheduled=False) + assert "NOT a required status check" not in reason, ( + "the required-check deny fired on an immediate merge, which has no post-scheduling window") diff --git a/scripts/tests/test_remote_state_inventory.py b/scripts/tests/test_remote_state_inventory.py new file mode 100644 index 000000000..6d8e9078c --- /dev/null +++ b/scripts/tests/test_remote_state_inventory.py @@ -0,0 +1,182 @@ +"""`docs/remote-state-inventory.md` covers exactly the executables that talk to a remote service +(ersatztv#778). + +WHAT THIS CANNOT DO, said first because #778's own issue body says it: there is no lint for "this +code should have pinned a sha." `docs/defect-shapes-773.md` §4 grades detector D as a *fix pattern* +whose detector is detector A applied to an enumerated inventory. So this file does not try to grade +pinning. It guarantees that every in-scope file has been CLASSIFIED BY SOMEONE, and that no file +joined the scope without acquiring a row — which converts "remember to think about this" into "the +suite is red until you have". + +The split is deliberate and mirrors `test_guard_inventory.py`: + + * the POPULATION is derived from the filesystem and compared for SET EQUALITY, both directions; + * the CLASSIFICATION vocabulary is closed, so a typo cannot invent a state; + * whether a `PINNED` row is TELLING THE TRUTH is not checked here and cannot be. That stays with + review, and the inventory's prose is what review reads. + +SCOPE vs POPULATION, per `testing.guard-derives-population-from-source`: the scope (which +directories, and the outbound-network token set) is a hand-written policy choice. The population +inside that scope is derived every run. The token filter deliberately OVER-includes — a file +mentioning `curl` only in a comment still needs a row — because over-inclusion costs one row and +under-inclusion costs a blind spot. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +INVENTORY = REPO_ROOT / "docs" / "remote-state-inventory.md" + +CLASSES = {"PINNED", "CAS", "UNSAFE-KNOWN", "N/A"} + +# An outbound call by any mechanism this repo actually uses. Kept small and literal on purpose: a +# clever regex here would be a string predicate over source, which this repo's record says takes +# three or more rounds to get right (#629, #633, #698). +NETWORK_TOKENS = ( + "curl", "wget", "urllib", "requests.", "http.client", + "docker pull", "docker manifest", "docker push", +) + +# Directories whose files enter the population only if they carry a network token. +FILTERED_SCOPE = ( + (REPO_ROOT / "scripts", ("*.sh", "*.py")), + (REPO_ROOT / ".claude" / "hooks", ("*.sh",)), + (REPO_ROOT / ".husky", ("*",)), +) +# Every workflow is in scope unconditionally: they are the remote-facing surface by nature, there +# are only a handful, and a workflow can reach a remote service through an `uses:` action that +# carries none of the tokens above. +WORKFLOWS = REPO_ROOT / ".gitea" / "workflows" + +# `| ` + backticked path, optionally followed by ` — `, then the class cell. +_ROW = re.compile(r"^\|\s*`([^`]+?)`[^|]*\|\s*`([^`]+)`\s*\|", re.M) + + +def _has_network_token(path: Path) -> bool: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + return any(tok in text for tok in NETWORK_TOKENS) + + +def derived_population() -> set[str]: + """Every in-scope file, as a repo-relative posix path.""" + found: set[str] = set() + for directory, globs in FILTERED_SCOPE: + if not directory.is_dir(): + continue + for pattern in globs: + for path in sorted(directory.glob(pattern)): + if path.is_file() and _has_network_token(path): + found.add(path.relative_to(REPO_ROOT).as_posix()) + if WORKFLOWS.is_dir(): + for path in sorted(WORKFLOWS.glob("*.yml")): + found.add(path.relative_to(REPO_ROOT).as_posix()) + return found + + +def inventory_rows(text: str | None = None) -> list[tuple[str, str]]: + if text is None: + text = INVENTORY.read_text(encoding="utf-8") + return _ROW.findall(text) + + +def inventory_sites(text: str | None = None) -> set[str]: + return {site for site, _ in inventory_rows(text)} + + +def test_the_inventory_file_exists_and_is_not_empty(): + assert INVENTORY.is_file(), f"{INVENTORY} is missing" + assert INVENTORY.stat().st_size > 0 + + +def test_anti_vacuity_the_derivation_and_the_table_both_found_something(): + """The characteristic failure of a completeness check is reporting success over an empty + population. Both sides get a floor, because either one collapsing to zero would make the set + comparison below pass trivially.""" + population = derived_population() + sites = inventory_sites() + assert len(population) >= 10, ( + f"derived only {len(population)} in-scope files — the globs or the token list are broken, " + "not the repo") + assert len(sites) >= 10, ( + f"parsed only {len(sites)} rows out of the inventory — the row regex has drifted from the " + "table format") + + +def test_every_in_scope_file_has_a_row_and_every_row_names_a_real_file(): + """Set equality in BOTH directions, because the two failures are different defects and a single + 'sets differ' message invites fixing one and re-running. + + MISSING: a script that talks to a remote service and was never classified — the defect #778 + exists to prevent. PHANTOM: a row for a file that was renamed or deleted, which leaves the table + claiming coverage it has lost. + """ + population = derived_population() + sites = inventory_sites() + missing = sorted(population - sites) + phantom = sorted(sites - population) + assert not missing, ( + "in scope but absent from docs/remote-state-inventory.md (classify each as " + f"PINNED / CAS / UNSAFE-KNOWN / N/A): {missing}") + assert not phantom, ( + f"listed in docs/remote-state-inventory.md but no such in-scope file exists: {phantom}") + + +def test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected(): + """The proof that the set comparison above is load-bearing (`testing.guard-ships-with-mutation- + proof`). This guard IS a test, so disarming it makes it absent rather than red; the admissible + proof is therefore the contrapositive — introduce the defect into an isolated copy of the + GUARDED ARTIFACT and show the comparison reports it. + + Both directions are mutated, because they are different defects: a dropped row is an + unclassified script, a phantom row is a table claiming coverage it has lost. This ran for real + on the day it was written — `dependency-scan.yml` was genuinely absent from the first draft of + the inventory and this comparison is what found it. + """ + text = INVENTORY.read_text(encoding="utf-8") + population = derived_population() + + victim = sorted(population)[0] + dropped = "\n".join( + line for line in text.splitlines() if not line.startswith(f"| `{victim}`")) + assert victim not in inventory_sites(dropped), ( + f"the mutation did not actually remove {victim}; the proof below would be vacuous") + assert population - inventory_sites(dropped), ( + "a row was removed from the inventory and the comparison still reported complete coverage") + + phantom = text + "\n| `scripts/does-not-exist.sh` — invented | `PINNED` | n/a |\n" + assert inventory_sites(phantom) - population == {"scripts/does-not-exist.sh"}, ( + "a row naming a file that does not exist was not reported as phantom") + + +def test_every_class_cell_comes_from_the_closed_vocabulary(): + bad = sorted({cls for _, cls in inventory_rows() if cls not in CLASSES}) + assert not bad, ( + f"unknown classification(s) {bad}; allowed: {sorted(CLASSES)}. A typo here would silently " + "create a state nobody reviews.") + + +def test_every_unsafe_row_states_why_the_residual_is_accepted(): + """`UNSAFE-KNOWN` means 'accepted with a reason', not 'noticed'. A row that records the window + without the argument for tolerating it is how a deferral becomes permanent by default.""" + text = INVENTORY.read_text(encoding="utf-8") + thin = [] + for line in text.splitlines(): + # TABLE ROWS ONLY. The first version matched any line containing the token, so the prose in + # "Columns" that DEFINES `UNSAFE-KNOWN` was parsed as a row and the split blew up. A guard + # that reads its own documentation as data is the failure this whole change is about. + if not line.startswith("|") or "`UNSAFE-KNOWN`" not in line: + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 3: + continue + site, note = cells[0], cells[-1] + if len(note) < 120: + thin.append(site[:60]) + assert not thin, ( + f"UNSAFE-KNOWN row(s) with no stated justification: {thin}") -- 2.47.3 From a0849eec58965e0a431bb9874262f0113802d6e1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 16 Aug 2026 13:20:42 +0200 Subject: [PATCH 02/20] =?UTF-8?q?fix(778):=20address=20the=20cold=20review?= =?UTF-8?q?=20=E2=80=94=20drop=20the=20population=20filter,=20grade=20down?= =?UTF-8?q?=20three=20overclaims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent cross-family review (Codex, cold brief, read-only worktree) returned BLOCKED with 9 findings. All 9 accepted; #5 partially, keeping one row PINNED with its justification stated. POPULATION (the finding that mattered most). The derivation filtered the scope by an outbound-network token list and argued that was a scope choice rather than a population filter. It omitted `git fetch` — this repo's most common remote read — so prepush-rebase-check.sh, which fetches origin/main and derives a PUSH DECISION, was structurally invisible to a guard claiming to cover "every executable that reads live remote state", along with three others. The defence offered was that over-inclusion is the safe direction; the filter also under-included. The content filter is gone: the population is now all 55 files in the scoped directories, and a file that reads no remote state carries an explicit N/A row. OVERCLAIMS, graded down. Three rows asserted more than the code does: - the scheduled-merge path was PINNED while the hook's own comment concedes the branch-protection read pins nothing → UNSAFE-KNOWN, "preflight, not a pin"; - pr-changed-files.sh was PINNED and claimed "any movement fails", but before-and-after equality is ABA-vulnerable (main → scratch → main) → UNSAFE-KNOWN, pointing at the caller-side event-count fence that does close it; - the CI toolchain image was PINNED on a mutable TAG, against this file's own definition naming a digest → UNSAFE-KNOWN. The release smoke pull stays PINNED: it pulls the tag the same concurrency-serialized job just pushed. The guard-inventory MUTATION regrade is reverted to BEHAVIOUR-ONLY (8/6/20). The review is right on species: the test feeds the real script an input the clause rejects, which this table explicitly defines as behaviour-only and has already regraded three rows for. A manually-executed disarm does not change what the test is. TWO REAL FAIL-OPENS FIXED: - jq `index()` on a STRING is substring search, so a status_check_contexts arriving as "prefix-review-verdict/h10-suffix" answered yes and would auto-grant. Membership is now exact equality over a value first proven to be an array of strings. - post-review-verdict.sh guarded both re-read comparisons with `[ -n "$x" ] &&`, so a 2xx body that merely omitted .head.sha or .base.ref made the check a no-op and the status was posted having confirmed nothing. That second fix carries a lesson worth the line: the obvious mutation (disarm the new `-z` arm) stays GREEN, because the unconditional `!=` also rejects empty — the two overlap, exactly the duplicate-guards-mask-each-other shape. The proof is taken against the REAL predecessor with the `-n` conjunct restored, which goes red showing returncode=0 and a status written. Also: 404 is now separated from 403/transport (an unprotected branch is the strongest form of the finding; `curl -sf` collapses both to an empty string), and the positive control asserts the decision is `allow` and that the endpoint was actually reached, rather than the absence of one phrase. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 56 +++++-- .../process/check-and-use-pins-a-version.md | 13 +- docs/guard-inventory.md | 4 +- docs/remote-state-inventory.md | 77 ++++++++-- scripts/post-review-verdict.sh | 25 +++- .../test_merge_consent_required_check.py | 141 ++++++++++++++++-- scripts/tests/test_post_review_verdict.py | 35 +++++ scripts/tests/test_remote_state_inventory.py | 57 ++++--- 8 files changed, 330 insertions(+), 78 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index bc5ba5fc1..73f6d46ca 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -341,24 +341,50 @@ else # present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened, # and the whole point of that issue is that the failure is silent from the merge caller's side. [ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling." - bp=$(gq "repos/$owner/$repo/branch_protections/$base_ref") - if [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then - decide ask "H6/H10 merge gate: could not read branch protection for '$base_ref' (Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs). Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling." + # 404 MUST be separated from every other failure, and `gq` cannot do it: it wraps `curl -sf`, + # which turns any HTTP error into exit 22 and empty output, so "no branch protection exists at + # all" is indistinguishable from "Gitea is down" through that helper. Those are opposite answers. + # A 404 on this endpoint is the STRONGEST possible form of the thing being checked — the base is + # entirely unprotected, so `review-verdict/h10` is definitively not required and a scheduled + # auto-merge is exactly #622's hole — while a 403 or a timeout says only that we could not look. + # Verified against this Gitea (2026-08-16): an unprotected branch returns 404 with a JSON body. + # Collapsing the two, which the first version of this guard did, sends the single most likely + # real-world trigger ("branch protection was removed") to a human prompt that reads like a + # transient hiccup. + bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge." + if [ -n "${ETV_GITEA_TOKEN:-}" ]; then + bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections/$base_ref" 2>/dev/null || true) + else + bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections/$base_ref" 2>/dev/null || true) fi - # jq's `index` yields the position (0 is TRUTHY in jq — only false/null are falsy), so the result - # is compared against null and mapped to a WORD rather than used as a condition. + bp=$(cat "$bp_file" 2>/dev/null || true) + rm -f "$bp_file" + if [ "$bp_code" = "404" ]; then + decide deny "H6/H10 merge gate: BLOCKED — '$base_ref' has NO branch protection at all (Gitea returned 404), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green." + fi + if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then + decide ask "H6/H10 merge gate: could not read branch protection for '$base_ref' (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs). Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling." + fi + # The membership test is `any(.[]; . == …)` over a value FIRST PROVEN to be an array of strings — + # never `index()`. `index` on a STRING is substring search, so a `status_check_contexts` that + # arrived as the string "prefix-review-verdict/h10-suffix" would answer "yes" and auto-grant a + # merge on a base where no such context is required. That is a FALSE-OPEN in the gate, reachable + # from any payload shape drift, and it is the direction that matters: a false-closed costs a + # prompt, a false-open costs an unreviewed merge. # - # The word is then matched exhaustively, because "" is not a third synonym for "no". Validating - # `$bp` as an object above does not make its MEMBERS well-formed: a `status_check_contexts` that - # is a number makes jq error, `|| true` turns that into an empty string, and a bare - # `[ "$guarded" = "yes" ] || decide deny` would then emit "NOT a required status check" for a - # payload it never managed to read. That is the same one-level-down swallow that survived the - # first fix in the #632 base-change guard: the validation domain has to match the CONSUMPTION - # domain, not stop at the top-level type. + # Validating `$bp` as an object does not make its MEMBERS well-formed, which is the same + # one-level-down swallow that survived the first fix in the #632 base-change guard — the + # validation domain has to match the CONSUMPTION domain, not stop at the top-level type. So the + # shape is checked explicitly and anything else becomes "unknown" rather than a decision. + # + # `null` and `[]` are legitimate (an unprotected-in-practice branch) and answer "no", not + # "unknown": absent IS the finding here, not a read failure. The word is then matched + # exhaustively, because "" is not a third synonym for "no". guarded=$(printf '%s' "$bp" \ - | jq -r 'if (.enable_status_check == true) - and (((.status_check_contexts // []) | index("review-verdict/h10")) != null) - then "yes" else "no" end' 2>/dev/null || true) + | jq -r 'def ctxs: (.status_check_contexts // []); + if (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown" + elif (.enable_status_check == true) and any(ctxs[]; . == "review-verdict/h10") then "yes" + else "no" end' 2>/dev/null || true) case "$guarded" in yes) : ;; no) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is NOT a required status check on '$base_ref' (branch protection reports enable_status_check/status_check_contexts without it). A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622); without it, arming merge_when_checks_succeed freezes consent at a head Gitea may not be the one to merge. Restore it in branch protection, or merge immediately (without merge_when_checks_succeed) once CI is green." ;; diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md index 815e4047c..314ad6561 100644 --- a/docs/decisions/records/process/check-and-use-pins-a-version.md +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -58,7 +58,18 @@ So the class has a second face: not only "the state moved between the check and outcomes, not two — present proceeds, unreadable **asks** (a transient failure or a credential without repo-admin scope is not evidence of safety), absent **denies**. Denying on absence is the point: that is #622's hole reopened, and its defining property is that it is silent from the merge -caller's side. +caller's side. The 404 is separated from every other failure deliberately, because "this branch has +no protection at all" is the strongest form of the finding while a 403 says only that we could not +look — and `curl -sf`, which the hook's generic helper wraps, collapses both into an empty string. + +**And that check is a preflight, not a pin — say so, because the temptation is to bank it.** The +first draft of the inventory graded that path `PINNED`; cold review pointed out that the hook's own +comment concedes the read pins nothing, and it was right. Branch protection has no version, ETag or +conditional read, so an admin can still weaken it after the hook looks. What the check buys is drift +DETECTION and the removal of an unobserved assumption, which is the honest ceiling for that API. The +residual is closed one layer down, not here — the required check is enforced server-side per sha at +merge time. A row claiming otherwise would be exactly the overclaim this record warns about, and it +is recorded here because the record's own deliverable made it on the first pass. This is the same gap `testing.guard-derives-population-from-source` already flagged one directory over — `MARKED_JOBS` in `test_ci_dropped_step_guard.py` is a hand-written mirror of those same diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index 1d479d52c..aeeb4e785 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -83,7 +83,7 @@ all. | `.claude/hooks/pretooluse-agent-ram.sh` | an Agent dispatch under 10% free RAM | GUARD | NONE | — | | `.claude/hooks/pretooluse-bash-guard.sh` | a Bash call setting `ETV_UPDATE_GOLDENS` | GUARD | NONE | — | | `.claude/hooks/pretooluse-bom-guard.sh` | a commit/push carrying a BOM in a touched `.cs` | GUARD | MUTATION | `test_bom_guard_detection.py::test_DISARMING_the_BOM_comparison_stops_detection` | -| `.claude/hooks/pretooluse-merge-consent.sh` | a PR merge without derived consent | GUARD | MUTATION | `test_merge_consent_required_check.py::test_a_base_without_the_required_check_denies_a_SCHEDULED_merge` | +| `.claude/hooks/pretooluse-merge-consent.sh` | a PR merge without derived consent | GUARD | BEHAVIOUR-ONLY | `test_merge_consent_exemption.py::test_protected_path_on_a_LATER_page_is_still_seen` | | `.claude/hooks/pretooluse-nav-guard.sh` | a browser navigate to a streaming URL | GUARD | NONE | — | | `.claude/hooks/pretooluse-worktree-guard.sh` | a commit/merge in a foreign worktree | GUARD | NONE | — | | `.husky/commit-msg` | a commit with no `Co-Authored-By` trailer | GUARD | NONE | — | @@ -130,7 +130,7 @@ all. ## What the numbers say -34 guards, 5 tooling scripts, 15 proof files. **9 guards carry a mutation proof; 5 are +34 guards, 5 tooling scripts, 15 proof files. **8 guards carry a mutation proof; 6 are behaviour-only; 20 have none.** These figures are asserted against the table by `test_the_summary_counts_match_the_table` — they were wrong in the first draft (28/4/6/3/19 against a table holding 27/5/6/3/18), because a hand-maintained summary of a table is a second copy of it, diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index cd8690801..0b5c8165f 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -40,28 +40,70 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| | `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `PINNED` | Every comparison uses the **full** 40-char `.head.sha`; `${sha:0:7}` appears only in human-readable strings. On the immediate-merge path there is no async window — the merge happens in the tool call this hook is gating. | -| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `PINNED` | The hook's proof is a snapshot; Gitea merges later. Safety comes from `review-verdict/h10` being a **required status check**, which a later commit cannot inherit (#622). Since #778 the hook **verifies** that protection instead of asserting it: absent → deny, unreadable → ask. Residual: a PreToolUse hook cannot add `head_commit_id` to the merge call, so it cannot convert its own grant into a CAS — it can only refuse. | +| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check (404/absent → deny, unreadable → ask) instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted because the residual is closed one layer down rather than here: the required check is enforced server-side per-sha at merge time (#622), so a commit pushed after scheduling cannot inherit the verdict regardless of what this hook concluded. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | | `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `PINNED` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632). Deliberately compares the base **ref**, not `base.sha` — the tip moves on every unrelated merge, and comparing it would deadlock every open PR. | | `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. | | `.claude/hooks/prepush-donewhen.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. | -| `.claude/hooks/pretooluse-nav-guard.sh` | `N/A` | Reads only the proposed tool call's own parameters and decides synchronously. The `curl` token that puts it in the population appears in prose, not in an executed line. | +| `.claude/hooks/pretooluse-nav-guard.sh` | `N/A` | Reads only the proposed tool call's own parameters and decides synchronously; the `curl` mentions in the file are prose, not executed lines. | + +| `.claude/hooks/prepush-rebase-check.sh` — `git fetch origin main`, then `git merge-base --is-ancestor origin/main HEAD` to decide whether to block the push as behind | `UNSAFE-KNOWN` | Fetch-then-decide with no re-validation before the verdict; `origin/main` can advance inside that window. Accepted because the decision is self-correcting and cannot reach `main`: a push allowed on a now-stale read still lands on a feature branch, since direct pushes to `main` are refused server-side (#743), so the worst case is a rebase nag arriving one push later rather than a bad merge. This row exists because the previous token filter did not list `git fetch` and so could not see it at all. | +| `.claude/hooks/prepush-clean-worktree-check.sh` — `git fetch origin main`, then diffs `origin/main...HEAD` to scope which dirty files are in the pushed diff | `UNSAFE-KNOWN` | Same fetch-then-decide shape and the same bound: the verdict advises a feature-branch push only, `main` refuses direct pushes server-side (#743), and a stale `origin/main` read at worst lets a dirty-file push through, which the downstream review gate still catches before any merge. | +| `.claude/hooks/decisions-guard.sh` | `N/A` | Reads no live remote state — runs `scripts/decisions_validate.py` over the local working tree; no fetch, no HTTP call. | +| `.claude/hooks/design-sync-reminder.sh` | `N/A` | Reads no live remote state — compares local `git diff`/`ls-files` output against the session's own edits, never contacts origin. | +| `.claude/hooks/posttooluse-worktree-marker.sh` | `N/A` | Reads no live remote state — parses the tool call's own JSON payload and writes a local ownership marker. | +| `.claude/hooks/pretooluse-agent-model.sh` | `N/A` | Reads no live remote state — inspects only the proposed Agent call's own `model`/`subagent_type` fields. | +| `.claude/hooks/pretooluse-agent-ram.sh` | `N/A` | Reads no live remote state — samples local `memory_pressure -Q` output. | +| `.claude/hooks/pretooluse-bash-guard.sh` | `N/A` | Reads no live remote state — pattern-matches the proposed Bash command string for `ETV_UPDATE_GOLDENS=`. | +| `.claude/hooks/pretooluse-bom-guard.sh` | `N/A` | Reads no live remote state — inspects local `git diff` output and reads local `.cs` bytes for a BOM. | +| `.claude/hooks/pretooluse-worktree-guard.sh` | `N/A` | Reads no live remote state — reads a local `.claude-worktree-owner` marker in the target worktree. | + +### Husky git hooks + +| Site | Class | Note | +|---|---|---| +| `.husky/pre-push` | `N/A` | Delegates every remote read to `prepush-donewhen.sh`, `prepush-rebase-check.sh` and `prepush-clean-worktree-check.sh`, each of which carries its own row. This file forwards stdin ref lines and runs local `npm run check:api/lint/typecheck/build` against the checked-out tree. | +| `.husky/pre-commit` | `N/A` | Reads no live remote state — local lint-staged, `decisions-guard.sh`, a `git diff --cached` scan, and local `dotnet format --verify-no-changes`. | +| `.husky/commit-msg` | `N/A` | Reads no live remote state — greps the local commit-message file for a trailer. | ### Scripts | Site | Class | Note | |---|---|---| -| `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on any mismatch. Closes #706 and #632 for this path by read-compare-refuse, not by CAS — Gitea's status API offers no conditional write. Residual: the **comment** is posted *before* that re-read, so a head that moves in between leaves a verdict comment with no status. The comment is not the gate, but the mismatch is confusing; tracked in **#792**. | -| `scripts/pr-changed-files.sh` — paged file enumeration | `PINNED` | This is #707's fix. `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after; any movement fails the whole enumeration closed rather than emitting a short list. Pages are **not** diffed per-page against a re-read tip — the whole run is rejected instead, which is what makes a mid-paging `main` advance observable rather than silent. | +| `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on a mismatch **or on a field it cannot read**. That last clause is new: both comparisons were guarded by `[ -n "$x" ] &&`, so a well-formed 2xx body that merely omitted the field made the check a no-op and the status was posted having confirmed nothing — found by cold review on #778 and regression-tested against the real predecessor, since the redundant `-z` arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual: the **comment** is posted *before* the re-read, so a head that moves in between leaves a verdict comment with no status — the comment is not the gate, but the mismatch is confusing and is tracked in **#792**. | +| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix, graded honestly after cold review: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone: the answer is the caller-side monotonic event-count fence (`ci.verdict-write-retarget-fence`), which counts `change_target_branch` events precisely because a name can alias and a count cannot. | | `scripts/select-queue.sh` — issue list, then per-issue `/dependencies` | `UNSAFE-KNOWN` | The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed `in-progress` in that gap still appears on the shortlist. Accepted: the script authorizes **no write**. The real gate is the four-way claim check in `process.parallel-session-claim`, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. | | `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. | | `scripts/issue-qualification-audit.sh` | `N/A` | Pure read-and-report; exits 0/1 with a list. Authorizes no write and no merge. | | `scripts/security-scan.sh` | `N/A` | `docker pull` of a mutable tag, then scans exactly what was pulled. Resolution and use are one step — no check-then-use split. | | `scripts/migration-smoke.sh` | `N/A` | Same shape. The resolved image id is **reported** for the operator rather than compared against a prior read, which is an operator-trust gap (the script says so) and not a race. | -| `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir. In the population only because `curl` appears in a comment. | +| `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir; the only `curl` in the file is in a comment. | | `scripts/e2e-local.sh` | `N/A` | No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. | | `scripts/e2e-ui.sh` | `N/A` | Launches a local Chromium and runs specs against `http://localhost:$PORT`. | | `scripts/e2e-functional.sh` | `N/A` | Every call targets `$BASE_URL`, defaulting to `http://localhost:8409`. The one non-local-looking address, `192.0.2.1`, is TEST-NET-1 (RFC 5737) — written into the DB as a connection row precisely so it is unroutable, never dialed by the script. | +| `scripts/ci-detect-docs-only.sh` — `git fetch origin "$base"`, then diffs the fetched tip against HEAD to emit `docs_only`, which gates whether the required test/migrations jobs run their real steps | `UNSAFE-KNOWN` | Read-then-act with no re-check between the fetch and the emitted value, and the decision genuinely gates required CI work. Accepted because the script is deliberately asymmetric: every ambiguous, undeterminable or shallow-checkout case resolves to `docs_only=false` (run everything), and only an exact unanimous all-docs diff yields `true` — so a stale or racing base read can at worst cause an unnecessary full run, never a skipped one (#416). | +| `scripts/refresh-shared-checkout.sh` — `git fetch origin main`, then `git merge --ff-only origin/main` and a conditional `npm ci` | `UNSAFE-KNOWN` | Fetch-then-act with no re-check between the fetch and the merge. Accepted because every action is self-refusing or reversible: `--ff-only` fails harmlessly rather than diverging if the ref moved on, the script refuses outright when the tree is not clean `main` or is ahead or mid-rebase, and this is a developer-convenience checkout rather than a release or merge-authorization path — a stale read costs one extra fetch next run, never lost work. | +| `scripts/check-review-verdict.sh` | `N/A` | Reads no live remote state itself — classifies a comments JSON payload supplied on stdin; the fetch belongs to the caller's row. | +| `scripts/decisions_validate.py` | `N/A` | Reads no live remote state — its `git log`/`show`/`ls-tree`/`merge-base` calls operate on refs the caller already checked out or passed via `--base`/`--head`, never a fetch. | +| `scripts/prove-fix.sh` | `N/A` | Reads no live remote state — `git worktree add`/`rev-parse`/`diff-tree` operate on the local repository's own objects. | +| `scripts/add-migration.sh` | `N/A` | Reads no live remote state — runs `dotnet ef migrations add` against local project files; implicit NuGet resolution is dependency supply-chain, out of this class per limit 1. | +| `scripts/update-openapi.sh` | `N/A` | Reads no live remote state — a local `dotnet build`/`GenerateOpenApiDocuments` then a local python script. | +| `scripts/cleanup-code.sh` | `N/A` | `dotnet tool restore` resolves and uses tooling in one step (limit 1, not a check-and-use split); the rest is a local `git status --porcelain` scan. | +| `scripts/cleanup-all-code.sh` | `N/A` | Same shape as `cleanup-code.sh` — restore-and-use in one step, no check-then-act over remote state. | +| `scripts/build_decisions_catalog.py` | `N/A` | Reads no live remote state — parses local decision records and writes the local catalog. | +| `scripts/decisions_lib.py` | `N/A` | Reads no live remote state — pure parser over local decision-record files. | +| `scripts/migrate_decisions_split.py` | `N/A` | Reads no live remote state — one-shot local file migration over `docs/decisions/`. | +| `scripts/generate-endpoint-index.py` | `N/A` | Reads no live remote state — reads local `v1.json` and writes a local markdown index. | +| `scripts/check-kickoff-guard.sh` | `N/A` | Reads no live remote state — scans a fixed local file list for forbidden phrasing. | +| `scripts/check-local-lsp.sh` | `N/A` | Reads no live remote state — probes local PATH binaries and spawns a local MCP server over stdio. | +| `scripts/mcp_smoke.py` | `N/A` | Reads no live remote state — spawns a local subprocess and speaks JSON-RPC over stdio pipes, no network socket. | +| `scripts/jq-preflight.sh` | `N/A` | Reads no live remote state — runs local `jq --version`. | +| `scripts/ci-peak-anon.sh` | `N/A` | Reads no live remote state — samples the runner's local cgroup `memory.stat`/`memory.peak`. | +| `scripts/ci-prove-ban-detects.sh` | `N/A` | Reads no live remote state — mutates a local workflow copy and runs pytest against the local checkout. | +| `scripts/ci-step-ran.sh` | `N/A` | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. | +| `scripts/set-provider.sh` | `N/A` | Reads no live remote state — sets local `dotnet user-secrets` values. | +| `scripts/__init__.py` | `N/A` | Empty package marker — executes nothing. | + ### Workflows | Site | Class | Note | @@ -69,10 +111,10 @@ classifications differ; otherwise the strictest applies and the Note names the e | `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed twice rather than claimed closed: a monotonic `change_target_branch` **event-count** fence refuses to write if the count moved (`ci.verdict-write-retarget-fence`; the count is used because the branch *name* is ABA-vulnerable), and a high-water-mark re-read repairs a `success` posted over a human verdict back to `pending`. The file states the residual window explicitly rather than asserting safety. | | `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. | | `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `PINNED` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base; inherits that row's pins. | -| `.gitea/workflows/docker-build.yml` — CI toolchain image | `PINNED` | Jobs consume `ersatztv-ci:`, never `:latest`, and `pr-checks.yml`'s `ci-image-pin` job fails the build if the pin drifts from the last commit touching `docker/ci/`. The pin is a **tag, not a digest**, so immutability is convention plus that guard rather than a registry property — the availability half of that gap is **#772**. | -| `.gitea/workflows/docker-build.yml` — release smoke pull | `PINNED` | Pulls `${IMAGE}:${SMOKE_SHORT_SHA}` — the tag the same job pushed moments earlier, serialized by the job's `concurrency` group. | +| `.gitea/workflows/docker-build.yml` — CI toolchain image | `UNSAFE-KNOWN` | Graded down by cold review, correctly: this file's own definition of `PINNED` names an image **digest**, and `ersatztv-ci:` is a mutable **tag**. A registry tag can be repointed after `ci-image-pin` verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume `:latest`, and `pr-checks.yml`'s `ci-image-pin` fails the build if the tag drifts from the last commit touching `docker/ci/`. Consuming `image@sha256:…` is the real fix and is the natural companion to **#772**, which already covers the availability half of this tag's weakness. | +| `.gitea/workflows/docker-build.yml` — release smoke pull | `PINNED` | The one registry row that genuinely qualifies, and for a reason the tag itself does not supply: it pulls `${IMAGE}:${SMOKE_SHORT_SHA}` — the tag *this same job* pushed moments earlier, inside one `concurrency` group, so no other run can be repointing it in the window. | | `.gitea/workflows/docker-build.yml` — `api-docs` / `format` base fetch | `N/A` | Fetches the live base tip to diff generated artifacts. Advisory, non-required contexts; re-fetched fresh on every trigger with no state carried across runs. | -| `.gitea/workflows/ci-image.yml` — verify the pushed image | `PINNED` | Pulls back the immutable `:` it just pushed. `:latest` is moved for humans and documented as never consumed by a job. | +| `.gitea/workflows/ci-image.yml` — verify the pushed image | `PINNED` | Pulls back the `:` tag it pushed in the immediately preceding step of the same `concurrency`-serialized run — same argument as the release smoke pull, and the same caveat that the tag is immutable by convention, not by the registry. | | `.gitea/workflows/pr-checks.yml` — `prove-fix` | `PINNED` | `base`/`head` are full shas from the event payload, immune to later PR mutation. | | `.gitea/workflows/pr-checks.yml` — `ci-image-pin`, `docs-reminder`, `decisions-guard` | `N/A` | Local-tree comparisons or advisory base fetches; none authorize a merge or a write. | | `.gitea/workflows/renovate.yml` — `renovate/renovate:43` | `N/A` | Out of this class, in scope for a different one — see the limits below. | @@ -93,12 +135,19 @@ classifications differ; otherwise the strictest applies and the Note names the e find-all-references tooling, tracked in #777 — and it is why the Note column is prose a reviewer reads rather than a field a script checks. -3. **Scope is hand-written; the population inside it is derived.** Scope is `scripts/`, - `.claude/hooks/`, `.husky/` (filtered to files containing an outbound-network token) plus **all** - of `.gitea/workflows/` unconditionally. The token filter over-includes — `hook-fire-log.sh` is - here because of a comment — which is the safe direction, since over-inclusion costs a row and - under-inclusion costs a blind spot. It under-includes only if someone reaches the network by a - mechanism none of those tokens name. +3. **Scope is hand-written; the population inside it is derived, with no content filter at all.** + Scope is every file under `scripts/` (`*.sh`, `*.py`), `.claude/hooks/`, `.husky/` and + `.gitea/workflows/` — a reviewed policy choice. Inside it, *every* file gets a row, and a file + that reads no remote state earns an explicit `N/A` rather than silently staying out. + + The first version filtered that scope by an outbound-network token list and argued the filter was + a scope choice rather than a population filter. Cold review rejected the distinction, and the + evidence settled it: the list omitted `git fetch`, which is this repo's most common remote read, + so `prepush-rebase-check.sh` — which fetches `origin/main` and derives a **push decision** from + it — was structurally invisible to a guard claiming to cover "every executable that reads live + remote state", along with three others. The defence offered was that over-inclusion is the safe + direction; the filter also *under*-included, which is the direction that costs a blind spot. + Enumerating the directories costs more rows and has none. 4. **Nothing here checks that a `PINNED` claim is true.** The test asserts every in-scope file has a row and that the classifications come from a closed vocabulary. Whether a row is honest stays a diff --git a/scripts/post-review-verdict.sh b/scripts/post-review-verdict.sh index 1a1da93f3..3b72764ed 100755 --- a/scripts/post-review-verdict.sh +++ b/scripts/post-review-verdict.sh @@ -142,15 +142,36 @@ printf 'posted comment: Review-verdict: %s @ %s\n' "$verdict" "$short" # introduced by the refactor, so the refusal is now explicit rather than a side effect of `set -e`. prjson_now=$(api_get "repos/$owner/$repo/pulls/$pr") \ || die "could not re-read PR #$pr to confirm the head and base had not moved while posting — no status was written. Re-run once Gitea is reachable." +# `[ -n "$x" ] && [ "$x" != "$want" ]` was a fail-OPEN on BOTH of the checks below (ersatztv#778). +# A 2xx body that merely LOST the field — `{"head":{},"base":{}}` — yields an empty value, so the +# `-n` conjunct is false, the comparison never runs, and the status is posted having confirmed +# NOTHING about either the head or the base. The re-read exists precisely to refuse when it cannot +# confirm, so a field it cannot read must die exactly like a field that moved. The transport failure +# one line up is already fatal; this closes the same hole one level down, which is where it keeps +# reappearing in this repo. +# +# WHICH LINE CARRIES THE SAFETY, stated because it is not the one it looks like: dropping the `-n` +# conjunct is the fix. The unconditional `!=` below already rejects an empty value, so the explicit +# `-z` arms are REDUNDANT for the safety property and exist only to give the operator an accurate +# message ("carried no head sha" rather than "moved to ''"). Disarming a `-z` arm alone therefore +# leaves the suite green — the two overlap, and a mutation proof aimed at it would be vacuous. The +# proof in `test_a_reread_that_LOSES_a_field_refuses_instead_of_posting` is taken against the real +# predecessor (the `-n` conjunct restored), which is what actually goes red. sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""') -if [ -n "$sha_now" ] && [ "$sha_now" != "$sha" ]; then +if [ -z "$sha_now" ]; then + die "re-read PR #$pr but its response carried no head sha, so it is UNPROVEN that the head is still $short — no status was written. Re-run once Gitea returns a well-formed PR body." +fi +if [ "$sha_now" != "$sha" ]; then die "head moved from $short to ${sha_now:0:7} while posting — that commit is UNREVIEWED, so no status was written. Re-review the new head and run this again." fi # The same TOCTOU window applies to the base (ersatztv#632): a retarget between the read above and # the status write below would bind the verdict to a base that is no longer the PR's, and the head # sha check would not notice because retargeting does not move the head. base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""') -if [ -n "$base_now" ] && [ "$base_now" != "$base_ref" ]; then +if [ -z "$base_now" ]; then + die "re-read PR #$pr but its response carried no base ref, so it is UNPROVEN that the base is still '$base_ref' — no status was written. Re-run once Gitea returns a well-formed PR body." +fi +if [ "$base_now" != "$base_ref" ]; then die "base branch changed from '$base_ref' to '$base_now' while posting — the diff you reviewed is not the diff this PR now merges, so no status was written. Re-review against the new base and run this again." fi diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index fffaf20f1..53f30c373 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -47,18 +47,55 @@ state = pathlib.Path(os.environ["STUB_DIR"]) args = sys.argv[1:] url = [a for a in args if a.startswith("http")][-1] +# The branch-protection call uses `-o -w '%{http_code}'` rather than `curl -sf`, precisely so +# it can tell 404 from every other failure. The shim must therefore behave like real curl for those +# flags: body to the -o file, status code to stdout. A shim that ignored them would make the hook +# read an empty body and a blank code on EVERY path, and the tests would pass by accident against a +# guard that never ran — the "test double's fidelity claim" failure this repo has on record. +def respond(body, code="200"): + if "-o" in args: + pathlib.Path(args[args.index("-o") + 1]).write_text(body) + else: + sys.stdout.write(body) + if "-w" in args: + sys.stdout.write(code) + sys.exit(0) + if "/branch_protections/" in url: + # Record the call so the positive control can prove the guarded case REACHED this endpoint, + # rather than passing because the hook bailed earlier for an unrelated reason. + (state / "bp_called").write_text("yes") mode = (state / "bp").read_text().strip() if mode == "TRANSPORT-ERROR": - sys.exit(22) + # curl itself fails: no body, and `-w` prints 000. + respond("", "000") + if mode == "NOT-FOUND": + respond('{"message":"not found"}', "404") + if mode == "FORBIDDEN": + respond('{"message":"token does not have at least one of required scope(s)"}', "403") if mode == "GARBAGE": - print('[]'); sys.exit(0) + respond('[]') if mode == "EMPTY": - print(''); sys.exit(0) + respond('') if mode == "MALFORMED-MEMBER": # A valid JSON OBJECT whose contexts member is not a list — passes a top-level type check # and then makes jq error one level down. - print('{"enable_status_check": true, "status_check_contexts": 7}'); sys.exit(0) + respond('{"enable_status_check": true, "status_check_contexts": 7}') + if mode == "SUBSTRING-STRING": + # The false-OPEN shape: a bare string that CONTAINS the context name. jq's `index` on a + # string is substring search, so the naive membership test answers "yes" here. + respond('{"enable_status_check": true,' + ' "status_check_contexts": "prefix-review-verdict/h10-suffix"}') + if mode == "NON-STRING-MEMBER": + respond('{"enable_status_check": true, "status_check_contexts": [7, "review-verdict/h10"]}') + if mode == "EMPTY-CONTEXTS": + respond('{"enable_status_check": true, "status_check_contexts": []}') + if mode == "NULL-CONTEXTS": + respond('{"enable_status_check": true, "status_check_contexts": null}') + if mode == "LONGER-STRING-MEMBER": + # A genuine array whose member merely CONTAINS the name must NOT match. + respond('{"enable_status_check": true,' + ' "status_check_contexts": ["xx-review-verdict/h10-yy"]}') contexts = ["Build ErsatzTV Image / Build & test (.NET) (pull_request)"] enabled = True if mode == "GUARDED": @@ -67,9 +104,8 @@ if "/branch_protections/" in url: contexts.append("review-verdict/h10") enabled = False # mode == "MISSING-CONTEXT" falls through with h10 absent - print(json.dumps({"branch_name": "main", "enable_status_check": enabled, - "status_check_contexts": contexts})) - sys.exit(0) + respond(json.dumps({"branch_name": "main", "enable_status_check": enabled, + "status_check_contexts": contexts})) if "/pulls/" in url and "/files" in url: q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) @@ -125,6 +161,9 @@ def hook(tmp_path): def set_branch_protection(self, mode): (state / "bp").write_text(mode) + def branch_protection_was_fetched(self): + return (state / "bp_called").exists() + def decision(self, scheduled=True): payload = {"tool_input": {"method": "merge", "owner": "timothy", "repo": "ersatztv", "pull_number": 42, @@ -165,12 +204,61 @@ def test_status_checks_disabled_wholesale_also_denies(hook): "status checks were disabled entirely and the listed context was read as protection anyway") -def test_positive_control_a_guarded_base_does_not_trigger_this_deny(hook): - """Without this, the two tests above pass if the hook denies on every path — which it very - nearly does, since this PR is non-docs and several later conditions are stubbed only loosely.""" +def test_positive_control_a_guarded_base_REACHES_the_check_and_still_auto_grants(hook): + """Without this, every test above passes if the hook denies on all paths — which it very nearly + does, since this PR is non-docs and several later conditions are stubbed only loosely. + + Asserting the absence of one phrase was not enough (cold review): an unrelated early `ask`, or a + differently-worded deny, would satisfy it while proving nothing. So this pins all three of the + things that must be true — the branch-protection endpoint was actually CALLED, the decision is + `allow`, and the reason is the satisfied-gate message rather than any refusal. + """ + decision = hook.decision() + assert hook.branch_protection_was_fetched(), ( + "the guarded case never reached the branch-protection endpoint, so the other tests are not " + "exercising the code they claim to") + assert decision is not None, "the hook passed through instead of auto-granting" + verdict = decision["hookSpecificOutput"]["permissionDecision"] + assert verdict == "allow", f"a fully-satisfied gate did not auto-grant (got {verdict!r})" + assert "satisfied" in decision["hookSpecificOutput"]["permissionDecisionReason"] + + +@pytest.mark.parametrize("shape", ["SUBSTRING-STRING", "LONGER-STRING-MEMBER"]) +def test_a_context_name_that_merely_CONTAINS_the_required_one_does_not_satisfy_it(hook, shape): + """The false-OPEN this guard must not have. + + jq's `index()` on a STRING is substring search, so `"prefix-review-verdict/h10-suffix"` answers + yes to a naive membership test — auto-granting a scheduled merge on a base where the context is + not required at all. `LONGER-STRING-MEMBER` covers the same confusion inside a real array. + A false-closed here costs one prompt; a false-open costs an unreviewed merge, so the membership + test is exact equality over a value first proven to be an array of strings. + """ + hook.set_branch_protection(shape) reason = hook.reason() - assert "NOT a required status check" not in reason, ( - "the required-check guard fired on a base that carries the context") + assert "allow" not in reason or "deny" in reason or "ask" in reason, ( + f"payload shape {shape} auto-granted a scheduled merge") + assert "satisfied" not in reason, ( + f"a context name that merely contains 'review-verdict/h10' ({shape}) was accepted as it") + + +@pytest.mark.parametrize("shape", ["EMPTY-CONTEXTS", "NULL-CONTEXTS"]) +def test_an_empty_or_null_contexts_list_DENIES_rather_than_asking(hook, shape): + """Absent is the finding, not a read failure. An empty or null list is a well-formed answer + meaning "nothing is required here", so it must take the deny arm and not be swept into the + unknown-shape ask alongside genuinely unreadable payloads.""" + hook.set_branch_protection(shape) + reason = hook.reason() + assert "deny" in reason, ( + f"{shape} was treated as unreadable rather than as a confirmed absent required check") + + +def test_a_non_string_MEMBER_inside_the_array_asks(hook): + """`[7, "review-verdict/h10"]` contains the context, but the payload is not the shape this + guard knows how to reason about. Answering "yes" would mean trusting a structure we cannot + validate; the honest answer is that we could not tell.""" + hook.set_branch_protection("NON-STRING-MEMBER") + reason = hook.reason() + assert "ask" in reason, "an array with a non-string member produced a decision anyway" def test_a_malformed_contexts_MEMBER_asks_rather_than_denying_with_the_wrong_reason(hook): @@ -188,6 +276,35 @@ def test_a_malformed_contexts_MEMBER_asks_rather_than_denying_with_the_wrong_rea "an unreadable payload was reported as a confirmed missing required check") +def test_a_base_with_NO_branch_protection_at_all_denies_rather_than_asking(hook): + """The strongest form of the thing being checked, and the likeliest real trigger. + + `curl -sf` (the `gq` helper) collapses every HTTP error into exit 22 with an empty body, so a + 404 — "this branch is entirely unprotected", i.e. `review-verdict/h10` is definitively not + required — was indistinguishable from "Gitea is down". Those are opposite answers, and routing + the first to a human prompt makes the single most likely real-world trigger (branch protection + removed) read like a transient hiccup. Verified against the live instance 2026-08-16: an + unprotected branch returns 404. + """ + hook.set_branch_protection("NOT-FOUND") + reason = hook.reason() + assert "deny" in reason, ( + "a base with no branch protection at all did not deny a scheduled auto-merge") + assert "404" in reason or "NO branch protection" in reason, ( + "the deny must distinguish 'unprotected' from 'could not read'") + + +def test_a_403_asks_because_it_says_only_that_we_could_not_look(hook): + """The other side of the 404 split. A credential without repo-admin scope proves nothing about + the protection, so this must NOT deny — otherwise the guard strands every scheduled merge run + with a narrower token.""" + hook.set_branch_protection("FORBIDDEN") + reason = hook.reason() + assert "ask" in reason, "a 403 was treated as evidence about the protection" + assert "NO branch protection" not in reason, ( + "a 403 was reported as a confirmed absence of branch protection") + + @pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE", "EMPTY"]) def test_an_UNREADABLE_branch_protection_asks_rather_than_denying_or_passing(hook, failure): """"Could not check" is a third outcome, not a synonym for either neighbour. diff --git a/scripts/tests/test_post_review_verdict.py b/scripts/tests/test_post_review_verdict.py index dbf27844a..c09ceba6e 100644 --- a/scripts/tests/test_post_review_verdict.py +++ b/scripts/tests/test_post_review_verdict.py @@ -73,6 +73,10 @@ if "/pulls/" in url and not url.endswith("/files"): "state": (state / "pr_state").read_text().strip(), "html_url": "http://gitea.example/timothy/ersatztv/pulls/42", } + # A 2xx body that merely LOST the field, as distinct from an unreachable PR ('GONE' above). + # This is the shape the `[ -n "$x" ] &&` conjunct used to wave through (ersatztv#778). + if sha == "NOHEAD": + body["head"] = {} if base != "MISSING": body["base"] = {"ref": base} print(json.dumps(body)) @@ -374,3 +378,34 @@ def test_each_verdict_word_posts_its_established_polarity(word, expected, gitea) "matching arm, so a token that has appeared in the other arm resolves there silently — and " "a BLOCKED verdict posting `success` writes a green required context." ) + + +@pytest.mark.parametrize( + ("head_seq", "base_seq", "field"), + [ + (("NOHEAD",), ("main", "main"), "head sha"), + ((SHA_A,), ("main", "MISSING"), "base ref"), + ], +) +def test_a_reread_that_LOSES_a_field_refuses_instead_of_posting(head_seq, base_seq, field, gitea): + """The fail-OPEN one level below the TOCTOU guard, found by cold review (ersatztv#778). + + Both re-read checks were written as `[ -n "$x" ] && [ "$x" != "$want" ]`. That conjunct makes an + EMPTY value a no-op: a well-formed 2xx response that merely omits `.head.sha` or `.base.ref` + yields an empty variable, neither comparison runs, and the status is posted having confirmed + NOTHING about the head or the base — while the script's whole purpose at that point is to refuse + unless it can confirm. The transport failure one line above was already fatal, which is exactly + what made this shape easy to miss: the loud case was handled and the quiet one was not. + + Asserted on the OBSERVABLE outcome — no status written — rather than on message text, so it + still holds if the wording changes. + """ + gitea.set_head_sequence(SHA_A, *head_seq) + gitea.set_base_sequence(*base_seq) + result = gitea.run("42", "MERGEABLE") + assert result.returncode != 0, ( + f"a re-read missing its {field} was accepted; the script posted a verdict having confirmed " + "nothing about it") + assert not gitea.statuses(), ( + f"a status was written despite the re-read carrying no {field} — this is the fail-open " + "the -n conjunct created") diff --git a/scripts/tests/test_remote_state_inventory.py b/scripts/tests/test_remote_state_inventory.py index 6d8e9078c..21b629087 100644 --- a/scripts/tests/test_remote_state_inventory.py +++ b/scripts/tests/test_remote_state_inventory.py @@ -32,50 +32,43 @@ INVENTORY = REPO_ROOT / "docs" / "remote-state-inventory.md" CLASSES = {"PINNED", "CAS", "UNSAFE-KNOWN", "N/A"} -# An outbound call by any mechanism this repo actually uses. Kept small and literal on purpose: a -# clever regex here would be a string predicate over source, which this repo's record says takes -# three or more rounds to get right (#629, #633, #698). -NETWORK_TOKENS = ( - "curl", "wget", "urllib", "requests.", "http.client", - "docker pull", "docker manifest", "docker push", -) - -# Directories whose files enter the population only if they carry a network token. -FILTERED_SCOPE = ( +# NO CONTENT FILTER. The population is every file in the scoped directories, and a file that reads +# no remote state earns an `N/A` row rather than silently staying out. +# +# The first version filtered on a token list (`curl`, `wget`, `urllib`, ...) and called that a +# SCOPE choice rather than a population filter. Cold review rejected the distinction and was right: +# the list omitted `git fetch`, which is this repo's most common remote read, so +# `.claude/hooks/prepush-rebase-check.sh` — which fetches `origin/main` and derives a PUSH DECISION +# from it — was structurally invisible to a guard whose stated claim is "every executable that +# reads live remote state". Three more (`prepush-clean-worktree-check.sh`, `ci-detect-docs-only.sh`, +# `refresh-shared-checkout.sh`) were missing for the same reason. +# +# That is precisely the defect `testing.guard-derives-population-from-source` describes: a filter +# cannot see the member that is missing, because the absent member is not a row the predicate +# rejected, it is a row that was never produced. The defence offered for it — "over-inclusion is the +# safe direction" — was answered by the filter ALSO under-including. Enumerating the directories +# costs more rows and has no blind spot; that is the trade the rule already made. +SCOPE = ( (REPO_ROOT / "scripts", ("*.sh", "*.py")), (REPO_ROOT / ".claude" / "hooks", ("*.sh",)), (REPO_ROOT / ".husky", ("*",)), + (REPO_ROOT / ".gitea" / "workflows", ("*.yml",)), ) -# Every workflow is in scope unconditionally: they are the remote-facing surface by nature, there -# are only a handful, and a workflow can reach a remote service through an `uses:` action that -# carries none of the tokens above. -WORKFLOWS = REPO_ROOT / ".gitea" / "workflows" # `| ` + backticked path, optionally followed by ` — `, then the class cell. _ROW = re.compile(r"^\|\s*`([^`]+?)`[^|]*\|\s*`([^`]+)`\s*\|", re.M) -def _has_network_token(path: Path) -> bool: - try: - text = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return False - return any(tok in text for tok in NETWORK_TOKENS) - - def derived_population() -> set[str]: - """Every in-scope file, as a repo-relative posix path.""" + """Every in-scope file, as a repo-relative posix path. No content predicate of any kind.""" found: set[str] = set() - for directory, globs in FILTERED_SCOPE: + for directory, globs in SCOPE: if not directory.is_dir(): continue for pattern in globs: for path in sorted(directory.glob(pattern)): - if path.is_file() and _has_network_token(path): + if path.is_file(): found.add(path.relative_to(REPO_ROOT).as_posix()) - if WORKFLOWS.is_dir(): - for path in sorted(WORKFLOWS.glob("*.yml")): - found.add(path.relative_to(REPO_ROOT).as_posix()) return found @@ -100,10 +93,10 @@ def test_anti_vacuity_the_derivation_and_the_table_both_found_something(): comparison below pass trivially.""" population = derived_population() sites = inventory_sites() - assert len(population) >= 10, ( - f"derived only {len(population)} in-scope files — the globs or the token list are broken, " - "not the repo") - assert len(sites) >= 10, ( + assert len(population) >= 40, ( + f"derived only {len(population)} in-scope files — the globs are broken, not the repo " + "(the scope holds 55 files as of 2026-08-16, and it only grows)") + assert len(sites) >= 40, ( f"parsed only {len(sites)} rows out of the inventory — the row regex has drifted from the " "table format") -- 2.47.3 From 84b4c11188ee5fbf082e289221402a2ddc8d31c5 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 16 Aug 2026 13:44:10 +0200 Subject: [PATCH 03/20] =?UTF-8?q?fix(778):=20round=202=20=E2=80=94=20recur?= =?UTF-8?q?sive=20population,=20and=20grade=20down=20five=20more=20overcla?= =?UTF-8?q?ims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second cold review: no Blockers, 4 High / 1 Medium / 2 Low. All accepted. POPULATION, WRONG A SECOND TIME. Round 1 removed a content filter that had omitted `git fetch`. Round 2 found the replacement traversal used non-recursive `Path.glob`, so four nested files were still outside it — including scripts/scripted-schedules/entrypoint.py, which calls get_context() against a live ErsatzTV server and then drives define_content/reset_playout/build_playout off the result. Now rglob, with scripts/tests/ as the single stated DIRECTORY-level exclusion (a scope choice, reviewable in one line; not a predicate over content). Population 55 -> 59, rows 63 -> 68. The generalisation is in the record, because the deliverable made the same mistake twice: the scope may be hand-written, but anything narrowing the POPULATION has to be executed and its output compared against the filesystem — the members it drops are invisible by construction. That is the #774 rule turned on the artifact meant to enforce it. FIVE MORE OVERCLAIMS GRADED DOWN. Both merge-consent head/base rows (the hook returns `allow` and a separate call merges, so the window is small, not absent — "no async window" was simply false); the release smoke pull and the ci-image verify (the concurrency group is PER-REF, so a branch build and a tag build of one commit can publish the same :); and the workflow base-fetch rows, which are not advisory — the merge hook reads the COMBINED status, so any red context blocks the auto-grant. Also fixed a stale cross-reference where the enumeration row still said it "inherits that row's pins" from a row graded down in the same commit. Four PINNED rows survive out of 68. That ratio is the honest finding. CIRCULAR JUSTIFICATION REMOVED. The scheduled-merge row said its residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed, it is BOUNDED by a trust assumption, and the row now says so. Low: jq's `//` fires on `false` as well as null, so `status_check_contexts: false` was defaulted to [] and produced a confident deny from a shape never understood — absent and null are now defaulted explicitly, everything else is "unknown". And the title sentence claimed "every executable in this repo" while the guard covers four directories; both it and the docs/README entry now say what is actually enforced. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 7 +++- docs/README.md | 6 ++- .../process/check-and-use-pins-a-version.md | 22 ++++++++++ docs/remote-state-inventory.md | 30 +++++++++----- .../test_merge_consent_required_check.py | 26 ++++++++++-- scripts/tests/test_remote_state_inventory.py | 41 +++++++++++++++---- 6 files changed, 107 insertions(+), 25 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 73f6d46ca..80ced0509 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -380,8 +380,13 @@ else # `null` and `[]` are legitimate (an unprotected-in-practice branch) and answer "no", not # "unknown": absent IS the finding here, not a read failure. The word is then matched # exhaustively, because "" is not a third synonym for "no". + # `// []` defaults on FALSE as well as on null, because jq's alternative operator fires for both. + # So `"status_check_contexts": false` — a malformed shape — became `[]` and answered "no", i.e. a + # confident DENY derived from a payload that was never understood. Absent and null are defaulted + # explicitly; every other non-array is "unknown". guarded=$(printf '%s' "$bp" \ - | jq -r 'def ctxs: (.status_check_contexts // []); + | jq -r 'def ctxs: if (has("status_check_contexts") | not) or .status_check_contexts == null + then [] else .status_check_contexts end; if (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown" elif (.enable_status_check == true) and any(ctxs[]; . == "review-verdict/h10") then "yes" else "no" end' 2>/dev/null || true) diff --git a/docs/README.md b/docs/README.md index 8870bb88a..946158df5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -113,8 +113,10 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K and an audit of which configured hooks/MCP servers/LSPs are actually invoked. Read it before proposing a new guard or CI check — §4 is the detector menu, and it argues against enumerating cases one incident at a time. -- **`docs/remote-state-inventory.md`** — every executable that reads live remote state and acts on - that read, classified `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window and what bounds it. +- **`docs/remote-state-inventory.md`** — every executable **in `scripts/`, `.claude/hooks/`, + `.husky/` and `.gitea/workflows/`** that reads live remote state and acts on that read, classified + `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window and what bounds it. Code outside those + four directories is out of scope and the doc says so. The population is derived from the filesystem and compared for set equality by `scripts/tests/test_remote_state_inventory.py`, so a new script that talks to a remote service cannot ship unclassified. Read it with `process.check-and-use-pins-a-version`; it is that record's diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md index 314ad6561..b3e88bc41 100644 --- a/docs/decisions/records/process/check-and-use-pins-a-version.md +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -76,6 +76,28 @@ over — `MARKED_JOBS` in `test_ci_dropped_step_guard.py` is a hand-written mirr required contexts, annotated with a date. A dated comment is a claim about the past. Two independent guards now depend on that configuration; one of them checks it. +**The deliverable's own population was wrong twice, and that is the most transferable part of this +record.** The inventory is the detector, so its population *is* the guard. Round one derived it by +filtering the scope on an outbound-network token list — which omitted `git fetch`, this repo's +commonest remote read, so a hook that fetches `origin/main` and derives a push decision was +invisible. Round two dropped the filter but used non-recursive `glob`, so four nested files stayed +out, one of them calling a live ErsatzTV API and acting on the reply. Both rounds shipped with an +argument for why the traversal was sufficient, and both arguments were wrong in the same way: a +completeness claim resting on a traversal nobody had compared against the filesystem. The +generalisation is not "use rglob"; it is that **the scope may be hand-written and reviewable, but +anything narrowing the population must be executed and its output eyeballed against `ls`**, because +the members it drops are invisible by construction — which is `testing.guard-derives-population-from-source` +turned on the very artifact meant to enforce it. + +**Grade down before you argue.** Cold review demoted six rows across two rounds — the scheduled-merge +path, both merge-consent head/base reads, the file enumerator and every registry-tag row — and in +each case the row asserted a property the code did not have while the code beneath it was fine. Four +`PINNED` rows survive out of 68. That ratio is the honest finding about this class in a tooling +codebase: almost nothing that talks to a remote service is genuinely pinned, most of it is bounded by +an argument, and the argument is what has to be written down. A row that overstates is worse than a +row that admits a gap, because this repo's own record is that a guard described as sound stops being +re-examined. + **What this record does not claim.** The inventory grades *files*, so it cannot see an existing file growing a second unpinned read — the sites-in-code limit tracked in #777. And nothing verifies that a row marked `PINNED` is telling the truth. Both residues are stated in diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 0b5c8165f..854ae127c 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -1,7 +1,12 @@ # Remote-state inventory (ersatztv#778) -Every executable in this repo that reads live remote state and later acts on that read, and whether -the read is bound to something that cannot change underneath it. +Every executable **in the four scoped directories** (see limit 3) that reads live remote state and +later acts on that read, and whether the read is bound to something that cannot change underneath it. + +The scope qualifier is load-bearing, not throat-clearing: this file twice claimed to cover "every +executable in this repo" while its own derivation missed real remote readers, so the title now says +what the guard actually enforces. C#/TypeScript guards, `web/`, and anything outside those four +directories are **not** covered — stated in limit 2. `scripts/tests/test_remote_state_inventory.py` derives the population from the filesystem and asserts **set equality** against the `Site` column, so a new script that talks to a remote service @@ -39,9 +44,9 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| -| `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `PINNED` | Every comparison uses the **full** 40-char `.head.sha`; `${sha:0:7}` appears only in human-readable strings. On the immediate-merge path there is no async window — the merge happens in the tool call this hook is gating. | -| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check (404/absent → deny, unreadable → ask) instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted because the residual is closed one layer down rather than here: the required check is enforced server-side per-sha at merge time (#622), so a commit pushed after scheduling cannot inherit the verdict regardless of what this hook concluded. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | -| `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `PINNED` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632). Deliberately compares the base **ref**, not `base.sha` — the tip moves on every unrelated merge, and comparing it would deadlock every open PR. | +| `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `UNSAFE-KNOWN` | Every comparison uses the **full** 40-char `.head.sha` (`${sha:0:7}` appears only in human-readable strings), which is the right identifier — but the hook returns `allow` and the merge is executed afterwards by a separate call, so the window is small rather than absent. "No async window" was the second overclaim cold review removed from this file. The merge API accepts an optional `head_commit_id`, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. | +| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check (404/absent → deny, unreadable → ask) instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | +| `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `UNSAFE-KNOWN` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632), and it deliberately compares the base **ref** rather than `base.sha` — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. | | `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. | | `.claude/hooks/prepush-donewhen.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. | | `.claude/hooks/pretooluse-nav-guard.sh` | `N/A` | Reads only the proposed tool call's own parameters and decides synchronously; the `curl` mentions in the file are prose, not executed lines. | @@ -103,6 +108,10 @@ classifications differ; otherwise the strictest applies and the Note names the e | `scripts/ci-step-ran.sh` | `N/A` | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. | | `scripts/set-provider.sh` | `N/A` | Reads no live remote state — sets local `dotnet user-secrets` values. | | `scripts/__init__.py` | `N/A` | Empty package marker — executes nothing. | +| `scripts/scripted-schedules/entrypoint.py` — `ScriptedScheduleApi.get_context(build_id)`, then `define_content` / `reset_playout` / `build_playout` against the same live server | `UNSAFE-KNOWN` | A genuine read-then-act over live ErsatzTV state, and the row cold review found missing when the population was still non-recursive. The context is fetched, handed to user-supplied script functions that mutate the playout, and re-fetched after a reset with nothing pinning either read — a concurrent build or edit between them is invisible. Accepted because it runs inside a single scripted-schedule build the server itself serialises per playout, and because the API exposes no version or ETag on the context to compare against; the honest bound is that the blast radius is one playout's content, reversible by rebuilding. | +| `scripts/macOS/bundle.sh` | `N/A` | Reads no live remote state — moves files and creates symlinks in a local app bundle. | +| `scripts/macOS/sign.sh` | `N/A` | `codesign --timestamp` contacts Apple's timestamp server, but resolution and use are one step with no earlier check whose answer is later trusted — the same boundary as limit 1. | +| `scripts/macOS/sign-dmg.sh` | `N/A` | Same shape as `sign.sh` — a timestamped `codesign` over a local DMG, no check-then-act over remote state. | ### Workflows @@ -110,13 +119,14 @@ classifications differ; otherwise the strictest applies and the Note names the e |---|---|---| | `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed twice rather than claimed closed: a monotonic `change_target_branch` **event-count** fence refuses to write if the count moved (`ci.verdict-write-retarget-fence`; the count is used because the branch *name* is ABA-vulnerable), and a high-water-mark re-read repairs a `success` posted over a human verdict back to `pending`. The file states the residual window explicitly rather than asserting safety. | | `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. | -| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `PINNED` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base; inherits that row's pins. | +| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — this row said "inherits that row's pins" while the row it points at was being graded down, which is exactly the stale-cross-reference a multi-round edit produces. Accepted on better terms than the enumerator alone, though: this is the one caller that also runs the monotonic `change_target_branch` event-count fence (`ci.verdict-write-retarget-fence`), which is precisely the control that catches the ABA the enumerator cannot see. | | `.gitea/workflows/docker-build.yml` — CI toolchain image | `UNSAFE-KNOWN` | Graded down by cold review, correctly: this file's own definition of `PINNED` names an image **digest**, and `ersatztv-ci:` is a mutable **tag**. A registry tag can be repointed after `ci-image-pin` verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume `:latest`, and `pr-checks.yml`'s `ci-image-pin` fails the build if the tag drifts from the last commit touching `docker/ci/`. Consuming `image@sha256:…` is the real fix and is the natural companion to **#772**, which already covers the availability half of this tag's weakness. | -| `.gitea/workflows/docker-build.yml` — release smoke pull | `PINNED` | The one registry row that genuinely qualifies, and for a reason the tag itself does not supply: it pulls `${IMAGE}:${SMOKE_SHORT_SHA}` — the tag *this same job* pushed moments earlier, inside one `concurrency` group, so no other run can be repointing it in the window. | -| `.gitea/workflows/docker-build.yml` — `api-docs` / `format` base fetch | `N/A` | Fetches the live base tip to diff generated artifacts. Advisory, non-required contexts; re-fetched fresh on every trigger with no state carried across runs. | -| `.gitea/workflows/ci-image.yml` — verify the pushed image | `PINNED` | Pulls back the `:` tag it pushed in the immediately preceding step of the same `concurrency`-serialized run — same argument as the release smoke pull, and the same caveat that the tag is immutable by convention, not by the registry. | +| `.gitea/workflows/docker-build.yml` — release smoke pull | `UNSAFE-KNOWN` | Pulls `${IMAGE}:${SMOKE_SHORT_SHA}`, the tag this same job pushed moments earlier. The first draft called that `PINNED` on the strength of the job's `concurrency` group; cold review showed the group is **per-ref**, so a branch build and a tag build of the same commit sit in DIFFERENT groups and can publish the same `:` — the smoke step can therefore pull the other run's image. Accepted rather than fixed here because the fix is the same one-line change as the row above (pull `image@sha256:…`, propagated from the push step) and belongs with it; until then no registry row in this file claims to be pinned. | +| `.gitea/workflows/docker-build.yml` — `api-docs` / `format` base fetch | `UNSAFE-KNOWN` | Fetches the live base tip to diff generated artifacts, with nothing pinning it. Graded up from `N/A` by cold review, which was right that "advisory" was too quick: these are not branch-protection-required contexts, but the merge-consent hook reads Gitea's **combined** status, and a combined state that is not `success` blocks the auto-grant — so a wrong answer here does participate in merge consent. Accepted because the failure direction is benign: a base that advanced mid-job makes a generated artifact look stale and FAILS the job, costing a re-run, rather than passing something it should not. | +| `.gitea/workflows/ci-image.yml` — verify the pushed image | `UNSAFE-KNOWN` | Pulls back the `:` tag it pushed in the immediately preceding step. Same demotion and same reason as the two rows above: a registry tag is not a digest, and anything able to write to the registry can repoint it between the push and the verify, which would make the verification confirm an image other than the one built. Accepted on the same terms — the exposure requires registry write access, i.e. an actor already inside the trust boundary — and the same fix applies. | | `.gitea/workflows/pr-checks.yml` — `prove-fix` | `PINNED` | `base`/`head` are full shas from the event payload, immune to later PR mutation. | -| `.gitea/workflows/pr-checks.yml` — `ci-image-pin`, `docs-reminder`, `decisions-guard` | `N/A` | Local-tree comparisons or advisory base fetches; none authorize a merge or a write. | +| `.gitea/workflows/pr-checks.yml` — `ci-image-pin` | `N/A` | A purely local comparison — `git log` over the checked-out tree against a literal in `docker-build.yml`. It never queries the registry, which is exactly why it cannot bound the retagging the image rows above describe. | +| `.gitea/workflows/pr-checks.yml` — `docs-reminder` / `decisions-guard` base fetch | `UNSAFE-KNOWN` | Both fetch the live base tip and diff against it, unpinned. `docs-reminder` is explicitly non-blocking; `decisions-guard` can fail its job and therefore reaches the combined status the merge hook reads. Accepted on the same grounds as the `api-docs`/`format` row: a base that moved mid-job produces a spurious FAILURE and a re-run, never a spurious pass, and both re-fetch fresh on every trigger with no state carried between runs. | | `.gitea/workflows/renovate.yml` — `renovate/renovate:43` | `N/A` | Out of this class, in scope for a different one — see the limits below. | | `.gitea/workflows/dependency-scan.yml` — NuGet advisory query | `N/A` | `dotnet list package --vulnerable` queries a live advisory database and fails the job on the report marker in the same step, so there is no check-then-act split. Worth one line anyway: a green here means "no advisories **as of this run**", which is a dated claim about mutable remote data rather than a property of the tree — which is why the scan runs on a weekly schedule instead of only on PRs. | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 53f30c373..11ce19740 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -86,6 +86,10 @@ if "/branch_protections/" in url: # string is substring search, so the naive membership test answers "yes" here. respond('{"enable_status_check": true,' ' "status_check_contexts": "prefix-review-verdict/h10-suffix"}') + if mode == "FALSE-CONTEXTS": + # jq's `//` alternative fires on FALSE as well as null, so `// []` silently turned this + # malformed shape into an empty list and produced a confident deny. + respond('{"enable_status_check": true, "status_check_contexts": false}') if mode == "NON-STRING-MEMBER": respond('{"enable_status_check": true, "status_check_contexts": [7, "review-verdict/h10"]}') if mode == "EMPTY-CONTEXTS": @@ -252,6 +256,15 @@ def test_an_empty_or_null_contexts_list_DENIES_rather_than_asking(hook, shape): f"{shape} was treated as unreadable rather than as a confirmed absent required check") +def test_a_FALSE_contexts_value_asks_rather_than_denying(hook): + """jq's `//` alternative fires on `false`, not only on null, so `// []` mapped this malformed + payload to an empty list and answered "no" — a confident deny derived from a shape that was + never understood. Absent and null are defaulted explicitly; everything else is unknown.""" + hook.set_branch_protection("FALSE-CONTEXTS") + reason = hook.reason() + assert "ask" in reason, "a false contexts value was defaulted to [] and produced a deny" + + def test_a_non_string_MEMBER_inside_the_array_asks(hook): """`[7, "review-verdict/h10"]` contains the context, but the payload is not the shape this guard knows how to reason about. Answering "yes" would mean trusting a structure we cannot @@ -323,10 +336,15 @@ def test_an_UNREADABLE_branch_protection_asks_rather_than_denying_or_passing(hoo def test_an_IMMEDIATE_merge_is_not_subjected_to_this_check(hook): - """Scope, deliberately narrow. Without `merge_when_checks_succeed` there is no async window: - the merge happens now, against the head just verified. The required check is what protects the - SCHEDULED path specifically, so denying an immediate merge over branch-protection config would - block a safe operation and invite the guard being switched off wholesale.""" + """Scope, deliberately narrow — and stated without the overclaim cold review removed. + + An immediate merge is not window-FREE: the hook returns `allow` and a separate call performs the + merge, so a push can still land in between. What it lacks is a SCHEDULER — nothing waits on + pending checks, so the gap is one tool call rather than however long CI takes. The required + branch-protection check is what protects the scheduled path specifically, so extending this deny + to immediate merges would block a materially safer operation and invite the whole guard being + switched off. The residual on this path is carried in docs/remote-state-inventory.md. + """ hook.set_branch_protection("MISSING-CONTEXT") reason = hook.reason(scheduled=False) assert "NOT a required status check" not in reason, ( diff --git a/scripts/tests/test_remote_state_inventory.py b/scripts/tests/test_remote_state_inventory.py index 21b629087..7b5ad9e34 100644 --- a/scripts/tests/test_remote_state_inventory.py +++ b/scripts/tests/test_remote_state_inventory.py @@ -15,11 +15,20 @@ The split is deliberate and mirrors `test_guard_inventory.py`: * whether a `PINNED` row is TELLING THE TRUTH is not checked here and cannot be. That stays with review, and the inventory's prose is what review reads. -SCOPE vs POPULATION, per `testing.guard-derives-population-from-source`: the scope (which -directories, and the outbound-network token set) is a hand-written policy choice. The population -inside that scope is derived every run. The token filter deliberately OVER-includes — a file -mentioning `curl` only in a comment still needs a row — because over-inclusion costs one row and -under-inclusion costs a blind spot. +SCOPE vs POPULATION, per `testing.guard-derives-population-from-source`: the SCOPE — four +directories, two file extensions, and one excluded subdirectory — is a hand-written policy choice +and is reviewable as one. The POPULATION inside that scope is derived on every run, RECURSIVELY, +with no content predicate at all: a file that reads no remote state earns an explicit `N/A` row +rather than staying out. + +That wording is the third version, and the history is the point. The first filtered the scope by an +outbound-network token list, which omitted `git fetch` — this repo's commonest remote read — so a +hook that fetches `origin/main` and derives a push decision was invisible. The second dropped the +filter but used non-recursive `glob`, so four nested files were invisible, including one that calls +a live ErsatzTV API and acts on the reply. Both were the same error at different depths: a +completeness claim resting on a traversal nobody had compared against the filesystem. Hence the +anti-vacuity floor below, and hence the scope being stated as directories rather than as a +predicate over content. """ from __future__ import annotations @@ -55,6 +64,19 @@ SCOPE = ( (REPO_ROOT / ".gitea" / "workflows", ("*.yml",)), ) +# RECURSIVE. `Path.glob` is not, and the second cold review found the consequence: four nested files +# were outside the population, one of them `scripts/scripted-schedules/entrypoint.py`, which calls +# `get_context` against a live ErsatzTV server and then acts on the result. That is the third time +# this population has been found incomplete, each time by a different mechanism — first a content +# filter that omitted `git fetch`, then a non-recursive glob. Both were the same error: a claim of +# completeness resting on a traversal nobody had checked against the filesystem. +# +# `scripts/tests/` is the one DIRECTORY-level exclusion, and it is a scope decision rather than a +# content filter: those files run only under pytest, authorize nothing, and their only network calls +# are to stub executables the tests themselves place on PATH. Excluding a directory is reviewable in +# one line; excluding files by what they contain is what kept failing. +EXCLUDED_DIRS = (REPO_ROOT / "scripts" / "tests",) + # `| ` + backticked path, optionally followed by ` — `, then the class cell. _ROW = re.compile(r"^\|\s*`([^`]+?)`[^|]*\|\s*`([^`]+)`\s*\|", re.M) @@ -66,9 +88,12 @@ def derived_population() -> set[str]: if not directory.is_dir(): continue for pattern in globs: - for path in sorted(directory.glob(pattern)): - if path.is_file(): - found.add(path.relative_to(REPO_ROOT).as_posix()) + for path in sorted(directory.rglob(pattern)): + if not path.is_file(): + continue + if any(excluded in path.parents for excluded in EXCLUDED_DIRS): + continue + found.add(path.relative_to(REPO_ROOT).as_posix()) return found -- 2.47.3 From 25048d9ea4f202c166cc1380846e012f7b188c0d Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 16 Aug 2026 13:58:40 +0200 Subject: [PATCH 04/20] =?UTF-8?q?fix(778):=20round=203=20=E2=80=94=20the?= =?UTF-8?q?=20fence=20covers=20BASE=20aliasing=20only,=20and=20stop=20citi?= =?UTF-8?q?ng=20a=20moving=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third cold review: no Blockers, 1 High / 5 Medium / 1 Low / 1 Nit. All accepted. It independently re-derived the 59-file population and matched it against `find`, so the traversal that was wrong in rounds 1 and 2 is now verified rather than argued. HIGH — the ABA claim was too broad. `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it fences the BASE axis and nothing else. A force-push H1 -> H2 -> H1 during pagination leaves the final `.head.sha` comparison equal while the middle pages were enumerated against H2, and no counter moves. Two rows implied the fence covered that; both now state the head residual as real and unfenced, with what closing it would take. Also: the record still said the scheduled-merge residual was "closed one layer down" by the branch protection an admin may have removed — the circular sentence that was rewritten in the inventory last round and left standing in its twin. The hook header still called an immediate merge "sound". Both now describe the bounded window. `docs-reminder` was over-demoted by grouping it with `decisions-guard`: it cannot fail its job, so it authorizes nothing and is N/A, while `decisions-guard` reaches the combined status. Split, per this file's own rule that differing classifications get separate rows. Over-demotion is a defect too — it makes the column noise. The scope heading and the docs/README entry now name the `scripts/tests/` exclusion explicitly, so nobody adds a remote-reading test executable expecting a red guard that stays green, and a wrong limit cross-reference is corrected. The exclusion's justification was also factually false — it claimed the only network calls were to PATH stubs, but test_hook_fire_log.py starts a real http.server on 127.0.0.1 and drives it with real curl. The exclusion stands on confinement, not on absence, and now says so. COUNTS. "4 of 68 PINNED" was wrong (3), and rewriting it as "3 of 68" went stale in the same commit when splitting a row moved the denominator to 69 — three stale figures in three rounds, in the record warning against exactly this. The exact denominator is gone: three rows survive as PINNED out of roughly seventy, and the load-bearing claim is "almost nothing is pinned", not an integer. A hand-maintained count is a second copy of the table; guard-inventory.md gives its counts an equality check because they ARE the point, and a rationale record should not pretend to. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 8 ++++-- .../process/check-and-use-pins-a-version.md | 25 +++++++++++++------ docs/remote-state-inventory.md | 20 +++++++++------ scripts/tests/test_remote_state_inventory.py | 13 +++++++--- 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 80ced0509..f2063883b 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -8,8 +8,12 @@ # LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode: # "re-review the fix commit, not just the initial PR diff"). # -# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. That is sound for an -# immediate merge and UNSOUND for a scheduled one: with merge_when_checks_succeed, Gitea merges +# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an +# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and this comment used to say +# "sound", which is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call +# performs the merge, so a push can still land in between. The merge API accepts an optional +# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an +# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges # later, against whatever head is green then (ersatztv#622). So the sha-bound half of H10 is # enforced by the SERVER, not here — `review-verdict/h10` is a required status check on `main`, # written per-sha by scripts/post-review-verdict.sh, and a new commit cannot inherit it. This hook diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md index b3e88bc41..542136129 100644 --- a/docs/decisions/records/process/check-and-use-pins-a-version.md +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -67,8 +67,10 @@ first draft of the inventory graded that path `PINNED`; cold review pointed out comment concedes the read pins nothing, and it was right. Branch protection has no version, ETag or conditional read, so an admin can still weaken it after the hook looks. What the check buys is drift DETECTION and the removal of an unobserved assumption, which is the honest ceiling for that API. The -residual is closed one layer down, not here — the required check is enforced server-side per sha at -merge time. A row claiming otherwise would be exactly the overclaim this record warns about, and it +residual is BOUNDED, not closed, and the bound is a trust assumption worth naming: everything on that +path assumes repo-admin branch-protection config is not hostile. Saying it was "closed one layer +down" by the very protection an admin may have removed was circular — the same sentence appeared in +the inventory and was rewritten there first, which is how a stale twin survives a fix round. A row claiming otherwise would be exactly the overclaim this record warns about, and it is recorded here because the record's own deliverable made it on the first pass. This is the same gap `testing.guard-derives-population-from-source` already flagged one directory @@ -89,12 +91,21 @@ anything narrowing the population must be executed and its output eyeballed agai the members it drops are invisible by construction — which is `testing.guard-derives-population-from-source` turned on the very artifact meant to enforce it. -**Grade down before you argue.** Cold review demoted six rows across two rounds — the scheduled-merge -path, both merge-consent head/base reads, the file enumerator and every registry-tag row — and in -each case the row asserted a property the code did not have while the code beneath it was fine. Four -`PINNED` rows survive out of 68. That ratio is the honest finding about this class in a tooling +**Grade down before you argue.** Three cold-review rounds demoted row after row — the scheduled-merge +path, both merge-consent head/base reads, the file enumerator, every registry-tag row — and in each +case the row asserted a property the code did not have while the code beneath it was fine. Only **three** rows +survive as `PINNED`, out of roughly seventy. That is the honest finding about this class in a tooling codebase: almost nothing that talks to a remote service is genuinely pinned, most of it is bounded by -an argument, and the argument is what has to be written down. A row that overstates is worse than a +an argument, and the argument is what has to be written down. + +The denominator is deliberately approximate, and that is a finding rather than laziness. Written as +an exact "N of M" it went stale **three times in three rounds** — twice because a demotion landed +after the count, once because splitting one row into two moved M inside the very commit that cited +it. A figure that changes whenever the artifact it describes is edited is a second copy of that +artifact, and this repo already knows what to do about a hand-maintained mirror: give it an equality +check or stop maintaining it. `docs/guard-inventory.md` took the first route because its counts are +the point; a rationale record takes the second, because the load-bearing claim here is "almost +nothing is pinned", not any particular integer. A row that overstates is worse than a row that admits a gap, because this repo's own record is that a guard described as sound stops being re-examined. diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 854ae127c..d45c94fbd 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -1,12 +1,15 @@ # Remote-state inventory (ersatztv#778) -Every executable **in the four scoped directories** (see limit 3) that reads live remote state and -later acts on that read, and whether the read is bound to something that cannot change underneath it. +Every executable in `scripts/` (**excluding `scripts/tests/`**), `.claude/hooks/`, `.husky/` and +`.gitea/workflows/` that reads live remote state and later acts on that read, and whether the read is +bound to something that cannot change underneath it. The scope qualifier is load-bearing, not throat-clearing: this file twice claimed to cover "every -executable in this repo" while its own derivation missed real remote readers, so the title now says -what the guard actually enforces. C#/TypeScript guards, `web/`, and anything outside those four -directories are **not** covered — stated in limit 2. +executable in this repo" while its own derivation missed real remote readers, so the heading now +states exactly what the guard enforces — including the `scripts/tests/` exclusion, so nobody adds a +remote-reading test executable expecting a red guard that will stay green. C#/TypeScript guards, +`web/`, and anything outside those directories are **not** covered. Scope is limit 3; the +files-not-call-sites limit is limit 2. `scripts/tests/test_remote_state_inventory.py` derives the population from the filesystem and asserts **set equality** against the `Site` column, so a new script that talks to a remote service @@ -75,7 +78,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| | `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on a mismatch **or on a field it cannot read**. That last clause is new: both comparisons were guarded by `[ -n "$x" ] &&`, so a well-formed 2xx body that merely omitted the field made the check a no-op and the status was posted having confirmed nothing — found by cold review on #778 and regression-tested against the real predecessor, since the redundant `-z` arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual: the **comment** is posted *before* the re-read, so a head that moves in between leaves a verdict comment with no status — the comment is not the gate, but the mismatch is confusing and is tracked in **#792**. | -| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix, graded honestly after cold review: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone: the answer is the caller-side monotonic event-count fence (`ci.verdict-write-retarget-fence`), which counts `change_target_branch` events precisely because a name can alias and a count cannot. | +| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix, graded honestly after cold review: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it catches the BASE alias and **nothing else**. A HEAD alias is not covered by anything — a force-push `H1 -> H2 -> H1` during pagination leaves the final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, and no counter moves. That residual is real, unfenced, and stated here rather than papered over; closing it needs a monotonic head-mutation fence or enumeration bound to an immutable tree, neither of which exists today. | | `scripts/select-queue.sh` — issue list, then per-issue `/dependencies` | `UNSAFE-KNOWN` | The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed `in-progress` in that gap still appears on the shortlist. Accepted: the script authorizes **no write**. The real gate is the four-way claim check in `process.parallel-session-claim`, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. | | `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. | | `scripts/issue-qualification-audit.sh` | `N/A` | Pure read-and-report; exits 0/1 with a list. Authorizes no write and no merge. | @@ -119,14 +122,15 @@ classifications differ; otherwise the strictest applies and the Note names the e |---|---|---| | `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed twice rather than claimed closed: a monotonic `change_target_branch` **event-count** fence refuses to write if the count moved (`ci.verdict-write-retarget-fence`; the count is used because the branch *name* is ABA-vulnerable), and a high-water-mark re-read repairs a `success` posted over a human verdict back to `pending`. The file states the residual window explicitly rather than asserting safety. | | `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. | -| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — this row said "inherits that row's pins" while the row it points at was being graded down, which is exactly the stale-cross-reference a multi-round edit produces. Accepted on better terms than the enumerator alone, though: this is the one caller that also runs the monotonic `change_target_branch` event-count fence (`ci.verdict-write-retarget-fence`), which is precisely the control that catches the ABA the enumerator cannot see. | +| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — this row said "inherits that row's pins" while the row it points at was being graded down, which is exactly the stale-cross-reference a multi-round edit produces. Accepted on better terms than the enumerator alone — this is the one caller that also runs the monotonic `change_target_branch` event-count fence (`ci.verdict-write-retarget-fence`) — but only for the BASE axis. The HEAD alias described in the enumerator's row (`H1 -> H2 -> H1` during pagination) is unfenced here too, and this row previously implied the fence covered it. | | `.gitea/workflows/docker-build.yml` — CI toolchain image | `UNSAFE-KNOWN` | Graded down by cold review, correctly: this file's own definition of `PINNED` names an image **digest**, and `ersatztv-ci:` is a mutable **tag**. A registry tag can be repointed after `ci-image-pin` verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume `:latest`, and `pr-checks.yml`'s `ci-image-pin` fails the build if the tag drifts from the last commit touching `docker/ci/`. Consuming `image@sha256:…` is the real fix and is the natural companion to **#772**, which already covers the availability half of this tag's weakness. | | `.gitea/workflows/docker-build.yml` — release smoke pull | `UNSAFE-KNOWN` | Pulls `${IMAGE}:${SMOKE_SHORT_SHA}`, the tag this same job pushed moments earlier. The first draft called that `PINNED` on the strength of the job's `concurrency` group; cold review showed the group is **per-ref**, so a branch build and a tag build of the same commit sit in DIFFERENT groups and can publish the same `:` — the smoke step can therefore pull the other run's image. Accepted rather than fixed here because the fix is the same one-line change as the row above (pull `image@sha256:…`, propagated from the push step) and belongs with it; until then no registry row in this file claims to be pinned. | | `.gitea/workflows/docker-build.yml` — `api-docs` / `format` base fetch | `UNSAFE-KNOWN` | Fetches the live base tip to diff generated artifacts, with nothing pinning it. Graded up from `N/A` by cold review, which was right that "advisory" was too quick: these are not branch-protection-required contexts, but the merge-consent hook reads Gitea's **combined** status, and a combined state that is not `success` blocks the auto-grant — so a wrong answer here does participate in merge consent. Accepted because the failure direction is benign: a base that advanced mid-job makes a generated artifact look stale and FAILS the job, costing a re-run, rather than passing something it should not. | | `.gitea/workflows/ci-image.yml` — verify the pushed image | `UNSAFE-KNOWN` | Pulls back the `:` tag it pushed in the immediately preceding step. Same demotion and same reason as the two rows above: a registry tag is not a digest, and anything able to write to the registry can repoint it between the push and the verify, which would make the verification confirm an image other than the one built. Accepted on the same terms — the exposure requires registry write access, i.e. an actor already inside the trust boundary — and the same fix applies. | | `.gitea/workflows/pr-checks.yml` — `prove-fix` | `PINNED` | `base`/`head` are full shas from the event payload, immune to later PR mutation. | | `.gitea/workflows/pr-checks.yml` — `ci-image-pin` | `N/A` | A purely local comparison — `git log` over the checked-out tree against a literal in `docker-build.yml`. It never queries the registry, which is exactly why it cannot bound the retagging the image rows above describe. | -| `.gitea/workflows/pr-checks.yml` — `docs-reminder` / `decisions-guard` base fetch | `UNSAFE-KNOWN` | Both fetch the live base tip and diff against it, unpinned. `docs-reminder` is explicitly non-blocking; `decisions-guard` can fail its job and therefore reaches the combined status the merge hook reads. Accepted on the same grounds as the `api-docs`/`format` row: a base that moved mid-job produces a spurious FAILURE and a re-run, never a spurious pass, and both re-fetch fresh on every trigger with no state carried between runs. | +| `.gitea/workflows/pr-checks.yml` — `docs-reminder` base fetch | `N/A` | Fetches the live base tip, but the result only selects the text of a non-blocking warning: the job cannot fail and never reaches the combined status. It reads remote state and draws no authorization from it, which is this file's second `N/A` clause. Grouping it with `decisions-guard` over-demoted it, and the two have different classifications, so per the rule above they get separate rows. | +| `.gitea/workflows/pr-checks.yml` — `decisions-guard` base fetch | `UNSAFE-KNOWN` | Fetches the live base tip and diffs against it, unpinned, and unlike `docs-reminder` this job CAN fail — so its answer reaches the combined status the merge hook reads and therefore participates in consent. Accepted on the same grounds as the `api-docs`/`format` row: a base that moved mid-job produces a spurious FAILURE and a re-run, never a spurious pass, and it re-fetches fresh on every trigger with no state carried between runs. | | `.gitea/workflows/renovate.yml` — `renovate/renovate:43` | `N/A` | Out of this class, in scope for a different one — see the limits below. | | `.gitea/workflows/dependency-scan.yml` — NuGet advisory query | `N/A` | `dotnet list package --vulnerable` queries a live advisory database and fails the job on the report marker in the same step, so there is no check-then-act split. Worth one line anyway: a green here means "no advisories **as of this run**", which is a dated claim about mutable remote data rather than a property of the tree — which is why the scan runs on a weekly schedule instead of only on PRs. | diff --git a/scripts/tests/test_remote_state_inventory.py b/scripts/tests/test_remote_state_inventory.py index 7b5ad9e34..3e0e6e4da 100644 --- a/scripts/tests/test_remote_state_inventory.py +++ b/scripts/tests/test_remote_state_inventory.py @@ -72,9 +72,14 @@ SCOPE = ( # completeness resting on a traversal nobody had checked against the filesystem. # # `scripts/tests/` is the one DIRECTORY-level exclusion, and it is a scope decision rather than a -# content filter: those files run only under pytest, authorize nothing, and their only network calls -# are to stub executables the tests themselves place on PATH. Excluding a directory is reviewable in -# one line; excluding files by what they contain is what kept failing. +# content filter: those files run only under pytest and authorize nothing. Their network activity is +# CONFINED rather than absent — the first wording here claimed the only calls were to PATH-installed +# stubs, which cold review falsified: `test_hook_fire_log.py` starts a real `http.server` on +# 127.0.0.1 and drives it with real `curl`, and several suites create real local git remotes. All of +# it is fixture state the test itself creates and tears down, so there is no live remote to race, +# which is why the exclusion stands — but the justification had to be true to be worth anything. +# Excluding a directory is reviewable in one line; excluding files by what they contain is what kept +# failing. EXCLUDED_DIRS = (REPO_ROOT / "scripts" / "tests",) # `| ` + backticked path, optionally followed by ` — `, then the class cell. @@ -120,7 +125,7 @@ def test_anti_vacuity_the_derivation_and_the_table_both_found_something(): sites = inventory_sites() assert len(population) >= 40, ( f"derived only {len(population)} in-scope files — the globs are broken, not the repo " - "(the scope holds 55 files as of 2026-08-16, and it only grows)") + "(the scope held 59 files on 2026-08-16, and it only grows)") assert len(sites) >= 40, ( f"parsed only {len(sites)} rows out of the inventory — the row regex has drifted from the " "table format") -- 2.47.3 From 8e02b9961ddf932329f401ff447414484a4c4e54 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 16 Aug 2026 14:15:24 +0200 Subject: [PATCH 05/20] =?UTF-8?q?fix(778):=20round=203=20=E2=80=94=20fix?= =?UTF-8?q?=20the=20overclaim=20I=20left=20in=20the=20code,=20and=20defer?= =?UTF-8?q?=20the=20pre-existing=20ones=20to=20#803?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth cold review: no Blockers, 2 High / 2 Medium / 2 Low. It independently re-derived the population (59 files, 59 sites, 69 rows, 3 PINNED) and verified every numeric and factual claim in the diff, including the corrected confinement rationale. THE ONE THAT STINGS. The grant reason string still said a commit pushed before Gitea merges "will clear it and block the merge" — the exact sentence the new decision record quotes as THE overclaim this issue exists to remove. I documented it in three files and left it in the code a human actually reads. It now states the guarantee and its condition: the required check was confirmed rather than assumed, and it holds while that branch protection stands. FIXED HERE (all in files this PR already touches): - enable_status_check is validated as a BOOLEAN. `"true"` is not `true`, and comparing the string to `true` produced a confident deny from a payload never understood — the tri-state collapsing to two, the same defect as the contexts shape one line down. - `.statuses` members are validated, not just the array (see the honest caveat below). - the docs-reminder N/A rationale said "the job cannot fail and never reaches the combined status", which is false — any job's status joins the combined state. The true, narrower reason is that its fetch and diff are failure-swallowed, so the remote read can only change the warning's wording. - docs/README names the scripts/tests exclusion in BOTH statements. A VACUOUS TEST, CAUGHT BY ITS OWN MUTATION PROOF. The regression case for the `.statuses` member validation stays GREEN against the predecessor: the #632 base-retarget block runs first and already validates every member it consumes, so it catches the payload before the scheduled branch is reached. The two guards overlap — duplicate guards masking each other, again — which makes that finding LATENT, not live, and my added clause defence-in-depth rather than a fix. The test now asserts the observable contract (a decision is always emitted) and says plainly that it is not a mutation proof of the newer clause. Shipping it as one would have been the exact grade inflation round 2 rejected. DEFERRED to #803, with the reason stated rather than implied: a head-ABA (force-push H1 -> H2 -> H1 during pagination) defeats pr-changed-files.sh, and three OLDER contracts still assert more than the new inventory rows do. That residual predates #778 and lives in #707's mechanism; correcting another active decision record inside a PR already at four review rounds is how a scoped change stops being reviewable. The inventory rows are accurate today and now point at #803. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 24 ++++++++-- docs/README.md | 11 ++--- docs/remote-state-inventory.md | 4 +- .../test_merge_consent_required_check.py | 45 +++++++++++++++++++ 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index f2063883b..f3744fb03 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -312,8 +312,19 @@ else # status decide this. Here the fallthrough happens to land on `vstate=""` -> deny (fail-CLOSED, # so this was never a hole), but it would have surfaced the wrong message — a "BLOCKED, no # verdict" deny instead of the "could not read the status" ask this branch exists to give. - if [ -z "${vjson//[[:space:]]/}" ] || ! printf '%s' "$vjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then - decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable or an unexpected response). Confirm the current head is reviewed before scheduling an auto-merge." + # Validate the MEMBERS, not just the array. `.statuses | type == "array"` passes for + # `{"statuses":[1]}`, and the extraction below then errors with "Cannot index number with string" + # and exits 5 — which, under `set -e`, aborts this hook with NO JSON on stdout at all. A consent + # hook that emits nothing has violated its own contract: it neither grants, denies nor asks. Same + # one-level-down swallow as the #632 base-change guard and the branch-protection shape check + # below; the validation domain must match the CONSUMPTION domain (ersatztv#778). + if [ -z "${vjson//[[:space:]]/}" ] \ + || ! printf '%s' "$vjson" \ + | jq -e '(.statuses | type == "array") + and all(.statuses[]; type == "object" + and ((.context | type) == "string") + and ((.status | type) == "string"))' >/dev/null 2>&1; then + decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable, or a response whose status rows are not the expected shape). Confirm the current head is reviewed before scheduling an auto-merge." fi vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""') case "$vstate" in @@ -388,10 +399,15 @@ else # So `"status_check_contexts": false` — a malformed shape — became `[]` and answered "no", i.e. a # confident DENY derived from a payload that was never understood. Absent and null are defaulted # explicitly; every other non-array is "unknown". + # `enable_status_check` is validated as a BOOLEAN before it is trusted, for the same reason the + # contexts list is: `"true"` (the string) is not `true`, and comparing it to `true` yields a + # confident "no" -> deny derived from a payload never understood. Every malformed shape on this + # endpoint has to reach the same "unknown" -> ask arm, or the tri-state is only two states. guarded=$(printf '%s' "$bp" \ | jq -r 'def ctxs: if (has("status_check_contexts") | not) or .status_check_contexts == null then [] else .status_check_contexts end; - if (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown" + if (.enable_status_check | type) != "boolean" then "unknown" + elif (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown" elif (.enable_status_check == true) and any(ctxs[]; . == "review-verdict/h10") then "yes" else "no" end' 2>/dev/null || true) case "$guarded" in @@ -453,7 +469,7 @@ if [ "$class" = "positive" ]; then # path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green" # there was a plain falsehood in the one message a human reads to decide whether to trust the gate. if [ "$mwcs" = "true" ]; then - decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed), and because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it and block the merge (ersatztv#622). Auto-granted." + decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed). A commit pushed before Gitea merges clears the sha-bound verdict status and is blocked by the 'review-verdict/h10' required check (ersatztv#622) — which this hook has just CONFIRMED is still required on '$base_ref' rather than assuming it. That guarantee holds while that branch protection stands; if it is weakened after this check, nothing here would see it (ersatztv#778). Auto-granted." fi decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)." fi diff --git a/docs/README.md b/docs/README.md index 946158df5..17cbd3724 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,7 @@ doc below, or that changes which sections a task signal points to.** | CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` | | Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the two rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md` and `…/guard-ships-with-mutation-proof.md` | | Adding / changing / deleting a guard file | `docs/guard-inventory.md` — every guard's row is machine-checked by `scripts/tests/test_guard_inventory.py`, so a new guard must acquire a row before the suite goes green | -| Writing code that reads live Gitea/remote state and then acts on it | `docs/decisions/records/process/check-and-use-pins-a-version.md`, then `docs/remote-state-inventory.md` — a new executable that talks to a remote service must acquire a row there before `scripts/tests/test_remote_state_inventory.py` goes green | +| Writing code that reads live Gitea/remote state and then acts on it | `docs/decisions/records/process/check-and-use-pins-a-version.md`, then `docs/remote-state-inventory.md` — a new executable under `scripts/` (**excluding `scripts/tests/`**), `.claude/hooks/`, `.husky/` or `.gitea/workflows/` must acquire a row there before `scripts/tests/test_remote_state_inventory.py` goes green | | Finding every site that references a symbol (multi-site fix/sweep) | `docs/local-lsp-tooling.md` — which surface answers, and why a delegated agent must be pointed at the `csharp-lsp` MCP tools rather than the `LSP` tool | | Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.sh` | | Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` | @@ -113,10 +113,11 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K and an audit of which configured hooks/MCP servers/LSPs are actually invoked. Read it before proposing a new guard or CI check — §4 is the detector menu, and it argues against enumerating cases one incident at a time. -- **`docs/remote-state-inventory.md`** — every executable **in `scripts/`, `.claude/hooks/`, - `.husky/` and `.gitea/workflows/`** that reads live remote state and acts on that read, classified - `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window and what bounds it. Code outside those - four directories is out of scope and the doc says so. +- **`docs/remote-state-inventory.md`** — every executable in `scripts/` (**excluding + `scripts/tests/`**), `.claude/hooks/`, `.husky/` and `.gitea/workflows/` that reads live remote + state and acts on that read, classified `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window + and what bounds it. Code outside those directories — C#/TypeScript guards, `web/`, and the test + suites themselves — is out of scope, and the doc states that rather than implying coverage. The population is derived from the filesystem and compared for set equality by `scripts/tests/test_remote_state_inventory.py`, so a new script that talks to a remote service cannot ship unclassified. Read it with `process.check-and-use-pins-a-version`; it is that record's diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index d45c94fbd..9182b6d52 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -78,7 +78,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| | `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on a mismatch **or on a field it cannot read**. That last clause is new: both comparisons were guarded by `[ -n "$x" ] &&`, so a well-formed 2xx body that merely omitted the field made the check a no-op and the status was posted having confirmed nothing — found by cold review on #778 and regression-tested against the real predecessor, since the redundant `-z` arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual: the **comment** is posted *before* the re-read, so a head that moves in between leaves a verdict comment with no status — the comment is not the gate, but the mismatch is confusing and is tracked in **#792**. | -| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix, graded honestly after cold review: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it catches the BASE alias and **nothing else**. A HEAD alias is not covered by anything — a force-push `H1 -> H2 -> H1` during pagination leaves the final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, and no counter moves. That residual is real, unfenced, and stated here rather than papered over; closing it needs a monotonic head-mutation fence or enumeration bound to an immutable tree, neither of which exists today. | +| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix, graded honestly after cold review: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it catches the BASE alias and **nothing else**. A HEAD alias is not covered by anything — a force-push `H1 -> H2 -> H1` during pagination leaves the final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, and no counter moves. That residual is real, unfenced, and stated here rather than papered over; closing it needs a monotonic head-mutation fence or enumeration bound to an immutable tree, neither of which exists today — tracked in **#803**, which also carries the three older contracts that still assert more than this row does. | | `scripts/select-queue.sh` — issue list, then per-issue `/dependencies` | `UNSAFE-KNOWN` | The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed `in-progress` in that gap still appears on the shortlist. Accepted: the script authorizes **no write**. The real gate is the four-way claim check in `process.parallel-session-claim`, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. | | `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. | | `scripts/issue-qualification-audit.sh` | `N/A` | Pure read-and-report; exits 0/1 with a list. Authorizes no write and no merge. | @@ -129,7 +129,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | `.gitea/workflows/ci-image.yml` — verify the pushed image | `UNSAFE-KNOWN` | Pulls back the `:` tag it pushed in the immediately preceding step. Same demotion and same reason as the two rows above: a registry tag is not a digest, and anything able to write to the registry can repoint it between the push and the verify, which would make the verification confirm an image other than the one built. Accepted on the same terms — the exposure requires registry write access, i.e. an actor already inside the trust boundary — and the same fix applies. | | `.gitea/workflows/pr-checks.yml` — `prove-fix` | `PINNED` | `base`/`head` are full shas from the event payload, immune to later PR mutation. | | `.gitea/workflows/pr-checks.yml` — `ci-image-pin` | `N/A` | A purely local comparison — `git log` over the checked-out tree against a literal in `docker-build.yml`. It never queries the registry, which is exactly why it cannot bound the retagging the image rows above describe. | -| `.gitea/workflows/pr-checks.yml` — `docs-reminder` base fetch | `N/A` | Fetches the live base tip, but the result only selects the text of a non-blocking warning: the job cannot fail and never reaches the combined status. It reads remote state and draws no authorization from it, which is this file's second `N/A` clause. Grouping it with `decisions-guard` over-demoted it, and the two have different classifications, so per the rule above they get separate rows. | +| `.gitea/workflows/pr-checks.yml` — `docs-reminder` base fetch | `N/A` | Fetches the live base tip, but the result only selects the text of a non-blocking warning. The rationale has to be narrower than the first draft's "the job cannot fail and never reaches the combined status", which is false — any job's status joins the combined state, and runner or checkout failure can redden it. What is true, and is what earns the `N/A`: the fetch and diff are failure-swallowed, so the remote read cannot change this job's outcome, only the warning's wording. It draws no authorization from what it reads, this file's second `N/A` clause. Grouping it with `decisions-guard` over-demoted it, and the two have different classifications, so per the rule above they get separate rows. | | `.gitea/workflows/pr-checks.yml` — `decisions-guard` base fetch | `UNSAFE-KNOWN` | Fetches the live base tip and diffs against it, unpinned, and unlike `docs-reminder` this job CAN fail — so its answer reaches the combined status the merge hook reads and therefore participates in consent. Accepted on the same grounds as the `api-docs`/`format` row: a base that moved mid-job produces a spurious FAILURE and a re-run, never a spurious pass, and it re-fetches fresh on every trigger with no state carried between runs. | | `.gitea/workflows/renovate.yml` — `renovate/renovate:43` | `N/A` | Out of this class, in scope for a different one — see the limits below. | | `.gitea/workflows/dependency-scan.yml` — NuGet advisory query | `N/A` | `dotnet list package --vulnerable` queries a live advisory database and fails the job on the report marker in the same step, so there is no check-then-act split. Worth one line anyway: a green here means "no advisories **as of this run**", which is a dated claim about mutable remote data rather than a property of the tree — which is why the scan runs on a weekly schedule instead of only on PRs. | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 11ce19740..b48802e30 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -86,6 +86,9 @@ if "/branch_protections/" in url: # string is substring search, so the naive membership test answers "yes" here. respond('{"enable_status_check": true,' ' "status_check_contexts": "prefix-review-verdict/h10-suffix"}') + if mode == "STRING-ENABLE": + respond('{"enable_status_check": "true",' + ' "status_check_contexts": ["review-verdict/h10"]}') if mode == "FALSE-CONTEXTS": # jq's `//` alternative fires on FALSE as well as null, so `// []` silently turned this # malformed shape into an empty list and produced a confident deny. @@ -121,6 +124,12 @@ if "/pulls/" in url and "/files" in url: sys.exit(0) if "/status" in url: + # A 2xx body whose `.statuses` IS an array but whose members are scalars. `.statuses | type == + # "array"` passes; indexing a number then makes jq exit 5 and, under `set -e`, kills the hook + # with no JSON at all. + if (state / "bp").read_text().strip() == "SCALAR-STATUS-ROW": + print('{"state":"success","statuses":[1]}') + sys.exit(0) print(json.dumps({"state": "success", "statuses": [ {"context": "review-verdict/h10", "status": "success", "description": "Review-verdict: MERGEABLE @ %s (base: main)" % os.environ["STUB_SHORT"]}]})) @@ -349,3 +358,39 @@ def test_an_IMMEDIATE_merge_is_not_subjected_to_this_check(hook): reason = hook.reason(scheduled=False) assert "NOT a required status check" not in reason, ( "the required-check deny fired on an immediate merge, which has no post-scheduling window") + + +def test_a_NON_BOOLEAN_enable_status_check_asks(hook): + """`"true"` is not `true`. Comparing the string to `true` yields a confident "no" -> deny from a + payload never understood, which collapses the documented tri-state into two states. Every + malformed shape on this endpoint has to reach the same ask arm.""" + hook.set_branch_protection("STRING-ENABLE") + reason = hook.reason() + assert "ask" in reason, "a string enable_status_check produced a decision instead of a question" + assert "NOT a required status check" not in reason + + +def test_a_SCALAR_status_row_asks_instead_of_killing_the_hook(hook): + """The consent hook's contract is that it always emits exactly one of grant/deny/ask, and + `{"statuses":[1]}` is the payload that can break it: it passes an `.statuses | type == "array"` + check, after which indexing a number errors and exits 5, which under `set -e` aborts the hook + with NO JSON at all. A gate that emits nothing has not failed closed; it has failed to decide. + + WHAT THIS TEST DOES *NOT* PROVE, stated because the mutation showed it. Restoring the + scheduled-branch validation to its predecessor leaves this test GREEN, because the #632 + base-retarget block runs FIRST and already validates every member it consumes — so it catches + this payload and asks before the scheduled branch is ever reached. The two guards overlap, which + is the masking shape `duplicate guards mask each other` describes, and it means the member + validation added to the scheduled branch is DEFENCE IN DEPTH against a reordering or a removal + of the earlier block, not a fix for a reachable live defect. + + So this asserts the OBSERVABLE contract — a decision is always emitted for this payload — which + is true and worth pinning whichever guard supplies it. It is deliberately not offered as a + mutation proof of the newer clause, because it is not one. + """ + hook.set_branch_protection("SCALAR-STATUS-ROW") + decision = hook.decision() + assert decision is not None, ( + "the hook emitted no decision at all for a malformed statuses payload — it neither granted, " + "denied nor asked") + assert decision["hookSpecificOutput"]["permissionDecision"] == "ask" -- 2.47.3 From f1e9cce4ae97c420cbfee98f330f70a5cacba3b0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 16 Aug 2026 15:31:49 +0200 Subject: [PATCH 06/20] =?UTF-8?q?fix(778):=20round=204=20=E2=80=94=20the?= =?UTF-8?q?=20guard=20was=20checking=20a=20stale=20base,=20which=20is=20th?= =?UTF-8?q?e=20defect=20it=20enforces=20against?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth cold review: no Blockers, no Highs. 1 Medium, 1 Low, both fixed here. It also confirmed the #803 deferral is sound and re-verified every count (59 files, 69 rows, 3 PINNED; guards 34/5/15, proofs 8/6/20). MEDIUM — and it is the one worth the round. The branch-protection lookup used `$base_ref` from the PR snapshot taken at the TOP of the hook, and everything between is round trips (the file enumeration alone can be forty pages). A PERSISTENT retarget in that gap needs no ABA and no force-push: the lookup names the OLD base, confirms `review-verdict/h10` on a branch the PR no longer targets, and grants a scheduled merge onto one that may require nothing. The guard written to enforce "checking a stale identifier is not checking" was doing exactly that. The base is now re-read and compared immediately before the lookup; a move denies and names both branches. Mutation-proven. LOW — my caveat erred in the rare direction, understating a clause instead of overstating it. The scalar-row test's docstring called the whole `.statuses` member validation defence-in-depth because the #632 block masks it. That block validates `.context` and `.description` but NOT `.status`, so an object row with a numeric status passes it and does reach the new validator — where without the clause it becomes `vstate=7` and is reported as "the verdict is '7'" rather than as an unreadable payload. The caveat is now scoped to the payload rather than the clause, and the reachable case has its own test, also mutation-proven. The inventory row now conditions the guarantee on BOTH of its preconditions — the PR still targeting that base (fixed here) and the protection still standing (cannot be closed here, and said so). refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 19 +++++ .../test_merge_consent_required_check.py | 79 +++++++++++++++++-- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index f3744fb03..738227099 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -356,6 +356,25 @@ else # present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened, # and the whole point of that issue is that the failure is silent from the merge caller's side. [ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling." + # RE-READ THE BASE FIRST — this guard had the very defect it exists to enforce (found in the fifth + # cold-review round). `$base_ref` comes from the PR snapshot taken at the top of this hook, and + # everything between then and here is round trips: the file enumeration alone can be forty pages. + # A PERSISTENT retarget in that gap needs no ABA and no force-push: the protection lookup would + # name the OLD base, confirm `review-verdict/h10` on a branch the PR no longer targets, and grant a + # scheduled merge onto one that may require nothing. Checking a stale identifier is not checking. + # So the identifier is refreshed and compared before it is used, which is this record's own + # read-compare-refuse rule applied to the read that implements it. + prjson_bp=$(gq "repos/$owner/$repo/pulls/$pr") + if [ -z "${prjson_bp//[[:space:]]/}" ] || ! printf '%s' "$prjson_bp" | jq -e 'type == "object"' >/dev/null 2>&1; then + decide ask "H6/H10 merge gate: could not re-read PR #$pr to confirm it still targets '$base_ref' before checking that branch's protection. Confirm the target branch and its 'review-verdict/h10' required check manually, or merge immediately instead of scheduling." + fi + base_now_bp=$(printf '%s' "$prjson_bp" | jq -r '.base.ref // ""' 2>/dev/null || true) + if [ -z "$base_now_bp" ]; then + decide ask "H6/H10 merge gate: re-read PR #$pr but it reported no base branch, so it is unproven that it still targets '$base_ref'. Confirm the target and its required checks before scheduling an auto-merge." + fi + if [ "$base_now_bp" != "$base_ref" ]; then + decide deny "H6/H10 merge gate: BLOCKED — PR #$pr was retargeted from '$base_ref' to '$base_now_bp' while this gate was evaluating. Every check above was formed against '$base_ref', including the review verdict, so none of them describe the merge that would now happen (ersatztv#632). Re-review against '$base_now_bp' and run: scripts/post-review-verdict.sh $pr MERGEABLE" + fi # 404 MUST be separated from every other failure, and `gq` cannot do it: it wraps `curl -sf`, # which turns any HTTP error into exit 22 and empty output, so "no branch protection exists at # all" is indistinguishable from "Gitea is down" through that helper. Those are opposite answers. diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index b48802e30..1b9444fc4 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -86,6 +86,9 @@ if "/branch_protections/" in url: # string is substring search, so the naive membership test answers "yes" here. respond('{"enable_status_check": true,' ' "status_check_contexts": "prefix-review-verdict/h10-suffix"}') + if mode == "NONSTRING-STATUS-ROW": + respond('{"enable_status_check": true,' + ' "status_check_contexts": ["review-verdict/h10"]}') if mode == "STRING-ENABLE": respond('{"enable_status_check": "true",' ' "status_check_contexts": ["review-verdict/h10"]}') @@ -127,9 +130,19 @@ if "/status" in url: # A 2xx body whose `.statuses` IS an array but whose members are scalars. `.statuses | type == # "array"` passes; indexing a number then makes jq exit 5 and, under `set -e`, kills the hook # with no JSON at all. - if (state / "bp").read_text().strip() == "SCALAR-STATUS-ROW": + _m = (state / "bp").read_text().strip() + if _m == "SCALAR-STATUS-ROW": print('{"state":"success","statuses":[1]}') sys.exit(0) + if _m == "NONSTRING-STATUS-ROW": + # Object shape, string context, valid description — passes the #632 block, which does NOT + # validate `.status` — but a NUMERIC status. This is the shape that actually reaches the + # scheduled branch's validator, i.e. the new clause's reachable contribution. + print(json.dumps({"state": "success", "statuses": [ + {"context": "review-verdict/h10", "status": 7, + "description": "Review-verdict: MERGEABLE @ %s (base: main)" + % os.environ["STUB_SHORT"]}]})) + sys.exit(0) print(json.dumps({"state": "success", "statuses": [ {"context": "review-verdict/h10", "status": "success", "description": "Review-verdict: MERGEABLE @ %s (base: main)" % os.environ["STUB_SHORT"]}]})) @@ -144,8 +157,17 @@ if "/issues/" in url: sys.exit(0) if "/pulls/" in url: + # The base is served per-GET so a PERSISTENT retarget mid-run can be modelled: the first read + # (top of the hook) sees `main`, a later one sees whatever `retarget` names. + counter = state / "pr_get_count" + n = int(counter.read_text()) if counter.exists() else 0 + counter.write_text(str(n + 1)) + base = "main" + rt = state / "retarget" + if rt.exists() and n >= 1: + base = rt.read_text().strip() print(json.dumps({"head": {"sha": os.environ["STUB_SHA"]}, - "base": {"ref": "main", "sha": "b" * 40}, + "base": {"ref": base, "sha": "b" * 40}, "body": "fixes #1"})) sys.exit(0) @@ -174,6 +196,10 @@ def hook(tmp_path): def set_branch_protection(self, mode): (state / "bp").write_text(mode) + def set_retarget(self, ref): + """Persistent retarget: every PR read after the first reports `ref`.""" + (state / "retarget").write_text(ref) + def branch_protection_was_fetched(self): return (state / "bp_called").exists() @@ -378,11 +404,16 @@ def test_a_SCALAR_status_row_asks_instead_of_killing_the_hook(hook): WHAT THIS TEST DOES *NOT* PROVE, stated because the mutation showed it. Restoring the scheduled-branch validation to its predecessor leaves this test GREEN, because the #632 - base-retarget block runs FIRST and already validates every member it consumes — so it catches - this payload and asks before the scheduled branch is ever reached. The two guards overlap, which - is the masking shape `duplicate guards mask each other` describes, and it means the member - validation added to the scheduled branch is DEFENCE IN DEPTH against a reordering or a removal - of the earlier block, not a fix for a reachable live defect. + base-retarget block runs FIRST and validates the members it consumes — so it catches THIS + payload and asks before the scheduled branch is reached. The two guards overlap, which is the + masking shape `duplicate guards mask each other` describes. + + The caveat is scoped to this payload, NOT to the clause. The earlier block validates `.context` + and `.description` but not `.status`, so an object row with a numeric status passes it and does + reach the new validator — `test_a_NON_STRING_status_reaches_the_scheduled_validator` covers that + and goes red when the clause is removed. So the clause is masked for scalar rows and load-bearing + for that one. An earlier draft called the whole clause defence-in-depth, understating it in the + opposite direction from this repo's usual error. So this asserts the OBSERVABLE contract — a decision is always emitted for this payload — which is true and worth pinning whichever guard supplies it. It is deliberately not offered as a @@ -394,3 +425,37 @@ def test_a_SCALAR_status_row_asks_instead_of_killing_the_hook(hook): "the hook emitted no decision at all for a malformed statuses payload — it neither granted, " "denied nor asked") assert decision["hookSpecificOutput"]["permissionDecision"] == "ask" + + +def test_a_PERSISTENT_retarget_after_the_first_read_denies(hook): + """The defect this guard had itself, found in the fifth cold-review round. + + `$base_ref` is captured from the PR snapshot at the top of the hook, and everything between + then and the branch-protection lookup is round trips — the file enumeration alone can be forty + pages. A retarget in that gap needs no ABA and no force-push: the lookup would name the OLD + base, confirm `review-verdict/h10` on a branch the PR no longer targets, and grant a scheduled + merge onto one that may require nothing. Checking a stale identifier is not checking, which is + the whole of `process.check-and-use-pins-a-version` — so the guard enforcing that rule had to + stop breaking it. + """ + hook.set_retarget("scratch") + reason = hook.reason() + assert "deny" in reason, ( + "the PR was retargeted mid-evaluation and the gate still granted on the original base") + assert "scratch" in reason and "main" in reason, ( + "the deny must name both branches; a reader cannot act on 'the base changed'") + + +def test_a_NON_STRING_status_reaches_the_scheduled_validator(hook): + """The reachable contribution of the scheduled-branch member validation, which the previous + caveat understated. + + The #632 block validates `.context` and `.description` but NOT `.status`, so an object row with + a numeric status passes it and arrives here. Without this clause it becomes `vstate=7` and falls + to the catch-all deny arm — fail-closed, but reported as "the verdict is '7'" rather than as a + payload that could not be read. So the clause is masked for scalar rows and load-bearing for + this one; the caveat on the scalar test is scoped accordingly. + """ + hook.set_branch_protection("NONSTRING-STATUS-ROW") + reason = hook.reason() + assert "ask" in reason, "a non-string .status produced a verdict-shaped decision" -- 2.47.3 From 3322d68948b0b13c3dedbf53d8be3480c926a850 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 17:28:46 +0200 Subject: [PATCH 07/20] docs(778): state that `$sha` is stale mid-run too, and track it in #803 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by applying round 5's own finding symmetrically. `$base_ref` was re-read before the branch-protection lookup because using a value captured at hook start is not checking. `$sha` is captured from the same snapshot and is never re-read, so every later check — CI status, H10 status, verdict comments — evaluates against the commit a mid-run push replaced. Not fixed here: the base case was inside the code this PR introduced, while the sha spans the pre-existing H10 logic, and opening that at round five of review is how a scoped change stops being reviewable. The row now names both staleness windows (within the run, and after the decision) instead of only the second, and #803 carries the fix. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- docs/remote-state-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 9182b6d52..7e23c0f0b 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -47,7 +47,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| -| `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `UNSAFE-KNOWN` | Every comparison uses the **full** 40-char `.head.sha` (`${sha:0:7}` appears only in human-readable strings), which is the right identifier — but the hook returns `allow` and the merge is executed afterwards by a separate call, so the window is small rather than absent. "No async window" was the second overclaim cold review removed from this file. The merge API accepts an optional `head_commit_id`, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. | +| `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `UNSAFE-KNOWN` | Every comparison uses the **full** 40-char `.head.sha` (`${sha:0:7}` appears only in human-readable strings), which is the right identifier — but it is captured ONCE from the PR snapshot at the top of the hook, and is stale in two distinct ways. FIRST, within the run: every later check (CI status, H10 status, verdict comments) is evaluated against that captured sha, so a push landing mid-run is checked against the commit it replaced. This is the same defect that WAS live for `$base_ref` until it was re-read before the branch-protection lookup; the sha is not re-read, and closing it symmetrically is tracked in **#803**. SECOND, after the decision: the hook returns `allow` and a separate call performs the merge. "No async window" was the second overclaim cold review removed from this file. Both are bounded the same way — a head the verdict does not cover cannot inherit the sha-bound required status, so the server refuses it — and neither is bounded by anything in this hook. The merge API accepts an optional `head_commit_id`, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. | | `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check (404/absent → deny, unreadable → ask) instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | | `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `UNSAFE-KNOWN` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632), and it deliberately compares the base **ref** rather than `base.sha` — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. | | `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. | -- 2.47.3 From 4261f76dd22a7f53c28da3b88ef914687fdec9fa Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 17:53:25 +0200 Subject: [PATCH 08/20] =?UTF-8?q?fix(778):=20derive=20the=20population=20f?= =?UTF-8?q?rom=20git,=20not=20the=20disk=20=E2=80=94=20the=20guard=20was?= =?UTF-8?q?=20red=20on=20every=20dev=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth cold review (a different reviewer, in-repo, worktree-isolated after the cross-family runs wedged twice on their sandbox). One High, one Medium, two Low, two Nit. All fixed. HIGH, and it is the third time this population has been wrong. `rglob` is recursive, so it also enumerated `.husky/_/` — 17 husky shims generated by `npm ci` via web/package.json's `prepare`, gitignored and untracked. The guard therefore derived 76 files against a 59-row table and was RED on every checkout that has run `npm ci`, while staying GREEN in CI, whose `script-tests` job checks out and pip-installs but never runs `npm ci`. A guard that fails everywhere except where it runs is the fastest possible route to "that test is always broken, ignore it" — on the artifact whose entire thesis is population correctness. Reproduced, then fixed at the source rather than with a fourth traversal patch: the population now comes from `git ls-files`. The index is authoritative, identical for CI and every checkout, and excludes untracked build output by construction instead of by an exclusion list someone must maintain. That is what this PR's own record says to do; the first three attempts each derived from whatever happened to be on disk. Three tests go red against the rglob predecessor. MEDIUM — twin-missed, in the fix from the previous round. Round 4 re-read the base before the branch-protection lookup, inside the scheduled branch only, leaving the #632 retarget DETECTION still reading the top-of-hook snapshot. The reviewer demonstrated it with this PR's own fixture: scheduled+retarget denied while immediate+retarget AUTO-GRANTED. The re-read is now hoisted above every base-dependent consumer, so one read serves both paths, and the duplicate is gone. Note for the record: the hoist is the load-bearing part — once `live_base` is fresh, #632's own comparison catches the retarget too, so the explicit deny only bites when no verdict records a base. The tests are scoped to exactly that case, because as first written they passed under mutation. LOW — a 404 from `branch_protections/` does not prove the branch is unprotected. Gitea keys that endpoint on the RULE name, so a base covered by a glob rule 404s while being fully protected, and an unencoded ref containing `/` (`release/26.4`) 404s because the path is malformed. Both produced a hard deny stating a specific, false cause — and a deny blocks outright rather than prompting. The ref is percent-encoded, and a 404 now consults the rule list before denying; an unreadable list asks. LOW/NIT — the scope prose attached the extension restriction to `scripts/` alone while the guard applied it everywhere (a `.py` hook would have joined the described scope and acquired no row); `.yaml` workflows are now in scope too. The `PINNED` definition required re-validation, which two legitimately-pinned rows do not do because their check and use are one step over an immutable event-payload sha. Row ordering restored. And once more, the recurring one: adding a scope TABLE to the doc made three prose rows parse as inventory sites — the parser reading its own documentation as data, the same defect as the UNSAFE-KNOWN check that once parsed the paragraph defining UNSAFE-KNOWN. Row parsing is now bounded to the inventory section explicitly. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 87 +++++++--- docs/guard-inventory.md | 2 +- docs/remote-state-inventory.md | 24 ++- .../test_merge_consent_required_check.py | 76 ++++++++- scripts/tests/test_remote_state_inventory.py | 159 ++++++++++++++---- 5 files changed, 282 insertions(+), 66 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 738227099..8ebf9654c 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -179,10 +179,33 @@ fi # after which a later, successful status read could still auto-grant. A transient failure would then # have produced a "merge gate: satisfied" message for a comparison that never happened. Every # unreadable input here therefore falls through to a human (`ask`), never to silence. -live_base=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true) -if [ -z "$live_base" ]; then +# RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778). +# +# `$base_ref` above comes from the PR snapshot taken at the top of this hook, and the docs-only +# enumeration between there and here is up to forty round trips. A PERSISTENT retarget in that gap +# needs no ABA and no force-push: every base-dependent decision below would be formed against a +# branch the PR no longer targets. Checking a stale identifier is not checking — which is the whole +# of `process.check-and-use-pins-a-version`, so the guard enforcing that rule must not break it. +# +# This re-read first landed inside the scheduled-auto-merge branch only, which fixed the branch- +# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot. Cold +# review demonstrated the consequence with this repo's own fixture: scheduled+retarget denied, while +# immediate+retarget auto-GRANTED. That is the twin-missed shape — a fix applied to the path where it +# was noticed — so the re-read is hoisted above every consumer rather than duplicated into each. +prjson_now=$(gq "repos/$owner/$repo/pulls/$pr") +if [ -z "${prjson_now//[[:space:]]/}" ] || ! printf '%s' "$prjson_now" | jq -e 'type == "object"' >/dev/null 2>&1; then + decide ask "H10 merge gate: could not re-read PR #$pr to confirm it still targets '$base_ref' before checking the verdict against it. Confirm the target branch, then merge." +fi +base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""' 2>/dev/null || true) +if [ -z "$base_now" ]; then decide ask "H10 merge gate: PR #$pr reports no base branch (.base.ref), so the verdict cannot be checked against the branch it was formed for (ersatztv#632). Confirm the PR still targets the branch it was reviewed against before merging." fi +if [ -n "$base_ref" ] && [ "$base_now" != "$base_ref" ]; then + decide deny "H6/H10 merge gate: BLOCKED — PR #$pr was retargeted from '$base_ref' to '$base_now' while this gate was evaluating. Every check formed against '$base_ref', including the changed-file enumeration and the review verdict, describes a merge that is no longer the one being requested (ersatztv#632). Re-review against '$base_now' and run: scripts/post-review-verdict.sh $pr MERGEABLE" +fi +# From here on both names are the freshly-confirmed base; they are equal by the check above. +base_ref=$base_now +live_base=$base_now if [ -n "$sha" ]; then # This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the # scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow @@ -356,25 +379,10 @@ else # present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened, # and the whole point of that issue is that the failure is silent from the merge caller's side. [ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling." - # RE-READ THE BASE FIRST — this guard had the very defect it exists to enforce (found in the fifth - # cold-review round). `$base_ref` comes from the PR snapshot taken at the top of this hook, and - # everything between then and here is round trips: the file enumeration alone can be forty pages. - # A PERSISTENT retarget in that gap needs no ABA and no force-push: the protection lookup would - # name the OLD base, confirm `review-verdict/h10` on a branch the PR no longer targets, and grant a - # scheduled merge onto one that may require nothing. Checking a stale identifier is not checking. - # So the identifier is refreshed and compared before it is used, which is this record's own - # read-compare-refuse rule applied to the read that implements it. - prjson_bp=$(gq "repos/$owner/$repo/pulls/$pr") - if [ -z "${prjson_bp//[[:space:]]/}" ] || ! printf '%s' "$prjson_bp" | jq -e 'type == "object"' >/dev/null 2>&1; then - decide ask "H6/H10 merge gate: could not re-read PR #$pr to confirm it still targets '$base_ref' before checking that branch's protection. Confirm the target branch and its 'review-verdict/h10' required check manually, or merge immediately instead of scheduling." - fi - base_now_bp=$(printf '%s' "$prjson_bp" | jq -r '.base.ref // ""' 2>/dev/null || true) - if [ -z "$base_now_bp" ]; then - decide ask "H6/H10 merge gate: re-read PR #$pr but it reported no base branch, so it is unproven that it still targets '$base_ref'. Confirm the target and its required checks before scheduling an auto-merge." - fi - if [ "$base_now_bp" != "$base_ref" ]; then - decide deny "H6/H10 merge gate: BLOCKED — PR #$pr was retargeted from '$base_ref' to '$base_now_bp' while this gate was evaluating. Every check above was formed against '$base_ref', including the review verdict, so none of them describe the merge that would now happen (ersatztv#632). Re-review against '$base_now_bp' and run: scripts/post-review-verdict.sh $pr MERGEABLE" - fi + # The base was re-read and confirmed unchanged above, for every path — see the hoist comment + # there. It is deliberately NOT re-read a second time here: two reads would create a window + # between them for no gain, and the hoisted check already covers the enumeration gap that made + # this necessary. # 404 MUST be separated from every other failure, and `gq` cannot do it: it wraps `curl -sf`, # which turns any HTTP error into exit 22 and empty output, so "no branch protection exists at # all" is indistinguishable from "Gitea is down" through that helper. Those are opposite answers. @@ -385,13 +393,40 @@ else # Collapsing the two, which the first version of this guard did, sends the single most likely # real-world trigger ("branch protection was removed") to a human prompt that reads like a # transient hiccup. + # The ref goes in a URL PATH SEGMENT, so it is percent-encoded. A base like `release/26.4` would + # otherwise inject a `/` and request a path that does not exist — producing a 404 that this code + # would read as "unprotected" and turn into a hard DENY whose reason names a specific, false cause. + bp_ref=$(printf '%s' "$base_ref" | jq -sRr @uri) bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge." - if [ -n "${ETV_GITEA_TOKEN:-}" ]; then - bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections/$base_ref" 2>/dev/null || true) - else - bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections/$base_ref" 2>/dev/null || true) - fi + bp_get() { # $1 = path under the repo + if [ -n "${ETV_GITEA_TOKEN:-}" ]; then + curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/$1" 2>/dev/null || true + else + curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/$1" 2>/dev/null || true + fi + } + bp_code=$(bp_get "branch_protections/$bp_ref") bp=$(cat "$bp_file" 2>/dev/null || true) + # A 404 here does NOT prove the branch is unprotected: Gitea keys this endpoint on the RULE NAME, + # so a base covered by a GLOB rule (`rel/*`) 404s on its own name while being fully protected. + # Before denying, ask the list endpoint whether any rule matches — a deny is not a prompt, it + # blocks the operation outright, and a deny whose stated cause is false is worse than an ask. + if [ "$bp_code" = "404" ]; then + bp_code=$(bp_get "branch_protections") + bp_list=$(cat "$bp_file" 2>/dev/null || true) + if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then + # Match a rule by exact name or by its glob, the same way Gitea applies them. + bp=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c 'map(select((.branch_name // .rule_name // "") as $n | $n == $b or ($b | test("^" + ($n | gsub("\\*"; ".*")) + "$")))) | first // empty' 2>/dev/null || true) + if [ -n "${bp//[[:space:]]/}" ]; then + bp_code=200 # a rule DOES cover this base; fall through and inspect it + else + bp_code=404 # the list was readable and nothing matches: truly unprotected + fi + else + bp_code=${bp_code:-000} # could not read the list either -> the ask arm below + bp="" + fi + fi rm -f "$bp_file" if [ "$bp_code" = "404" ]; then decide deny "H6/H10 merge gate: BLOCKED — '$base_ref' has NO branch protection at all (Gitea returned 404), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green." diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index aeeb4e785..02cdba063 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -125,8 +125,8 @@ all. | `scripts/tests/test_post_review_verdict.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_pr_changed_files.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_prepush_rebase_check_tag_exemption.py` | the `script-tests` job | PROOF | NONE | — | -| `scripts/tests/test_remote_state_inventory.py` | the `script-tests` job, on an executable that talks to a remote service with no row in `docs/remote-state-inventory.md` | GUARD | MUTATION | `test_remote_state_inventory.py::test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected` | | `scripts/tests/test_prove_fix.py` | the `script-tests` job | PROOF | NONE | — | +| `scripts/tests/test_remote_state_inventory.py` | the `script-tests` job, on an executable that talks to a remote service with no row in `docs/remote-state-inventory.md` | GUARD | MUTATION | `test_remote_state_inventory.py::test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected` | ## What the numbers say diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 7e23c0f0b..c3088b280 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -1,8 +1,18 @@ # Remote-state inventory (ersatztv#778) -Every executable in `scripts/` (**excluding `scripts/tests/`**), `.claude/hooks/`, `.husky/` and -`.gitea/workflows/` that reads live remote state and later acts on that read, and whether the read is -bound to something that cannot change underneath it. +Every **git-tracked** file matching one of these, that reads live remote state and later acts on +that read, and whether the read is bound to something that cannot change underneath it: + +| Directory | Files | +|---|---| +| `scripts/` (recursive, **excluding `scripts/tests/`**) | `*.sh`, `*.py` | +| `.claude/hooks/` | `*.sh` | +| `.husky/` | all tracked files | +| `.gitea/workflows/` | `*.yml`, `*.yaml` | + +The per-directory extensions are stated because the prose once attached them to `scripts/` alone +while the guard applied them everywhere, so a `.py` hook would have joined the scope the doc +described and acquired no row. The scope qualifier is load-bearing, not throat-clearing: this file twice claimed to cover "every executable in this repo" while its own derivation missed real remote readers, so the heading now @@ -30,8 +40,12 @@ artifact; the test keeps it from rotting. - **Class** — - `PINNED` — the read is bound to an immutable version identifier (a **full** commit sha, an - image digest, a monotonic event count) **and** the action re-validates against that identifier - before it commits. Binding alone is not enough; a snapshot nobody re-checks is not pinned. + image digest, a monotonic event count), and that binding still holds when the action runs. Two + shapes qualify, and the second was missing from the first wording: either the action + **re-validates** against the identifier immediately before committing (a snapshot nobody + re-checks is not pinned — binding alone is never enough), **or** the check and the use are a + single step over a value that cannot move, such as a workflow reading a full sha straight out of + its own fixed event payload. What never qualifies is a value captured early and trusted later. - `CAS` — the write itself carries a compare-and-set condition the server enforces. - `UNSAFE-KNOWN` — read-then-act with nothing pinning it, **accepted** with the reason stated in the Note. Every row here must say why the residual is tolerable, not merely that it exists. diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 1b9444fc4..97075eea0 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -61,6 +61,17 @@ def respond(body, code="200"): sys.stdout.write(code) sys.exit(0) +if url.rstrip("/").endswith("/branch_protections"): + # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name + # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. + mode = (state / "bp").read_text().strip() + if mode == "GLOB-RULE": + respond(json.dumps([{"branch_name": "m*", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "LIST-UNREADABLE": + respond('{"message":"internal error"}', "500") + respond("[]") + if "/branch_protections/" in url: # Record the call so the positive control can prove the guarded case REACHED this endpoint, # rather than passing because the hook bailed earlier for an unrelated reason. @@ -69,7 +80,7 @@ if "/branch_protections/" in url: if mode == "TRANSPORT-ERROR": # curl itself fails: no body, and `-w` prints 000. respond("", "000") - if mode == "NOT-FOUND": + if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -126,6 +137,15 @@ if "/pulls/" in url and "/files" in url: print("[]") sys.exit(0) +if "/status" in url and (state / "no_recorded_base").exists(): + # A verdict posted before ersatztv#632 carries no `(base: …)` marker, so the #632 detection + # takes its graceful-adoption path and forms no opinion. That isolates the hoisted retarget + # check as the ONLY guard that can deny. + print(json.dumps({"state": "success", "statuses": [ + {"context": "review-verdict/h10", "status": "success", + "description": "Review-verdict: MERGEABLE @ %s" % os.environ["STUB_SHORT"]}]})) + sys.exit(0) + if "/status" in url: # A 2xx body whose `.statuses` IS an array but whose members are scalars. `.statuses | type == # "array"` passes; indexing a number then makes jq exit 5 and, under `set -e`, kills the hook @@ -196,6 +216,10 @@ def hook(tmp_path): def set_branch_protection(self, mode): (state / "bp").write_text(mode) + def drop_recorded_base(self): + """Serve a pre-#632 verdict (no `(base: …)`), so only the hoisted check can deny.""" + (state / "no_recorded_base").write_text("1") + def set_retarget(self, ref): """Persistent retarget: every PR read after the first reports `ref`.""" (state / "retarget").write_text(ref) @@ -427,6 +451,23 @@ def test_a_SCALAR_status_row_asks_instead_of_killing_the_hook(hook): assert decision["hookSpecificOutput"]["permissionDecision"] == "ask" +def test_a_PERSISTENT_retarget_denies_on_the_IMMEDIATE_path_too(hook): + """The twin. The re-read first landed inside the scheduled branch only, so this exact case — + same fixture, `merge_when_checks_succeed` absent — AUTO-GRANTED while its sibling denied. + + Cold review demonstrated it side by side, and it is the shape this repo has on record as + "fix one path, then check its TWIN": the fix was applied where the defect was noticed, and the + other consumer of the same stale value kept it. The re-read is now hoisted above every + base-dependent decision rather than duplicated into the branch that happened to be under review. + """ + hook.drop_recorded_base() + hook.set_retarget("scratch") + reason = hook.reason(scheduled=False) + assert "deny" in reason, ( + "an immediate merge was auto-granted after the PR was retargeted mid-evaluation") + assert "scratch" in reason and "main" in reason + + def test_a_PERSISTENT_retarget_after_the_first_read_denies(hook): """The defect this guard had itself, found in the fifth cold-review round. @@ -438,6 +479,7 @@ def test_a_PERSISTENT_retarget_after_the_first_read_denies(hook): the whole of `process.check-and-use-pins-a-version` — so the guard enforcing that rule had to stop breaking it. """ + hook.drop_recorded_base() hook.set_retarget("scratch") reason = hook.reason() assert "deny" in reason, ( @@ -459,3 +501,35 @@ def test_a_NON_STRING_status_reaches_the_scheduled_validator(hook): hook.set_branch_protection("NONSTRING-STATUS-ROW") reason = hook.reason() assert "ask" in reason, "a non-string .status produced a verdict-shaped decision" + + +def test_a_base_covered_by_a_GLOB_rule_is_not_reported_as_unprotected(hook): + """Gitea keys the by-name branch-protection endpoint on the RULE name, so a base covered by a + glob rule (`m*`, `rel/*`) 404s on its own name while being fully protected. + + Reading that 404 as "unprotected" produced a hard DENY stating a specific, false cause — and a + deny is not a prompt, it blocks the operation outright. The list endpoint is consulted before + denying, and a matching rule is inspected like any other. + """ + hook.set_branch_protection("GLOB-RULE") + reason = hook.reason() + assert "NO branch protection" not in reason, ( + "a base protected by a glob rule was reported as having no protection at all") + assert "deny" not in reason, ( + "a glob rule that DOES require review-verdict/h10 was not honoured") + + +def test_an_unreadable_LIST_asks_rather_than_denying_on_the_404(hook): + """The 404 fallback must not convert 'could not confirm' into 'confirmed absent'. If the by-name + lookup 404s and the list cannot be read, nothing has been established either way.""" + hook.set_branch_protection("LIST-UNREADABLE") + reason = hook.reason() + assert "ask" in reason, "an unreadable rule list was treated as proof of absence" + assert "NO branch protection" not in reason + + +def test_the_base_ref_is_URL_ENCODED_in_the_protection_lookup(): + """A base like `release/26.4` would otherwise inject a path separator and request a URL that + does not exist, producing a 404 this code would read as 'unprotected'.""" + src = HOOK.read_text() + assert "@uri" in src, "the base ref must be percent-encoded before going in a URL path segment" diff --git a/scripts/tests/test_remote_state_inventory.py b/scripts/tests/test_remote_state_inventory.py index 3e0e6e4da..dca76aa00 100644 --- a/scripts/tests/test_remote_state_inventory.py +++ b/scripts/tests/test_remote_state_inventory.py @@ -33,7 +33,9 @@ predicate over content. from __future__ import annotations +import fnmatch import re +import subprocess from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] @@ -58,54 +60,99 @@ CLASSES = {"PINNED", "CAS", "UNSAFE-KNOWN", "N/A"} # safe direction" — was answered by the filter ALSO under-including. Enumerating the directories # costs more rows and has no blind spot; that is the trade the rule already made. SCOPE = ( - (REPO_ROOT / "scripts", ("*.sh", "*.py")), - (REPO_ROOT / ".claude" / "hooks", ("*.sh",)), - (REPO_ROOT / ".husky", ("*",)), - (REPO_ROOT / ".gitea" / "workflows", ("*.yml",)), + ("scripts", ("*.sh", "*.py")), + (".claude/hooks", ("*.sh",)), + (".husky", ("*",)), + (".gitea/workflows", ("*.yml", "*.yaml")), ) -# RECURSIVE. `Path.glob` is not, and the second cold review found the consequence: four nested files -# were outside the population, one of them `scripts/scripted-schedules/entrypoint.py`, which calls -# `get_context` against a live ErsatzTV server and then acts on the result. That is the third time -# this population has been found incomplete, each time by a different mechanism — first a content -# filter that omitted `git fetch`, then a non-recursive glob. Both were the same error: a claim of -# completeness resting on a traversal nobody had checked against the filesystem. +# THE POPULATION IS DERIVED FROM `git ls-files`, NOT FROM THE FILESYSTEM. # -# `scripts/tests/` is the one DIRECTORY-level exclusion, and it is a scope decision rather than a -# content filter: those files run only under pytest and authorize nothing. Their network activity is -# CONFINED rather than absent — the first wording here claimed the only calls were to PATH-installed -# stubs, which cold review falsified: `test_hook_fire_log.py` starts a real `http.server` on -# 127.0.0.1 and drives it with real `curl`, and several suites create real local git remotes. All of -# it is fixture state the test itself creates and tears down, so there is no live remote to race, -# which is why the exclusion stands — but the justification had to be true to be worth anything. -# Excluding a directory is reviewable in one line; excluding files by what they contain is what kept -# failing. -EXCLUDED_DIRS = (REPO_ROOT / "scripts" / "tests",) +# This is the third time this population has been found incomplete or wrong, each time by a +# different mechanism, and the third fix is the one that stops patching the traversal: +# +# 1. a content filter on an outbound-network token list, which omitted `git fetch` — this repo's +# commonest remote read — so a hook that fetches `origin/main` and derives a PUSH DECISION was +# structurally invisible; +# 2. a non-recursive `Path.glob`, which missed four nested files including one that calls a live +# ErsatzTV API and acts on the reply; +# 3. `Path.rglob`, which is recursive and therefore ALSO enumerated `.husky/_/` — 17 husky shims +# generated by `npm ci` via web/package.json's `prepare` script, gitignored (`.husky/_/.gitignore` +# is `*`) and untracked. That made this guard RED on every developer checkout while staying green +# in CI, whose `script-tests` job checks out and pip-installs but never runs `npm ci`. A guard +# that fails everywhere except where it runs is worse than no guard: it trains its readers to +# ignore it, and it would have done so on the artifact whose entire thesis is population +# correctness. +# +# The lesson each time was the same one this repo already wrote down — derive the population from an +# AUTHORITATIVE source — and the filesystem is not one. It reports build output, editor droppings and +# anything else that happens to be on disk, and it varies per machine. The repo's index is +# authoritative, versioned, identical for every checkout and for CI, and it excludes untracked +# generated files by construction rather than by an exclusion list that must be maintained. +# +# `scripts/tests/` is still excluded explicitly, because those files ARE tracked. That exclusion is a +# scope decision, reviewable in one line: they run only under pytest and authorize nothing. Their +# network activity is CONFINED rather than absent — `test_hook_fire_log.py` starts a real +# `http.server` on 127.0.0.1 and drives it with real `curl`, and several suites create real local git +# remotes — but all of it is fixture state the test creates and tears down, so there is no live remote +# to race. +EXCLUDED_DIRS = ("scripts/tests",) # `| ` + backticked path, optionally followed by ` — `, then the class cell. _ROW = re.compile(r"^\|\s*`([^`]+?)`[^|]*\|\s*`([^`]+)`\s*\|", re.M) +def _tracked_files() -> list[str]: + """Every file git tracks, as repo-relative posix paths. + + Fails LOUDLY rather than returning nothing: an empty population would make every completeness + assertion below pass vacuously, which is the exact failure this guard exists to prevent. + """ + proc = subprocess.run( + ["git", "-C", str(REPO_ROOT), "ls-files", "-z"], + capture_output=True, check=True, + ) + return [p for p in proc.stdout.decode().split("\0") if p] + + def derived_population() -> set[str]: - """Every in-scope file, as a repo-relative posix path. No content predicate of any kind.""" + """Every in-scope tracked file, as a repo-relative posix path. + + No content predicate of any kind: a file that reads no remote state earns an explicit `N/A` row + rather than silently staying out. + """ found: set[str] = set() - for directory, globs in SCOPE: - if not directory.is_dir(): + for path in _tracked_files(): + if any(path == d or path.startswith(d + "/") for d in EXCLUDED_DIRS): continue - for pattern in globs: - for path in sorted(directory.rglob(pattern)): - if not path.is_file(): - continue - if any(excluded in path.parents for excluded in EXCLUDED_DIRS): - continue - found.add(path.relative_to(REPO_ROOT).as_posix()) + for directory, globs in SCOPE: + if not (path == directory or path.startswith(directory + "/")): + continue + name = path.rsplit("/", 1)[-1] + if any(fnmatch.fnmatch(name, pattern) for pattern in globs): + found.add(path) + break return found +def _inventory_section(text: str) -> str: + """Only the classification tables, never the surrounding prose. + + This is the second time this parser has read the document's own explanation as data: the + UNSAFE-KNOWN justification check once parsed the "Columns" paragraph that DEFINES + `UNSAFE-KNOWN`, and adding a scope TABLE to the heading made three more prose rows parse as + sites. A guard that treats its own documentation as input is the failure this whole change is + about, so the boundary is explicit rather than left to a cleverer regex. + """ + start = text.index("## The inventory") + end = text.index("## Limits", start) + return text[start:end] + + def inventory_rows(text: str | None = None) -> list[tuple[str, str]]: if text is None: text = INVENTORY.read_text(encoding="utf-8") - return _ROW.findall(text) + return _ROW.findall(_inventory_section(text)) def inventory_sites(text: str | None = None) -> set[str]: @@ -172,7 +219,11 @@ def test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected(): assert population - inventory_sites(dropped), ( "a row was removed from the inventory and the comparison still reported complete coverage") - phantom = text + "\n| `scripts/does-not-exist.sh` — invented | `PINNED` | n/a |\n" + # Inserted INSIDE the inventory section, not appended to the file: rows are parsed only between + # "## The inventory" and "## Limits", so appending at the end would test nothing. + phantom = text.replace( + "## Limits", + "| `scripts/does-not-exist.sh` — invented | `PINNED` | n/a |\n\n## Limits", 1) assert inventory_sites(phantom) - population == {"scripts/does-not-exist.sh"}, ( "a row naming a file that does not exist was not reported as phantom") @@ -187,7 +238,7 @@ def test_every_class_cell_comes_from_the_closed_vocabulary(): def test_every_unsafe_row_states_why_the_residual_is_accepted(): """`UNSAFE-KNOWN` means 'accepted with a reason', not 'noticed'. A row that records the window without the argument for tolerating it is how a deferral becomes permanent by default.""" - text = INVENTORY.read_text(encoding="utf-8") + text = _inventory_section(INVENTORY.read_text(encoding="utf-8")) thin = [] for line in text.splitlines(): # TABLE ROWS ONLY. The first version matched any line containing the token, so the prose in @@ -203,3 +254,45 @@ def test_every_unsafe_row_states_why_the_residual_is_accepted(): thin.append(site[:60]) assert not thin, ( f"UNSAFE-KNOWN row(s) with no stated justification: {thin}") + + +def test_the_population_never_includes_a_file_git_does_not_track(monkeypatch): + """The regression for the third population defect, and the reason the source is the index. + + `Path.rglob` enumerated `.husky/_/` — 17 husky shims generated by `npm ci`, gitignored and + untracked — so this guard was RED on every developer checkout and GREEN in CI, which never runs + `npm ci`. A guard that fails everywhere except where it runs trains its readers to ignore it. + + Asserted through the mechanism rather than against the current disk, so it holds on a machine + that has never installed husky: the tracked list is narrowed, and anything outside it must + disappear from the population even though it is still sitting on disk and still matches the + scope globs. + """ + real = derived_population() + assert real, "empty population — the derivation is broken, not the repo" + + victim = sorted(real)[0] + tracked = [p for p in _tracked_files() if p != victim] + monkeypatch.setattr( + "scripts.tests.test_remote_state_inventory._tracked_files", + lambda: tracked, + raising=False, + ) + # Patch the module object this test is running inside, whatever name it was imported under. + import sys + mod = sys.modules[__name__] + monkeypatch.setattr(mod, "_tracked_files", lambda: tracked) + + assert (REPO_ROOT / victim).is_file(), ( + f"{victim} must still exist on disk for this proof to mean anything") + assert victim not in derived_population(), ( + f"{victim} is on disk and matches the scope, but git no longer tracks it — it must not enter " + "the population, or untracked build output can redden this guard again") + + +def test_every_derived_member_is_tracked(): + """The same property stated as an invariant over the real tree, so a future refactor that goes + back to walking the filesystem fails here rather than on someone's laptop.""" + tracked = set(_tracked_files()) + stray = sorted(p for p in derived_population() if p not in tracked) + assert not stray, f"population contains untracked path(s): {stray}" -- 2.47.3 From e5996b7c1effcfb5172dc82db0b0ad63abd6fe28 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 17:54:00 +0200 Subject: [PATCH 09/20] =?UTF-8?q?docs(778):=20the=20population=20was=20wro?= =?UTF-8?q?ng=20three=20times=20=E2=80=94=20record=20what=20finally=20fixe?= =?UTF-8?q?d=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record said "wrong twice" and drew the lesson "execute the traversal and eyeball it". The third instance (rglob picking up untracked `.husky/_` shims) shows that was still the wrong generalisation: every round had executed its traversal, and every round had an argument for why it was sufficient. What held was changing the SOURCE, not the walk — `git ls-files` instead of the filesystem. The index is authoritative, identical for CI and every checkout, and excludes untracked build output by construction. So the lesson is the one `testing.guard-derives-population-from-source` already states, one level up: ask what the authoritative list of these things IS, and if the answer is "whatever the walk finds", the guard is not finished however carefully the walk is written. Decisions-Edit: yes refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .../process/check-and-use-pins-a-version.md | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md index 542136129..dcc7d9afe 100644 --- a/docs/decisions/records/process/check-and-use-pins-a-version.md +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -78,18 +78,27 @@ over — `MARKED_JOBS` in `test_ci_dropped_step_guard.py` is a hand-written mirr required contexts, annotated with a date. A dated comment is a claim about the past. Two independent guards now depend on that configuration; one of them checks it. -**The deliverable's own population was wrong twice, and that is the most transferable part of this -record.** The inventory is the detector, so its population *is* the guard. Round one derived it by -filtering the scope on an outbound-network token list — which omitted `git fetch`, this repo's -commonest remote read, so a hook that fetches `origin/main` and derives a push decision was -invisible. Round two dropped the filter but used non-recursive `glob`, so four nested files stayed -out, one of them calling a live ErsatzTV API and acting on the reply. Both rounds shipped with an -argument for why the traversal was sufficient, and both arguments were wrong in the same way: a -completeness claim resting on a traversal nobody had compared against the filesystem. The -generalisation is not "use rglob"; it is that **the scope may be hand-written and reviewable, but -anything narrowing the population must be executed and its output eyeballed against `ls`**, because -the members it drops are invisible by construction — which is `testing.guard-derives-population-from-source` -turned on the very artifact meant to enforce it. +**The deliverable's own population was wrong three times, and that is the most transferable part of +this record.** The inventory is the detector, so its population *is* the guard. Round one filtered +the scope on an outbound-network token list, which omitted `git fetch` — this repo's commonest remote +read — so a hook that fetches `origin/main` and derives a push decision was invisible. Round two +dropped the filter but used non-recursive `glob`, so four nested files stayed out, one of them +calling a live ErsatzTV API and acting on the reply. Round three used `rglob`, which is recursive +and therefore also enumerated `.husky/_/` — untracked, gitignored shims that `npm ci` generates — +leaving the guard **red on every developer checkout and green in CI**, which never runs `npm ci`. A +guard that fails everywhere except where it runs teaches its readers to ignore it, which is worse +than no guard at all. + +Every round shipped with an argument for why the traversal was sufficient, and every argument was +wrong the same way. The fix that finally held was not a better traversal: it was **deriving the +population from `git ls-files`**. The filesystem is not an authoritative source — it reports build +output, editor droppings and whatever else is on disk, and it differs per machine. The index is +authoritative, versioned, identical for CI and every checkout, and excludes untracked generated files +by construction rather than by an exclusion list somebody has to maintain. So the generalisation is +the one `testing.guard-derives-population-from-source` already states, applied one level up: when a +guard enumerates a population, ask **what is the authoritative list of these things** — and if the +answer is "whatever the walk finds", the guard is not finished, however carefully the walk is +written. **Grade down before you argue.** Three cold-review rounds demoted row after row — the scheduled-merge path, both merge-consent head/base reads, the file enumerator, every registry-tag row — and in each -- 2.47.3 From 01de6cbd8b8e81dbab201e637c8d4944b79aa66d Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 18:01:42 +0200 Subject: [PATCH 10/20] =?UTF-8?q?fix(778):=20`*`=20is=20the=20only=20wildc?= =?UTF-8?q?ard=20=E2=80=94=20a=20rule=20name's=20regex=20metacharacters=20?= =?UTF-8?q?were=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by attacking my own glob fallback from the previous commit before the reviewer got to it, which is the round where this PR's defects have landed every time. The 404 fallback matched a branch-protection rule by substituting `*` into a raw regex, leaving every other metacharacter active. Verified directly: a rule named `main.x` matched the base `mainax`, and `a+b` matched `aab`. The direction is the one that matters — a spurious match to some OTHER rule that happens to require `review-verdict/h10` reports this base as protected when nothing governs it, so a consent gate answers yes on evidence about a different branch. Each literal segment is now escaped before the pattern is assembled, so the wildcard survives and nothing else does. Verified across 11 rule/base pairs: metacharacters are literal, `*` still spans, exact and non-matches unaffected. The regression test needed two goes to stop being vacuous, both times for the same reason the rest of this PR keeps hitting: the stub never 404'd for the new mode, so the run denied earlier via the by-name lookup and never reached the fallback at all. It now goes red against the unescaped predecessor. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 9 ++++++- .../test_merge_consent_required_check.py | 25 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 8ebf9654c..28d4ab4e4 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -416,7 +416,14 @@ else bp_list=$(cat "$bp_file" 2>/dev/null || true) if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then # Match a rule by exact name or by its glob, the same way Gitea applies them. - bp=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c 'map(select((.branch_name // .rule_name // "") as $n | $n == $b or ($b | test("^" + ($n | gsub("\\*"; ".*")) + "$")))) | first // empty' 2>/dev/null || true) + # + # `*` is the ONLY wildcard: every other regex metacharacter in a rule name is escaped before + # the pattern is built. Substituting `*` into a raw regex made a rule named `main.x` match the + # base `mainax`, and `a+b` match `aab` — a spurious match to some OTHER rule that happens to + # require `review-verdict/h10` would report this base as protected when nothing governs it, + # which is a false-open in a consent gate. Escaping is done per literal segment, so the + # wildcard survives and nothing else does. + bp=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c 'map(select((.branch_name // .rule_name // "") as $n | (($n | split("*") | map(gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\\\" + .c)) | join(".*")) as $re | ($n == $b) or ($b | test("^" + $re + "$"))))) | first // empty' 2>/dev/null || true) if [ -n "${bp//[[:space:]]/}" ]; then bp_code=200 # a rule DOES cover this base; fall through and inspect it else diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 97075eea0..f002d6580 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -65,6 +65,11 @@ if url.rstrip("/").endswith("/branch_protections"): # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. mode = (state / "bp").read_text().strip() + if mode == "REGEX-META-RULE": + # A rule whose NAME contains regex metacharacters, governing a DIFFERENT branch. Substituting + # `*` into a raw regex made `mai.` match `main`; the rule must be treated literally. + respond(json.dumps([{"branch_name": "mai.", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "GLOB-RULE": respond(json.dumps([{"branch_name": "m*", "enable_status_check": True, "status_check_contexts": ["review-verdict/h10"]}])) @@ -80,7 +85,7 @@ if "/branch_protections/" in url: if mode == "TRANSPORT-ERROR": # curl itself fails: no body, and `-w` prints 000. respond("", "000") - if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE"): + if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -533,3 +538,21 @@ def test_the_base_ref_is_URL_ENCODED_in_the_protection_lookup(): does not exist, producing a 404 this code would read as 'unprotected'.""" src = HOOK.read_text() assert "@uri" in src, "the base ref must be percent-encoded before going in a URL path segment" + + +def test_a_rule_name_with_REGEX_METACHARACTERS_does_not_match_a_different_base(hook): + """The false-open in the glob fallback: `*` must be the only wildcard. + + Substituting `*` into a raw regex left every other metacharacter live, so a rule named `mai.` + matched the base `main` (and `a+b` matched `aab`). A spurious match to some OTHER rule that + happens to require `review-verdict/h10` reports this base as protected when nothing governs it — + a consent gate answering yes on evidence about a different branch. Verified directly before the + fix: `main.x` matched `mainax`. + + Here the only rule is `mai.`, which governs a branch that is not `main`, so nothing protects the + base and the gate must deny rather than grant. + """ + hook.set_branch_protection("REGEX-META-RULE") + reason = hook.reason() + assert "deny" in reason, ( + "a rule named 'mai.' was regex-matched against base 'main' and read as protection") -- 2.47.3 From 7db4101b248587637b7b55c26f0ad1bbf86a5b53 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 18:21:04 +0200 Subject: [PATCH 11/20] fix(778): the escape emitted TWO backslashes, turning a false-open into a false DENY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh cold review. One High, one Medium, three Low, seven Nit — all in the two newest commits, which is where every round of this PR has found its defects. HIGH, and it is my own fix from the previous commit. In jq source `"\\\\"` decodes to TWO backslashes, so escaping produced `\\.` — "a literal backslash, then any character" — instead of an escaped dot. Every rule name containing a metacharacter became UNMATCHABLE, and a rule named `a[b` crashed jq outright (swallowed by `|| true`). Verified: `release/26.*` no longer matched base `release/26.4`, so the fallback found nothing and hard-DENIED with the stated cause "has NO branch protection at all" — converting a false-open into a false deny, which the block's own comment calls the worse outcome. One character: `"\\" + .c`. Correct across 14 rule/base pairs. WHY MY TEST MISSED IT, which is the transferable part: it asserted only the NEGATIVE direction (`mai.` must not match `main`). A rule matched literally and a rule made unmatchable both fail to match the wrong base, so the assertion passed for the wrong reason. Only a rule that SHOULD match separates them, and there was no positive control. There is now — plus a char-class case — and both go red against the over-escaped version. That also needed a base containing a dot: a rule cannot carry a metacharacter and still match `main`, so the first attempt at the positive control was unsatisfiable by construction. MEDIUM — four live claims that the population "derives from the filesystem", left standing by the commit that replaced that mechanism: the guard's own docstring 45 lines above a comment shouting the opposite, the inventory heading 21 lines under "Every git-tracked file", the docs/README entry, and — worst — the record's `mechanics:` frontmatter, which is the copy the catalog and MemPalace mirror, so discovery would have returned the superseded lesson. All corrected. LOW/NIT: the URL-encoding test grepped the source for `@uri` (it now asserts the URL actually requested, and reddens when the encoding is removed); the hoist comment said "every path below" without noting the docs-only enumeration above it (bounded — that path is a passthrough to a human prompt, never a grant); a now-unreachable guard is annotated rather than left reading as live; `issue-qualification-audit.sh` was `N/A` while `select-queue.sh` was `UNSAFE-KNOWN` on the same argument, and `security-scan.sh` claimed "one step" for a pull-then-run over a mutable tag — both regraded; the `PINNED` definition now says what separates its second shape from an `N/A` "one step" row (the identifier's immutability, not the step count); the section parser raises a message naming both required headings instead of a bare ValueError; and the record's body is rewrapped. Decisions-Edit: yes refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 21 ++- docs/README.md | 2 +- .../process/check-and-use-pins-a-version.md | 141 +++++++++--------- docs/remote-state-inventory.md | 10 +- .../test_merge_consent_required_check.py | 85 ++++++++++- scripts/tests/test_remote_state_inventory.py | 36 +++-- 6 files changed, 200 insertions(+), 95 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 28d4ab4e4..dbb2bf41d 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -181,6 +181,12 @@ fi # unreadable input here therefore falls through to a human (`ask`), never to silence. # RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778). # +# "Below" is literal, and the one consumer ABOVE is disclosed rather than implied: the docs-only +# enumeration still runs against the snapshot `$base_ref` and can `decide allow` before reaching +# this point. That is bounded and deliberate — a docs-only match is a PASSTHROUGH to the ordinary +# human prompt, never an auto-grant, so a stale base there costs a prompt someone was going to see +# anyway. Every path that can GRANT passes through the check below. +# # `$base_ref` above comes from the PR snapshot taken at the top of this hook, and the docs-only # enumeration between there and here is up to forty round trips. A PERSISTENT retarget in that gap # needs no ABA and no force-push: every base-dependent decision below would be formed against a @@ -378,6 +384,10 @@ else # Tri-state, matching this file's idiom throughout: unreadable -> ask (a human adjudicates), # present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened, # and the whole point of that issue is that the failure is silent from the merge caller's side. + # Belt-and-braces: `$base_ref` was proven non-empty and re-confirmed at the hoisted check above, + # so this cannot fire today. Kept because it is the precondition this block's URL depends on, and + # a future edit that moves either piece should fail loudly here rather than request a URL with an + # empty path segment. [ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling." # The base was re-read and confirmed unchanged above, for every path — see the hoist comment # there. It is deliberately NOT re-read a second time here: two reads would create a window @@ -423,7 +433,16 @@ else # require `review-verdict/h10` would report this base as protected when nothing governs it, # which is a false-open in a consent gate. Escaping is done per literal segment, so the # wildcard survives and nothing else does. - bp=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c 'map(select((.branch_name // .rule_name // "") as $n | (($n | split("*") | map(gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\\\" + .c)) | join(".*")) as $re | ($n == $b) or ($b | test("^" + $re + "$"))))) | first // empty' 2>/dev/null || true) + # + # The REPLACEMENT string is `"\\" + .c`, one backslash in jq source, and the first version + # wrote two. That is not a near-miss: `\\.` means "a literal backslash then any character", + # so every rule containing a metacharacter became UNMATCHABLE and the fallback fell through to + # a hard deny stating "no branch protection at all" — turning a false-open into a false + # DENY, which this block's own comment calls the worse outcome. A rule name containing `[` + # additionally crashed jq outright. Both are covered below, and the tests assert BOTH + # directions: a negative-only assertion cannot tell "matched literally" from "made + # unmatchable", which is exactly why the first attempt looked green. + bp=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c 'map(select((.branch_name // .rule_name // "") as $n | (($n | split("*") | map(gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c)) | join(".*")) as $re | ($n == $b) or ($b | test("^" + $re + "$"))))) | first // empty' 2>/dev/null || true) if [ -n "${bp//[[:space:]]/}" ]; then bp_code=200 # a rule DOES cover this base; fall through and inspect it else diff --git a/docs/README.md b/docs/README.md index 17cbd3724..726a52a29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -118,7 +118,7 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K state and acts on that read, classified `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window and what bounds it. Code outside those directories — C#/TypeScript guards, `web/`, and the test suites themselves — is out of scope, and the doc states that rather than implying coverage. - The population is derived from the filesystem and compared for set equality by + The population is derived from `git ls-files` and compared for set equality by `scripts/tests/test_remote_state_inventory.py`, so a new script that talks to a remote service cannot ship unclassified. Read it with `process.check-and-use-pins-a-version`; it is that record's detector, since the class has no plausible linter (`docs/defect-shapes-773.md` §4 detector D). diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md index dcc7d9afe..2e762aa97 100644 --- a/docs/decisions/records/process/check-and-use-pins-a-version.md +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Where a CHECK authorizes an ACTION over state that can change in between, the two are bound to ONE version of that state. Binding alone is not enough and is the half that keeps being skipped: a snapshot nothing re-validates is not pinned, it is a stale read wearing a version number. Three substrates, three mechanisms, and they are the SAME rule — in-process, a compare-exchange claim taken by the caller, never a `Volatile.Read` in one place and an `Interlocked` in another (`ffmpeg.work-ahead-slot-atomic`); over our own HTTP API, RFC 7232 `If-Match`/ETag, with the force-write path named explicitly rather than left implicit (`concurrency.ifmatch-rfc7232`, `concurrency.force-write-non-ifmatch`); against a remote service, a full commit sha, an image digest or a monotonic event count re-read immediately before the write. Prefer true compare-and-set where the server offers it. Where it does not — Gitea''s commit-status API has no ETag, no If-Match and no expected-previous-state — the ceiling is READ-COMPARE-REFUSE: re-read the identifier immediately before the write and FAIL CLOSED on any movement, which narrows the window to one round trip and makes the loss observable instead of silent. A residual that cannot be closed is STATED in the code and carried in `docs/remote-state-inventory.md` as `UNSAFE-KNOWN` with the reason it is tolerable; "noticed" is not "accepted". Two identifier traps are load-bearing here: compare the FULL sha, never a 7-char prefix, and compare a base BRANCH REF rather than its tip sha, because the tip moves on every unrelated merge and comparing it deadlocks every open PR. Finally, and this is the failure #778 actually found: a mitigation that lives OUTSIDE the code relying on it — branch protection, a required status context, a server-side refusal — must be VERIFIED at the point of use, not asserted in a comment or in the reason string a human reads. A dated claim about configuration is not a check, and it is worse than no claim, because it talks the next reader out of looking.' signals: 'check-and-use race · TOCTOU over remote state · pin a version or compare-and-set · read-compare-refuse · fail closed on movement · snapshot that stops being true · full sha never a 7-char prefix · base ref not base tip sha · monotonic event count not a branch name · ABA · required status check verified not asserted · the mitigation lives outside the code that relies on it · `UNSAFE-KNOWN` with a stated reason · paths: `docs/remote-state-inventory.md`, `scripts/tests/test_remote_state_inventory.py`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/post-review-verdict.sh`, `scripts/pr-changed-files.sh` · issues: #778, #773, #707, #706, #632, #622, #536' -mechanics: 'The detector is not a linter — there is no way to spot "this code should have pinned a sha". It is `testing.guard-derives-population-from-source` applied to an enumerated inventory: `docs/remote-state-inventory.md` classifies every in-scope executable `PINNED`/`CAS`/`UNSAFE-KNOWN`/`N/A`, and `scripts/tests/test_remote_state_inventory.py` derives the population from the filesystem and asserts set equality both ways, so a new script that talks to a remote service cannot ship unclassified. Nothing checks that a `PINNED` claim is true; that stays with review.' +mechanics: 'The detector is not a linter — there is no way to spot "this code should have pinned a sha". It is `testing.guard-derives-population-from-source` applied to an enumerated inventory: `docs/remote-state-inventory.md` classifies every in-scope executable `PINNED`/`CAS`/`UNSAFE-KNOWN`/`N/A`, and `scripts/tests/test_remote_state_inventory.py` derives the population from `git ls-files` — the index, never the filesystem, which reports untracked build output and differs per machine — and asserts set equality both ways, so a new script that talks to a remote service cannot ship unclassified. Nothing checks that a `PINNED` claim is true; that stays with review.' --- `docs/defect-shapes-773.md` §3 names this as **Family D**, the one class #773's taxonomy had no @@ -15,111 +15,114 @@ bucket for at all. Three raters proposed it unprompted; a fourth, working blind, under its own name. Five records: #536, #622, #632, #706, #707. **The unifying property, and why the name matters.** A check and the action it authorizes are -separated in time over state that can change in between, with nothing pinning a version. #622's own -record states it exactly: *"The gate was never bypassed — it was satisfied against a snapshot that -stops being true."* That sentence is the whole class. Nothing is mis-scoped and no predicate is -wrong; the answer was simply computed about a different world than the one the action lands in. +separated in time over state that can change in between, with nothing pinning a version. #622's +own record states it exactly: *"The gate was never bypassed — it was satisfied against a snapshot +that stops being true."* That sentence is the whole class. Nothing is mis-scoped and no predicate +is wrong; the answer was simply computed about a different world than the one the action lands in. **The repo had already solved this twice without noticing it was one problem.** #536 was fixed with a compare-exchange claim. The whole `/api/v1` write surface was given RFC 7232 `If-Match` a year earlier. Both are this rule; neither pointed at the other, and the tooling — the third substrate, -where the state is somebody else's server — got the fix a third time from scratch at #706 and #707. -That is the same one-record-per-instance growth `defect-shapes-773.md` §4 criticises in this repo's -own knowledge base, and it is the reason this record is written at the class level. +where the state is somebody else's server — got the fix a third time from scratch at #706 and +#707. That is the same one-record-per-instance growth `defect-shapes-773.md` §4 criticises in this +repo's own knowledge base, and it is the reason this record is written at the class level. **Binding is the easy half; re-validation is the half that gets skipped.** A sha captured into a variable and then used in a URL feels pinned and is not. `scripts/post-review-verdict.sh` is the -worked example of doing it properly: it re-reads the PR immediately before the status POST, compares -both `.head.sha` and `.base.ref`, and `die`s without writing anything on either mismatch. The -comparison, not the capture, is what makes it safe. +worked example of doing it properly: it re-reads the PR immediately before the status POST, +compares both `.head.sha` and `.base.ref`, and `die`s without writing anything on either mismatch. +The comparison, not the capture, is what makes it safe. **Where no compare-and-set exists, say so instead of implying one.** Gitea's status API offers no conditional write, so `review-verdict.yml` cannot make its read and its POST one operation. It -narrows the window twice — a monotonic `change_target_branch` event-count fence, and a high-water-mark -re-read that repairs a `success` posted over a human verdict back to `pending` — and then states the -remaining gap in the file. The count is used rather than the branch NAME because a name is -ABA-vulnerable: `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a -forged exemption. An honest residual is a design output. A file claiming atomicity it does not have -is the thing that stops getting re-examined. +narrows the window twice — a monotonic `change_target_branch` event-count fence, and a +high-water-mark re-read that repairs a `success` posted over a human verdict back to `pending` — +and then states the remaining gap in the file. The count is used rather than the branch NAME +because a name is ABA-vulnerable: `main -> S -> main` reads `main` at both ends, which is how #698 +route 1 obtained a forged exemption. An honest residual is a design output. A file claiming +atomicity it does not have is the thing that stops getting re-examined. **The failure this issue actually found, which none of the five records predicted.** The merge-consent hook's scheduled-auto-merge path is safe only because `review-verdict/h10` is a REQUIRED status check on `main` — a commit status belongs to one sha, so a commit pushed after -scheduling cannot inherit the verdict and Gitea refuses the merge. That is #622's fix and it works. -But it is *branch-protection configuration*. It lives outside this repo, nothing in the repo -compared the two, and the hook asserted it in a comment **and in the grant reason a human reads**: +scheduling cannot inherit the verdict and Gitea refuses the merge. That is #622's fix and it +works. But it is *branch-protection configuration*. It lives outside this repo, nothing in the +repo compared the two, and the hook asserted it in a comment **and in the grant reason a human +reads**: *"because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it and block the merge."* Switch that context off and every word of that sentence becomes false while the hook keeps printing it and keeps auto-granting. So the class has a second face: not only "the state moved between the check and the action", but -"the thing that made the action safe was never observed at all". The hook now reads -`GET /repos/{owner}/{repo}/branch_protections/{base}` on that path and treats the result as three +"the thing that made the action safe was never observed at all". The hook now reads `GET +/repos/{owner}/{repo}/branch_protections/{base}` on that path and treats the result as three outcomes, not two — present proceeds, unreadable **asks** (a transient failure or a credential without repo-admin scope is not evidence of safety), absent **denies**. Denying on absence is the point: that is #622's hole reopened, and its defining property is that it is silent from the merge -caller's side. The 404 is separated from every other failure deliberately, because "this branch has -no protection at all" is the strongest form of the finding while a 403 says only that we could not -look — and `curl -sf`, which the hook's generic helper wraps, collapses both into an empty string. +caller's side. The 404 is separated from every other failure deliberately, because "this branch +has no protection at all" is the strongest form of the finding while a 403 says only that we could +not look — and `curl -sf`, which the hook's generic helper wraps, collapses both into an empty +string. **And that check is a preflight, not a pin — say so, because the temptation is to bank it.** The -first draft of the inventory graded that path `PINNED`; cold review pointed out that the hook's own -comment concedes the read pins nothing, and it was right. Branch protection has no version, ETag or -conditional read, so an admin can still weaken it after the hook looks. What the check buys is drift -DETECTION and the removal of an unobserved assumption, which is the honest ceiling for that API. The -residual is BOUNDED, not closed, and the bound is a trust assumption worth naming: everything on that -path assumes repo-admin branch-protection config is not hostile. Saying it was "closed one layer -down" by the very protection an admin may have removed was circular — the same sentence appeared in -the inventory and was rewritten there first, which is how a stale twin survives a fix round. A row claiming otherwise would be exactly the overclaim this record warns about, and it -is recorded here because the record's own deliverable made it on the first pass. +first draft of the inventory graded that path `PINNED`; cold review pointed out that the hook's +own comment concedes the read pins nothing, and it was right. Branch protection has no version, +ETag or conditional read, so an admin can still weaken it after the hook looks. What the check +buys is drift DETECTION and the removal of an unobserved assumption, which is the honest ceiling +for that API. The residual is BOUNDED, not closed, and the bound is a trust assumption worth +naming: everything on that path assumes repo-admin branch-protection config is not hostile. Saying +it was "closed one layer down" by the very protection an admin may have removed was circular — the +same sentence appeared in the inventory and was rewritten there first, which is how a stale twin +survives a fix round. A row claiming otherwise would be exactly the overclaim this record warns +about, and it is recorded here because the record's own deliverable made it on the first pass. This is the same gap `testing.guard-derives-population-from-source` already flagged one directory over — `MARKED_JOBS` in `test_ci_dropped_step_guard.py` is a hand-written mirror of those same -required contexts, annotated with a date. A dated comment is a claim about the past. Two independent -guards now depend on that configuration; one of them checks it. +required contexts, annotated with a date. A dated comment is a claim about the past. Two +independent guards now depend on that configuration; one of them checks it. **The deliverable's own population was wrong three times, and that is the most transferable part of this record.** The inventory is the detector, so its population *is* the guard. Round one filtered -the scope on an outbound-network token list, which omitted `git fetch` — this repo's commonest remote -read — so a hook that fetches `origin/main` and derives a push decision was invisible. Round two -dropped the filter but used non-recursive `glob`, so four nested files stayed out, one of them -calling a live ErsatzTV API and acting on the reply. Round three used `rglob`, which is recursive -and therefore also enumerated `.husky/_/` — untracked, gitignored shims that `npm ci` generates — -leaving the guard **red on every developer checkout and green in CI**, which never runs `npm ci`. A -guard that fails everywhere except where it runs teaches its readers to ignore it, which is worse -than no guard at all. +the scope on an outbound-network token list, which omitted `git fetch` — this repo's commonest +remote read — so a hook that fetches `origin/main` and derives a push decision was invisible. +Round two dropped the filter but used non-recursive `glob`, so four nested files stayed out, one +of them calling a live ErsatzTV API and acting on the reply. Round three used `rglob`, which is +recursive and therefore also enumerated `.husky/_/` — untracked, gitignored shims that `npm ci` +generates — leaving the guard **red on every developer checkout and green in CI**, which never +runs `npm ci`. A guard that fails everywhere except where it runs teaches its readers to ignore +it, which is worse than no guard at all. Every round shipped with an argument for why the traversal was sufficient, and every argument was wrong the same way. The fix that finally held was not a better traversal: it was **deriving the population from `git ls-files`**. The filesystem is not an authoritative source — it reports build output, editor droppings and whatever else is on disk, and it differs per machine. The index is -authoritative, versioned, identical for CI and every checkout, and excludes untracked generated files -by construction rather than by an exclusion list somebody has to maintain. So the generalisation is -the one `testing.guard-derives-population-from-source` already states, applied one level up: when a -guard enumerates a population, ask **what is the authoritative list of these things** — and if the -answer is "whatever the walk finds", the guard is not finished, however carefully the walk is -written. +authoritative, versioned, identical for CI and every checkout, and excludes untracked generated +files by construction rather than by an exclusion list somebody has to maintain. So the +generalisation is the one `testing.guard-derives-population-from-source` already states, applied +one level up: when a guard enumerates a population, ask **what is the authoritative list of these +things** — and if the answer is "whatever the walk finds", the guard is not finished, however +carefully the walk is written. **Grade down before you argue.** Three cold-review rounds demoted row after row — the scheduled-merge -path, both merge-consent head/base reads, the file enumerator, every registry-tag row — and in each -case the row asserted a property the code did not have while the code beneath it was fine. Only **three** rows -survive as `PINNED`, out of roughly seventy. That is the honest finding about this class in a tooling -codebase: almost nothing that talks to a remote service is genuinely pinned, most of it is bounded by -an argument, and the argument is what has to be written down. +path, both merge-consent head/base reads, the file enumerator, every registry-tag row — and in +each case the row asserted a property the code did not have while the code beneath it was fine. +Only **three** rows survive as `PINNED`, out of roughly seventy. That is the honest finding about +this class in a tooling codebase: almost nothing that talks to a remote service is genuinely +pinned, most of it is bounded by an argument, and the argument is what has to be written down. -The denominator is deliberately approximate, and that is a finding rather than laziness. Written as -an exact "N of M" it went stale **three times in three rounds** — twice because a demotion landed -after the count, once because splitting one row into two moved M inside the very commit that cited -it. A figure that changes whenever the artifact it describes is edited is a second copy of that -artifact, and this repo already knows what to do about a hand-maintained mirror: give it an equality -check or stop maintaining it. `docs/guard-inventory.md` took the first route because its counts are -the point; a rationale record takes the second, because the load-bearing claim here is "almost -nothing is pinned", not any particular integer. A row that overstates is worse than a -row that admits a gap, because this repo's own record is that a guard described as sound stops being -re-examined. +The denominator is deliberately approximate, and that is a finding rather than laziness. Written +as an exact "N of M" it went stale **three times in three rounds** — twice because a demotion +landed after the count, once because splitting one row into two moved M inside the very commit +that cited it. A figure that changes whenever the artifact it describes is edited is a second copy +of that artifact, and this repo already knows what to do about a hand-maintained mirror: give it +an equality check or stop maintaining it. `docs/guard-inventory.md` took the first route because +its counts are the point; a rationale record takes the second, because the load-bearing claim here +is "almost nothing is pinned", not any particular integer. A row that overstates is worse than a +row that admits a gap, because this repo's own record is that a guard described as sound stops +being re-examined. **What this record does not claim.** The inventory grades *files*, so it cannot see an existing file -growing a second unpinned read — the sites-in-code limit tracked in #777. And nothing verifies that a -row marked `PINNED` is telling the truth. Both residues are stated in -`docs/remote-state-inventory.md` rather than papered over, because a guard described as sound stops -being re-examined, which is the failure mode this whole family is made of. +growing a second unpinned read — the sites-in-code limit tracked in #777. And nothing verifies +that a row marked `PINNED` is telling the truth. Both residues are stated in +`docs/remote-state-inventory.md` rather than papered over, because a guard described as sound +stops being re-examined, which is the failure mode this whole family is made of. diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index c3088b280..4cbaf1194 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -21,7 +21,7 @@ remote-reading test executable expecting a red guard that will stay green. C#/Ty `web/`, and anything outside those directories are **not** covered. Scope is limit 3; the files-not-call-sites limit is limit 2. -`scripts/tests/test_remote_state_inventory.py` derives the population from the filesystem and +`scripts/tests/test_remote_state_inventory.py` derives the population from `git ls-files` and asserts **set equality** against the `Site` column, so a new script that talks to a remote service cannot ship without acquiring a row. @@ -46,6 +46,10 @@ artifact; the test keeps it from rotting. re-checks is not pinned — binding alone is never enough), **or** the check and the use are a single step over a value that cannot move, such as a workflow reading a full sha straight out of its own fixed event payload. What never qualifies is a value captured early and trusted later. + The second shape is distinguished from an `N/A` row that says "resolution and use are one step" + by the IDENTIFIER, not by the step count: `PINNED` requires the value itself to be immutable (a + full sha, a digest), while a one-step read of a MUTABLE identifier — a registry tag, a branch + name — is not pinned and is graded on what it authorizes. - `CAS` — the write itself carries a compare-and-set condition the server enforces. - `UNSAFE-KNOWN` — read-then-act with nothing pinning it, **accepted** with the reason stated in the Note. Every row here must say why the residual is tolerable, not merely that it exists. @@ -95,8 +99,8 @@ classifications differ; otherwise the strictest applies and the Note names the e | `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix, graded honestly after cold review: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it catches the BASE alias and **nothing else**. A HEAD alias is not covered by anything — a force-push `H1 -> H2 -> H1` during pagination leaves the final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, and no counter moves. That residual is real, unfenced, and stated here rather than papered over; closing it needs a monotonic head-mutation fence or enumeration bound to an immutable tree, neither of which exists today — tracked in **#803**, which also carries the three older contracts that still assert more than this row does. | | `scripts/select-queue.sh` — issue list, then per-issue `/dependencies` | `UNSAFE-KNOWN` | The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed `in-progress` in that gap still appears on the shortlist. Accepted: the script authorizes **no write**. The real gate is the four-way claim check in `process.parallel-session-claim`, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. | | `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. | -| `scripts/issue-qualification-audit.sh` | `N/A` | Pure read-and-report; exits 0/1 with a list. Authorizes no write and no merge. | -| `scripts/security-scan.sh` | `N/A` | `docker pull` of a mutable tag, then scans exactly what was pulled. Resolution and use are one step — no check-then-use split. | +| `scripts/issue-qualification-audit.sh` — paged issue list, then a report | `UNSAFE-KNOWN` | Pages the open-issue list and reports which issues lack a `priority:` label, so like every paged read here its pages can straddle a change and the report can name a state no single instant held. Graded to match `select-queue.sh` rather than `N/A`: the two run the same shape, and the reason offered for accepting `select-queue.sh` — it authorizes no write — cannot simultaneously be the reason this one is out of the class. Accepted on the same terms: it is advisory, session-end, human-read, and the labels it prompts for are applied by hand afterwards. | +| `scripts/security-scan.sh` — `docker pull`, then `docker run` the same tag | `UNSAFE-KNOWN` | Pull and run are two steps over a MUTABLE tag, which is the same shape the registry rows below were graded down for; "resolution and use are one step" overstated it. In practice the second step resolves against the local daemon, which holds the image the pull just placed, so a mid-window retag does not change what runs. Accepted on that, plus the scope: this boots a throwaway container and scans it, authorizing nothing. | | `scripts/migration-smoke.sh` | `N/A` | Same shape. The resolved image id is **reported** for the operator rather than compared against a prior read, which is an operator-trust gap (the script says so) and not a race. | | `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir; the only `curl` in the file is in a comment. | | `scripts/e2e-local.sh` | `N/A` | No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index f002d6580..0b349d0aa 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -65,6 +65,19 @@ if url.rstrip("/").endswith("/branch_protections"): # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. mode = (state / "bp").read_text().strip() + if mode == "GLOB-WITH-DOT-RULE": + # The POSITIVE control: a glob rule whose literal segment contains a metacharacter, which + # genuinely governs this base. Must MATCH — an over-escaped pattern makes it unmatchable and + # produces a hard deny with a false stated cause. + respond(json.dumps([{"branch_name": "release/26.*", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "CHARCLASS-RULE": + # A rule name containing `[` crashed jq outright when escaping was wrong; the error was + # swallowed and became the same false deny. + respond(json.dumps([{"branch_name": "a[b", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}, + {"branch_name": "main", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "REGEX-META-RULE": # A rule whose NAME contains regex metacharacters, governing a DIFFERENT branch. Substituting # `*` into a raw regex made `mai.` match `main`; the rule must be treated literally. @@ -79,13 +92,17 @@ if url.rstrip("/").endswith("/branch_protections"): if "/branch_protections/" in url: # Record the call so the positive control can prove the guarded case REACHED this endpoint, - # rather than passing because the hook bailed earlier for an unrelated reason. + # rather than passing because the hook bailed earlier for an unrelated reason. The full URL is + # kept so a test can assert what was actually REQUESTED, not merely what the source contains. (state / "bp_called").write_text("yes") + with (state / "bp_urls").open("a") as fh: + fh.write(url + "\n") mode = (state / "bp").read_text().strip() if mode == "TRANSPORT-ERROR": # curl itself fails: no body, and `-w` prints 000. respond("", "000") - if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE"): + if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE", + "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -188,6 +205,9 @@ if "/pulls/" in url: n = int(counter.read_text()) if counter.exists() else 0 counter.write_text(str(n + 1)) base = "main" + bo = state / "base_override" + if bo.exists(): + base = bo.read_text().strip() rt = state / "retarget" if rt.exists() and n >= 1: base = rt.read_text().strip() @@ -221,6 +241,10 @@ def hook(tmp_path): def set_branch_protection(self, mode): (state / "bp").write_text(mode) + def set_base(self, ref): + """The base reported by EVERY PR read — a stable target, not a retarget.""" + (state / "base_override").write_text(ref) + def drop_recorded_base(self): """Serve a pre-#632 verdict (no `(base: …)`), so only the hoisted check can deny.""" (state / "no_recorded_base").write_text("1") @@ -232,6 +256,10 @@ def hook(tmp_path): def branch_protection_was_fetched(self): return (state / "bp_called").exists() + def branch_protection_urls(self): + f = state / "bp_urls" + return f.read_text().splitlines() if f.exists() else [] + def decision(self, scheduled=True): payload = {"tool_input": {"method": "merge", "owner": "timothy", "repo": "ersatztv", "pull_number": 42, @@ -533,11 +561,21 @@ def test_an_unreadable_LIST_asks_rather_than_denying_on_the_404(hook): assert "NO branch protection" not in reason -def test_the_base_ref_is_URL_ENCODED_in_the_protection_lookup(): +def test_the_base_ref_is_URL_ENCODED_in_the_protection_lookup(hook): """A base like `release/26.4` would otherwise inject a path separator and request a URL that - does not exist, producing a 404 this code would read as 'unprotected'.""" - src = HOOK.read_text() - assert "@uri" in src, "the base ref must be percent-encoded before going in a URL path segment" + does not exist, producing a 404 this code reads as 'unprotected'. + + Asserted against the URL the hook actually REQUESTED, not against the source. The first version + grepped for `@uri`, which proves a token appears somewhere in the file — it would have passed + with the encoding applied to the wrong variable, or applied and then discarded. + """ + hook.set_base("release/26.4") + hook.drop_recorded_base() + hook.reason() + urls = [u for u in hook.branch_protection_urls() if "/branch_protections/" in u] + assert urls, "the by-name branch-protection endpoint was never requested" + assert any("release%2F26.4" in u for u in urls), ( + f"the base ref reached the URL unencoded: {urls}") def test_a_rule_name_with_REGEX_METACHARACTERS_does_not_match_a_different_base(hook): @@ -556,3 +594,38 @@ def test_a_rule_name_with_REGEX_METACHARACTERS_does_not_match_a_different_base(h reason = hook.reason() assert "deny" in reason, ( "a rule named 'mai.' was regex-matched against base 'main' and read as protection") + + +def test_a_GLOB_rule_whose_literal_part_has_a_metacharacter_still_MATCHES(hook): + """The positive control the first escaping attempt lacked, and the reason it looked green. + + Escaping is only half the property: `*` must still span. The first version emitted TWO + backslashes (`\\.` = "a literal backslash, then any character"), which made every rule + containing a metacharacter UNMATCHABLE — so the fallback found nothing and hard-denied with the + stated cause "has NO branch protection at all", a false-open converted into a false DENY. + + A negative-only assertion cannot see that: a rule matched literally and a rule made unmatchable + both fail to match the wrong base. Only a rule that SHOULD match distinguishes them. Here + `ma*n.x` genuinely governs the base, so the gate must not deny. + """ + hook.set_base("release/26.4") + hook.drop_recorded_base() # keep the #632 comparison out of this test's way + hook.set_branch_protection("GLOB-WITH-DOT-RULE") + reason = hook.reason() + assert "NO branch protection" not in reason, ( + "the glob rule 'release/26.*' governs base 'release/26.4' but was made unmatchable by " + "over-escaping, so the gate reported the base as entirely unprotected") + assert "deny" not in reason, ( + "a rule requiring review-verdict/h10 and matching this base was not honoured") + + +def test_a_rule_name_containing_a_CHAR_CLASS_bracket_does_not_crash_the_matcher(hook): + """`a[b` built the pattern `a\\[b`, which is a premature end of char-class: jq exits 5, the + `|| true` swallows it, and the whole list is discarded — so an unrelated rule's NAME could + poison the lookup and deny a base that a later rule in the same list protects.""" + hook.set_branch_protection("CHARCLASS-RULE") + reason = hook.reason() + assert "NO branch protection" not in reason, ( + "one rule with a bracket in its name discarded the whole list, including the rule that " + "actually protects this base") + assert "deny" not in reason diff --git a/scripts/tests/test_remote_state_inventory.py b/scripts/tests/test_remote_state_inventory.py index dca76aa00..7205efd6c 100644 --- a/scripts/tests/test_remote_state_inventory.py +++ b/scripts/tests/test_remote_state_inventory.py @@ -1,5 +1,5 @@ -"""`docs/remote-state-inventory.md` covers exactly the executables that talk to a remote service -(ersatztv#778). +"""`docs/remote-state-inventory.md` covers exactly the in-scope files, each classified for whether +it reads live remote state and acts on that read (ersatztv#778). WHAT THIS CANNOT DO, said first because #778's own issue body says it: there is no lint for "this code should have pinned a sha." `docs/defect-shapes-773.md` §4 grades detector D as a *fix pattern* @@ -10,25 +10,19 @@ suite is red until you have". The split is deliberate and mirrors `test_guard_inventory.py`: - * the POPULATION is derived from the filesystem and compared for SET EQUALITY, both directions; + * the POPULATION is derived from `git ls-files` and compared for SET EQUALITY, both directions; * the CLASSIFICATION vocabulary is closed, so a typo cannot invent a state; * whether a `PINNED` row is TELLING THE TRUTH is not checked here and cannot be. That stays with review, and the inventory's prose is what review reads. SCOPE vs POPULATION, per `testing.guard-derives-population-from-source`: the SCOPE — four -directories, two file extensions, and one excluded subdirectory — is a hand-written policy choice -and is reviewable as one. The POPULATION inside that scope is derived on every run, RECURSIVELY, -with no content predicate at all: a file that reads no remote state earns an explicit `N/A` row -rather than staying out. +directories, their per-directory file patterns, and one excluded subdirectory — is a hand-written +policy choice and is reviewable as one. The POPULATION inside that scope is derived on every run +from the git index, with no content predicate at all: a file that reads no remote state earns an +explicit `N/A` row rather than staying out, which is why 46 of the 69 rows are `N/A`. -That wording is the third version, and the history is the point. The first filtered the scope by an -outbound-network token list, which omitted `git fetch` — this repo's commonest remote read — so a -hook that fetches `origin/main` and derives a push decision was invisible. The second dropped the -filter but used non-recursive `glob`, so four nested files were invisible, including one that calls -a live ErsatzTV API and acts on the reply. Both were the same error at different depths: a -completeness claim resting on a traversal nobody had compared against the filesystem. Hence the -anti-vacuity floor below, and hence the scope being stated as directories rather than as a -predicate over content. +That is the fourth version, and the history is the point — see the `SCOPE` comment below for the +three that failed and what each one hid. """ from __future__ import annotations @@ -131,6 +125,8 @@ def derived_population() -> set[str]: name = path.rsplit("/", 1)[-1] if any(fnmatch.fnmatch(name, pattern) for pattern in globs): found.add(path) + # First matching scope entry wins. Safe only while no scope directory nests inside + # another; if one ever does, the inner entry's patterns would be silently skipped. break return found @@ -144,8 +140,18 @@ def _inventory_section(text: str) -> str: sites. A guard that treats its own documentation as input is the failure this whole change is about, so the boundary is explicit rather than left to a cleverer regex. """ + for heading in ("## The inventory", "## Limits"): + if heading not in text: + raise AssertionError( + f"{INVENTORY.name} has no {heading!r} heading. Row parsing is bounded by " + "'## The inventory' and '## Limits'; renaming or reordering either one would " + "silently change which rows are checked, so it fails here instead.") start = text.index("## The inventory") end = text.index("## Limits", start) + if end <= start: + raise AssertionError( + f"{INVENTORY.name}: '## Limits' precedes '## The inventory'; the parsed window would " + "be empty and every completeness assertion would pass vacuously.") return text[start:end] -- 2.47.3 From 456042ee510ce49e7eb86f6cfe37b1cfcc9d5ac0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 18:52:18 +0200 Subject: [PATCH 12/20] =?UTF-8?q?fix(778):=20stop=20claiming=20parity=20wi?= =?UTF-8?q?th=20Gitea's=20glob=20matcher=20=E2=80=94=20classify,=20or=20as?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth cold review: no Blocker, no High. Three Medium, two Low, one Nit. MEDIUM — the substantive one. The glob fallback asserted it matched rules "the same way Gitea applies them", and it does not. Gitea compiles a rule name with gobwas/glob and a `/` separator: its `*` does NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case-insensitively. Mine used `.*` for `*` and escaped the rest. The divergence has a false-OPEN direction — `release/*` does not govern `release/26/hotfix` in Gitea, but `release/.*` matched it here, which would auto-grant a scheduled merge on a base where the check is not required. That is #622's hole, reached through the block written to close it, via exactly the failure this PR records: a claim about an external system asserted rather than verified. Reimplementing somebody else's glob dialect would be a second copy of a parser, which this repo has already withdrawn a change for. So the classification is three-way and each arm is safe WITHOUT knowing the dialect: an exact non-glob name folded case-insensitively is decidable; a glob rule that could govern the base is UNDECIDABLE and asks; and "could" is tested with a provable superset of any glob dialect — literal prefix, `.*`, literal suffix — so if even that cannot match, no dialect can. Over-matching would grant on unestablished protection; under-matching would deny with a false cause. Asking is the only answer honest in both directions, and it is rare: this repo's rule is the plain name `main`. MEDIUM — a count that was wrong the moment it was written ("46 of the 69 rows are N/A"; it is 44). It was added by the same commit that demoted two rows. That is the FOURTH stale number in this change, in the deliverable whose own record argues against hand-maintained counts. Removed rather than corrected, with the reason stated. MEDIUM — `migration-smoke.sh` still said "Same shape" as `security-scan.sh`, whose note had just been rewritten to the opposite conclusion, so the backreference had silently inverted. It is the same pull-then-run over a mutable tag and deserves the grade MORE, not less: `security-scan.sh` boots a throwaway container and authorizes nothing, while this gates a production stack recreation. Regraded. LOW/NIT: an `end <= start` guard that `str.index(…, start)` makes unreachable by construction is replaced with the reachable failure it was describing; a docstring still named a fixture from an earlier draft; a reflowed `#707.` was line-initial. Two mutations were ineffective on the first attempt — one left the `decide ask` continuation in place, the other had no test covering case-folding. Both redone; each arm now reddens a named test. Decisions-Edit: yes refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 60 +++++++++++------- .../process/check-and-use-pins-a-version.md | 4 +- docs/remote-state-inventory.md | 2 +- .../test_merge_consent_required_check.py | 61 +++++++++++++------ scripts/tests/test_remote_state_inventory.py | 20 ++++-- 5 files changed, 100 insertions(+), 47 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index dbb2bf41d..e01f7c3a9 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -425,29 +425,47 @@ else bp_code=$(bp_get "branch_protections") bp_list=$(cat "$bp_file" 2>/dev/null || true) if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then - # Match a rule by exact name or by its glob, the same way Gitea applies them. + # DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would + # be the exact defect this PR records (a mitigation outside the code, asserted rather than + # verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does + # NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case- + # insensitively. Reimplementing that here would be a second copy of somebody else's parser. # - # `*` is the ONLY wildcard: every other regex metacharacter in a rule name is escaped before - # the pattern is built. Substituting `*` into a raw regex made a rule named `main.x` match the - # base `mainax`, and `a+b` match `aab` — a spurious match to some OTHER rule that happens to - # require `review-verdict/h10` would report this base as protected when nothing governs it, - # which is a false-open in a consent gate. Escaping is done per literal segment, so the - # wildcard survives and nothing else does. + # So the classification is deliberately THREE-way, and each arm is safe without knowing the + # dialect: + # exact — the rule name has no glob metacharacter at all, and equals the base + # case-insensitively (matching Gitea's EqualFold). Decidable; inspect the rule. + # undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any + # glob dialect: literal prefix before the first metacharacter, `.*`, literal + # suffix after the last. If even that does not match, no dialect can. + # none — nothing can possibly govern the base, so it is genuinely unprotected. # - # The REPLACEMENT string is `"\\" + .c`, one backslash in jq source, and the first version - # wrote two. That is not a near-miss: `\\.` means "a literal backslash then any character", - # so every rule containing a metacharacter became UNMATCHABLE and the fallback fell through to - # a hard deny stating "no branch protection at all" — turning a false-open into a false - # DENY, which this block's own comment calls the worse outcome. A rule name containing `[` - # additionally crashed jq outright. Both are covered below, and the tests assert BOTH - # directions: a negative-only assertion cannot tell "matched literally" from "made - # unmatchable", which is exactly why the first attempt looked green. - bp=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c 'map(select((.branch_name // .rule_name // "") as $n | (($n | split("*") | map(gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c)) | join(".*")) as $re | ($n == $b) or ($b | test("^" + $re + "$"))))) | first // empty' 2>/dev/null || true) - if [ -n "${bp//[[:space:]]/}" ]; then - bp_code=200 # a rule DOES cover this base; fall through and inspect it - else - bp_code=404 # the list was readable and nothing matches: truly unprotected - fi + # `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base + # whose protection we never established (#622's hole, reached through the block written to + # close it); under-matching would deny with a stated cause that is false, which this block's + # own comment calls the worse outcome. Asking is the only answer that is honest in both + # directions, and it is rare: the repo's rule today is the plain name `main`. + bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c ' + def esc: gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c); + def offs: [match("[*?\\[\\]{}]"; "g").offset]; + def superset: . as $n | (offs) as $o + | ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc); + . as $rules | $b as $base | + ($rules | map(select((.branch_name // .rule_name // "") as $n + | (($n|offs|length) == 0) + and (($n|ascii_downcase) == ($base|ascii_downcase)))) | first) as $exact | + if $exact then {verdict:"exact", rule:$exact} + elif ($rules | any((.branch_name // .rule_name // "") as $n + | (($n|offs|length) > 0) + and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"} + else {verdict:"none"} end' 2>/dev/null || true) + case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in + exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule'); bp_code=200 ;; + undecidable) rm -f "$bp_file" + decide ask "H6/H10 merge gate: '$base_ref' has no branch-protection rule under its own name, but a GLOB rule on this repo could govern it. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;; + none) bp_code=404; bp="" ;; + *) bp_code=000; bp="" ;; + esac else bp_code=${bp_code:-000} # could not read the list either -> the ask arm below bp="" diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md index 2e762aa97..48b1375a0 100644 --- a/docs/decisions/records/process/check-and-use-pins-a-version.md +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -23,8 +23,8 @@ is wrong; the answer was simply computed about a different world than the one th **The repo had already solved this twice without noticing it was one problem.** #536 was fixed with a compare-exchange claim. The whole `/api/v1` write surface was given RFC 7232 `If-Match` a year earlier. Both are this rule; neither pointed at the other, and the tooling — the third substrate, -where the state is somebody else's server — got the fix a third time from scratch at #706 and -#707. That is the same one-record-per-instance growth `defect-shapes-773.md` §4 criticises in this +where the state is somebody else's server — got the fix a third time from scratch at #706 and #707. +That is the same one-record-per-instance growth `defect-shapes-773.md` §4 criticises in this repo's own knowledge base, and it is the reason this record is written at the class level. **Binding is the easy half; re-validation is the half that gets skipped.** A sha captured into a diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 4cbaf1194..5afde7b9e 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -101,7 +101,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. | | `scripts/issue-qualification-audit.sh` — paged issue list, then a report | `UNSAFE-KNOWN` | Pages the open-issue list and reports which issues lack a `priority:` label, so like every paged read here its pages can straddle a change and the report can name a state no single instant held. Graded to match `select-queue.sh` rather than `N/A`: the two run the same shape, and the reason offered for accepting `select-queue.sh` — it authorizes no write — cannot simultaneously be the reason this one is out of the class. Accepted on the same terms: it is advisory, session-end, human-read, and the labels it prompts for are applied by hand afterwards. | | `scripts/security-scan.sh` — `docker pull`, then `docker run` the same tag | `UNSAFE-KNOWN` | Pull and run are two steps over a MUTABLE tag, which is the same shape the registry rows below were graded down for; "resolution and use are one step" overstated it. In practice the second step resolves against the local daemon, which holds the image the pull just placed, so a mid-window retag does not change what runs. Accepted on that, plus the scope: this boots a throwaway container and scans it, authorizing nothing. | -| `scripts/migration-smoke.sh` | `N/A` | Same shape. The resolved image id is **reported** for the operator rather than compared against a prior read, which is an operator-trust gap (the script says so) and not a race. | +| `scripts/migration-smoke.sh` — `docker pull`, then `docker run` the same tag | `UNSAFE-KNOWN` | The same pull-then-run over a mutable tag as `security-scan.sh` above, and graded with it rather than left behind: its row previously said "Same shape" as a note that has since been rewritten to the opposite conclusion, so the backreference had quietly inverted. This one deserves the grade MORE, not less — `security-scan.sh` boots a throwaway container and authorizes nothing, while this is the pre-deploy migration smoke that gates a production stack recreation. Accepted on the same bound (the run resolves against the local daemon holding the image the pull just placed) plus its own stated operator-trust gap: the resolved image id is **reported** for a human rather than compared against a prior read. | | `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir; the only `curl` in the file is in a comment. | | `scripts/e2e-local.sh` | `N/A` | No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. | | `scripts/e2e-ui.sh` | `N/A` | Launches a local Chromium and runs specs against `http://localhost:$PORT`. | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 0b349d0aa..3b1ed8440 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -65,6 +65,11 @@ if url.rstrip("/").endswith("/branch_protections"): # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. mode = (state / "bp").read_text().strip() + if mode == "CASEFOLD-RULE": + # Gitea compares a plain (non-glob) rule name with EqualFold, so a rule named `MAIN` + # governs base `main`. A case-SENSITIVE comparison here would miss it and deny. + respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "GLOB-WITH-DOT-RULE": # The POSITIVE control: a glob rule whose literal segment contains a metacharacter, which # genuinely governs this base. Must MATCH — an over-escaped pattern makes it unmatchable and @@ -102,7 +107,7 @@ if "/branch_protections/" in url: # curl itself fails: no body, and `-w` prints 000. respond("", "000") if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE", - "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE"): + "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -536,20 +541,22 @@ def test_a_NON_STRING_status_reaches_the_scheduled_validator(hook): assert "ask" in reason, "a non-string .status produced a verdict-shaped decision" -def test_a_base_covered_by_a_GLOB_rule_is_not_reported_as_unprotected(hook): - """Gitea keys the by-name branch-protection endpoint on the RULE name, so a base covered by a - glob rule (`m*`, `rel/*`) 404s on its own name while being fully protected. +def test_a_base_that_a_GLOB_rule_could_govern_ASKS(hook): + """Gitea keys the by-name endpoint on the RULE name, so a base covered by a glob rule 404s on + its own name while being fully protected. Reading that 404 as "unprotected" produced a hard DENY + with a specific, false cause. - Reading that 404 as "unprotected" produced a hard DENY stating a specific, false cause — and a - deny is not a prompt, it blocks the operation outright. The list endpoint is consulted before - denying, and a matching rule is inspected like any other. + But the opposite error is worse: deciding the glob DOES match would auto-grant on a base whose + protection was never established. This hook does not reimplement Gitea's glob dialect (its `*` + does not cross `/`, and `?`/`[…]`/`{a,b}` are wildcards), so a glob rule that COULD govern the + base is undecidable and asks — the only answer honest in both directions. """ hook.set_branch_protection("GLOB-RULE") reason = hook.reason() + assert "ask" in reason, ( + "a base a glob rule could govern was decided rather than referred to a human") assert "NO branch protection" not in reason, ( - "a base protected by a glob rule was reported as having no protection at all") - assert "deny" not in reason, ( - "a glob rule that DOES require review-verdict/h10 was not honoured") + "a base a glob rule could govern was reported as having no protection at all") def test_an_unreadable_LIST_asks_rather_than_denying_on_the_404(hook): @@ -594,6 +601,9 @@ def test_a_rule_name_with_REGEX_METACHARACTERS_does_not_match_a_different_base(h reason = hook.reason() assert "deny" in reason, ( "a rule named 'mai.' was regex-matched against base 'main' and read as protection") + assert "NO branch protection" in reason, ( + "'mai.' contains no GLOB metacharacter, so it is decidable: it simply does not govern " + "'main', and the base is genuinely unprotected") def test_a_GLOB_rule_whose_literal_part_has_a_metacharacter_still_MATCHES(hook): @@ -605,18 +615,20 @@ def test_a_GLOB_rule_whose_literal_part_has_a_metacharacter_still_MATCHES(hook): stated cause "has NO branch protection at all", a false-open converted into a false DENY. A negative-only assertion cannot see that: a rule matched literally and a rule made unmatchable - both fail to match the wrong base. Only a rule that SHOULD match distinguishes them. Here - `ma*n.x` genuinely governs the base, so the gate must not deny. + both fail to match the wrong base. Only a rule that SHOULD match distinguishes them. Here the rule + `release/26.*` could govern the base `release/26.4`, so the gate must reach a decision about it + rather than reporting the base as unprotected. """ hook.set_base("release/26.4") hook.drop_recorded_base() # keep the #632 comparison out of this test's way hook.set_branch_protection("GLOB-WITH-DOT-RULE") reason = hook.reason() + assert "ask" in reason, ( + "the glob rule 'release/26.*' could govern base 'release/26.4', which is undecidable here " + "and must ask") assert "NO branch protection" not in reason, ( - "the glob rule 'release/26.*' governs base 'release/26.4' but was made unmatchable by " - "over-escaping, so the gate reported the base as entirely unprotected") - assert "deny" not in reason, ( - "a rule requiring review-verdict/h10 and matching this base was not honoured") + "a base a glob rule could govern was reported as entirely unprotected — the over-escaping " + "failure this test exists to catch") def test_a_rule_name_containing_a_CHAR_CLASS_bracket_does_not_crash_the_matcher(hook): @@ -625,7 +637,20 @@ def test_a_rule_name_containing_a_CHAR_CLASS_bracket_does_not_crash_the_matcher( poison the lookup and deny a base that a later rule in the same list protects.""" hook.set_branch_protection("CHARCLASS-RULE") reason = hook.reason() + assert reason, "the hook emitted no decision at all" assert "NO branch protection" not in reason, ( - "one rule with a bracket in its name discarded the whole list, including the rule that " - "actually protects this base") + "one rule with a bracket in its name discarded the whole list, including the exact-name " + "rule that actually protects this base") + assert "deny" not in reason, ( + "an exact-name rule requiring review-verdict/h10 was present and was not honoured") + + +def test_a_plain_rule_name_is_matched_CASE_INSENSITIVELY(hook): + """Gitea compares a rule name with no glob metacharacter using `EqualFold`, so a rule named + `MAIN` governs the base `main`. Comparing case-sensitively here would find no rule, conclude the + base is unprotected, and deny with a false stated cause.""" + hook.set_branch_protection("CASEFOLD-RULE") + reason = hook.reason() + assert "NO branch protection" not in reason, ( + "a rule named 'MAIN' governs base 'main' in Gitea but was missed by a case-sensitive compare") assert "deny" not in reason diff --git a/scripts/tests/test_remote_state_inventory.py b/scripts/tests/test_remote_state_inventory.py index 7205efd6c..7270a1537 100644 --- a/scripts/tests/test_remote_state_inventory.py +++ b/scripts/tests/test_remote_state_inventory.py @@ -19,7 +19,12 @@ SCOPE vs POPULATION, per `testing.guard-derives-population-from-source`: the SCO directories, their per-directory file patterns, and one excluded subdirectory — is a hand-written policy choice and is reviewable as one. The POPULATION inside that scope is derived on every run from the git index, with no content predicate at all: a file that reads no remote state earns an -explicit `N/A` row rather than staying out, which is why 46 of the 69 rows are `N/A`. +explicit `N/A` row rather than staying out, which is why most rows are `N/A`. + +No count is written here on purpose. An exact "N of M" has now gone stale FOUR times in this change, +most recently in the same commit that demoted two rows — a hand-maintained number is a second copy of +the table, and `docs/guard-inventory.md` earns its counts by having a test assert them. Nothing +asserts one here, so nothing states one. That is the fourth version, and the history is the point — see the `SCOPE` comment below for the three that failed and what each one hid. @@ -147,11 +152,16 @@ def _inventory_section(text: str) -> str: "'## The inventory' and '## Limits'; renaming or reordering either one would " "silently change which rows are checked, so it fails here instead.") start = text.index("## The inventory") - end = text.index("## Limits", start) - if end <= start: + try: + end = text.index("## Limits", start) + except ValueError: + # Reachable when '## Limits' exists but PRECEDES '## The inventory' — the presence check + # above passes and the bounded search does not. An earlier version put an `end <= start` + # guard here instead, which `str.index(…, start)` makes unreachable by construction: it + # either returns an index >= start or raises. A guard that cannot execute proves nothing. raise AssertionError( - f"{INVENTORY.name}: '## Limits' precedes '## The inventory'; the parsed window would " - "be empty and every completeness assertion would pass vacuously.") + f"{INVENTORY.name}: '## Limits' precedes '## The inventory', so the parsed window " + "would be empty and every completeness assertion would pass vacuously.") from None return text[start:end] -- 2.47.3 From cd1b2863788af1226ff0079709a6d2e3b8d37b6f Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 18:59:36 +0200 Subject: [PATCH 13/20] fix(778): backslash is a metacharacter too, or the `none` arm's proof does not hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-found while stress-testing the superset claim I introduced one commit earlier — the crux the new classifier's safety rests on. `none` authorises a DENY on the stated grounds that nothing can possibly govern this base, so its premise must hold unconditionally, not usually. The superset was "literal prefix + .* + literal suffix", which is sound for every glob dialect EXCEPT one case: gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b` governs the base `a{b`, while a superset treating `\` as an ordinary character builds `a\.*b`, misses, and denies a base that is in fact protected. Verified before and after: with `\` outside the metacharacter class the classifier answered `none` for that pair; with it inside, `undecidable` -> ask. 18 adversarial rule/base pairs (brace alternation, negated and ranged classes, `**`, leading and trailing metacharacters, unicode, empty alternation) all answer `undecidable`, never `none`, so no dialect-matching case falls through the deny arm. Git ref rules make this nearly unreachable — a branch name may not contain `*`, `?`, `[` or `\` — but `{` IS legal in one, and "nearly unreachable" is not the standard for the arm that issues a deny. Checked with `git check-ref-format` rather than assumed. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 13 ++++++-- .../test_merge_consent_required_check.py | 32 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index e01f7c3a9..04d7b7ffe 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -437,7 +437,16 @@ else # case-insensitively (matching Gitea's EqualFold). Decidable; inspect the rule. # undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any # glob dialect: literal prefix before the first metacharacter, `.*`, literal - # suffix after the last. If even that does not match, no dialect can. + # suffix after the last. If even that does not match, no dialect can, because + # every dialect requires the literal head and tail to match literally. + # + # BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the + # superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b` + # governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`, + # fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this + # nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain + # `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern + # this base", so its premise has to hold unconditionally rather than usually. # none — nothing can possibly govern the base, so it is genuinely unprotected. # # `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base @@ -447,7 +456,7 @@ else # directions, and it is rare: the repo's rule today is the plain name `main`. bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c ' def esc: gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c); - def offs: [match("[*?\\[\\]{}]"; "g").offset]; + def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset]; def superset: . as $n | (offs) as $o | ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc); . as $rules | $b as $base | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 3b1ed8440..4152a4cf0 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -65,6 +65,12 @@ if url.rstrip("/").endswith("/branch_protections"): # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. mode = (state / "bp").read_text().strip() + if mode == "ESCAPED-META-RULE": + # gobwas/glob reads `\\{` as a LITERAL brace, so this rule governs the base `a{b`. If the + # superset treats `\\` as literal it builds `a\\.*b`, misses, and answers `none` — denying a + # base that IS protected. + respond(json.dumps([{"branch_name": "a\\{b", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "CASEFOLD-RULE": # Gitea compares a plain (non-glob) rule name with EqualFold, so a rule named `MAIN` # governs base `main`. A case-SENSITIVE comparison here would miss it and deny. @@ -107,7 +113,8 @@ if "/branch_protections/" in url: # curl itself fails: no body, and `-w` prints 000. respond("", "000") if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE", - "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE"): + "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE", + "ESCAPED-META-RULE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -654,3 +661,26 @@ def test_a_plain_rule_name_is_matched_CASE_INSENSITIVELY(hook): assert "NO branch protection" not in reason, ( "a rule named 'MAIN' governs base 'main' in Gitea but was missed by a case-sensitive compare") assert "deny" not in reason + + +def test_a_BACKSLASH_ESCAPED_metacharacter_in_a_rule_name_is_undecidable_not_absent(hook): + """The one case that breaks the superset proof the `none` arm rests on. + + `none` authorises a DENY on the stated grounds that nothing can possibly govern this base, so + its premise must hold unconditionally. gobwas/glob reads `\\{` as a LITERAL brace, so the rule + `a\\{b` governs the base `a{b`; a superset that treated `\\` as an ordinary character would build + `a\\.*b`, fail to match, and deny a base that is in fact protected. Treating backslash as a + metacharacter restores the property. + + Git ref rules make this nearly unreachable — a branch name may not contain `*`, `?`, `[` or `\\` + — but `{` IS legal in a branch name, and "nearly unreachable" is not the standard for the arm + that issues a deny. + """ + hook.set_base("a{b") + hook.drop_recorded_base() + hook.set_branch_protection("ESCAPED-META-RULE") + reason = hook.reason() + assert "NO branch protection" not in reason, ( + "a rule whose escaped brace governs this base was reported as unable to govern it") + assert "ask" in reason, ( + "an escaped-metacharacter rule is undecidable here and must ask") -- 2.47.3 From d8ff6fa2292ef170cf665dd5275793e99569b038 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 19:47:06 +0200 Subject: [PATCH 14/20] fix(778): ask before trusting an exact rule, and make the tests tell classified from crashed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth cold review: MERGEABLE, no Blocker, no High. Its three Mediums taken anyway, because each was a one-line fix retiring the last "asserted rather than verified" surface in the file whose whole subject is that shape. M1 (backslash missing from the metacharacter class) was already closed in cd1b28637 — found independently while stress-testing the superset claim, in the window the reviewer was working against the previous head. M2 — the `exact` arm was not decidable. Gitea picks the governing rule with GetFirstMatched over a list sorted by Priority and THEN by plain-name-ness, so a glob rule with a better Priority outranks an exactly-named one. Preferring `exact` would inspect a rule Gitea might not be applying: if the exact rule requires `review-verdict/h10` and a higher-priority glob rule does not, the gate auto-grants on a base where the check is not enforced. `undecidable` is now evaluated FIRST, which makes the classifier sound without knowing the precedence rules at all — the only claim this code is entitled to make about somebody else's resolver. M3 — four tests could not distinguish "classified correctly" from "classifier crashed", because a dead classifier lands on the generic could-not-read ask and they asserted only `"ask" in reason`. Measured with the reviewer's method rather than argued: injecting `error(...)` at the head of the jq program left 2 of them red; the strengthened assertions leave 6. The glob arms now pin the text unique to the undecidable ask, and the decidable arms assert the rule was HONOURED rather than referred to a human. Also: the module docstring still described a three-arm contract after this change added a fourth; the inventory row still summarised the old two-way behaviour; the ASCII-only case fold is now stated as a deliberate under-match rather than as parity with EqualFold; the live-config claim is dated; and one jq call rejoined the file's `|| true` discipline. A `trap ... EXIT` for temp-file cleanup was tried and REVERTED: it suppressed the hook's decision output entirely and took 20 tests red. A gate that prints nothing is the one outcome it must never produce, so the tidier construct loses to the one that works, with the reason recorded where the next person will try it. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 40 +++++++++++++++---- docs/remote-state-inventory.md | 2 +- .../test_merge_consent_required_check.py | 24 +++++++++-- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 04d7b7ffe..f6e332f8e 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -408,6 +408,13 @@ else # would read as "unprotected" and turn into a hard DENY whose reason names a specific, false cause. bp_ref=$(printf '%s' "$base_ref" | jq -sRr @uri) bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge." + # Cleanup is hand-placed on each exit path rather than a `trap ... EXIT`. That was tried and + # REVERTED: this hook is driven through a `decide` helper that prints its JSON and exits, and + # installing an EXIT trap here suppressed the decision output entirely — 20 tests went from green + # to "the hook passed through instead of auto-granting". A gate that prints nothing is the one + # outcome it must never produce, so the tidier construct loses to the one that demonstrably works. + # Every path between here and the reads below removes the file exactly once; cold review traced + # them independently. bp_get() { # $1 = path under the repo if [ -n "${ETV_GITEA_TOKEN:-}" ]; then curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/$1" 2>/dev/null || true @@ -433,8 +440,23 @@ else # # So the classification is deliberately THREE-way, and each arm is safe without knowing the # dialect: - # exact — the rule name has no glob metacharacter at all, and equals the base - # case-insensitively (matching Gitea's EqualFold). Decidable; inspect the rule. + # exact — no glob rule could apply, AND some rule name has no glob metacharacter and + # equals the base case-insensitively. Only then is a single rule decidable. + # + # UNDECIDABLE IS EVALUATED FIRST, and the order is the point. Gitea picks the + # governing rule with `GetFirstMatched` over a list sorted by Priority, THEN + # by plain-name-ness — so a glob rule with a better Priority outranks an + # exactly-named one. Preferring `exact` would therefore inspect a rule Gitea + # might not be applying: if the exact rule requires `review-verdict/h10` and a + # higher-priority glob rule does not, the gate auto-grants on a base where the + # check is not enforced. Asking whenever ANY glob rule could apply is sound + # without knowing the precedence rules at all, which is the only claim this + # code is entitled to make about somebody else's resolver. + # + # Case folding is ASCII-only here, while Gitea's `EqualFold` is Unicode-aware. + # That can only ever fail to find a match, never invent one, so it degrades + # into the `none`/deny direction rather than into a grant. Stated rather than + # papered over: this is not parity with `EqualFold`. # undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any # glob dialect: literal prefix before the first metacharacter, `.*`, literal # suffix after the last. If even that does not match, no dialect can, because @@ -453,7 +475,9 @@ else # whose protection we never established (#622's hole, reached through the block written to # close it); under-matching would deny with a stated cause that is false, which this block's # own comment calls the worse outcome. Asking is the only answer that is honest in both - # directions, and it is rare: the repo's rule today is the plain name `main`. + # directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain + # name `main`, so the by-name lookup returns 200 and this classifier is never reached at all. + # That is a dated observation about mutable remote config, not a property to rely on. bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c ' def esc: gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c); def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset]; @@ -463,13 +487,13 @@ else ($rules | map(select((.branch_name // .rule_name // "") as $n | (($n|offs|length) == 0) and (($n|ascii_downcase) == ($base|ascii_downcase)))) | first) as $exact | - if $exact then {verdict:"exact", rule:$exact} - elif ($rules | any((.branch_name // .rule_name // "") as $n - | (($n|offs|length) > 0) - and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"} + if ($rules | any((.branch_name // .rule_name // "") as $n + | (($n|offs|length) > 0) + and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"} + elif $exact then {verdict:"exact", rule:$exact} else {verdict:"none"} end' 2>/dev/null || true) case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in - exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule'); bp_code=200 ;; + exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;; undecidable) rm -f "$bp_file" decide ask "H6/H10 merge gate: '$base_ref' has no branch-protection rule under its own name, but a GLOB rule on this repo could govern it. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;; none) bp_code=404; bp="" ;; diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 5afde7b9e..a0d4e83de 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -66,7 +66,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| | `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `UNSAFE-KNOWN` | Every comparison uses the **full** 40-char `.head.sha` (`${sha:0:7}` appears only in human-readable strings), which is the right identifier — but it is captured ONCE from the PR snapshot at the top of the hook, and is stale in two distinct ways. FIRST, within the run: every later check (CI status, H10 status, verdict comments) is evaluated against that captured sha, so a push landing mid-run is checked against the commit it replaced. This is the same defect that WAS live for `$base_ref` until it was re-read before the branch-protection lookup; the sha is not re-read, and closing it symmetrically is tracked in **#803**. SECOND, after the decision: the hook returns `allow` and a separate call performs the merge. "No async window" was the second overclaim cold review removed from this file. Both are bounded the same way — a head the verdict does not cover cannot inherit the sha-bound required status, so the server refuses it — and neither is bounded by anything in this hook. The merge API accepts an optional `head_commit_id`, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. | -| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check (404/absent → deny, unreadable → ask) instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | +| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check — absent → deny, unreadable → ask, and a **glob rule that could govern the base → ask**, because the hook deliberately does not reimplement Gitea's glob dialect instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | | `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `UNSAFE-KNOWN` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632), and it deliberately compares the base **ref** rather than `base.sha` — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. | | `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. | | `.claude/hooks/prepush-donewhen.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 4152a4cf0..d0fcb5c0e 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -11,12 +11,17 @@ compared the two — the hook asserted it in a comment and in the reason string is a claim about the past, not a check. These tests pin the conversion of that assumption into a precondition. -The three-way outcome is the contract, and each arm is asserted separately because collapsing any -two of them is how this class of guard has failed here before: +The outcome set is the contract, and each arm is asserted separately because collapsing any two of +them is how this class of guard has failed here before: - * required check PRESENT -> proceed (no opinion drawn from this check) + * required check PRESENT -> proceed (no opinion drawn from this check) * branch protection UNREADABLE -> ask (a transient failure is not evidence of safety) - * required check ABSENT -> deny (this is #622's hole reopened, not a degraded read) + * required check ABSENT -> deny (this is #622's hole reopened, not a degraded read) + * a GLOB rule COULD govern the base -> ask, and distinctly from the unreadable case. This hook + does not reimplement Gitea's glob dialect, so "some rule might apply and we cannot tell" is a + fourth answer, not a flavour of the third. Tests that assert only `"ask" in reason` cannot tell + the two apart — and a crashed classifier also produces an ask — so each arm is pinned on the + text unique to it. Observable contract: the hook exits 0 with EMPTY stdout when it has no opinion, and emits a JSON `permissionDecision` otherwise. @@ -562,6 +567,9 @@ def test_a_base_that_a_GLOB_rule_could_govern_ASKS(hook): reason = hook.reason() assert "ask" in reason, ( "a base a glob rule could govern was decided rather than referred to a human") + assert "could govern it" in reason, ( + "the ask came from the generic could-not-read arm, not the undecidable-glob arm — those " + "are different outcomes and a crashed classifier must not pass as a correct classification") assert "NO branch protection" not in reason, ( "a base a glob rule could govern was reported as having no protection at all") @@ -633,6 +641,8 @@ def test_a_GLOB_rule_whose_literal_part_has_a_metacharacter_still_MATCHES(hook): assert "ask" in reason, ( "the glob rule 'release/26.*' could govern base 'release/26.4', which is undecidable here " "and must ask") + assert "could govern it" in reason, ( + "the ask must come from the undecidable-glob arm, not from the classifier failing") assert "NO branch protection" not in reason, ( "a base a glob rule could govern was reported as entirely unprotected — the over-escaping " "failure this test exists to catch") @@ -645,6 +655,9 @@ def test_a_rule_name_containing_a_CHAR_CLASS_bracket_does_not_crash_the_matcher( hook.set_branch_protection("CHARCLASS-RULE") reason = hook.reason() assert reason, "the hook emitted no decision at all" + assert "ask" not in reason, ( + "the exact-name rule was decidable and must have been honoured; an ask here means the " + "classifier failed rather than classified") assert "NO branch protection" not in reason, ( "one rule with a bracket in its name discarded the whole list, including the exact-name " "rule that actually protects this base") @@ -658,6 +671,9 @@ def test_a_plain_rule_name_is_matched_CASE_INSENSITIVELY(hook): base is unprotected, and deny with a false stated cause.""" hook.set_branch_protection("CASEFOLD-RULE") reason = hook.reason() + assert "ask" not in reason, ( + "the case-folded exact rule was decidable and must have been honoured; an ask means the " + "classifier failed rather than classified") assert "NO branch protection" not in reason, ( "a rule named 'MAIN' governs base 'main' in Gitea but was missed by a case-sensitive compare") assert "deny" not in reason -- 2.47.3 From 356cddbb594ab8c0541c1968969a349dc755a33c Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 19:54:47 +0200 Subject: [PATCH 15/20] fix(778): pin the arm order, and correct a wrong explanation I had just written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two self-found defects while pre-empting the confirmation round's own questions. THE REORDER WAS UNPINNED. Swapping the classifier arms back to exact-first left all 29 tests green, so the previous commit's central change was invisible to the suite — an unproven change shipping under a green run. The missing fixture is the one that distinguishes the orders: a list holding BOTH an exactly-named rule that requires `review-verdict/h10` AND a glob rule that could also govern the base and does not. Exact-first inspects the rule that requires h10 and auto-grants; undecidable-first asks. Now mutation-proven in both directions. I HAD MISDIAGNOSED THE TRAP, and asserted the wrong cause in a comment. A three-line repro disproves "an EXIT trap suppresses output" — it does not. The real mechanism is that this file already owns its EXIT trap: `scripts/hook-fire-log.sh` installs `trap 'etv_hook_fire_end "$?"' EXIT` (#776), and in capture mode that handler is what REPLAYS the decision JSON to stdout. A second `trap ... EXIT` silently replaces it, so the decision is captured and never emitted. The trap slot is a single shared resource and the sourced library claimed it first. That second one is the shape this whole PR is about, committed against my own work: an explanation that fits the symptom, is written down as fact, and is wrong. It would have told the next person the construct is unusable rather than that the slot is taken. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 20 +++++++---- .../test_merge_consent_required_check.py | 33 ++++++++++++++++++- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index f6e332f8e..ef0eebb39 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -408,13 +408,19 @@ else # would read as "unprotected" and turn into a hard DENY whose reason names a specific, false cause. bp_ref=$(printf '%s' "$base_ref" | jq -sRr @uri) bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge." - # Cleanup is hand-placed on each exit path rather than a `trap ... EXIT`. That was tried and - # REVERTED: this hook is driven through a `decide` helper that prints its JSON and exits, and - # installing an EXIT trap here suppressed the decision output entirely — 20 tests went from green - # to "the hook passed through instead of auto-granting". A gate that prints nothing is the one - # outcome it must never produce, so the tidier construct loses to the one that demonstrably works. - # Every path between here and the reads below removes the file exactly once; cold review traced - # them independently. + # Cleanup is hand-placed on each exit path, and a `trap 'rm -f' EXIT` MUST NOT be added here. + # + # It was tried, and it took 20 tests from green to "the hook passed through instead of + # auto-granting". The first explanation written here — that an EXIT trap suppresses output — was + # WRONG, and a three-line repro disproves it. The real cause is that THIS FILE ALREADY OWNS ITS + # EXIT TRAP: `scripts/hook-fire-log.sh` installs `trap 'etv_hook_fire_end "$?"' EXIT` (ersatztv#776), + # and in `capture` mode that handler is what REPLAYS the hook's decision JSON to stdout. A second + # `trap ... EXIT` silently replaces it, so the decision is captured and then never emitted — a + # consent gate printing nothing at all, which is the one outcome it must never produce. + # + # The trap slot is a single shared resource, and the sourced library claimed it first. Every path + # between here and the reads below removes the file exactly once; cold review traced them + # independently. bp_get() { # $1 = path under the repo if [ -n "${ETV_GITEA_TOKEN:-}" ]; then curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/$1" 2>/dev/null || true diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index d0fcb5c0e..42af6df6a 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -70,6 +70,15 @@ if url.rstrip("/").endswith("/branch_protections"): # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. mode = (state / "bp").read_text().strip() + if mode == "EXACT-PLUS-GLOB-RULE": + # BOTH an exactly-named rule that requires h10 AND a glob rule that could also govern this + # base and does NOT. Gitea sorts by Priority before plain-name-ness, so the glob rule can be + # the one actually applied — which means inspecting the exact rule would auto-grant on a base + # where h10 is not enforced. Undecidable must win. + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}, + {"branch_name": "m*", "enable_status_check": True, + "status_check_contexts": ["Build ErsatzTV Image / Build & test (.NET)"]}])) if mode == "ESCAPED-META-RULE": # gobwas/glob reads `\\{` as a LITERAL brace, so this rule governs the base `a{b`. If the # superset treats `\\` as literal it builds `a\\.*b`, misses, and answers `none` — denying a @@ -119,7 +128,7 @@ if "/branch_protections/" in url: respond("", "000") if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE", "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE", - "ESCAPED-META-RULE"): + "ESCAPED-META-RULE", "EXACT-PLUS-GLOB-RULE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -700,3 +709,25 @@ def test_a_BACKSLASH_ESCAPED_metacharacter_in_a_rule_name_is_undecidable_not_abs "a rule whose escaped brace governs this base was reported as unable to govern it") assert "ask" in reason, ( "an escaped-metacharacter rule is undecidable here and must ask") + + +def test_a_GLOB_rule_that_could_outrank_an_exact_one_wins_and_ASKS(hook): + """The arm ORDER, pinned. Without this the reorder is invisible to the suite — swapping the arms + back left all 29 tests green, which is how an unproven change ships. + + Gitea picks the governing rule with `GetFirstMatched` over a list sorted by Priority and THEN by + plain-name-ness, so a glob rule can outrank an exactly-named one. Here `main` requires + `review-verdict/h10` and `m*` does not. Evaluating `exact` first inspects the rule that requires + h10, concludes the base is protected, and AUTO-GRANTS a scheduled merge onto a base where the + check may not be enforced at all — #622's hole, reached through the block written to close it. + + Evaluating `undecidable` first is sound without knowing Gitea's precedence rules, which is the + only claim this code is entitled to make about somebody else's resolver. + """ + hook.set_branch_protection("EXACT-PLUS-GLOB-RULE") + reason = hook.reason() + assert "ask" in reason, ( + "an exact rule was trusted while a glob rule could outrank it — the gate granted on a base " + "whose enforced rule it never identified") + assert "could govern it" in reason, ( + "the ask must come from the undecidable-glob arm, not from the classifier failing") -- 2.47.3 From c7bea972d6722c4b4174bb872220d59738df6224 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 20:23:32 +0200 Subject: [PATCH 16/20] =?UTF-8?q?fix(778):=20close=20the=20last=20three=20?= =?UTF-8?q?residuals=20=E2=80=94=20ambiguous=20exact=20rules,=20non-ASCII?= =?UTF-8?q?=20folds,=20one=20unpinned=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmation review returned MERGEABLE at 356cddbb5, having independently re-executed every claim (the "2 -> 6 dead-classifier" figure exact, the "20 tests" trap figure exactly reproducible, the arm reorder pinned, live branch protection confirming the dated claim). Its three residuals are taken here. M1 — the backslash test hit the undecidable arm but asserted only `"ask" in reason`, which a CRASHED classifier also satisfies. Its two siblings got `"could govern it"` one commit earlier and this one did not. Under the dead-classifier mutation it was the single green test of that arm; now red. L1 — with two plain rules differing only in case, `first` picked LIST order while Gitea picks by Priority. Given `MAIN` requiring review-verdict/h10 and `main` not, the hook could inspect the rule that requires it and auto-grant on a base where Gitea enforces the other — the same defect as the arm order, one level down. Two fold-equal rules are now undecidable. L2 — the same standard, applied where I had waived it. The backslash paragraph rejects "nearly unreachable" as a standard for the arm that issues a DENY, and two paragraphs up the ASCII-only fold was accepted on exactly those grounds: rule `ünstable` and base `Ünstable` fold equal under Gitea's EqualFold and not under `ascii_downcase`, landing on `none` -> deny with a false stated cause. A non-ASCII rule name or base is now undecidable rather than fold-compared. The non-ASCII test is `explode | any(. > 127)`, not a `\uXXXX` regex: the first attempt was a character-class regex whose backslashes are ambiguous through a single-quoted shell string into jq, and a standalone probe showed it classifying plain `main` as non-ASCII. Codepoints have no escaping layer to get wrong. Both new arms are mutation-proven individually; an earlier attempt at those proofs used perl substitutions that silently matched nothing and reported green, which is why they were redone in python with an explicit assert on the target. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 24 ++++++++--- docs/remote-state-inventory.md | 2 +- .../test_merge_consent_required_check.py | 41 ++++++++++++++++++- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index ef0eebb39..07044d0f8 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -459,10 +459,17 @@ else # without knowing the precedence rules at all, which is the only claim this # code is entitled to make about somebody else's resolver. # - # Case folding is ASCII-only here, while Gitea's `EqualFold` is Unicode-aware. - # That can only ever fail to find a match, never invent one, so it degrades - # into the `none`/deny direction rather than into a grant. Stated rather than - # papered over: this is not parity with `EqualFold`. + # Case folding is ASCII-only here, while Gitea's `EqualFold` is + # Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there + # and not here. ASCII-fold equality implies EqualFold equality, so the gap can + # only MISS a match, never invent one; but a miss lands on `none`, which + # DENIES with the stated cause "no branch protection at all". The backslash + # paragraph below rejects "nearly unreachable" as a standard for that arm, and + # the same standard has to apply here, so a rule name carrying any non-ASCII + # byte is `undecidable` rather than fold-compared. Two fold-equal plain names + # are undecidable too: this code picks by list order while Gitea picks by + # Priority, and guessing which one is enforced is the defect the arm order + # above exists to avoid. # undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any # glob dialect: literal prefix before the first metacharacter, `.*`, literal # suffix after the last. If even that does not match, no dialect can, because @@ -489,14 +496,19 @@ else def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset]; def superset: . as $n | (offs) as $o | ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc); + def nonascii: explode | any(. > 127); . as $rules | $b as $base | ($rules | map(select((.branch_name // .rule_name // "") as $n | (($n|offs|length) == 0) - and (($n|ascii_downcase) == ($base|ascii_downcase)))) | first) as $exact | + and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts | + (($base|nonascii) or ($rules | any((.branch_name // .rule_name // "") as $n + | ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable | if ($rules | any((.branch_name // .rule_name // "") as $n | (($n|offs|length) > 0) and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"} - elif $exact then {verdict:"exact", rule:$exact} + elif $unfoldable then {verdict:"undecidable"} + elif ($exacts | length) > 1 then {verdict:"undecidable"} + elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)} else {verdict:"none"} end' 2>/dev/null || true) case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;; diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index a0d4e83de..57b1774c5 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -66,7 +66,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| | `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `UNSAFE-KNOWN` | Every comparison uses the **full** 40-char `.head.sha` (`${sha:0:7}` appears only in human-readable strings), which is the right identifier — but it is captured ONCE from the PR snapshot at the top of the hook, and is stale in two distinct ways. FIRST, within the run: every later check (CI status, H10 status, verdict comments) is evaluated against that captured sha, so a push landing mid-run is checked against the commit it replaced. This is the same defect that WAS live for `$base_ref` until it was re-read before the branch-protection lookup; the sha is not re-read, and closing it symmetrically is tracked in **#803**. SECOND, after the decision: the hook returns `allow` and a separate call performs the merge. "No async window" was the second overclaim cold review removed from this file. Both are bounded the same way — a head the verdict does not cover cannot inherit the sha-bound required status, so the server refuses it — and neither is bounded by anything in this hook. The merge API accepts an optional `head_commit_id`, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. | -| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check — absent → deny, unreadable → ask, and a **glob rule that could govern the base → ask**, because the hook deliberately does not reimplement Gitea's glob dialect instead of asserting it. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | +| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check — absent → deny, unreadable → ask, and a **glob rule that could govern the base → ask**, because the hook deliberately does not reimplement Gitea's glob dialect. It also asks when two rule names fold equal, or when either name is non-ASCII, since Gitea's `EqualFold` is Unicode-aware and its rule precedence is not derivable here. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | | `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `UNSAFE-KNOWN` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632), and it deliberately compares the base **ref** rather than `base.sha` — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. | | `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. | | `.claude/hooks/prepush-donewhen.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 42af6df6a..ce40a2d25 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -70,6 +70,16 @@ if url.rstrip("/").endswith("/branch_protections"): # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. mode = (state / "bp").read_text().strip() + if mode == "TWO-CASE-VARIANT-RULES": + # Two plain rules differing only in case. `first` picks LIST order; Gitea picks by Priority, + # so which one is enforced cannot be derived here. + respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}, + {"branch_name": "main", "enable_status_check": True, + "status_check_contexts": ["Build ErsatzTV Image / Build & test (.NET)"]}])) + if mode == "NONASCII-RULE": + respond(json.dumps([{"branch_name": "\u00fcnstable", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "EXACT-PLUS-GLOB-RULE": # BOTH an exactly-named rule that requires h10 AND a glob rule that could also govern this # base and does NOT. Gitea sorts by Priority before plain-name-ness, so the glob rule can be @@ -128,7 +138,8 @@ if "/branch_protections/" in url: respond("", "000") if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE", "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE", - "ESCAPED-META-RULE", "EXACT-PLUS-GLOB-RULE"): + "ESCAPED-META-RULE", "EXACT-PLUS-GLOB-RULE", + "TWO-CASE-VARIANT-RULES", "NONASCII-RULE"): respond('{"message":"not found"}', "404") if mode == "FORBIDDEN": respond('{"message":"token does not have at least one of required scope(s)"}', "403") @@ -709,6 +720,9 @@ def test_a_BACKSLASH_ESCAPED_metacharacter_in_a_rule_name_is_undecidable_not_abs "a rule whose escaped brace governs this base was reported as unable to govern it") assert "ask" in reason, ( "an escaped-metacharacter rule is undecidable here and must ask") + assert "could govern it" in reason, ( + "the ask must come from the undecidable-glob arm, not from the classifier failing — this " + "test hits the same arm as its two siblings and needs the same pin") def test_a_GLOB_rule_that_could_outrank_an_exact_one_wins_and_ASKS(hook): @@ -731,3 +745,28 @@ def test_a_GLOB_rule_that_could_outrank_an_exact_one_wins_and_ASKS(hook): "whose enforced rule it never identified") assert "could govern it" in reason, ( "the ask must come from the undecidable-glob arm, not from the classifier failing") + + +def test_two_rules_differing_only_in_CASE_are_undecidable(hook): + """`first` picks list order; Gitea picks by Priority. With `MAIN` requiring `review-verdict/h10` + and `main` not, inspecting whichever the API happened to list first would auto-grant on a base + whose enforced rule was never identified — the same defect as the arm order, one level down.""" + hook.set_branch_protection("TWO-CASE-VARIANT-RULES") + reason = hook.reason() + assert "ask" in reason, ( + "two fold-equal rules disagree about review-verdict/h10 and one was picked by list order") + assert "could govern it" in reason + + +def test_a_NON_ASCII_rule_or_base_is_undecidable_rather_than_fold_compared(hook): + """`ascii_downcase` is not Gitea's Unicode-aware `EqualFold`, so a rule `ünstable` and a base + `Ünstable` fold equal there and not here. The miss lands on `none`, which DENIES with the stated + cause "no branch protection at all" — and the backslash arm already rejects "nearly unreachable" + as a standard for the arm that issues a deny, so the same standard applies here.""" + hook.set_base("\u00dcnstable") + hook.drop_recorded_base() + hook.set_branch_protection("NONASCII-RULE") + reason = hook.reason() + assert "NO branch protection" not in reason, ( + "a rule that folds equal to this base under EqualFold was reported as unable to govern it") + assert "ask" in reason -- 2.47.3 From f3863c372058b165e052286194eed0a9b334afaa Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 21:07:09 +0200 Subject: [PATCH 17/20] =?UTF-8?q?fix(778):=20delete=20the=20by-name=20look?= =?UTF-8?q?up=20=E2=80=94=20the=20precedence=20check=20was=20guarding=20th?= =?UTF-8?q?e=20dead=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final review returned MERGEABLE with no Blocker and no High. Its one Medium is taken, and it is my own recurring trap for the third time in this PR: fix one path, then check its TWIN. The hook looked a rule up by NAME first and enumerated the rule list only on a 404. But `branch_protections/{name}` is an exact DB lookup — `GetProtectedBranchRuleByName` — which performs no matching and knows nothing about precedence. A 200 from it means "a rule with this NAME exists and lists this context", never "this context is required on this branch". So the precedence argument added last round guarded the 404 path while the 200 path granted without it — and since this repo's rule IS named `main`, the by-name lookup always returns 200. The hardened code was dead and the unhardened code was live. Given a rule `main` requiring review-verdict/h10 and a rule `m*` with better Priority that does not, Gitea applies `m*`; the by-name hit on `main` saw h10 and granted anyway. Fixed by DELETING the twin rather than documenting it: one fetch of the full list, one classifier, one argument, no second path to keep in step. Two things fall out for free — the ref no longer reaches a URL segment, so the percent-encoding hazard is removed by construction rather than escaped (its test is replaced by one asserting no ref reaches the URL at all), and every case the classifier already covered now applies to the live path instead of an unreachable one. Verified against the live Gitea: the classifier returns `exact` -> rule `main` -> enable_status_check=true, h10 present. A new test pins that the precedence check runs even when an exactly-named rule exists, and asserts the by-name endpoint is never requested, so the split cannot come back silently. The grant string now states what was actually established — read from the full rule list, matched with Gitea's own plain-vs-glob split, refusing wherever precedence or folding is not derivable — rather than the stronger "confirmed required" it claimed while consulting a single named rule. 724 tests green. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 70 +++--- docs/remote-state-inventory.md | 2 +- .../test_merge_consent_required_check.py | 223 +++++++++--------- 3 files changed, 139 insertions(+), 156 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 07044d0f8..7914f51ef 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -403,41 +403,28 @@ else # Collapsing the two, which the first version of this guard did, sends the single most likely # real-world trigger ("branch protection was removed") to a human prompt that reads like a # transient hiccup. - # The ref goes in a URL PATH SEGMENT, so it is percent-encoded. A base like `release/26.4` would - # otherwise inject a `/` and request a path that does not exist — producing a 404 that this code - # would read as "unprotected" and turn into a hard DENY whose reason names a specific, false cause. - bp_ref=$(printf '%s' "$base_ref" | jq -sRr @uri) + # ALWAYS enumerate the rule LIST; never look a rule up by name. The by-name endpoint + # (`branch_protections/{name}`) is an exact DB lookup — `GetProtectedBranchRuleByName` — which + # performs no matching and knows nothing about precedence, so a 200 from it means only "a rule + # with this NAME exists and lists this context", never "this context is required on this branch". + # + # It was used first, with the list consulted only on a 404, and cold review found what that left + # behind: the precedence argument below guarded the 404 path while the 200 path — the one this + # repo actually takes — granted without it. Given a rule `main` requiring `review-verdict/h10` and + # a rule `m*` with better Priority that does not, Gitea applies `m*`, and the by-name hit on + # `main` granted anyway. The hardened path was dead code and the unhardened one was live. Deleting + # the twin rather than documenting it is the point: one fetch, one classifier, one argument, and + # no second path to keep in step. The ref no longer reaches a URL segment, so it needs no + # encoding either. bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge." - # Cleanup is hand-placed on each exit path, and a `trap 'rm -f' EXIT` MUST NOT be added here. - # - # It was tried, and it took 20 tests from green to "the hook passed through instead of - # auto-granting". The first explanation written here — that an EXIT trap suppresses output — was - # WRONG, and a three-line repro disproves it. The real cause is that THIS FILE ALREADY OWNS ITS - # EXIT TRAP: `scripts/hook-fire-log.sh` installs `trap 'etv_hook_fire_end "$?"' EXIT` (ersatztv#776), - # and in `capture` mode that handler is what REPLAYS the hook's decision JSON to stdout. A second - # `trap ... EXIT` silently replaces it, so the decision is captured and then never emitted — a - # consent gate printing nothing at all, which is the one outcome it must never produce. - # - # The trap slot is a single shared resource, and the sourced library claimed it first. Every path - # between here and the reads below removes the file exactly once; cold review traced them - # independently. - bp_get() { # $1 = path under the repo - if [ -n "${ETV_GITEA_TOKEN:-}" ]; then - curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/$1" 2>/dev/null || true - else - curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/$1" 2>/dev/null || true - fi - } - bp_code=$(bp_get "branch_protections/$bp_ref") - bp=$(cat "$bp_file" 2>/dev/null || true) - # A 404 here does NOT prove the branch is unprotected: Gitea keys this endpoint on the RULE NAME, - # so a base covered by a GLOB rule (`rel/*`) 404s on its own name while being fully protected. - # Before denying, ask the list endpoint whether any rule matches — a deny is not a prompt, it - # blocks the operation outright, and a deny whose stated cause is false is worse than an ask. - if [ "$bp_code" = "404" ]; then - bp_code=$(bp_get "branch_protections") - bp_list=$(cat "$bp_file" 2>/dev/null || true) - if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then + if [ -n "${ETV_GITEA_TOKEN:-}" ]; then + bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true) + else + bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true) + fi + bp_list=$(cat "$bp_file" 2>/dev/null || true) + bp="" + if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then # DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would # be the exact defect this PR records (a mitigation outside the code, asserted rather than # verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does @@ -513,21 +500,20 @@ else case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;; undecidable) rm -f "$bp_file" - decide ask "H6/H10 merge gate: '$base_ref' has no branch-protection rule under its own name, but a GLOB rule on this repo could govern it. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;; + decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;; none) bp_code=404; bp="" ;; *) bp_code=000; bp="" ;; esac - else - bp_code=${bp_code:-000} # could not read the list either -> the ask arm below - bp="" - fi + else + bp_code=${bp_code:-000} # unreadable list -> the ask arm below + bp="" fi rm -f "$bp_file" if [ "$bp_code" = "404" ]; then - decide deny "H6/H10 merge gate: BLOCKED — '$base_ref' has NO branch protection at all (Gitea returned 404), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green." + decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green." fi if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then - decide ask "H6/H10 merge gate: could not read branch protection for '$base_ref' (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs). Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling." + decide ask "H6/H10 merge gate: could not read this repo's branch-protection rules (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling." fi # The membership test is `any(.[]; . == …)` over a value FIRST PROVEN to be an array of strings — # never `index()`. `index` on a STRING is substring search, so a `status_check_contexts` that @@ -618,7 +604,7 @@ if [ "$class" = "positive" ]; then # path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green" # there was a plain falsehood in the one message a human reads to decide whether to trust the gate. if [ "$mwcs" = "true" ]; then - decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed). A commit pushed before Gitea merges clears the sha-bound verdict status and is blocked by the 'review-verdict/h10' required check (ersatztv#622) — which this hook has just CONFIRMED is still required on '$base_ref' rather than assuming it. That guarantee holds while that branch protection stands; if it is weakened after this check, nothing here would see it (ersatztv#778). Auto-granted." + decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed). A commit pushed before Gitea merges clears the sha-bound verdict status and is blocked by the 'review-verdict/h10' required check (ersatztv#622) — which this hook has just CONFIRMED is still required on '$base_ref' — read from the repo's full rule list and matched with Gitea's own plain-vs-glob split, refusing rather than guessing wherever precedence or folding is not derivable. That guarantee holds while that branch protection stands; if it is weakened after this check, nothing here would see it (ersatztv#778). Auto-granted." fi decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)." fi diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 57b1774c5..e2fb528af 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -66,7 +66,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| | `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `UNSAFE-KNOWN` | Every comparison uses the **full** 40-char `.head.sha` (`${sha:0:7}` appears only in human-readable strings), which is the right identifier — but it is captured ONCE from the PR snapshot at the top of the hook, and is stale in two distinct ways. FIRST, within the run: every later check (CI status, H10 status, verdict comments) is evaluated against that captured sha, so a push landing mid-run is checked against the commit it replaced. This is the same defect that WAS live for `$base_ref` until it was re-read before the branch-protection lookup; the sha is not re-read, and closing it symmetrically is tracked in **#803**. SECOND, after the decision: the hook returns `allow` and a separate call performs the merge. "No async window" was the second overclaim cold review removed from this file. Both are bounded the same way — a head the verdict does not cover cannot inherit the sha-bound required status, so the server refuses it — and neither is bounded by anything in this hook. The merge API accepts an optional `head_commit_id`, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. | -| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check — absent → deny, unreadable → ask, and a **glob rule that could govern the base → ask**, because the hook deliberately does not reimplement Gitea's glob dialect. It also asks when two rule names fold equal, or when either name is non-ASCII, since Gitea's `EqualFold` is Unicode-aware and its rule precedence is not derivable here. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | +| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check, reading the repo's **full rule list** (never the by-name endpoint, which does no matching and knows nothing about precedence) — nothing can govern the base → deny, unreadable → ask, and a **glob rule that could govern it → ask**, because the hook deliberately does not reimplement Gitea's glob dialect. It also asks when two rule names fold equal, or when either name is non-ASCII, since Gitea's `EqualFold` is Unicode-aware and its rule precedence is not derivable here. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. | | `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `UNSAFE-KNOWN` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632), and it deliberately compares the base **ref** rather than `base.sha` — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. | | `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. | | `.claude/hooks/prepush-donewhen.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. | diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index ce40a2d25..bea822332 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -67,12 +67,45 @@ def respond(body, code="200"): sys.exit(0) if url.rstrip("/").endswith("/branch_protections"): - # The LIST endpoint, consulted only after a 404 on the by-name lookup. Gitea keys the by-name - # endpoint on the RULE name, so a base covered by a glob rule 404s there while being protected. + # The hook reads ONLY this endpoint now — the by-name lookup was deleted because it performs no + # matching and knows nothing about rule precedence, so a 200 from it proved less than it looked. + (state / "bp_called").write_text("yes") + with (state / "bp_urls").open("a") as fh: + fh.write(url + "\n") mode = (state / "bp").read_text().strip() + + if mode == "TRANSPORT-ERROR": + respond("", "000") + if mode == "FORBIDDEN": + respond('{"message":"token does not have at least one of required scope(s)"}', "403") + if mode == "GARBAGE": + respond('{"message":"not an array"}') + if mode == "EMPTY": + respond('') + if mode in ("NOT-FOUND", "LIST-UNREADABLE"): + # No rule can govern the base: the list is readable and empty (NOT-FOUND), or unreadable. + respond("[]") if mode == "NOT-FOUND" else respond('{"message":"internal error"}', "500") + if mode == "GLOB-RULE": + respond(json.dumps([{"branch_name": "m*", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "REGEX-META-RULE": + respond(json.dumps([{"branch_name": "mai.", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "GLOB-WITH-DOT-RULE": + respond(json.dumps([{"branch_name": "release/26.*", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "CHARCLASS-RULE": + respond(json.dumps([{"branch_name": "a[b", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}, + {"branch_name": "main", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "ESCAPED-META-RULE": + respond(json.dumps([{"branch_name": "a\\{b", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "CASEFOLD-RULE": + respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "TWO-CASE-VARIANT-RULES": - # Two plain rules differing only in case. `first` picks LIST order; Gitea picks by Priority, - # so which one is enforced cannot be derived here. respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True, "status_check_contexts": ["review-verdict/h10"]}, {"branch_name": "main", "enable_status_check": True, @@ -81,111 +114,46 @@ if url.rstrip("/").endswith("/branch_protections"): respond(json.dumps([{"branch_name": "\u00fcnstable", "enable_status_check": True, "status_check_contexts": ["review-verdict/h10"]}])) if mode == "EXACT-PLUS-GLOB-RULE": - # BOTH an exactly-named rule that requires h10 AND a glob rule that could also govern this - # base and does NOT. Gitea sorts by Priority before plain-name-ness, so the glob rule can be - # the one actually applied — which means inspecting the exact rule would auto-grant on a base - # where h10 is not enforced. Undecidable must win. respond(json.dumps([{"branch_name": "main", "enable_status_check": True, "status_check_contexts": ["review-verdict/h10"]}, {"branch_name": "m*", "enable_status_check": True, "status_check_contexts": ["Build ErsatzTV Image / Build & test (.NET)"]}])) - if mode == "ESCAPED-META-RULE": - # gobwas/glob reads `\\{` as a LITERAL brace, so this rule governs the base `a{b`. If the - # superset treats `\\` as literal it builds `a\\.*b`, misses, and answers `none` — denying a - # base that IS protected. - respond(json.dumps([{"branch_name": "a\\{b", "enable_status_check": True, - "status_check_contexts": ["review-verdict/h10"]}])) - if mode == "CASEFOLD-RULE": - # Gitea compares a plain (non-glob) rule name with EqualFold, so a rule named `MAIN` - # governs base `main`. A case-SENSITIVE comparison here would miss it and deny. - respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True, - "status_check_contexts": ["review-verdict/h10"]}])) - if mode == "GLOB-WITH-DOT-RULE": - # The POSITIVE control: a glob rule whose literal segment contains a metacharacter, which - # genuinely governs this base. Must MATCH — an over-escaped pattern makes it unmatchable and - # produces a hard deny with a false stated cause. - respond(json.dumps([{"branch_name": "release/26.*", "enable_status_check": True, - "status_check_contexts": ["review-verdict/h10"]}])) - if mode == "CHARCLASS-RULE": - # A rule name containing `[` crashed jq outright when escaping was wrong; the error was - # swallowed and became the same false deny. - respond(json.dumps([{"branch_name": "a[b", "enable_status_check": True, - "status_check_contexts": ["review-verdict/h10"]}, - {"branch_name": "main", "enable_status_check": True, - "status_check_contexts": ["review-verdict/h10"]}])) - if mode == "REGEX-META-RULE": - # A rule whose NAME contains regex metacharacters, governing a DIFFERENT branch. Substituting - # `*` into a raw regex made `mai.` match `main`; the rule must be treated literally. - respond(json.dumps([{"branch_name": "mai.", "enable_status_check": True, - "status_check_contexts": ["review-verdict/h10"]}])) - if mode == "GLOB-RULE": - respond(json.dumps([{"branch_name": "m*", "enable_status_check": True, - "status_check_contexts": ["review-verdict/h10"]}])) - if mode == "LIST-UNREADABLE": - respond('{"message":"internal error"}', "500") - respond("[]") - -if "/branch_protections/" in url: - # Record the call so the positive control can prove the guarded case REACHED this endpoint, - # rather than passing because the hook bailed earlier for an unrelated reason. The full URL is - # kept so a test can assert what was actually REQUESTED, not merely what the source contains. - (state / "bp_called").write_text("yes") - with (state / "bp_urls").open("a") as fh: - fh.write(url + "\n") - mode = (state / "bp").read_text().strip() - if mode == "TRANSPORT-ERROR": - # curl itself fails: no body, and `-w` prints 000. - respond("", "000") - if mode in ("NOT-FOUND", "GLOB-RULE", "LIST-UNREADABLE", "REGEX-META-RULE", - "GLOB-WITH-DOT-RULE", "CHARCLASS-RULE", "CASEFOLD-RULE", - "ESCAPED-META-RULE", "EXACT-PLUS-GLOB-RULE", - "TWO-CASE-VARIANT-RULES", "NONASCII-RULE"): - respond('{"message":"not found"}', "404") - if mode == "FORBIDDEN": - respond('{"message":"token does not have at least one of required scope(s)"}', "403") - if mode == "GARBAGE": - respond('[]') - if mode == "EMPTY": - respond('') if mode == "MALFORMED-MEMBER": - # A valid JSON OBJECT whose contexts member is not a list — passes a top-level type check - # and then makes jq error one level down. - respond('{"enable_status_check": true, "status_check_contexts": 7}') + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": 7}])) if mode == "SUBSTRING-STRING": - # The false-OPEN shape: a bare string that CONTAINS the context name. jq's `index` on a - # string is substring search, so the naive membership test answers "yes" here. - respond('{"enable_status_check": true,' - ' "status_check_contexts": "prefix-review-verdict/h10-suffix"}') - if mode == "NONSTRING-STATUS-ROW": - respond('{"enable_status_check": true,' - ' "status_check_contexts": ["review-verdict/h10"]}') - if mode == "STRING-ENABLE": - respond('{"enable_status_check": "true",' - ' "status_check_contexts": ["review-verdict/h10"]}') + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": "prefix-review-verdict/h10-suffix"}])) if mode == "FALSE-CONTEXTS": - # jq's `//` alternative fires on FALSE as well as null, so `// []` silently turned this - # malformed shape into an empty list and produced a confident deny. - respond('{"enable_status_check": true, "status_check_contexts": false}') + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": False}])) + if mode == "STRING-ENABLE": + respond(json.dumps([{"branch_name": "main", "enable_status_check": "true", + "status_check_contexts": ["review-verdict/h10"]}])) if mode == "NON-STRING-MEMBER": - respond('{"enable_status_check": true, "status_check_contexts": [7, "review-verdict/h10"]}') + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": [7, "review-verdict/h10"]}])) if mode == "EMPTY-CONTEXTS": - respond('{"enable_status_check": true, "status_check_contexts": []}') + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": []}])) if mode == "NULL-CONTEXTS": - respond('{"enable_status_check": true, "status_check_contexts": null}') + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": None}])) if mode == "LONGER-STRING-MEMBER": - # A genuine array whose member merely CONTAINS the name must NOT match. - respond('{"enable_status_check": true,' - ' "status_check_contexts": ["xx-review-verdict/h10-yy"]}') - contexts = ["Build ErsatzTV Image / Build & test (.NET) (pull_request)"] - enabled = True - if mode == "GUARDED": - contexts.append("review-verdict/h10") - elif mode == "STATUS-CHECK-OFF": - contexts.append("review-verdict/h10") - enabled = False - # mode == "MISSING-CONTEXT" falls through with h10 absent - respond(json.dumps({"branch_name": "main", "enable_status_check": enabled, - "status_check_contexts": contexts})) + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": ["xx-review-verdict/h10-yy"]}])) + if mode == "STATUS-CHECK-OFF": + respond(json.dumps([{"branch_name": "main", "enable_status_check": False, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "MISSING-CONTEXT": + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": + ["Build ErsatzTV Image / Build & test (.NET) (pull_request)"]}])) + # GUARDED + respond(json.dumps([{"branch_name": "main", "enable_status_check": True, + "status_check_contexts": + ["Build ErsatzTV Image / Build & test (.NET) (pull_request)", + "review-verdict/h10"]}])) if "/pulls/" in url and "/files" in url: q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) @@ -432,8 +400,9 @@ def test_a_base_with_NO_branch_protection_at_all_denies_rather_than_asking(hook) reason = hook.reason() assert "deny" in reason, ( "a base with no branch protection at all did not deny a scheduled auto-merge") - assert "404" in reason or "NO branch protection" in reason, ( - "the deny must distinguish 'unprotected' from 'could not read'") + assert "none matches" in reason, ( + "the deny must distinguish 'the list was read and nothing governs this base' from " + "'could not read'") def test_a_403_asks_because_it_says_only_that_we_could_not_look(hook): @@ -443,7 +412,7 @@ def test_a_403_asks_because_it_says_only_that_we_could_not_look(hook): hook.set_branch_protection("FORBIDDEN") reason = hook.reason() assert "ask" in reason, "a 403 was treated as evidence about the protection" - assert "NO branch protection" not in reason, ( + assert "none matches" not in reason, ( "a 403 was reported as a confirmed absence of branch protection") @@ -460,7 +429,7 @@ def test_an_UNREADABLE_branch_protection_asks_rather_than_denying_or_passing(hoo reason = hook.reason() assert "ask" in reason, ( f"an unreadable branch-protection response ({failure}) did not fall through to a human") - assert "branch protection" in reason or "base branch" in reason, ( + assert "branch-protection rules" in reason, ( "the ask must name what could not be checked") @@ -603,21 +572,23 @@ def test_an_unreadable_LIST_asks_rather_than_denying_on_the_404(hook): assert "NO branch protection" not in reason -def test_the_base_ref_is_URL_ENCODED_in_the_protection_lookup(hook): - """A base like `release/26.4` would otherwise inject a path separator and request a URL that - does not exist, producing a 404 this code reads as 'unprotected'. +def test_the_protection_lookup_puts_NO_ref_in_the_url(hook): + """The successor to a URL-encoding test, and the reason it could be retired. - Asserted against the URL the hook actually REQUESTED, not against the source. The first version - grepped for `@uri`, which proves a token appears somewhere in the file — it would have passed - with the encoding applied to the wrong variable, or applied and then discarded. + The ref used to be interpolated into `branch_protections/{name}`, where a base like + `release/26.4` injected a path separator and 404'd — read as "unprotected". That endpoint is + gone: it performed no matching and knew nothing about rule precedence, so a 200 from it proved + less than it looked. Only the LIST endpoint is read now, which takes no ref at all, so the whole + encoding hazard is removed by construction rather than escaped. """ hook.set_base("release/26.4") hook.drop_recorded_base() hook.reason() - urls = [u for u in hook.branch_protection_urls() if "/branch_protections/" in u] - assert urls, "the by-name branch-protection endpoint was never requested" - assert any("release%2F26.4" in u for u in urls), ( - f"the base ref reached the URL unencoded: {urls}") + urls = hook.branch_protection_urls() + assert urls, "the branch-protection endpoint was never requested" + for u in urls: + assert u.rstrip("/").endswith("/branch_protections"), ( + f"a ref reached the branch-protection URL: {u}") def test_a_rule_name_with_REGEX_METACHARACTERS_does_not_match_a_different_base(hook): @@ -636,7 +607,7 @@ def test_a_rule_name_with_REGEX_METACHARACTERS_does_not_match_a_different_base(h reason = hook.reason() assert "deny" in reason, ( "a rule named 'mai.' was regex-matched against base 'main' and read as protection") - assert "NO branch protection" in reason, ( + assert "none matches" in reason, ( "'mai.' contains no GLOB metacharacter, so it is decidable: it simply does not govern " "'main', and the base is genuinely unprotected") @@ -725,6 +696,32 @@ def test_a_BACKSLASH_ESCAPED_metacharacter_in_a_rule_name_is_undecidable_not_abs "test hits the same arm as its two siblings and needs the same pin") +def test_the_precedence_check_runs_even_when_an_exactly_named_rule_EXISTS(hook): + """The twin the restructure deletes, pinned so it cannot come back. + + The hook used to look the rule up by NAME first and only enumerate the list on a 404. That + by-name endpoint is an exact DB lookup that performs no matching and knows nothing about + precedence, so on a 200 — the path this repo actually takes, since its rule IS named `main` — + the gate granted having consulted one rule and never asked which rule Gitea would apply. The + precedence argument guarded the 404 path only: hardened code that was dead, next to live code + that was not. + + `EXACT-PLUS-GLOB-RULE` is exactly that configuration: a rule NAMED `main` that requires + `review-verdict/h10`, plus `m*` that does not. Under the old flow the by-name hit returned the + `main` rule, saw h10 and granted. Now there is one path, so the classifier sees both rules and + refuses to guess which one Gitea applies. + """ + hook.set_branch_protection("EXACT-PLUS-GLOB-RULE") + reason = hook.reason() + assert "ask" in reason, ( + "an exactly-named rule was trusted without asking which rule Gitea would actually apply") + assert "could govern it" in reason + # And the by-name endpoint must not be consulted at all — its existence is what split the paths. + for u in hook.branch_protection_urls(): + assert u.rstrip("/").endswith("/branch_protections"), ( + f"the by-name lookup is back, and with it the unguarded path: {u}") + + def test_a_GLOB_rule_that_could_outrank_an_exact_one_wins_and_ASKS(hook): """The arm ORDER, pinned. Without this the reorder is invisible to the suite — swapping the arms back left all 29 tests green, which is how an unproven change ships. -- 2.47.3 From 7d81f85b38bf5ec66abc2fb76385fee11d1eb18d Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 21:29:09 +0200 Subject: [PATCH 18/20] =?UTF-8?q?fix(778):=20make=20the=20by-name=20pin=20?= =?UTF-8?q?falsifiable=20=E2=80=94=20it=20was=20filtering=20on=20the=20pro?= =?UTF-8?q?perty=20it=20asserted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmation pass returned BLOCKED, and its lead finding is the one worth having. The test forbidding the by-name lookup recorded URLs from INSIDE the `endswith("/branch_protections")` branch, so the only URLs it could ever record were ones that already satisfied the assertion. A by-name request was invisible to the very test written to forbid it. Cold review proved it by reintroducing the lookup in the hook: the suite stayed 33/33 green. That is the filter-on-the-asserted-property defect this PR's sibling record exists to describe, committed inside the guard against it — and the commit message had called the twin "pinned so it cannot come back". The recorder now sees every branch-protection URL whatever its shape. Re-verified by the same mutation: reintroducing a by-name call reddens exactly the two tests that forbid it. Also from that pass: - an HTTP 404 on the LIST read reached the "the full rule list was read and none matches" deny — a claim about a read that never happened. Gitea answers 404 for a repo that is absent or invisible to the credential, so the classifier's own verdict is now the sentinel `nomatch` and HTTP failures reach the ask; - two comment blocks still described the deleted by-name endpoint as live, one of them asserting the classifier "is never reached at all"; - the decision record still documented `branch_protections/{base}` and its 404 semantics as the mechanism, in the record this PR authored — now rewritten to the list endpoint, with why reading the LIST is the load-bearing choice; - seven assertions on a string the hook no longer emits, and three test names/docstrings describing the removed 404 flow; - an unused fixture helper, and 79 lines left over-indented by the removed nesting. 724 tests green. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 179 +++++++++--------- .../process/check-and-use-pins-a-version.md | 28 ++- .../test_merge_consent_required_check.py | 52 ++--- 3 files changed, 136 insertions(+), 123 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 7914f51ef..ce6c2d7c8 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -393,16 +393,10 @@ else # there. It is deliberately NOT re-read a second time here: two reads would create a window # between them for no gain, and the hoisted check already covers the enumeration gap that made # this necessary. - # 404 MUST be separated from every other failure, and `gq` cannot do it: it wraps `curl -sf`, - # which turns any HTTP error into exit 22 and empty output, so "no branch protection exists at - # all" is indistinguishable from "Gitea is down" through that helper. Those are opposite answers. - # A 404 on this endpoint is the STRONGEST possible form of the thing being checked — the base is - # entirely unprotected, so `review-verdict/h10` is definitively not required and a scheduled - # auto-merge is exactly #622's hole — while a 403 or a timeout says only that we could not look. - # Verified against this Gitea (2026-08-16): an unprotected branch returns 404 with a JSON body. - # Collapsing the two, which the first version of this guard did, sends the single most likely - # real-world trigger ("branch protection was removed") to a human prompt that reads like a - # transient hiccup. + # A read failure here is NOT evidence about the branch. The deleted by-name endpoint answered 404 + # for "no rule with this name", which was a finding; the LIST endpoint's 404 means the repo was not + # found or is invisible to this credential, which is a read failure. Absence is now established by + # the classifier returning `nomatch` over a list that WAS read, never by an HTTP status. # ALWAYS enumerate the rule LIST; never look a rule up by name. The by-name endpoint # (`branch_protections/{name}`) is an exact DB lookup — `GetProtectedBranchRuleByName` — which # performs no matching and knows nothing about precedence, so a 200 from it means only "a rule @@ -425,91 +419,96 @@ else bp_list=$(cat "$bp_file" 2>/dev/null || true) bp="" if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then - # DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would - # be the exact defect this PR records (a mitigation outside the code, asserted rather than - # verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does - # NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case- - # insensitively. Reimplementing that here would be a second copy of somebody else's parser. - # - # So the classification is deliberately THREE-way, and each arm is safe without knowing the - # dialect: - # exact — no glob rule could apply, AND some rule name has no glob metacharacter and - # equals the base case-insensitively. Only then is a single rule decidable. - # - # UNDECIDABLE IS EVALUATED FIRST, and the order is the point. Gitea picks the - # governing rule with `GetFirstMatched` over a list sorted by Priority, THEN - # by plain-name-ness — so a glob rule with a better Priority outranks an - # exactly-named one. Preferring `exact` would therefore inspect a rule Gitea - # might not be applying: if the exact rule requires `review-verdict/h10` and a - # higher-priority glob rule does not, the gate auto-grants on a base where the - # check is not enforced. Asking whenever ANY glob rule could apply is sound - # without knowing the precedence rules at all, which is the only claim this - # code is entitled to make about somebody else's resolver. - # - # Case folding is ASCII-only here, while Gitea's `EqualFold` is - # Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there - # and not here. ASCII-fold equality implies EqualFold equality, so the gap can - # only MISS a match, never invent one; but a miss lands on `none`, which - # DENIES with the stated cause "no branch protection at all". The backslash - # paragraph below rejects "nearly unreachable" as a standard for that arm, and - # the same standard has to apply here, so a rule name carrying any non-ASCII - # byte is `undecidable` rather than fold-compared. Two fold-equal plain names - # are undecidable too: this code picks by list order while Gitea picks by - # Priority, and guessing which one is enforced is the defect the arm order - # above exists to avoid. - # undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any - # glob dialect: literal prefix before the first metacharacter, `.*`, literal - # suffix after the last. If even that does not match, no dialect can, because - # every dialect requires the literal head and tail to match literally. - # - # BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the - # superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b` - # governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`, - # fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this - # nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain - # `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern - # this base", so its premise has to hold unconditionally rather than usually. - # none — nothing can possibly govern the base, so it is genuinely unprotected. - # - # `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base - # whose protection we never established (#622's hole, reached through the block written to - # close it); under-matching would deny with a stated cause that is false, which this block's - # own comment calls the worse outcome. Asking is the only answer that is honest in both - # directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain - # name `main`, so the by-name lookup returns 200 and this classifier is never reached at all. - # That is a dated observation about mutable remote config, not a property to rely on. - bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c ' - def esc: gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c); - def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset]; - def superset: . as $n | (offs) as $o - | ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc); - def nonascii: explode | any(. > 127); - . as $rules | $b as $base | - ($rules | map(select((.branch_name // .rule_name // "") as $n - | (($n|offs|length) == 0) - and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts | - (($base|nonascii) or ($rules | any((.branch_name // .rule_name // "") as $n - | ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable | - if ($rules | any((.branch_name // .rule_name // "") as $n - | (($n|offs|length) > 0) - and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"} - elif $unfoldable then {verdict:"undecidable"} - elif ($exacts | length) > 1 then {verdict:"undecidable"} - elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)} - else {verdict:"none"} end' 2>/dev/null || true) - case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in - exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;; - undecidable) rm -f "$bp_file" - decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;; - none) bp_code=404; bp="" ;; - *) bp_code=000; bp="" ;; - esac + # DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would + # be the exact defect this PR records (a mitigation outside the code, asserted rather than + # verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does + # NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case- + # insensitively. Reimplementing that here would be a second copy of somebody else's parser. + # + # So the classification is deliberately THREE-way, and each arm is safe without knowing the + # dialect: + # exact — no glob rule could apply, AND some rule name has no glob metacharacter and + # equals the base case-insensitively. Only then is a single rule decidable. + # + # UNDECIDABLE IS EVALUATED FIRST, and the order is the point. Gitea picks the + # governing rule with `GetFirstMatched` over a list sorted by Priority, THEN + # by plain-name-ness — so a glob rule with a better Priority outranks an + # exactly-named one. Preferring `exact` would therefore inspect a rule Gitea + # might not be applying: if the exact rule requires `review-verdict/h10` and a + # higher-priority glob rule does not, the gate auto-grants on a base where the + # check is not enforced. Asking whenever ANY glob rule could apply is sound + # without knowing the precedence rules at all, which is the only claim this + # code is entitled to make about somebody else's resolver. + # + # Case folding is ASCII-only here, while Gitea's `EqualFold` is + # Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there + # and not here. ASCII-fold equality implies EqualFold equality, so the gap can + # only MISS a match, never invent one; but a miss lands on `none`, which + # DENIES with the stated cause "no branch protection at all". The backslash + # paragraph below rejects "nearly unreachable" as a standard for that arm, and + # the same standard has to apply here, so a rule name carrying any non-ASCII + # byte is `undecidable` rather than fold-compared. Two fold-equal plain names + # are undecidable too: this code picks by list order while Gitea picks by + # Priority, and guessing which one is enforced is the defect the arm order + # above exists to avoid. + # undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any + # glob dialect: literal prefix before the first metacharacter, `.*`, literal + # suffix after the last. If even that does not match, no dialect can, because + # every dialect requires the literal head and tail to match literally. + # + # BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the + # superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b` + # governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`, + # fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this + # nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain + # `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern + # this base", so its premise has to hold unconditionally rather than usually. + # none — nothing can possibly govern the base, so it is genuinely unprotected. + # + # `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base + # whose protection we never established (#622's hole, reached through the block written to + # close it); under-matching would deny with a stated cause that is false, which this block's + # own comment calls the worse outcome. Asking is the only answer that is honest in both + # directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain + # name `main`, which the classifier resolves to `exact` on every run. That is a dated + # observation about mutable remote config, not a property to rely on. + bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c ' + def esc: gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c); + def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset]; + def superset: . as $n | (offs) as $o + | ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc); + def nonascii: explode | any(. > 127); + . as $rules | $b as $base | + ($rules | map(select((.branch_name // .rule_name // "") as $n + | (($n|offs|length) == 0) + and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts | + (($base|nonascii) or ($rules | any((.branch_name // .rule_name // "") as $n + | ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable | + if ($rules | any((.branch_name // .rule_name // "") as $n + | (($n|offs|length) > 0) + and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"} + elif $unfoldable then {verdict:"undecidable"} + elif ($exacts | length) > 1 then {verdict:"undecidable"} + elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)} + else {verdict:"none"} end' 2>/dev/null || true) + case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in + exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;; + undecidable) rm -f "$bp_file" + decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;; + none) bp_code=nomatch; bp="" ;; + *) bp_code=000; bp="" ;; + esac else bp_code=${bp_code:-000} # unreadable list -> the ask arm below bp="" fi rm -f "$bp_file" - if [ "$bp_code" = "404" ]; then + # `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made + # this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the + # credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was + # read and none matches" about a read that never happened. A transport failure must reach the ask + # below, not a deny stating a finding. + if [ "$bp_code" = "nomatch" ]; then decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green." fi if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then diff --git a/docs/decisions/records/process/check-and-use-pins-a-version.md b/docs/decisions/records/process/check-and-use-pins-a-version.md index 48b1375a0..2088676aa 100644 --- a/docs/decisions/records/process/check-and-use-pins-a-version.md +++ b/docs/decisions/records/process/check-and-use-pins-a-version.md @@ -54,15 +54,25 @@ and block the merge."* Switch that context off and every word of that sentence b the hook keeps printing it and keeps auto-granting. So the class has a second face: not only "the state moved between the check and the action", but -"the thing that made the action safe was never observed at all". The hook now reads `GET -/repos/{owner}/{repo}/branch_protections/{base}` on that path and treats the result as three -outcomes, not two — present proceeds, unreadable **asks** (a transient failure or a credential -without repo-admin scope is not evidence of safety), absent **denies**. Denying on absence is the -point: that is #622's hole reopened, and its defining property is that it is silent from the merge -caller's side. The 404 is separated from every other failure deliberately, because "this branch -has no protection at all" is the strongest form of the finding while a 403 says only that we could -not look — and `curl -sf`, which the hook's generic helper wraps, collapses both into an empty -string. +"the thing that made the action safe was never observed at all". The hook now reads the repo's FULL +rule list, `GET /repos/{owner}/{repo}/branch_protections`, and classifies it — present proceeds, +unreadable **asks** (a transient failure, or a credential without the repo-admin scope that endpoint +needs, is not evidence of safety), nothing-can-govern **denies**, and anything it cannot decide +**asks**. Denying on absence is the point: that is #622's hole reopened, and its defining property +is that it is silent from the merge caller's side. + +**Reading the LIST rather than the rule named after the base is the load-bearing choice**, and the +first version got it wrong in the way this record is about. `GET …/branch_protections/{name}` is an +exact database lookup that performs no matching and knows nothing about precedence, so a 200 from it +establishes only that a rule with that NAME lists the context — never that the context is required +ON that branch. Gitea resolves the governing rule by Priority first and plain-name-ness second, so a +glob rule can outrank an exactly-named one. Using the by-name endpoint first and the list only on a +404 therefore guarded the 404 path while the 200 path — the one that actually fires, since the rule +IS named `main` — granted without the check at all: hardened code that was dead, beside live code +that was not. It was fixed by DELETING the by-name path, not by documenting the gap, so there is one +fetch, one classifier and one argument to keep true. Absence is now established by the classifier +over a list that WAS read, never by an HTTP status, because a 404 from the list endpoint means the +repo was not found rather than that the branch is unprotected. **And that check is a preflight, not a pin — say so, because the temptation is to bank it.** The first draft of the inventory graded that path `PINNED`; cold review pointed out that the hook's diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index bea822332..368b2a793 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -66,12 +66,20 @@ def respond(body, code="200"): sys.stdout.write(code) sys.exit(0) -if url.rstrip("/").endswith("/branch_protections"): - # The hook reads ONLY this endpoint now — the by-name lookup was deleted because it performs no - # matching and knows nothing about rule precedence, so a 200 from it proved less than it looked. +# RECORD BEFORE FILTERING. This recorder used to live inside the `endswith` branch below, which +# made the "no ref reaches the URL" assertion unfalsifiable: the only URLs it could record were ones +# that already satisfied it, so a by-name request was invisible to the very test written to forbid +# it. Cold review reintroduced a by-name lookup in the hook and the suite stayed 33/33 green. That is +# the filter-on-the-asserted-property defect this PR's sibling record is about, committed inside the +# guard against it — so the recorder now sees EVERY branch-protection URL, whatever its shape. +if "/branch_protections" in url: (state / "bp_called").write_text("yes") with (state / "bp_urls").open("a") as fh: fh.write(url + "\n") + +if url.rstrip("/").endswith("/branch_protections"): + # The hook reads ONLY this endpoint now — the by-name lookup was deleted because it performs no + # matching and knows nothing about rule precedence, so a 200 from it proved less than it looked. mode = (state / "bp").read_text().strip() if mode == "TRANSPORT-ERROR": @@ -258,9 +266,6 @@ def hook(tmp_path): """Persistent retarget: every PR read after the first reports `ref`.""" (state / "retarget").write_text(ref) - def branch_protection_was_fetched(self): - return (state / "bp_called").exists() - def branch_protection_urls(self): f = state / "bp_urls" return f.read_text().splitlines() if f.exists() else [] @@ -315,7 +320,7 @@ def test_positive_control_a_guarded_base_REACHES_the_check_and_still_auto_grants `allow`, and the reason is the satisfied-gate message rather than any refusal. """ decision = hook.decision() - assert hook.branch_protection_was_fetched(), ( + assert hook.branch_protection_urls(), ( "the guarded case never reached the branch-protection endpoint, so the other tests are not " "exercising the code they claim to") assert decision is not None, "the hook passed through instead of auto-granting" @@ -393,8 +398,7 @@ def test_a_base_with_NO_branch_protection_at_all_denies_rather_than_asking(hook) 404 — "this branch is entirely unprotected", i.e. `review-verdict/h10` is definitively not required — was indistinguishable from "Gitea is down". Those are opposite answers, and routing the first to a human prompt makes the single most likely real-world trigger (branch protection - removed) read like a transient hiccup. Verified against the live instance 2026-08-16: an - unprotected branch returns 404. + removed) read like a transient hiccup. """ hook.set_branch_protection("NOT-FOUND") reason = hook.reason() @@ -543,9 +547,9 @@ def test_a_NON_STRING_status_reaches_the_scheduled_validator(hook): def test_a_base_that_a_GLOB_rule_could_govern_ASKS(hook): - """Gitea keys the by-name endpoint on the RULE name, so a base covered by a glob rule 404s on - its own name while being fully protected. Reading that 404 as "unprotected" produced a hard DENY - with a specific, false cause. + """A base covered only by a glob rule has no rule bearing its own name, and the deleted by-name + endpoint answered 404 for exactly that — read as "unprotected", producing a hard DENY with a + specific, false cause. But the opposite error is worse: deciding the glob DOES match would auto-grant on a base whose protection was never established. This hook does not reimplement Gitea's glob dialect (its `*` @@ -559,17 +563,17 @@ def test_a_base_that_a_GLOB_rule_could_govern_ASKS(hook): assert "could govern it" in reason, ( "the ask came from the generic could-not-read arm, not the undecidable-glob arm — those " "are different outcomes and a crashed classifier must not pass as a correct classification") - assert "NO branch protection" not in reason, ( + assert "none matches" not in reason, ( "a base a glob rule could govern was reported as having no protection at all") -def test_an_unreadable_LIST_asks_rather_than_denying_on_the_404(hook): - """The 404 fallback must not convert 'could not confirm' into 'confirmed absent'. If the by-name - lookup 404s and the list cannot be read, nothing has been established either way.""" +def test_an_unreadable_rule_LIST_asks_rather_than_denying(hook): + """A read failure must not convert 'could not confirm' into 'confirmed absent'. Absence is + established only by the classifier returning `nomatch` over a list that WAS read.""" hook.set_branch_protection("LIST-UNREADABLE") reason = hook.reason() assert "ask" in reason, "an unreadable rule list was treated as proof of absence" - assert "NO branch protection" not in reason + assert "none matches" not in reason def test_the_protection_lookup_puts_NO_ref_in_the_url(hook): @@ -618,7 +622,7 @@ def test_a_GLOB_rule_whose_literal_part_has_a_metacharacter_still_MATCHES(hook): Escaping is only half the property: `*` must still span. The first version emitted TWO backslashes (`\\.` = "a literal backslash, then any character"), which made every rule containing a metacharacter UNMATCHABLE — so the fallback found nothing and hard-denied with the - stated cause "has NO branch protection at all", a false-open converted into a false DENY. + stated cause that no rule can govern the base — a false-open converted into a false DENY. A negative-only assertion cannot see that: a rule matched literally and a rule made unmatchable both fail to match the wrong base. Only a rule that SHOULD match distinguishes them. Here the rule @@ -634,7 +638,7 @@ def test_a_GLOB_rule_whose_literal_part_has_a_metacharacter_still_MATCHES(hook): "and must ask") assert "could govern it" in reason, ( "the ask must come from the undecidable-glob arm, not from the classifier failing") - assert "NO branch protection" not in reason, ( + assert "none matches" not in reason, ( "a base a glob rule could govern was reported as entirely unprotected — the over-escaping " "failure this test exists to catch") @@ -649,7 +653,7 @@ def test_a_rule_name_containing_a_CHAR_CLASS_bracket_does_not_crash_the_matcher( assert "ask" not in reason, ( "the exact-name rule was decidable and must have been honoured; an ask here means the " "classifier failed rather than classified") - assert "NO branch protection" not in reason, ( + assert "none matches" not in reason, ( "one rule with a bracket in its name discarded the whole list, including the exact-name " "rule that actually protects this base") assert "deny" not in reason, ( @@ -665,7 +669,7 @@ def test_a_plain_rule_name_is_matched_CASE_INSENSITIVELY(hook): assert "ask" not in reason, ( "the case-folded exact rule was decidable and must have been honoured; an ask means the " "classifier failed rather than classified") - assert "NO branch protection" not in reason, ( + assert "none matches" not in reason, ( "a rule named 'MAIN' governs base 'main' in Gitea but was missed by a case-sensitive compare") assert "deny" not in reason @@ -687,7 +691,7 @@ def test_a_BACKSLASH_ESCAPED_metacharacter_in_a_rule_name_is_undecidable_not_abs hook.drop_recorded_base() hook.set_branch_protection("ESCAPED-META-RULE") reason = hook.reason() - assert "NO branch protection" not in reason, ( + assert "none matches" not in reason, ( "a rule whose escaped brace governs this base was reported as unable to govern it") assert "ask" in reason, ( "an escaped-metacharacter rule is undecidable here and must ask") @@ -758,12 +762,12 @@ def test_two_rules_differing_only_in_CASE_are_undecidable(hook): def test_a_NON_ASCII_rule_or_base_is_undecidable_rather_than_fold_compared(hook): """`ascii_downcase` is not Gitea's Unicode-aware `EqualFold`, so a rule `ünstable` and a base `Ünstable` fold equal there and not here. The miss lands on `none`, which DENIES with the stated - cause "no branch protection at all" — and the backslash arm already rejects "nearly unreachable" + cause "none matches" — and the backslash arm already rejects "nearly unreachable" as a standard for the arm that issues a deny, so the same standard applies here.""" hook.set_base("\u00dcnstable") hook.drop_recorded_base() hook.set_branch_protection("NONASCII-RULE") reason = hook.reason() - assert "NO branch protection" not in reason, ( + assert "none matches" not in reason, ( "a rule that folds equal to this base under EqualFold was reported as unable to govern it") assert "ask" in reason -- 2.47.3 From 3f0af385095ef03375390174127459515a3519ef Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 22:09:16 +0200 Subject: [PATCH 19/20] =?UTF-8?q?fix(778):=20pin=20the=20`nomatch`=20senti?= =?UTF-8?q?nel=20=E2=80=94=20it=20shipped=20unpinned,=20exactly=20like=20t?= =?UTF-8?q?he=20guard=20before=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local review of the previous commit returned BLOCKED. Both findings taken; nothing pushed to CI while this was iterating. MEDIUM — the `nomatch` fix was entirely unpinned. Reverting both sites to `bp_code=404` left the suite 33/33 green, because no fixture ever emitted an HTTP 404 on the LIST read: the codes exercised were 000, 403, 500 and 200, and the old `NOT-FOUND` mode had been repurposed to return 200 with `[]`. So the defect that commit describes could be reintroduced silently — in a PR whose subject is unfalsifiable guards, one round after being blocked for precisely that shape. There is now a `LIST-404` fixture and a test; reverting the sentinel reddens two tests. The same class, one arm over and found while fixing it: a 200 whose `branch_name` is a number makes the classifier throw (`//` fires only on null/false), and that was mapped to `bp_code=000`, reporting "HTTP '000' — Gitea unreachable" about a read that plainly succeeded. It gets its own `unreadable-rules` sentinel and message, with a fixture and a test — reverting it reddens. The pattern across both: a sentinel that doubles as an HTTP code makes a decision state a cause that did not happen. The decision was safe each time; only the reason lied. MEDIUM — four comments still described 404-as-a-finding as live, contradicting the hook comment added in the same commit. The worst said a 404 means "this branch is entirely unprotected" in a test whose fixture now returns 200 with `[]`, which would have talked the next reader into re-adding the deny. Renamed that mode `EMPTY-LIST` so it says what it is. Also: the hook quoted a reason string it no longer emits; a stray over-indented line survived the de-indent; `bp_called` was write-only after its reader was removed. 727 tests green. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 14 +++- .../test_merge_consent_required_check.py | 78 +++++++++++++++---- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index ce6c2d7c8..6b0500771 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -440,11 +440,11 @@ else # without knowing the precedence rules at all, which is the only claim this # code is entitled to make about somebody else's resolver. # - # Case folding is ASCII-only here, while Gitea's `EqualFold` is + # Case folding is ASCII-only here, while Gitea's `EqualFold` is # Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there # and not here. ASCII-fold equality implies EqualFold equality, so the gap can # only MISS a match, never invent one; but a miss lands on `none`, which - # DENIES with the stated cause "no branch protection at all". The backslash + # DENIES with the stated cause that no rule can govern the base. The backslash # paragraph below rejects "nearly unreachable" as a standard for that arm, and # the same standard has to apply here, so a rule name carrying any non-ASCII # byte is `undecidable` rather than fold-compared. Two fold-equal plain names @@ -496,7 +496,7 @@ else undecidable) rm -f "$bp_file" decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;; none) bp_code=nomatch; bp="" ;; - *) bp_code=000; bp="" ;; + *) bp_code=unreadable-rules; bp="" ;; esac else bp_code=${bp_code:-000} # unreadable list -> the ask arm below @@ -511,6 +511,14 @@ else if [ "$bp_code" = "nomatch" ]; then decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green." fi + # `unreadable-rules` is the CLASSIFIER failing on a 200 it could not parse — a numeric + # `branch_name` makes jq throw, and `//` does not catch it because it fires only on null/false. + # It gets its own sentinel for the same reason `nomatch` does: reporting "HTTP '000' — Gitea + # unreachable" about a successful 200 read states a cause that did not happen, which is the defect + # fixed one arm over for the deny. + if [ "$bp_code" = "unreadable-rules" ]; then + decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling." + fi if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then decide ask "H6/H10 merge gate: could not read this repo's branch-protection rules (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling." fi diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 368b2a793..9818a4d2f 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -53,7 +53,9 @@ args = sys.argv[1:] url = [a for a in args if a.startswith("http")][-1] # The branch-protection call uses `-o -w '%{http_code}'` rather than `curl -sf`, precisely so -# it can tell 404 from every other failure. The shim must therefore behave like real curl for those +# it can tell a 200 from every other outcome (`curl -sf` collapses every HTTP error into exit 22 +# with empty output, hiding the difference between an empty list and a failed request). It does +# NOT treat 404 as a finding. The shim must therefore behave like real curl for those # flags: body to the -o file, status code to stdout. A shim that ignored them would make the hook # read an empty body and a blank code on EVERY path, and the tests would pass by accident against a # guard that never ran — the "test double's fidelity claim" failure this repo has on record. @@ -73,7 +75,6 @@ def respond(body, code="200"): # the filter-on-the-asserted-property defect this PR's sibling record is about, committed inside the # guard against it — so the recorder now sees EVERY branch-protection URL, whatever its shape. if "/branch_protections" in url: - (state / "bp_called").write_text("yes") with (state / "bp_urls").open("a") as fh: fh.write(url + "\n") @@ -90,9 +91,20 @@ if url.rstrip("/").endswith("/branch_protections"): respond('{"message":"not an array"}') if mode == "EMPTY": respond('') - if mode in ("NOT-FOUND", "LIST-UNREADABLE"): - # No rule can govern the base: the list is readable and empty (NOT-FOUND), or unreadable. - respond("[]") if mode == "NOT-FOUND" else respond('{"message":"internal error"}', "500") + if mode == "UNPARSEABLE-RULES": + # A 200 whose rule NAME is a number: `//` fires only on null/false, so the classifier's + # `explode`/`match` throws and the program dies on a read that plainly succeeded. + respond(json.dumps([{"branch_name": 7, "enable_status_check": True, + "status_check_contexts": ["review-verdict/h10"]}])) + if mode == "LIST-404": + # An HTTP 404 from the LIST endpoint: the repo is absent, or invisible to this credential. + # Gitea answers 404 for both, and it says NOTHING about whether the base is protected. + respond('{"message":"Not Found"}', "404") + if mode == "EMPTY-LIST": + # The list WAS read and holds no rule — the only shape that establishes absence. + respond("[]") + if mode == "LIST-UNREADABLE": + respond('{"message":"internal error"}', "500") if mode == "GLOB-RULE": respond(json.dumps([{"branch_name": "m*", "enable_status_check": True, "status_check_contexts": ["review-verdict/h10"]}])) @@ -394,13 +406,16 @@ def test_a_malformed_contexts_MEMBER_asks_rather_than_denying_with_the_wrong_rea def test_a_base_with_NO_branch_protection_at_all_denies_rather_than_asking(hook): """The strongest form of the thing being checked, and the likeliest real trigger. - `curl -sf` (the `gq` helper) collapses every HTTP error into exit 22 with an empty body, so a - 404 — "this branch is entirely unprotected", i.e. `review-verdict/h10` is definitively not - required — was indistinguishable from "Gitea is down". Those are opposite answers, and routing - the first to a human prompt makes the single most likely real-world trigger (branch protection - removed) read like a transient hiccup. + A rule list that is READABLE and EMPTY has nothing that can govern any base, so + `review-verdict/h10` is definitively not required and scheduling an auto-merge is #622's hole. + That must DENY, not ask: routing the most likely real-world trigger — branch protection removed + — to a human prompt would make it read like a transient hiccup. + + Absence is established by the LIST, never by a status code; this fixture returns 200 with `[]`. + An HTTP 404 means the repo was absent or invisible to the credential and is a read failure, + covered by `test_an_HTTP_404_on_the_LIST_read_asks_and_does_not_claim_the_list_was_read`. """ - hook.set_branch_protection("NOT-FOUND") + hook.set_branch_protection("EMPTY-LIST") reason = hook.reason() assert "deny" in reason, ( "a base with no branch protection at all did not deny a scheduled auto-merge") @@ -410,8 +425,8 @@ def test_a_base_with_NO_branch_protection_at_all_denies_rather_than_asking(hook) def test_a_403_asks_because_it_says_only_that_we_could_not_look(hook): - """The other side of the 404 split. A credential without repo-admin scope proves nothing about - the protection, so this must NOT deny — otherwise the guard strands every scheduled merge run + """A credential without the repo-admin scope this endpoint needs proves nothing about the + protection, so it must NOT deny — otherwise the guard strands every scheduled merge run made with a narrower token.""" hook.set_branch_protection("FORBIDDEN") reason = hook.reason() @@ -420,7 +435,7 @@ def test_a_403_asks_because_it_says_only_that_we_could_not_look(hook): "a 403 was reported as a confirmed absence of branch protection") -@pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE", "EMPTY"]) +@pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE", "EMPTY", "LIST-404"]) def test_an_UNREADABLE_branch_protection_asks_rather_than_denying_or_passing(hook, failure): """"Could not check" is a third outcome, not a synonym for either neighbour. @@ -771,3 +786,38 @@ def test_a_NON_ASCII_rule_or_base_is_undecidable_rather_than_fold_compared(hook) assert "none matches" not in reason, ( "a rule that folds equal to this base under EqualFold was reported as unable to govern it") assert "ask" in reason + + +def test_an_HTTP_404_on_the_LIST_read_asks_and_does_not_claim_the_list_was_read(hook): + """The `nomatch` sentinel, pinned — it shipped UNPINNED, and a full revert left the suite green. + + Absence must be established by the CLASSIFIER over a list that was actually read, never by an + HTTP status. Gitea answers 404 on this endpoint when the repo is absent or invisible to the + credential, which says nothing about the base. Reusing 404 for the classifier's own + nothing-can-govern verdict let that read reach the deny whose reason states "the full rule list + was read and none matches" — a claim about a read that never happened. + + No fixture emitted an HTTP 404 on the list before this test, which is exactly why reverting the + sentinel to `bp_code=404` changed nothing observable. + """ + hook.set_branch_protection("LIST-404") + reason = hook.reason() + assert "ask" in reason, "an unreadable repo was treated as evidence about the base" + assert "none matches" not in reason, ( + "a 404 read claimed the full rule list had been read and matched nothing") + + +def test_a_200_the_classifier_cannot_PARSE_asks_without_blaming_the_transport(hook): + """The twin of the `nomatch` fix, on the other arm — and pinned this time rather than assumed. + + A rule whose `branch_name` is a number makes the classifier throw on a read that plainly + succeeded. Mapping that to `bp_code=000` produced "could not read … (HTTP '000' — Gitea + unreachable…)", stating a transport cause for a 200. The decision (ask) was always safe; only + the reason lied, which is precisely the defect corrected one arm over for the deny. + """ + hook.set_branch_protection("UNPARSEABLE-RULES") + reason = hook.reason() + assert "ask" in reason, "an unparseable rule list produced a decision instead of a question" + assert "could not parse" in reason, ( + "the ask blamed the transport for a 200 the classifier simply could not read") + assert "unreachable" not in reason -- 2.47.3 From 7347a089794d0e47ae4a62470c43371093c04c77 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 19 Aug 2026 22:50:23 +0200 Subject: [PATCH 20/20] fix(778): the 200-not-an-array arm had the same lying reason, one branch earlier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local review returned MERGEABLE — no Blocker, High or Medium, both sentinels verified pinned by mutation, no regressions. Its one Low is taken rather than deferred, because it is a one-line fix and because deferring it would leave exactly the shape this PR exists to document. A 200 whose body is NOT an array never reaches the classifier: the array gate diverts it, `bp_code` stays 200, and the generic ask then reported "HTTP '200' — Gitea unreachable, or these credentials lack the repo-admin scope" about a read that plainly succeeded. That is the identical defect the previous commit fixed for the throw-inside-the-classifier arm, one branch earlier — fixed where it was noticed, left in its twin. The previous commit's message even generalised the pattern ("a sentinel that doubles as an HTTP code makes a decision state a cause that did not happen") while covering only one of the two arms it applies to. The test is now parametrised over all three shapes that reach an unusable 200 — UNPARSEABLE-RULES, GARBAGE, EMPTY — and reverting the new sentinel reddens the two that the first fix missed. Also finishes the de-indent the previous commit claimed: that comment block went from 19 leading spaces to 6 while its siblings use 2, so the claim was true of the direction and not of the result. 729 tests green. refs #778 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/pretooluse-merge-consent.sh | 13 +++++++++++-- scripts/tests/test_merge_consent_required_check.py | 11 +++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/pretooluse-merge-consent.sh b/.claude/hooks/pretooluse-merge-consent.sh index 6b0500771..d811139ef 100755 --- a/.claude/hooks/pretooluse-merge-consent.sh +++ b/.claude/hooks/pretooluse-merge-consent.sh @@ -440,7 +440,7 @@ else # without knowing the precedence rules at all, which is the only claim this # code is entitled to make about somebody else's resolver. # - # Case folding is ASCII-only here, while Gitea's `EqualFold` is + # Case folding is ASCII-only here, while Gitea's `EqualFold` is # Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there # and not here. ASCII-fold equality implies EqualFold equality, so the gap can # only MISS a match, never invent one; but a miss lands on `none`, which @@ -499,7 +499,16 @@ else *) bp_code=unreadable-rules; bp="" ;; esac else - bp_code=${bp_code:-000} # unreadable list -> the ask arm below + # A 200 whose body is NOT an array never reaches the classifier — it is diverted by the array + # gate above — so it needs the same sentinel, or the generic ask below reports + # "HTTP '200' — Gitea unreachable" about a read that plainly succeeded. Same defect as the + # throw-inside-the-classifier arm, one branch earlier; fixing only the arm where it was noticed + # is the twin-missed shape this PR is largely about. + if [ "$bp_code" = "200" ]; then + bp_code=unreadable-rules + else + bp_code=${bp_code:-000} # a real transport/HTTP failure -> the ask arm below + fi bp="" fi rm -f "$bp_file" diff --git a/scripts/tests/test_merge_consent_required_check.py b/scripts/tests/test_merge_consent_required_check.py index 9818a4d2f..cb9511992 100644 --- a/scripts/tests/test_merge_consent_required_check.py +++ b/scripts/tests/test_merge_consent_required_check.py @@ -807,15 +807,22 @@ def test_an_HTTP_404_on_the_LIST_read_asks_and_does_not_claim_the_list_was_read( "a 404 read claimed the full rule list had been read and matched nothing") -def test_a_200_the_classifier_cannot_PARSE_asks_without_blaming_the_transport(hook): +@pytest.mark.parametrize("shape", ["UNPARSEABLE-RULES", "GARBAGE", "EMPTY"]) +def test_a_200_the_classifier_cannot_PARSE_asks_without_blaming_the_transport(hook, shape): """The twin of the `nomatch` fix, on the other arm — and pinned this time rather than assumed. A rule whose `branch_name` is a number makes the classifier throw on a read that plainly succeeded. Mapping that to `bp_code=000` produced "could not read … (HTTP '000' — Gitea unreachable…)", stating a transport cause for a 200. The decision (ask) was always safe; only the reason lied, which is precisely the defect corrected one arm over for the deny. + + Parametrised over all three shapes that reach a 200 the hook cannot use, because the first fix + covered only `UNPARSEABLE-RULES` — the arm where it was noticed. `GARBAGE` (an object, not an + array) and `EMPTY` are diverted one branch EARLIER, by the array gate, and kept `bp_code=200`, + so they reported "HTTP '200' — Gitea unreachable" about a successful read. Fixing one arm and + leaving its twin is the shape this PR is largely about. """ - hook.set_branch_protection("UNPARSEABLE-RULES") + hook.set_branch_protection(shape) reason = hook.reason() assert "ask" in reason, "an unparseable rule list produced a decision instead of a question" assert "could not parse" in reason, ( -- 2.47.3