Files
ersatztv/scripts/pr-changed-files.sh
T
timothyandClaude Opus 5 f523fc535d
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
fix(698): bind the base, constrain the bot exemption by content, re-derive unattributable successes
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>
2026-07-29 19:52:00 +02:00

245 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Exhaustively enumerate a PR's changed file paths, or fail closed.
#
# ersatztv#649. This is the ONE implementation of the security-critical half of the merge gate.
# It exists because the same logic was written twice — once in `.claude/hooks/pretooluse-merge-consent.sh`
# (advisory: a failure produces a human prompt) and once in `.gitea/workflows/review-verdict.yml`
# (ENFORCED: it writes the branch-protection-required `review-verdict/h10` status). The advisory copy
# accumulated four rounds of hardening (ersatztv#643) that the enforced copy never received, leaving the
# copy with real authority strictly weaker than the copy without. Two copies of a security predicate
# drift; one cannot.
#
# SCOPE — mechanism, not policy. This script answers exactly one question: "what is the complete set of
# paths this PR touches, at one head, or can we not tell?" It deliberately does NOT classify the PR.
# The two callers' allow-lists differ ON PURPOSE and must stay separate:
# * the hook's docs-only pattern also lets .claude/ .gitea/ .husky/ through, which is safe there only
# because it falls through to a HUMAN PROMPT;
# * the workflow's is narrower, because there a match posts a green status with nobody in the loop.
# Sharing the enumeration fixes the drift; sharing the classification would erase an intended difference.
#
# CONTRACT
# Usage: pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>
# stdout: newline-delimited paths, BOTH sides of every rename, no blank lines. May be empty.
# exit 0 the enumeration is COMPLETE and bound to <expected-head-sha> AND <expected-base-ref>.
# stdout is authoritative.
# exit 1 the enumeration could NOT be completed or verified. stdout is meaningless — the caller
# MUST fail closed (withhold any exemption). A diagnostic goes to stderr.
# exit 2 usage error.
# Callers must treat any non-zero exit as "no exemption". Never read stdout without checking the status.
#
# WHY THE BASE REF IS AN ARGUMENT, AND WHY IT IS NOT OPTIONAL (ersatztv#698 route 1).
# `/pulls/{n}/files` computes the diff against the PR's **live** base, which is mutable. Retargeting a
# PR changes the enumerated file set without moving the head sha, so head-binding alone does not bind
# the ANSWER — only the commit it is nominally about. Reproduced live on this instance: a PR opened
# into `main` and retargeted mid-run to a scratch base enumerated as docs-only and was granted
# `review-verdict/h10=success`, while its diff against `main` carried a C# file (probe PR #703).
#
# REQUIRED rather than optional on purpose. An optional binding on a shared security primitive is an
# opt-out, and the caller that forgets it is precisely the caller that needed it — silently. Five
# arguments or exit 2.
#
# This NARROWS the window, it does not erase it. The base is re-read after the paging round trips
# alongside the head, so a retarget that is still in effect at that point fails closed; a retarget
# that opens and closes strictly between the files call and the re-read is not observable from here.
# Pinning the diff to two shas would close it, and Gitea 1.25.4 cannot serve that: `compare/{base}...
# {head}` returns `total_commits`/`commits` and NO `files`, and a `--depth=1` fetch of the two shas
# has no merge base, so a three-dot diff is impossible while a two-dot one over-reports every commit
# `main` gained since the branch point (both measured, #698). The remainder is covered one level up
# instead, by the workflow reclassifying on `edited` rather than trusting a machine-written success.
#
# AUTH/TRANSPORT is caller-supplied via env, because the two callers authenticate differently:
# ETV_GITEA_TOKEN | GITEA_TOKEN -> `Authorization: token`
# ETV_GITEA_BASICAUTH -> curl -u user:pass
# ETV_GITEA_URL | GITEA_BASE_URL -> API base; defaults to the homelab Gitea. A value ending in
# /api/v1 is used as-is, otherwise /api/v1 is appended.
#
# jq COMPATIBILITY (ersatztv#648). This runs on the CI runner, which ships **jq 1.6**, while it is
# authored on Macs shipping 1.8.x. It is therefore written to the 1.6-compatible subset:
# * never rely on `jq -e`'s exit status over EMPTY input — 1.6 exits 0 where >=1.7 exits 4, which is
# precisely the fail-open that ersatztv#647 found live in the enforced gate. Emptiness is always
# checked explicitly in shell FIRST.
# * never use `contains()` for substring tests — on 1.6 `contains("<NUL>")` is true for every string.
# * never distinguish a parse error from "no output" by exit code — 1.6 returns 4 for both.
# See docs/ci-cd.md -> "The jq contract".
set -euo pipefail
if [ "$#" -ne 5 ]; then
echo "usage: pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>" >&2
exit 2
fi
owner=$1
repo=$2
pr=$3
expected_sha=$4
expected_base=$5
if [ -z "$owner" ] || [ -z "$repo" ] || [ -z "$pr" ] || [ -z "$expected_sha" ] || [ -z "$expected_base" ]; then
echo "pr-changed-files: empty owner/repo/pr/sha/base argument" >&2
exit 2
fi
base_url="${ETV_GITEA_URL:-${GITEA_BASE_URL:-http://192.168.1.95:3000}}"
case "$base_url" in
*/api/v1) : ;;
*/) base_url="${base_url}api/v1" ;;
*) base_url="${base_url}/api/v1" ;;
esac
# Empty output on ANY failure, so every caller path treats a transport error the same way. The
# emptiness is then rejected explicitly below — never inferred from a jq exit code.
gq() {
local path="$1"
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
elif [ -n "${GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
else
printf ''
fi
}
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
echo "pr-changed-files: no Gitea credentials in env — cannot enumerate, failing closed" >&2
exit 1
fi
# Bind the BASE before the first page is requested (ersatztv#698 route 1). Checking only afterwards
# would leave the common case — a PR retargeted before the enumeration even starts — indistinguishable
# from an honest one, because every page would agree with every other page while all of them described
# a diff against the wrong base. Both ends are checked; neither alone is sufficient.
prjson_before=$(gq "repos/$owner/$repo/pulls/$pr")
if [ -z "${prjson_before//[[:space:]]/}" ]; then
echo "pr-changed-files: could not read PR #$pr to bind the base ref before enumerating — failing closed" >&2
exit 1
fi
base_before=$(printf '%s' "$prjson_before" | jq -r '.base.ref // ""' 2>/dev/null || true)
if [ -z "$base_before" ] || [ "$base_before" != "$expected_base" ]; then
echo "pr-changed-files: PR #$pr targets '${base_before:-<unreadable>}', not the expected '$expected_base' — the diff would be computed against a different base, failing closed" >&2
exit 1
fi
PAGE_SIZE=50
MAX_PAGES=40 # 2000 files; beyond this we refuse rather than guess
files=""
page=1
complete=no
while [ "$page" -le "$MAX_PAGES" ]; do
raw=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=${PAGE_SIZE}&page=${page}")
# An EMPTY body is rejected in SHELL, before jq sees it. `jq -e` over empty input exits 4 on
# jq >= 1.7 but 0 on jq 1.6, and the runner ships 1.6 — leaving this to jq's exit status is the
# exact fail-open ersatztv#647 found in the enforced copy. A transport failure must never
# masquerade as a legitimate short final page.
if [ -z "${raw//[[:space:]]/}" ]; then
echo "pr-changed-files: empty/unreadable response for page ${page}" >&2
complete=no; break
fi
# VALIDATE EVERY FIELD THE EXTRACTION BELOW CONSUMES, on EVERY row.
#
# * Top-level type alone is not enough: `[{}]` is a well-formed array whose rows carry no
# `filename`, so it contributes no paths, looks like a short page, and would complete the
# enumeration from a PARTIAL list — the same failure one level down. It also rejects arrays of
# scalars, which would otherwise make the extraction fail under `set -e`.
# * CR/LF in a path is rejected outright. `chunk` flattens paths into newline-delimited text, so a
# filename containing a newline splits into TWO lines matched against the allow-list separately:
# "safe.md\ndocs/Program.cs" yields `safe.md` and `docs/Program.cs`, both of which pass, while the
# real single path ends in `.cs`. Git permits newlines in filenames, so this is reachable and was
# reproduced against the hook.
# * `previous_filename` is validated on EVERY row, not only `renamed` ones, because `chunk` emits it
# for every row regardless of `.status`. Validating it only where it is semantically "supposed to"
# appear left a hole one predicate wide: a `status: "modified"` row carrying a newline in
# `previous_filename` was reproducibly exempted. The validation domain must match the CONSUMPTION
# domain.
# * `..` is rejected because the callers' allow-lists anchor `^docs/`, so `docs/../ErsatzTV/Program.cs`
# matches one. Git will not produce such a path; this guard's job is to fail closed on unexpected
# 2xx shapes rather than assume a well-behaved peer.
# * `.status` is checked against a CLOSED set. Be precise about what this does and does not do:
# the extraction below emits `(.previous_filename // empty)` UNCONDITIONALLY, so a present
# `previous_filename` is never dropped on account of `.status`. What the closed set actually buys
# is rejecting rows whose vocabulary we do not recognise — where a source path may be absent, or
# carried in some other field we are not reading. Without it, `"Renamed"` with a capital R, or an
# absent status, silently takes the `else true` branch of the clause below and skips the
# "renamed rows MUST carry previous_filename" requirement entirely. (An earlier version of this
# comment claimed the source path would be "dropped", which is not the mechanism; a maintainer
# who tested that claim would find it false and might conclude the check is redundant.)
# `modified` is accepted alongside `changed` deliberately: live Gitea 1.25.4 emits `changed`, but a
# closed allow-list built from the wrong vocabulary is a worse failure than the hole it closes — it
# would gate every genuine docs-only PR on any version that spells it differently. The property is
# "reject values we do not recognise", not "enumerate one version exactly".
if ! printf '%s' "$raw" \
| jq -e 'def ok: type == "string" and length > 0
and (test("[\\r\\n]") | not)
and (split("/") | index("..") | not);
type == "array" and all(.[];
(.filename | ok)
and (.previous_filename == null or (.previous_filename | ok))
and ((.status // "") as $s | ($s | type) == "string"
and (["added","deleted","changed","modified","renamed","copied"] | index($s)) != null)
and (if .status == "renamed"
then (.previous_filename | type == "string" and length > 0)
else true end))' \
>/dev/null 2>&1; then
echo "pr-changed-files: page ${page} failed row validation" >&2
complete=no; break
fi
# BOTH sides of a rename: Gitea reports a `git mv` as ONE row whose `filename` is the DESTINATION,
# with the source only in `previous_filename`. Reading `filename` alone lets a PR move a protected
# file INTO docs/ and pass as docs-only (verified live: `.gitea/workflows/renovate.yml` ->
# `docs/innocuous-note.md` showed no protected path). One renamed row is therefore ONE row but TWO
# paths, which is why the two counts below are computed differently.
n=$(printf '%s' "$raw" | jq -r 'length')
chunk=$(printf '%s' "$raw" | jq -r '.[] | (.filename // empty), (.previous_filename // empty)')
[ -n "$chunk" ] && files=$(printf '%s\n%s' "$files" "$chunk")
# Terminate ONLY on an explicitly validated EMPTY page — never on a merely SHORT one.
# "Fewer than 50 rows means last page" assumes the server's page size is the 50 we asked for, but
# Gitea caps `limit` at the server-wide MAX_RESPONSE_ITEMS (default 50, configurable) and is free to
# return fewer. A 30-row page followed by a page of code would complete the enumeration over a
# PARTIAL list — the same fail-open, reached without any transport error. Costs one extra request;
# the MAX_PAGES cap still fails closed.
if [ "$n" -eq 0 ]; then complete=yes; break; fi
page=$((page + 1))
done
if [ "$complete" != yes ]; then
echo "pr-changed-files: enumeration incomplete (stopped at page ${page}) — failing closed" >&2
exit 1
fi
# Bind the enumeration to ONE head. Paging is several round-trips; a force-push between them means
# page 1 came from head A and page 2 from head B, so the assembled list belongs to no single commit —
# B's code page can be skipped entirely while B's docs page reads as a clean short tail. Re-read the
# head and refuse if it moved.
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
if [ -z "${prjson//[[:space:]]/}" ]; then
echo "pr-changed-files: could not re-read PR head to bind the enumeration — failing closed" >&2
exit 1
fi
sha_after=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
if [ -z "$sha_after" ] || [ "$sha_after" != "$expected_sha" ]; then
echo "pr-changed-files: head moved during enumeration (${expected_sha:0:7} -> ${sha_after:0:7}) — failing closed" >&2
exit 1
fi
# The same round-trip window applies to the BASE, and the head check cannot see it: retargeting moves
# the diff without moving the head sha (ersatztv#698 route 1). Comparing `.base.ref` — the branch NAME,
# never its tip — is deliberate and matches `post-review-verdict.sh` (ersatztv#632): a base that merely
# ADVANCES is ordinary churn, while comparing tips would fail every enumeration on every unrelated
# merge to `main`.
base_after=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
if [ -z "$base_after" ] || [ "$base_after" != "$expected_base" ]; then
echo "pr-changed-files: base moved during enumeration ('$expected_base' -> '${base_after:-<unreadable>}') — the enumerated diff is against a base this PR no longer targets, failing closed" >&2
exit 1
fi
printf '%s\n' "$files" | grep -v '^$' || true
exit 0