fix(698): bind the base, constrain the bot exemption by content, re-derive unattributable successes #705

Merged
timothy merged 8 commits from fix/698-exemption-provenance into main 2026-07-30 21:22:02 +02:00
Owner

fixes #698

The exemption path decided from mutable or unattributed PR state, and a machine-written success was never revalidated. Three routes, one root cause, so one change.

Route 1 — retarget race (reproduced live, probe PR #703)

/pulls/{n}/files diffs against the PR's live base, so retargeting moves the answer without moving the head sha. Opened H → main so the trusted base definition ran, retargeted to a scratch base mid-flight: the job enumerated docs-only and posted h10=success — "Exempt: docs-only change". Retargeted back to main and nothing reclassified (created_at == updated_at), leaving a PR into main whose diff carried a C# file behind a green required check. Closed unmerged, branches deleted, no forged h10 left anywhere.

Fix: scripts/pr-changed-files.sh takes the expected base branch as a required 5th argument, checked before and after paging. Required rather than optional deliberately — an optional binding on a shared security primitive is a silent opt-out, and the caller that forgets it is the one that needed it. The workflow passes it from the pull_request_target payload, which a retarget cannot rewrite.

Why a binding and not a pinned diff, measured not assumed: compare/{base}...{head} returns total_commits/commits and no files on Gitea 1.25.4, and a --depth=1 fetch of the two shas has no merge base — three-dot is impossible, two-dot over-reports everything main gained since the branch point. A full-history fetch per PR event was judged too expensive. The residual window (a retarget opening and closing strictly between the files call and the re-read) is stated in the code and the record rather than papered over; it is covered by edited + re-derivation making it non-durable.

Route 2 — a bot ACCOUNT does not attribute the CODE

pull_request.user.login is the PR's immutable creator; its head is not. The bot exemption now also requires every path to be a dependency manifest — a set measured across all 11 Renovate PRs this repo has had (Directory.Packages.props ×10, .config/dotnet-tools.json ×1, plus the web/ manifests so a first SPA bump can't deadlock), not guessed.

Route 3 — inherited success

The short-circuit exited on any success, so a forgery obtained once was inherited forever. It now fires only for a status positively identified as a human verdict: non-null .creator.login and a Review-verdict: description. Measured on the combined endpoint the job actually reads — user-posted statuses carry a creator, Actions-posted ones carry null. Written in the positive direction so an unrecognised shape is re-derived rather than trusted; if Gitea later populates creator for Actions, the description half still fails and the guard degrades toward re-deriving.

Verification

  • 365 tests green. 17 new cases in scripts/tests/test_pr_changed_files.py.
  • Each new clause verified by mutation — 8 targeted mutations, 8 kills by the intended test, then restored and re-run green. A green security suite proves nothing until it can be made red.
  • edited is measured, not assumed (the issue flagged it unverified). The self-test gap blocks testing on main, so an edited-only probe workflow was put on a scratch base branch and a PR retargeted onto it — the run was created, which only happens if the event fired and the branches: filter matched the new base. Honest limit: the job never got a runner slot, so I have the run's creation, not its log line.
  • A regression was caught before commit: as an elif chain the new bot condition silently withdrew the docs-only exemption from Renovate docs PRs. Predicates are now composed, and a test pins it.

Corrections made during the work

An earlier draft of the decision record claimed historical Renovate PR #20 had been exempted for a source change. Verified and false — #20 merged 2026-06-27, the gate landed 2026-07-25, and it carries no h10 status. It is now stated as the counterfactual it is, with the correction kept in the record.

Does NOT close the class

Anyone who can POST a status directly can write both a creator and a Review-verdict: description and impersonate a verdict; branch protection binds the context, not its issuer. That is #697, deliberately left open — its durable fix is credential scoping, partly server-management territory.

Outstanding

The route 2 live reproduction (pushing code onto an open Renovate PR branch under a WIP guard) is not done: the permission classifier blocked the force-push needed to restore the branch afterwards, so I stopped rather than mutate a live dependency PR with no clean way back. #679 was left byte-identical to how it started. That Done-when box is therefore unticked on purpose.

Docs updated in this PR: docs/ci-cd.md, new record ci.exemption-provenance, ci.gate-trigger-base-resolved (residual resolved), ci.shared-pr-file-enumeration (contract now 5 args), regenerated catalog.

fixes #698 The exemption path decided from **mutable or unattributed PR state**, and a machine-written `success` was never revalidated. Three routes, one root cause, so one change. ## Route 1 — retarget race (reproduced live, probe PR #703) `/pulls/{n}/files` diffs against the PR's **live** base, so retargeting moves the answer without moving the head sha. Opened `H → main` so the trusted base definition ran, retargeted to a scratch base mid-flight: the job enumerated docs-only and posted `h10=success — "Exempt: docs-only change"`. Retargeted back to `main` and **nothing reclassified** (`created_at == updated_at`), leaving a PR into `main` whose diff carried a C# file behind a green required check. Closed unmerged, branches deleted, no forged `h10` left anywhere. Fix: `scripts/pr-changed-files.sh` takes the expected base branch as a **required 5th argument**, checked before *and* after paging. Required rather than optional deliberately — an optional binding on a shared security primitive is a silent opt-out, and the caller that forgets it is the one that needed it. The workflow passes it from the `pull_request_target` payload, which a retarget cannot rewrite. **Why a binding and not a pinned diff, measured not assumed:** `compare/{base}...{head}` returns `total_commits`/`commits` and **no `files`** on Gitea 1.25.4, and a `--depth=1` fetch of the two shas has **no merge base** — three-dot is impossible, two-dot over-reports everything `main` gained since the branch point. A full-history fetch per PR event was judged too expensive. The residual window (a retarget opening *and* closing strictly between the files call and the re-read) is stated in the code and the record rather than papered over; it is covered by `edited` + re-derivation making it non-durable. ## Route 2 — a bot ACCOUNT does not attribute the CODE `pull_request.user.login` is the PR's **immutable creator**; its head is not. The bot exemption now also requires **every** path to be a dependency manifest — a set measured across all 11 Renovate PRs this repo has had (`Directory.Packages.props` ×10, `.config/dotnet-tools.json` ×1, plus the `web/` manifests so a first SPA bump can't deadlock), not guessed. ## Route 3 — inherited success The short-circuit exited on **any** `success`, so a forgery obtained once was inherited forever. It now fires only for a status **positively identified as a human verdict**: non-null `.creator.login` **and** a `Review-verdict:` description. Measured on the combined endpoint the job actually reads — user-posted statuses carry a creator, Actions-posted ones carry `null`. Written in the *positive* direction so an unrecognised shape is re-derived rather than trusted; if Gitea later populates `creator` for Actions, the description half still fails and the guard degrades toward re-deriving. ## Verification - **365 tests green.** 17 new cases in `scripts/tests/test_pr_changed_files.py`. - **Each new clause verified by mutation** — 8 targeted mutations, 8 kills by the intended test, then restored and re-run green. A green security suite proves nothing until it can be made red. - **`edited` is measured, not assumed** (the issue flagged it unverified). The self-test gap blocks testing on `main`, so an `edited`-only probe workflow was put on a *scratch base branch* and a PR retargeted onto it — the run was created, which only happens if the event fired and the `branches:` filter matched the new base. Honest limit: the job never got a runner slot, so I have the run's creation, not its log line. - A regression was caught **before** commit: as an `elif` chain the new bot condition silently withdrew the docs-only exemption from Renovate docs PRs. Predicates are now composed, and a test pins it. ## Corrections made during the work An earlier draft of the decision record claimed historical Renovate PR #20 *had been* exempted for a source change. Verified and false — #20 merged 2026-06-27, the gate landed 2026-07-25, and it carries no `h10` status. It is now stated as the counterfactual it is, with the correction kept in the record. ## Does NOT close the class Anyone who can POST a status directly can write both a creator and a `Review-verdict:` description and impersonate a verdict; branch protection binds the *context*, not its issuer. That is #697, deliberately left open — its durable fix is credential scoping, partly server-management territory. ## Outstanding The **route 2 live reproduction** (pushing code onto an open Renovate PR branch under a WIP guard) is not done: the permission classifier blocked the force-push needed to restore the branch afterwards, so I stopped rather than mutate a live dependency PR with no clean way back. #679 was left byte-identical to how it started. That Done-when box is therefore unticked on purpose. Docs updated in this PR: `docs/ci-cd.md`, new record `ci.exemption-provenance`, `ci.gate-trigger-base-resolved` (residual resolved), `ci.shared-pr-file-enumeration` (contract now 5 args), regenerated catalog.
timothy added 1 commit 2026-07-29 20:03:31 +02:00
fix(698): bind the base, constrain the bot exemption by content, re-derive unattributable successes
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 28s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m8s
review-verdict/h10 Review-verdict: BLOCKED @ f523fc5 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 23m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
f523fc535d
The `review-verdict/h10` exemption path decided from mutable or unattributed PR
state, and a machine-written `success` was never revalidated. Three routes, one
root cause, so one change.

Route 1 (reproduced live as probe PR #703, closed unmerged): `/pulls/{n}/files`
diffs against the PR's LIVE base, so retargeting moves the answer without moving
the head sha. A PR opened into `main` and retargeted mid-run enumerated docs-only
and was granted `h10=success` while its diff against `main` carried a C# file;
retargeting back reclassified nothing. `scripts/pr-changed-files.sh` now takes the
expected base branch as a REQUIRED 5th argument (optional would be a silent
opt-out) and checks it before and after paging; the workflow passes it from the
`pull_request_target` payload, which a retarget cannot rewrite, and `edited` is in
`types:` so a retarget reclassifies.

A pinned two-sha diff would close route 1 outright but Gitea 1.25.4 cannot serve
one: `compare/{base}...{head}` returns no `files`, and a `--depth=1` fetch of the
two shas has no merge base. Measured, not assumed. The residual window is stated
in the code and the record rather than papered over.

Route 2: `pull_request.user.login` is the PR's immutable CREATOR while its head is
not, so pushing code onto an open Renovate branch kept the exemption. The bot
exemption now also requires EVERY path to be a dependency manifest — a set measured
across all 11 Renovate PRs this repo has had, not guessed.

Route 3: the never-overwrite short-circuit exited on ANY `success`, so a forgery
obtained once was inherited forever. It now fires only for a status positively
identified as a human verdict (non-null `.creator.login` AND a `Review-verdict:`
description — measured: user-posted statuses carry a creator, Actions-posted ones
carry null). Written in the positive direction so an unrecognised shape is
re-derived rather than trusted.

The two exemptions are composed, not chained: as an `elif` chain a Renovate
docs-only PR lost the docs-only exemption. Caught before commit and pinned by a
test.

Tests: 17 new cases in scripts/tests/test_pr_changed_files.py, each verified by
mutating the clause it covers (8 mutations, 8 kills). Both records trimmed under
the 60-line prose ceiling so the corpus tail-boundary check stays calibrated.

Does NOT close the class: anyone who can POST a status directly can still
impersonate a verdict — that is #697, deliberately left open.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: BLOCKED @ f523fc5

Cross-family adversarial review returned 3 Blockers + 2 Highs. Two verified independently by me. Blocker: grep -q under set -o pipefail inverts BOTH the docs-only and the PROTECTED guards once the path list exceeds the pipe buffer (grep exits early, printf takes SIGPIPE 141, pipefail turns a MATCH into a failed pipeline). Reproduced: a list of A.cs + 1900 docs paths classifies docs_only=yes, and a list led by .gitea/workflows/review-verdict.yml MISSES the PROTECTED guard entirely. This construct is PRE-EXISTING on main (review-verdict.yml:257), so main is currently exposed. Blocker: web/package.json + web/package-lock.json must come out of BOT_MANIFESTS -- renovate.json enables only nuget/github-actions/dockerfile (no npm), and package.json scripts execute in CI via npm ci / npm run build. Not merging.

Review-verdict: BLOCKED @ f523fc5 Cross-family adversarial review returned 3 Blockers + 2 Highs. Two verified independently by me. Blocker: grep -q under set -o pipefail inverts BOTH the docs-only and the PROTECTED guards once the path list exceeds the pipe buffer (grep exits early, printf takes SIGPIPE 141, pipefail turns a MATCH into a failed pipeline). Reproduced: a list of A.cs + 1900 docs paths classifies docs_only=yes, and a list led by .gitea/workflows/review-verdict.yml MISSES the PROTECTED guard entirely. This construct is PRE-EXISTING on main (review-verdict.yml:257), so main is currently exposed. Blocker: web/package.json + web/package-lock.json must come out of BOT_MANIFESTS -- renovate.json enables only nuget/github-actions/dockerfile (no npm), and package.json scripts execute in CI via npm ci / npm run build. Not merging.
timothy added 1 commit 2026-07-29 20:35:32 +02:00
fix(698): review round 2 — close a PRE-EXISTING SIGPIPE inversion, narrow the bot allow-list, correct two overclaims
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / Docs update reminder (pull_request) Successful in 24s
PR Gates / decisions lifecycle (pull_request) Successful in 33s
review-verdict/h10 Awaiting review verdict for bafb487
PR Gates / Script tests (pytest) (pull_request) Successful in 59s
Review verdict / Set review-verdict status (pull_request_target) Successful in 34s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m30s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m36s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
bafb487eaa
Cross-family adversarial review returned BLOCKED with 3 Blockers + 2 Highs. Two
were verified independently before acting on them.

BLOCKER 1 (pre-existing on main, not introduced here). `producer | grep -q…`
inverts under `set -o pipefail`: grep exits at its first match, the producer takes
SIGPIPE and exits 141 once the list exceeds the pipe buffer, so a MATCH is reported
as a FAILED pipeline. Reproduced at 1901 paths / ~171KB, inside the enumerator's
2000-file cap:
  * `A.cs` + 1900 docs paths  -> docs_only=yes, status 141 (a C# PR exempted)
  * `.gitea/…` + 1900 docs    -> the PROTECTED guard MISSES entirely
The second reaches a green required status on a PR rewriting the gate, with no
retarget, no bot account and no forged status. This construct shipped with the
original docs-only check (review-verdict.yml:257 on main), so it was live. Fixed in
both the enforced gate and the advisory hook by using here-strings, which have no
writer to signal. New record `ci.grep-q-pipefail-inversion`.

BLOCKER 2 (mine). `web/package.json` / `web/package-lock.json` are removed from
BOT_MANIFESTS. renovate.json enables only nuget/github-actions/dockerfile, so npm is
unmanaged here and the entry bought nothing — while package.json `scripts` are
EXECUTED by CI (npm ci, npm run build). It widened an exemption onto a
code-execution path for no benefit.

BLOCKER 3 + HIGH (documentation was wrong, code unchanged). The claim that `edited`
made the retarget residual "non-durable" is retracted: runs are not serialized, so a
stale run can post `success` after the reclassifying run posts `pending`. The ABA
transition is narrowed and observable, NOT closed. Likewise the provenance check
asks "posted by a user credential", not "posted by a reviewer" — ETV_STATUS_AUTH is
basic auth, so a #697 forgery gets a non-null creator AND an attacker-chosen
description and is preserved as human. Both now stated at full strength.

TESTS. 4 large-input cases crossing the pipe buffer, each paired with a large-input
POSITIVE control so "large lists now fail closed" (a deadlock) cannot pass as a fix.
Verified by mutation: reverting the here-strings turns all three negatives red while
the control stays green. Two of my own weak tests fixed — the "base advances" case
called head_moves_to(SHA) with the already-current sha (a duplicate positive control,
now a structural assertion that the comparator is .base.ref and never .base.sha), and
the arity test counted five arguments without checking the fifth was the base.

The whole class was invisible because every previous test used a handful of short
paths: a guard whose behaviour depends on a buffer threshold needs a test that
crosses it.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-29 20:53:40 +02:00
fix(698): review round 3 — count instead of matching, re-read before the POST, fix stale docs
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / decisions lifecycle (pull_request) Successful in 31s
review-verdict/h10 Awaiting review verdict for d8bd1dc
Review verdict / Set review-verdict status (pull_request_target) Successful in 36s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
d8bd1dcba9
Round-2 cross-family review returned BLOCKED: 2 High + 3 Medium.

HIGH — here-strings traded one fail-open for another. `grep -q… <<< "$data"` fixes
the SIGPIPE inversion, but bash materialises a large here-string via temporary
storage, so it fails when temp space is full or unwritable — and since these sit
inside `if`/`!`, that failure flips the predicate exactly as SIGPIPE did. It did NOT
reproduce on my bash 3.2, DID on the reviewer's Linux bash 5.x, and CI is Linux; the
disagreement is itself the argument for a construct that cannot fail either way.

Path predicates now COUNT with `grep -c`, which drains stdin (no early exit, no
SIGPIPE) over an ordinary pipe (no temp file), and grep's status is read honestly:
exit 1 means "zero matches", a legitimate answer, while >1 is a real error that FAILS
THE JOB rather than silently reading as "no match". `set -e` does not catch these on
its own — they sit in command substitution inside a conditional. Verified correct
under 171KB input AND an unwritable TMPDIR. The description test became a `case`
prefix match, removing another pipeline from a security predicate. New record
`ci.grep-q-pipefail-inversion` covers the whole class.

HIGH — a human verdict landing mid-run was still overwritten, and the code claimed
otherwise. The job read statuses once, classified over several round-trips, then
posted: a reviewer posting BLOCKED in between had it replaced by an exemption
`success`, turning an explicit rejection into a merge. Added a re-read immediately
before the POST which refuses to write over a human verdict found then. The heading
no longer says "never overwrite" — it cannot promise that, since there is no
compare-and-set on Gitea's status API. Remainder tracked as #706.

MEDIUM — documentation was stale in three places, all mine. The record's frontmatter
`rule:` still listed the npm manifests (I fixed the body and forgot the frontmatter,
so the canonical rule AND the generated catalog were wrong); docs/ci-cd.md still said
`edited` was absent from `types:`, contradicting a section I had just updated; and the
workflow header still implied the `edited` re-run settles the ABA race. All corrected
to say detection, not atomicity.

TESTS. 373 pass. New: a mid-run human verdict via a status stub that returns nothing
on the first read and BLOCKED on the re-read, and large-input regression tests for the
ADVISORY hook, which had none — the copy with less authority is the one that quietly
keeps a bug (#649's whole point). Mutation-verified: reverting the hook predicate,
the workflow predicates, or the pre-POST re-read turns exactly the intended tests red
while every positive control stays green.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-29 21:13:59 +02:00
fix(698): review round 4 — the PROTECTED guard was DEAD; define before use, fail closed, fix prescriptive docs
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
review-verdict/h10 Awaiting review verdict for d4c6001
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
d4c600149d
Round-3 cross-family review returned BLOCKED with 3 Mediums. The first was serious
and self-inflicted.

THE PROTECTED GUARD WAS A NO-OP. Round 3's `count_matching` / `count_not_matching`
helpers were defined AFTER the classification chain that calls them, so
`count_matching` was `command not found` on every run, `$( )` yielded an empty string,
`[ "" -gt 0 ]` errored, and the `elif` was simply skipped — the protected-path check
never executed at all. Confirmed by direct execution before fixing.

Three "protected path" tests stayed GREEN throughout, because a protected path is also
not a manifest and not docs-only, so the job still reached `pending` down a different
route. Asserting the STATE could not distinguish a working guard from a dead one. The
mutation battery missed it too: I had mutated the predicates, not their reachability.

Fixed three ways:
  * helpers are defined immediately after `gh()`, before any use;
  * the three counts are evaluated ONCE at TOP LEVEL and validated numeric, because
    `exit 1` inside `$( )` leaves only the subshell and, with the substitution sitting
    in a conditional, `set -e` never fires either — so a grep error had been silently
    reading as "no match". A non-numeric result now aborts with nothing posted, and an
    absent required check blocks the merge;
  * the helpers return a non-numeric sentinel instead of trying to `exit`.

Verified: an invalid regex now exits 2 and posts NOTHING (previously it classified and
posted). Renaming the helper at its definition turns six tests red.

TESTS, aimed at the failure mode rather than the symptom:
  * assert the DISCRIMINATOR (the job's `Decision:` reason line), not the outcome —
    when several branches yield the same verdict, the verdict cannot tell you which ran.
    A first draft of this test asserted the status description and failed against a
    WORKING guard, because for `pending` the description is constant;
  * a cheap stderr sweep for `command not found` / `integer expression expected` /
    `unbound variable` across four representative PR shapes. Each of those makes an `if`
    condition merely false while the job exits 0 and posts a plausible status, so this
    catches a whole family of silently-skipped guards.

DOCS. The record and ci-cd.md still PRESCRIBED the here-string that round 3 removed —
following them would have reintroduced the temp-storage failure. Both now prescribe
counting, define-before-use, top-level evaluation and numeric validation. The workflow's
measurement paragraph still said the npm manifests "are included" three lines above the
note saying they are excluded; corrected.

379 tests pass.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-29 21:44:46 +02:00
fix(698): review round 5 — a human verdict formed against ANOTHER base is no longer inherited
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
review-verdict/h10 Awaiting review verdict for e7bae06
PR Gates / decisions lifecycle (pull_request) Successful in 31s
Review verdict / Set review-verdict status (pull_request_target) Successful in 21s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m13s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
e7bae06385
Round-4 cross-family review returned BLOCKED with a single Medium; the three round-3
items were confirmed actually fixed.

THE SHA-BINDING WAS ESCAPABLE THROUGH THE HUMAN PATH, not the exemption path. The
short-circuit identified a human verdict by creator + `Review-verdict:` prefix and then
exited before looking at the base. So: earn a GENUINE `success` on head H while it
targets a scratch base with a benign diff, then retarget H onto `main`, where its diff
carries unreviewed code. Creator real, prefix real, status inherited — a green required
check over code nobody reviewed. `post-review-verdict.sh` has recorded the reviewed base
in the description since #632; this gate simply never read it. The merge-consent hook
did compare it, but that is advisory and covers only its own path: a merge through the
Gitea UI or API sees nothing but the status.

The gate now rejects a verdict whose recorded base differs from the PR's. An ABSENT base
is deliberately NOT a mismatch — verdicts predating #632 carry none, and re-deriving over
one would un-approve a genuinely reviewed head. Only present-and-different is rejected,
which is exactly the escape.

Tests: the mismatch case, plus two positive controls (matching base still short-circuits;
a legacy no-base verdict still short-circuits) so the check cannot pass by blanket
rejection. Mutation-verified: removing the check reddens only the mismatch test.

Also from round 4: sharpened the docstring of test_the_classify_step_runs_without_SHELL_ERRORS.
It catches guards that die NOISILY; it is not a general liveness check, since a clean
mutation like hardcoding n_protected=0 emits nothing. The branch-discriminator test is the
actual liveness guard. Claiming otherwise would have made a cheap net look like a strong one.

And fixed a dangling decision key I had just introduced: the base-in-description convention
belongs to `release.verdict-status-check`, not the `ci.verdict-records-base` I invented —
the breadcrumb hazard our own retrieval rules warn about.

382 tests pass.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-29 22:11:12 +02:00
fix(698): parse the recorded base at its FIRST occurrence, not its last
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
review-verdict/h10 Awaiting review verdict for ef92b46
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m40s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
ef92b46dd2
Self-found while writing the round-5 review brief, by asking what an attacker who can
influence the status description (#697) could do to the parse I had just added.

`${ex_desc##*"(base: "}` is greedy, so it reads the LAST occurrence. A description of
`Review-verdict: MERGEABLE @ abc1234 (base: probe/scratch) (base: main)` therefore parsed
as `main`, matched the PR's base, and the verdict was inherited — reopening the exact hole
the base check was added to close, one commit earlier. Measured both forms before choosing:
first-match yields `probe/scratch`, mismatches, and fails closed.

Two adjacent cases confirmed to fail in the safe direction: a `)` inside a branch name
truncates the value (mismatch), and an empty `(base: )` is present-but-different (mismatch),
so neither is waved through by the legacy-absent-base allowance.

Tests for both, and the appended-base test is mutation-verified: restoring `##` reddens it.

384 tests pass.

Refs: #698
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-30 20:52:00 +02:00
fix(698): compare the recorded base exactly, never parse it out
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 40s
PR Gates / decisions lifecycle (pull_request) Successful in 45s
review-verdict/h10 Awaiting review verdict for fe00e0d
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m23s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 21m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fe00e0d71f
Review round 5 returned BLOCKED with one High, and it needed no forgery and no #697 —
just a branch name.

`main)evil` IS A VALID GIT BRANCH NAME (`git check-ref-format --branch 'main)evil'`
succeeds). A genuine human verdict earned while head H targeted it is written
`(base: main)evil)`. Truncating at the first `)` yields exactly `main`, which matches a
PR that has since been retargeted onto `main`, so the verdict is inherited over a
completely different diff.

I had asserted the opposite in a code comment one commit earlier — that a `)` in a
branch name "mismatches — safe direction". That was generalised from `feat/foo)bar`,
which does mismatch, and is false for EVERY branch whose name starts with the target
base. Two attempts at extracting this value have now been defeated (`##` last-marker by
an appended marker, `#` first-marker by this), so the lesson is the shape, not the
off-by-one: do not parse a value out of user- or attacker-influenced text when you can
compare against the exact expected literal instead.

The description must now END with the literal `(base: <this PR's base>)` AND contain
exactly ONE marker — the marker count kills the append trick without having to decide
which occurrence is authoritative. Pure shell (`${#}` arithmetic), no truncation to
abuse. Verified across all six shapes, including a PR that legitimately targets
`main)evil` (accepted) and `(base: )` (rejected). Absent markers remain accepted, since
verdicts predating #632 carry none.

Mutation-verified: restoring the truncating parse reddens only the new paren test, while
the appended-marker, matching-base and legacy tests stay green.

385 tests pass. Note for the record: pytest has never executed inside the review sandbox
in any of the five rounds, so the suite has only ever been run here.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-30 21:03:44 +02:00
chore(698): drop a trailing blank line at EOF
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
Review verdict / Set review-verdict status (pull_request_target) Successful in 12s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ 57e33f9 (base: main)
57e33f9937
Nit from review round 6 (git diff --check). No behaviour change.

Refs: #698
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: MERGEABLE @ 57e33f9

Six cross-family adversarial review rounds: BLOCKED (3 Blockers + 2 Highs) -> BLOCKED (2H + 3M) -> BLOCKED (3M) -> BLOCKED (1M) -> BLOCKED (1 High) -> MERGEABLE, no new findings. Round 6 verified the base comparison across branch-name, marker-count, quoting and whitespace cases and found no bypass. CI green on this head: all 11 substantive checks success, image push skipped (normal for PRs). 385 local tests pass; every new guard mutation-verified red-then-green with positive controls. Scope note: this head is one whitespace-only commit past the reviewed one, removing a trailing blank line that round 6 itself flagged. Known limitation: pytest never executed inside the review sandbox in any round (no writable temp), so the suite has only ever been run locally. Residuals tracked, not dropped: #706 (status writes unserialized), #707 (paging vs live base), #708 (route-2 live reproduction), #697 (credentials that can write the status).

Review-verdict: MERGEABLE @ 57e33f9 Six cross-family adversarial review rounds: BLOCKED (3 Blockers + 2 Highs) -> BLOCKED (2H + 3M) -> BLOCKED (3M) -> BLOCKED (1M) -> BLOCKED (1 High) -> MERGEABLE, no new findings. Round 6 verified the base comparison across branch-name, marker-count, quoting and whitespace cases and found no bypass. CI green on this head: all 11 substantive checks success, image push skipped (normal for PRs). 385 local tests pass; every new guard mutation-verified red-then-green with positive controls. Scope note: this head is one whitespace-only commit past the reviewed one, removing a trailing blank line that round 6 itself flagged. Known limitation: pytest never executed inside the review sandbox in any round (no writable temp), so the suite has only ever been run locally. Residuals tracked, not dropped: #706 (status writes unserialized), #707 (paging vs live base), #708 (route-2 live reproduction), #697 (credentials that can write the status).
timothy merged commit a3458e6e2c into main 2026-07-30 21:22:02 +02:00
Sign in to join this conversation.