ETV_HOOK_FIRE_LIB is SOURCED from an env-var path in 12 hooks — a decoy tree's code runs inside the gate
#891
Closed
opened 2026-08-30 10:15:52 +02:00 by timothy
·
2 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
fix/830-add-items-error-surface
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
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.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#891
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.
Split out of #858. Reframed 2026-08-30 after cold review refuted the original framing by execution — this is not telemetry hygiene, it is arbitrary code execution inside a gate, and one of the two roots is reachable.
Measured, not argued
Every hook begins:
That is
.-SOURCED: whatever it names runs as code inside the hook, before stdin is read and beforedecideis defined. A file there that prints a decision and exits 0 IS the decision.Executed 2026-08-30 against
pretooluse-merge-consent.shbefore its fix, with a decoy tree containing onlyscripts/hook-fire-log.sh:The entire H6/H10 gate was bypassed before it ran.
Why this is reachable, and where
The obvious objection is that
$CLAUDE_PROJECT_DIRalso names the hook binary in.claude/settings.json, so a hostile value has already chosen which hook runs. That objection is correct for the Claude PreToolUse hooks — there the two roots agree by construction, and self-locating is consistency rather than repair.It does not hold for the prepush family, because husky is a different launcher:
A relative path from the pushed tree, wholly independent of
$CLAUDE_PROJECT_DIR. Executed:So a push from one worktree while the environment names another sources the other tree's code into a gate that can block or allow the push. Non-adversarial, routine in a repo that runs several worktrees at once, and it fails by returning a confident wrong answer rather than visibly.
Population — derive it, do not trust this number
scripts/hook-fire-log.sh:460re-derives$CLAUDE_PROJECT_DIRinternally as well, so there are two resolutions to reconcile, not one.The trap, stated up front
Do not fix them one at a time. Byte-identical copies are what make them safe to reason about; diverging one is how this repo got the defect that extracting
scripts/lib/branch-rule-classifier.jqwas meant to end. Either they all change together — ideally sourcing one shared resolution — or none do. (#858 changed exactly one, in the file whose gate it was hardening, and said so in the code and inprocess.hook-resolves-inputs-from-repo-root; that is a stated exception, not a precedent.)Note
scripts/tests/test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_elsepermits only its own recognised lines inside the instrumentation preamble, so explanatory comments must go above it, not beside the assignment.Done-when
git ls-fileswith-Fscripts/hook-fire-log.sh's internal$CLAUDE_PROJECT_DIRresolution reconciled with themprocess.hook-resolves-inputs-from-repo-root`ETV_HOOK_FIRE_LIB` resolves from `$CLAUDE_PROJECT_DIR` in all 11 hooks — decide it once, or record why it staysto `ETV_HOOK_FIRE_LIB` resolves from `$CLAUDE_PROJECT_DIR` in all 13 tracked hooks — decide it once, or record why it stays`ETV_HOOK_FIRE_LIB` resolves from `$CLAUDE_PROJECT_DIR` in all 13 tracked hooks — decide it once, or record why it staysto `ETV_HOOK_FIRE_LIB` is SOURCED from an env-var path in 12 hooks — a decoy tree's code runs inside the gateClaiming this (Claude Code session, 2026-08-30). Four-part claim check ran clean: no open PR references #891,
git ls-remote --heads origin '*891*'is empty, no prior comments, andorigin/mainis freshly fetched atcf5f42edf.Population re-derived here with
-F, matching the body: 12 of 13 tracked hooks still carry the${CLAUDE_PROJECT_DIR:-...}sourcing root (pretooluse-merge-consent.shis the one #858 already fixed). Fixing all 12 together in one change, plus the internal resolution inscripts/hook-fire-log.sh.Closing record
Shipped in PR #903, squashed to
8fd9eae0bonmain. Verified in git, not from the APIresponse: all 13 tracked hooks carry the canonical self-located assignment, none remain on the
${CLAUDE_PROJECT_DIR:-…}form.What was wrong
ETV_HOOK_FIRE_LIBwas assigned from an env-var-preferred path and then.-SOURCED. Sourcing isexecution, so whatever that path named ran as code inside the hook before stdin was read and before
the hook could decide anything. Measured against the merge gate before #858: a decoy tree's copy
printed an
allowand exited 0, granting the merge 500 lines above the checks.Reachable without an attacker. The objection that
$CLAUDE_PROJECT_DIRalso selects the hook binaryholds for the Claude
PreToolUsehooks, where both roots agree by construction — but husky is adifferent launcher:
.husky/pre-pushinvokes./.claude/hooks/…relative to the PUSHED tree,independent of the variable. A push from one worktree while the environment names another sources
the other tree's code into a gate that can allow or block that push.
What shipped
git ls-files(with-F;ugrep parses
{in${CLAUDE_PROJECT_DIR:-as an interval expression, which is how this issue wasfirst filed claiming 11 sites).
etv_hook_fire_repo_rootself-locates and now requires the root to own this sink (-ef, not-e): self-location alone still resolves somewhere, and a tree that merely holds a.claude/hookswould otherwise be reported on confidently.control, because the assertion is an ABSENCE and an inert fixture produces the same absence.
lexical rule was defeated by five successive shapes (
${VAR:-<self>}, backticks,$((…)),$(printenv), an indented/exported reassignment,$'…'). Same reasoning as the two withdrawalsalready recorded in
docs/guard-inventory.md.Two arms were unsubsumed AND unpinned
Found by review, then measured: the begin call's PRESENCE (
if not m:) and its missing stdout-modetoken (
if not mode:) had no proof.test_a_hook_that_LOSES_its_instrumentation_is_DETECTEDlookslike their proof and is not — it strips the whole preamble, which trips four arms on all 13
hooks, and asserts only that the fault list is NON-EMPTY, so deleting either arm left the suite
green with three arms still answering. Both now carry proofs asserting their OWN fault message;
disarming either reddens exactly its own test, 13/13, and nothing else.
Disarm with
pass, neverif False:on those two: the latter falls through to.group(1)onNoneand reds with anAttributeError— a red for the wrong reason that reads like a proof, andwhich briefly convinced a review round that an unpinned arm was pinned.
The expensive lesson
15 review rounds. Rounds 1-5 found functional defects. Rounds 6-13 found nothing but false
statements in this branch's own authoritative prose, and every round's fix introduced the next
one — a count that drifted, an arm credited with proofs it does not exercise, a
Blockscolumncalled machine-compared when the row parser discards it, a scope sentence whose banners do not
partition, two references to a numbered list deleted in the same commit that edited a sentence
depending on it.
The mechanism was one thing: an arm defended by a hand-maintained list instead of by a test. The
list had to be believed, so it kept being wrong. Round 13 stopped patching prose and pinned the arms;
rounds 14 and 15 came back clean, 15 finding nothing at all. The doc now defers to what a test
compares and says only what no artifact records.
CI
Two
Script lint and testsreds on the way in, both ersatztv#904 — a pre-existing flake intest_docs_only_detector_clone_depth.py, not this branch. Measured on the runner in the CI imageunder its real 1 GiB / 1 CPU caps, 30 runs of that file per sha: this head failed 2/30,
mainfailed 3/30. I twice attributed it wrongly before measuring (first "transient", then "it's my
branch" off a single run per sha); #904 records both errors and the refuted
/tmp-collisionhypothesis.
Docs
docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md— the SUBJECT/AUTHORITYboundary (a path a hook is asked to JUDGE is caller-supplied by design;
pretooluse-bom-guard.shand
decisions-guard.share subjects, not authorities), "scope actually shipped", and theunsubsumed-arm list corrected to include PRESENCE.
docs/guard-inventory.md— the hand-maintained clause enumeration withdrawn.