Files
ersatztv/docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md
T
8fd9eae0bf
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m3s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
fix(891): a sourced path is code, so every hook resolves it from its own tree (#903)
Every hook under `.claude/hooks/` assigned `ETV_HOOK_FIRE_LIB` from `${CLAUDE_PROJECT_DIR:-<self>}`
and then `. `-SOURCED it. Sourcing is execution, so a file of that name in an env-designated tree ran
as code inside the hook before stdin was read and before it could decide anything. Measured on the
merge gate before #858 fixed that one hook: a decoy tree's copy printed an `allow` and exited 0.

Reachable without an attacker, because husky is a different launcher: `.husky/pre-push` invokes
`./.claude/hooks/…` relative to the PUSHED tree, independent of the variable, so a push from one
worktree while the environment names another sources the other tree's code into a gate.

Sweeps the remaining twelve hooks together (population derived from `git ls-files`), reconciles the
second resolution inside `scripts/hook-fire-log.sh` itself, and requires the root to OWN the sink
(`-ef`, not `-e`). The static guard pins the preamble BYTE-FOR-BYTE — a withdrawal, after a lexical
rule was defeated by five successive shapes.

Also pins two arms of the checker that were unsubsumed AND unpinned: the begin call's presence and
its missing stdout-mode token. `…_LOSES_its_instrumentation_…` looked like their proof and was not —
it asserts only that the fault list is non-empty, and a stripped hook trips four arms, so deleting
either left the suite green.

fixes #891
refs #858, #859, #776

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYNbVwgVszv6Pum7ZuGd75
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 21:41:19 +00:00

11 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
process.hook-resolves-inputs-from-repo-root 2026-08-30 — a hook resolves every path it sources or executes from `$repo_root`, never from an environment variable (#858) active 2026-08-30 none none A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from `$repo_root` — derived from the hook's own `${BASH_SOURCE[0]}` — and never from `$CLAUDE_PROJECT_DIR` or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted `ETV_HOOK_FIRE_LIB` on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is `. `-SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before `decide` exists; a file there that prints an `allow` decision and exits 0 grants the merge having bypassed every check in the file. A path's PURPOSE does not bound its authority; how the hook consumes it does. THE SAME TEST DRAWS THE OTHER BOUNDARY, and it is not optional: a path the hook consumes as its SUBJECT — the tree it is asked to JUDGE — is caller-supplied by design, and binding it to `$repo_root` would break the guard, since the hook lives in one tree and must judge whichever worktree is being acted in. Two live cases, both deliberate: `pretooluse-bom-guard.sh` takes the committed tree from the command's `cd`, then the payload `cwd`, then `${CLAUDE_PROJECT_DIR:-$PWD}` as a last-resort default; and `decisions-guard.sh` `cd`s to `git rev-parse --show-toplevel` and EXECUTES that tree's `scripts/decisions_validate.py`, whose exit code is the hook's decision — the stronger case, since it is code and not data, and it is admissible for the same reason: a pre-commit validator must validate the tree being committed, and under husky, its only launcher, git sets the cwd to that tree so the two roots agree anyway. The distinction is not first-two-sources-versus-third and not data-versus-code: it is WHOSE QUESTION the path answers. Ban the variable in the assignment naming the AUTHORITY — the code that decides — never every mention of it in a hook. The failure that matters is not an attacker — the same variable already names the hook binary in `.claude/settings.json`, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as `./.claude/hooks/…`, a relative path wholly independent of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots. env var as a security input · `$CLAUDE_PROJECT_DIR` vs `$repo_root` · `${BASH_SOURCE[0]}` self-location · sourced file is code not configuration · which checkout's code decided this · launcher divergence · husky relative path vs harness env · a wrong path returns a confident answer, a missing one asks · both halves of a comparison from one root · paths: `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/hook-fire-log.sh`, `scripts/check-review-verdict.sh`, `scripts/check-required-contexts.sh` · issues: #858, #891, #787, #629 No linter can spot "this path should have come from `$repo_root`". Enforcement is review plus a behavioural test per site: point `$CLAUDE_PROJECT_DIR` at a decoy tree whose copy of the resolved file returns the OPPOSITE decision, and assert the outcome is unchanged. That test needs a NEGATIVE CONTROL or it proves nothing — the passing outcome is also what an inert decoy produces — so run the same decoy again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`, and assert the decision DOES flip. Since #891 that test is PARAMETRIZED OVER THE DERIVED HOOK POPULATION in `test_hook_fire_log.py` — `test_CLAUDE_PROJECT_DIR_cannot_choose_the_SOURCED_sink` beside `test_the_sink_decoy_IS_sourced_when_it_genuinely_is_the_repo_root`, and the husky RELATIVE launch as its own pair, since that construct resolves through the CWD — so a hook added tomorrow arrives covered rather than needing someone to remember it. The STATIC half is not a pattern over the line but BYTE-IDENTITY of the preamble's two lines (a lexical rule was tried and withdrawn; see the body), proved by `test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED` for the shape only it catches, with `test_an_ENV_VAR_resolved_sink_path_is_DETECTED` and `test_the_NEXT_env_var_to_be_invented_is_DETECTED` pinning the diagnostic arms, each asserting its OWN arm's phrase — repair for `test_an_ENV_VAR_resolved_sink_path_is_DETECTED`, whose assertion the byte-identity message's echo of the offending line did satisfy by itself, and prudence for the others. The sink's own root resolution additionally requires the root to OWN it, proved by `test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink`. Two worked single-site pairs live in `test_merge_consent_required_check.py`: `test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs` and `test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR`, each beside its `..._IS_honoured/sourced_when_it_genuinely_is_the_repo_root` control.

.claude/hooks/pretooluse-merge-consent.sh resolved three decision-bearing paths from two different roots, in one file. #787 bound the guard-scope freshness checker and its snapshot to $repo_root and wrote the reasoning beside them; the H10 verdict classifier, added at #629, kept ${CLAUDE_PROJECT_DIR:-.}; and the sourced fire-log library kept ${CLAUDE_PROJECT_DIR:-<self>}.

Two resolutions of the same question inside one file is the state most likely to be "tidied" toward the weaker one. That is why this is a record rather than a second inline comment: a reader reconciling the inconsistency has even odds of reconciling it the wrong way, and the comment that would have stopped them is attached to the other site.

The exemption this record tried to grant, and why it does not exist. The first draft drew the boundary at "does its CONTENT change the decision" and placed ETV_HOOK_FIRE_LIB outside it as telemetry. Cold review refuted that by running it: a decoy hook-fire-log.sh in an env-var-named tree printing an allow decision and exiting 0 took the merge decision, 500 lines before the gate. The line is . -SOURCED — sourcing is execution, and a file's stated purpose grants no protection at all. The lesson generalises past this variable: classify a path by how it is CONSUMED, never by what it is called.

Name the real threat, or the rule gets argued away. "An env var is a security input" invites the correct objection that $CLAUDE_PROJECT_DIR already selects the hook itself, so an attacker who controls it has won already — and an argument refutable on its face gets the rule reverted. The defensible claim is narrower and holds: the harness launcher and husky are DIFFERENT launchers. Claude PreToolUse hooks are started as $CLAUDE_PROJECT_DIR/.claude/hooks/…, so there the two roots agree by construction and binding to $repo_root is consistency rather than repair. Husky starts the prepush hooks as ./.claude/hooks/… from the pushed tree, wholly independent of the variable — measured 2026-08-30, a push from one worktree with the variable naming another sources the other tree's code into a gate that can block or allow the push. That is ordinary, non-adversarial, and produces a confident wrong answer rather than a visible failure.

Scope actually shipped, so nobody reads more into it. #858 fixed the verdict classifier and the fire-log resolution in pretooluse-merge-consent.sh only — because leaving a total gate bypass above the gate that PR was hardening would have made the rest of it decorative, not because one hook is special. #891 then swept the other twelve tracked hooks together, in one change, and reconciled the second resolution inside scripts/hook-fire-log.sh itself: its report preferred $CLAUDE_PROJECT_DIR when deriving the hook POPULATION, which reads as harmless because a report decides nothing — but the report's two halves ARE that population and the log, so resolving them from two roots earns a NEVER-FIRED row for a hook that fired. Every tracked hook now carries that assignment byte-identically. The twelve above is a dated fact about what #891 swept, not an invariant: the INVARIANT is "every tracked hook", and no count of it is pinned anywhere, because the population is derived from git ls-files by the guard — a new hook is covered the moment it exists rather than when someone remembers to update a number. (That derivation is the GUARD's. etv_hook_fire_report's own population is a filesystem glob over the tree it roots at — a report, not a gate — so the two are not the same population and should not be cited as one.)

Byte-identical is the property, not an aesthetic. Diverging one copy is how this repo acquired the defect that extracting scripts/lib/branch-rule-classifier.jq was meant to end, so the sweep was all-or-nothing, and the check is a comparison over the derived population rather than a comment in each hook — one machine-checked rule beats a paragraph repeated once per hook and drifting in all but one of them.

The check pins BYTE-IDENTITY, and arriving there took a withdrawal. The obvious rule is lexical — ban $CLAUDE_PROJECT_DIR in the assignment — and it is worth exactly the one name in it. Three review rounds walked the successors: ${NEXT_VAR:-$(… BASH_SOURCE …)} satisfies a containment test while the variable still wins; then backticks and $((…)); then $(printenv VAR), $1, $?, an INDENTED or exported reassignment further down that a column-anchored regex cannot see, and $'…' quoting that makes the required token literal. Each fix admitted the next shape, which is this repo's recorded signal to stop rather than patch again — a guard was WITHDRAWN from that same test file after four iterations of pattern-matching shell source. A shell assignment is not recognisable by regex, so the guard stopped trying: every non-comment line mentioning the variable must be one of the two canonical lines, byte for byte. The lexical arms over the preamble survive only to say WHY a line is wrong — that includes the pattern matching the sourcing line, which byte-identity necessarily matches and which therefore decides nothing on its own. The arms judging other things — a self-disabled hook, and the begin call's PRESENCE, name, mode and ordering — are not subsumed and still decide. Each is pinned by its own test asserting its own fault message, so this sentence is a map and not the guard: deleting an arm on the strength of this list reddens the suite. The cost is that a future rewrite of the line must edit one constant and all the hooks together, which is the property #891 exists to hold.