pretooluse-merge-consent.sh resolves its verdict classifier from $CLAUDE_PROJECT_DIR — an env var as a security input
#858
Closed
opened 2026-08-27 22:01:23 +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#858
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 by cold review while working #787, and labelled there as pre-existing rather than folded in.
.claude/hooks/pretooluse-merge-consent.shresolves the H10 verdict classifier as:$CLAUDE_PROJECT_DIRis an environment variable, and the value it selects decides whether a merge isgranted. A wrong value pointing at a tree that happens to contain an executable
scripts/check-review-verdict.shclassifies THIS PR's comments with ANOTHER checkout's code. Amissing path only asks, so the failure is quiet exactly where it is worst.
#787 hit the same question for its own arm and resolved it the other way: the guard-scope freshness
checker and its snapshot both bind to
$repo_root, derived from the hook's own${BASH_SOURCE[0]},with the reasoning written down beside it. The two resolutions now disagree inside one file, which is
the state most likely to be "tidied" toward the weaker one.
Not urgent, and say why:
$CLAUDE_PROJECT_DIRis set by the harness to the project directory, sotoday both resolve to the same tree. This is about removing an unsound input, not about a live
defect.
Scope decision (2026-08-30, during implementation)
The original first box read "
verdict_script(and any sibling using the same pattern)". Working itestablished that the parenthesis covers a genuinely different class, so it is split out to #891
rather than carried as a box this PR would leave unticked:
verdict_scriptselects a predicate whose content changes a decision → fixed here.ETV_HOOK_FIRE_LIBselects the telemetry logger → not fixed here. A wrong log destination isnot a wrong verdict, and the line is byte-identical across all 13 tracked hooks, so editing this
one copy would manufacture the one-of-many-copies divergence that extracting
scripts/lib/branch-rule-classifier.jqexists to prevent. Changing it is a cross-hook decision withits own population and its own review — that is #891.
The boundary itself ("does the resolved file's CONTENT change a decision") is recorded in the new
process.hook-resolves-inputs-from-repo-root, so the split is a stated rule rather than aconvenient stopping point.
Also worth recording: the obvious security framing does not survive contact, and the record says so.
$CLAUDE_PROJECT_DIRalready names the hook binary in.claude/settings.json, so a hostile value haschosen which hook runs and the gate is moot long before either inner path is read. The defensible
threat is worktree version skew — routine here — where a sibling checkout's copy of the H10
grammar classifies this PR.
Done-when
verdict_scriptresolves from$repo_rootrather than$CLAUDE_PROJECT_DIRCLAUDE_PROJECT_DIRdeliberately, so check each still reaches its arm rather than passing on achanged path
allowis also what an inert decoyproduces, so the decoy must be shown to flip the decision when it genuinely is
$repo_rootETV_HOOK_FIRE_LIBsibling is either fixed or explicitly scoped out with its reason recordedand a follow-up filed
Claiming as a bundle with #859 — Claude Code session, worktree
~/orca/workspaces/ersatztv/main-3, branchfix/858-859-merge-consent-hook-residualsofforigin/main@58681b3a7.Bundle rationale (
Bundlesaxis (c), shared subject): #858 and #859 were both split out of #787's cold review one second apart, and both land in.claude/hooks/pretooluse-merge-consent.sh/scripts/lib/branch-rule-classifier.jqand the same merge-consent test suite. Working them separately would put two sessions in one file.Parallel-session note: three sessions started simultaneously this morning and all three initially claimed #887. I yielded #887 to the earliest claim and posted a 3-way split there (#887 → session 22984, #855 →
main-2, #858+#859 → me), so this claim is de-conflicted rather than a race.Pre-claim checks clear: no remote branch matching
*858*/*859*/*787*, no open PR referencing either, no prior comments on either issue,git fetch origin main→58681b3a7.Closing record
Outcome: Shipped in PR #897 (squash
cf5f42edf).verdict_scriptnow resolves from$repo_root. But the issue's parenthetical — "and any sibling using the same pattern" — turned out to name the more serious half, and that is what this record is mostly about.Root cause: two resolutions of one question inside one file. #787 bound the guard-scope checker and its snapshot to
$repo_rootand wrote the reasoning beside them; the H10 verdict classifier, added at #629, kept${CLAUDE_PROJECT_DIR:-.}. The file's own thesis is that two answers to one question is the state most likely to be tidied toward the weaker one, and it was carrying exactly that.The finding the issue did not anticipate. The first draft of the fix exempted
ETV_HOOK_FIRE_LIBas "telemetry, not a predicate — a wrong log destination is not a wrong verdict". Cold review refuted that by execution: the line is.-SOURCED, so it is code, running before stdin is read and beforedecideexists. Measured:The entire H6/H10 gate bypassed before it ran. Hardening
verdict_scriptwhile leaving that would have been decorative, so this hook's copy is self-located too.Reachability, stated honestly — because the obvious security framing does not survive contact.
$CLAUDE_PROJECT_DIRalso names the hook binary in.claude/settings.json, so for PreToolUse hooks the two roots agree by construction and this is consistency rather than repair. It does not hold for the prepush family: husky launches them as./.claude/hooks/…, a relative path independent of the variable. Measured — a decoy tree's code executed insideprepush-donewhen.sh. That is the reachable case, and it is ordinary worktree usage, not an attack.Scope decision: the
ETV_HOOK_FIRE_LIBline is byte-identical across all 13 tracked hooks. One copy is fixed (the file whose gate this PR hardens); the other 12 are #891 (priority: high+security), left whole because diverging byte-identical copies is the defect that extractingscripts/lib/branch-rule-classifier.jqexists to end.scripts/hook-fire-log.sh's own USAGE recipe — what a new hook copies — was corrected too, so the sweep does not race new instances. The original first Done-when box was split rather than ticked on a technicality; that split is recorded in the issue body above.Decisions/conventions changed: new record
process.hook-resolves-inputs-from-repo-root. Its rule is not "an env var is a security input" (refutable, and it got refuted) but: classify a path by how it is CONSUMED, never by what it is called — a sourced path is code whatever its purpose, and the threat is launcher divergence, not an attacker. Both inline sites now cite the record instead of arguing it twice.Reusable knowledge:
$repo_rootand assert the decision flips.test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_elsepermits only its own recognised lines inside the instrumentation preamble — explanatory comments go above it, not beside the assignment.Verification: 1377 passed / 2 skipped; 11 declared mutants, 11 detected; CI green first run. Four cold rounds (1 cross-family Codex, 3 worktree-isolated Claude) + a bounded prose check. Round 4 confirmed this issue's Done-when box 2 explicitly: every pre-existing test that sets
CLAUDE_PROJECT_DIRalso passeshook_path=copied_hook, so each still reaches the arm it names after the resolution change.Deferred: #891 (the remaining 12 hooks +
hook-fire-log.sh:460's internal resolution). #895 unrelated to this issue's subject.Docs updated: new decision record + regenerated catalog;
docs/remote-state-inventory.md. No skill or vault impact — this is fork code, not homelab operations.