Merge-consent gate fails OPEN on jq 1.6: a transport failure mid-pagination lets the docs-only exemption fire over a PARTIAL file list #643
Closed
opened 2026-07-26 01:40:35 +02:00 by timothy
·
6 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
913-scripted-playout-401
929-vuln-pkcs-imagesharp-v4
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#643
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found while landing #631 (running
scripts/tests/in CI for the first time). Fixed in PR #637; filed separately because it changes the security posture of the merge gate and that history should be findable on its own rather than buried inside a "run the tests in CI" PR.The defect
.claude/hooks/pretooluse-merge-consent.shdecides whether a PR is docs-only and may therefore skip the Done-when / review-verdict checks entirely. To do that it must enumerate the changed-file list exhaustively, paging at 50 rows.The guard that rejects a failed page leaned on jq's exit status:
jq -eover EMPTY input exits 4 on jq ≥ 1.7 but 0 on jq 1.6. Verified against both binaries, not inferred:[]{"message":"internal error"}[{}]So on jq 1.6 the failed page passes the guard, and the chain continues:
n=$(... jq -r 'length')over empty input yields""[ "$n" -lt 50 ]errors ("integer expression expected") → false as anifcondition, andset -edoes not fire for a condition[]→files_complete=yesThis is the same defect the guard's own comment describes ("a transport/parse failure must not look like a legitimate short final page"), reintroduced one layer down by a jq version difference.
Blast radius
Preflight OK: jq-1.6).jq -esite (thereview-verdict/h10status read, hook line ~224) had the same shape but fell through tovstate=""→ deny. Fail-closed, so never a hole — it would only have surfaced the wrong message.Why it survived
Two compounding reasons, both worth keeping:
scripts/tests/never ran in CI (#631) — so the only place the bug is observable was never exercised.test_transport_failure_mid_pagination_withholds_the_exemptionalready existed and asserts exactly the right thing, but can only observe the bug where the host jq is 1.6. It passes on a developer Mac with the bug fully present. A test whose verdict depends on an unpinned host binary is not a regression test.Fix (in PR #637)
Reject an empty body explicitly rather than inferring it from jq's exit status, at both
jq -esites. A new test pins the property on any jq by shimming only the 1.6 empty-input behaviour; mutation-verified (revert the fix → red on jq 1.8.2).All six
gqconsumers in the hook were enumerated rather than just the two found: lines 81, 150, 169 and 248 already guard explicitly, so site 96 was the only genuine fail-open.Standing rule this leaves behind: never infer "empty input" from a jq exit status — check the string. Captured in
ci.script-tests-job.Done-when
jq -esites reject an empty body explicitly rather than via exit statusgqconsumer in the hook audited for the same shapeClaiming — picking this up together with #631, since both are resolved by PR #637. That PR is green on every check except the (correctly non-exempt)
review-verdict/h10; its branch is stale againstmain(which has since gained #629's verdict-classifier extraction to the same file), so this session rebases it, re-verifies the suite, runs the adversarial review, and lands it.Releasing this claim — redirected to other work before landing anything. No changes were pushed; PR #637 is untouched and still carries the fix.
One finding worth keeping for whoever picks this up: PR #637's branch is stale against
main, which has since gained #629's verdict-classifier extraction to the same file (.claude/hooks/pretooluse-merge-consent.sh). A diff of the PR head againstmaintherefore looks like it reverts #629 — it does not; that is an artifact of the stale base. I rebased it locally (clean, no conflicts) and re-verified: catalog--checkOK,decisions_validateOK, 180 passed, and the new regression test is mutation-verified (deleting the site-1 empty-body guard reddens exactlytest_transport_failure_withholds_exemption_even_on_jq16on jq 1.8.2, i.e. host-independently). The rebase was not pushed.So the remaining work is: rebase the PR branch, push, run the adversarial review, post the verdict via
scripts/post-review-verdict.sh, and merge.Claiming — actively being worked in this session; the fix is already carried by PR #637 (which also closes #631). Labelling
in-progressretroactively: this issue was split out of #631 mid-session and I filed it without claiming it, which left it briefly pickable by another session.Closes when #637 merges.
Correction: severity was over-stated, and the first fix was incomplete
Severity. The issue body frames this as the gate "failing open". A cold re-review pointed out the calibration I got wrong: the docs-only exemption ends in
decide allow ""— a passthrough to the normal permission prompt, not an auto-grant. Only the satisfied a+b+c path callsgrant. So every bypass in this issue downgrades a mechanical deny/ask into a human prompt; none of them can produce a silent self-merge. That is still a real weakening — the gate's Done-when/verdict analysis is skipped entirely — but it is not the "unreviewed code merges itself" scenario the original framing implied. Recorded inci.script-tests-job.The first fix was incomplete. Re-review of the fix commit found two further defects, both reproduced:
The validation domain didn't match the consumption domain.
chunkemits(.previous_filename // empty)for every row regardless of status, but the guard validated that field only when.status == "renamed". A row markedmodified— or Gitea's distinctcopied— carrying a newline inprevious_filenamewas still exempted. Verified EXEMPT against the previous commit for both statuses.The test covering that side was vacuous. Its payload's second segment was
ErsatzTV/Program.cs, which the allow-list rejects on its own merits, so the test passed with the newline guard entirely removed. It asserted the outcome without ever exercising the mechanism — which is exactly why defect 1 went unnoticed, and is the same filter-hides-the-defect trap this guard is about.Also hardened: a
..path component (docs/../ErsatzTV/Program.csmatched the anchored^docs/), plus a positive control so the tightened row schema can't be satisfied by never exempting anything.Two rules worth carrying forward, beyond these bugs:
All mutation-verified; 123 tests green under both jq 1.8.2 and jq 1.6. Fix commits
6da45ab8and30263a66on PR #637.Closing record
Outcome: Fixed in PR #637 (merged). Five defects closed in the docs-only exemption path of
.claude/hooks/pretooluse-merge-consent.sh, four of them found by review rounds after the original fix.Root cause: every one was an exhaustiveness failure in an enumeration whose completeness is load-bearing — the exemption is only safe if the changed-file list is complete, and each defect made an incomplete list look complete:
jq -eover empty input exits 4 on jq ≥1.7 but 0 on jq 1.6, so a transport failure passed the guard and the loop walked past the failed page..cs.limitatMAX_RESPONSE_ITEMSand may return fewer rows than asked.previous_filenamewas consumed on every row but validated only onrenamedrows, and an array-valued.statusslipped the closed allow-list (jq'sindexdoes subsequence matching on arrays).Severity — corrected from the original filing. I first framed this as the gate "failing open". It is not: the exemption ends in
decide allow "", a passthrough to the normal permission prompt, not an auto-grant. Every bypass downgrades a mechanical deny/ask to a human prompt; none can silently self-merge. A real weakening, worth fixing, but not what my first write-up implied.Reusable knowledge:
previous_filenameside usedErsatzTV/Program.cs, which the allow-list rejects anyway — so it passed with the guard entirely removed, which is precisely why defect 5 survived the first fix..statusallow-list gated all input becauseindex(.status)evaluates.statusagainst the array; only a positive control caught it.Verification: every fix mutation-verified — reverting each reddens exactly its own test and nothing else. Positive controls (legitimate docs-only PR, docs→docs rename, short-page-then-empty-page) prevent the guard from being satisfied by never exempting anything. 216 tests green under jq 1.8.2 and jq 1.6. Four cold review rounds; the last two found nothing exploitable against real Gitea output.
Deferred: two theoretical narrowings left unfixed and stated rather than papered over — a literal file named
.mdmatches.*\.md$, anddocs/../x.csis now rejected but only because..is explicitly banned. Both need malformed 2xx output that real Gitea does not produce.Docs updated:
docs/decisions/records/ci/script-tests-job.mdcarries the generalised lesson and all three jq rules.Follow-ups now tracked as real issues rather than prose in a closed record: 649