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
Owner

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:

ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-<self-location>}/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true

That is . -SOURCED: whatever it names runs as code inside the hook, before stdin is read and before decide is defined. A file there that prints a decision and exits 0 IS the decision.

Executed 2026-08-30 against pretooluse-merge-consent.sh before its fix, with a decoy tree containing only scripts/hook-fire-log.sh:

$ echo '{"tool_input":{"method":"merge",...}}' | CLAUDE_PROJECT_DIR=<decoy> bash .claude/hooks/pretooluse-merge-consent.sh
{"hookSpecificOutput":{...,"permissionDecision":"allow","permissionDecisionReason":"DECOY LOGGER GRANTED THIS"}}

The entire H6/H10 gate was bypassed before it ran.

Why this is reachable, and where

The obvious objection is that $CLAUDE_PROJECT_DIR also 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:

# .husky/pre-push
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-donewhen.sh || exit 1

A relative path from the pushed tree, wholly independent of $CLAUDE_PROJECT_DIR. Executed:

$ printf 'refs/heads/x aaa refs/heads/main bbb\n' | CLAUDE_PROJECT_DIR=<decoy> bash ./.claude/hooks/prepush-donewhen.sh
DECOY CODE EXECUTED INSIDE ./.claude/hooks/prepush-donewhen.sh

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

$ git ls-files '.claude/hooks/*' | xargs grep -lnF 'ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-' | wc -l
12          # was 13 before #858 fixed pretooluse-merge-consent.sh
$ git ls-files '.claude/hooks/*' | wc -l
13

⚠️ Pass -F. grep on the dev Mac is ugrep, which parses { in ${CLAUDE_PROJECT_DIR:- as an interval expression: the same pattern without -F matches nothing and exits 0. This issue was first filed claiming 11 sites on exactly that mistake.

scripts/hook-fire-log.sh:460 re-derives $CLAUDE_PROJECT_DIR internally 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.jq was 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 in process.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_else permits only its own recognised lines inside the instrumentation preamble, so explanatory comments must go above it, not beside the assignment.

Done-when

  • Population re-derived from git ls-files with -F
  • All remaining hooks resolve the sourced library from their own location, together, in one change
  • scripts/hook-fire-log.sh's internal $CLAUDE_PROJECT_DIR resolution reconciled with them
  • A test pins it with a negative control (the decoy must be shown to fire when it genuinely IS the root), per process.hook-resolves-inputs-from-repo-root
  • That record's "scope actually shipped" paragraph updated once this lands
  • Adversarial review passed
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: ```sh ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-<self-location>}/scripts/hook-fire-log.sh" || true [ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true ``` That is `. `-**SOURCED**: whatever it names runs as code inside the hook, *before* stdin is read and *before* `decide` is defined. A file there that prints a decision and exits 0 IS the decision. Executed 2026-08-30 against `pretooluse-merge-consent.sh` before its fix, with a decoy tree containing only `scripts/hook-fire-log.sh`: ``` $ echo '{"tool_input":{"method":"merge",...}}' | CLAUDE_PROJECT_DIR=<decoy> bash .claude/hooks/pretooluse-merge-consent.sh {"hookSpecificOutput":{...,"permissionDecision":"allow","permissionDecisionReason":"DECOY LOGGER GRANTED THIS"}} ``` The entire H6/H10 gate was bypassed before it ran. ## Why this is reachable, and where The obvious objection is that `$CLAUDE_PROJECT_DIR` also 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**: ```sh # .husky/pre-push printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-donewhen.sh || exit 1 ``` A **relative** path from the pushed tree, wholly independent of `$CLAUDE_PROJECT_DIR`. Executed: ``` $ printf 'refs/heads/x aaa refs/heads/main bbb\n' | CLAUDE_PROJECT_DIR=<decoy> bash ./.claude/hooks/prepush-donewhen.sh DECOY CODE EXECUTED INSIDE ./.claude/hooks/prepush-donewhen.sh ``` 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 ``` $ git ls-files '.claude/hooks/*' | xargs grep -lnF 'ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-' | wc -l 12 # was 13 before #858 fixed pretooluse-merge-consent.sh $ git ls-files '.claude/hooks/*' | wc -l 13 ``` > ⚠️ **Pass `-F`.** `grep` on the dev Mac is **ugrep**, which parses `{` in `${CLAUDE_PROJECT_DIR:-` as an interval expression: the same pattern without `-F` matches **nothing and exits 0**. This issue was first filed claiming 11 sites on exactly that mistake. `scripts/hook-fire-log.sh:460` re-derives `$CLAUDE_PROJECT_DIR` internally 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.jq` was 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 in `process.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_else` permits only its own recognised lines inside the instrumentation preamble, so explanatory comments must go **above** it, not beside the assignment. ## Done-when - [x] Population re-derived from `git ls-files` with `-F` - [x] All remaining hooks resolve the sourced library from their own location, together, in one change - [x] `scripts/hook-fire-log.sh`'s internal `$CLAUDE_PROJECT_DIR` resolution reconciled with them - [x] A test pins it with a negative control (the decoy must be shown to fire when it genuinely IS the root), per `process.hook-resolves-inputs-from-repo-root` - [x] That record's "scope actually shipped" paragraph updated once this lands - [x] Adversarial review passed
timothy changed title from `ETV_HOOK_FIRE_LIB` resolves from `$CLAUDE_PROJECT_DIR` in all 11 hooks — decide it once, or record why it stays to `ETV_HOOK_FIRE_LIB` resolves from `$CLAUDE_PROJECT_DIR` in all 13 tracked hooks — decide it once, or record why it stays 2026-08-30 10:17:26 +02:00
timothy added the ci-cdpriority: medium labels 2026-08-30 10:17:32 +02:00
timothy changed title from `ETV_HOOK_FIRE_LIB` resolves from `$CLAUDE_PROJECT_DIR` in all 13 tracked hooks — decide it once, or record why it stays to `ETV_HOOK_FIRE_LIB` is SOURCED from an env-var path in 12 hooks — a decoy tree's code runs inside the gate 2026-08-30 10:58:22 +02:00
timothy added priority: highsecurity and removed priority: medium labels 2026-08-30 10:58:32 +02:00
timothy added the in-progress label 2026-08-30 15:38:27 +02:00
Author
Owner

Claiming 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, and origin/main is freshly fetched at cf5f42edf.

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.sh is the one #858 already fixed). Fixing all 12 together in one change, plus the internal resolution in scripts/hook-fire-log.sh.

Claiming 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, and `origin/main` is freshly fetched at cf5f42edf. 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.sh` is the one #858 already fixed). Fixing all 12 together in one change, plus the internal resolution in `scripts/hook-fire-log.sh`.
Author
Owner

Closing record

Shipped in PR #903, squashed to 8fd9eae0b on main. Verified in git, not from the API
response: all 13 tracked hooks carry the canonical self-located assignment, none remain on the
${CLAUDE_PROJECT_DIR:-…} form.

What was wrong

ETV_HOOK_FIRE_LIB was assigned from an env-var-preferred path and then . -SOURCED. Sourcing is
execution, 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 allow and exited 0, granting the merge 500 lines above the checks.

Reachable without an attacker. The objection that $CLAUDE_PROJECT_DIR also selects the hook binary
holds for the Claude PreToolUse hooks, where both roots agree by construction — but husky is a
different launcher
: .husky/pre-push invokes ./.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

  • The remaining twelve hooks swept together, population derived from git ls-files (with -F;
    ugrep parses { in ${CLAUDE_PROJECT_DIR:- as an interval expression, which is how this issue was
    first filed claiming 11 sites).
  • etv_hook_fire_repo_root self-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/hooks would otherwise be reported on confidently.
  • Behavioural guards in BOTH launch constructs — absolute and husky-relative — each with a negative
    control, because the assertion is an ABSENCE and an inert fixture produces the same absence.
  • The static half pins the preamble byte-for-byte. That is a withdrawal, not a strengthening: a
    lexical rule was defeated by five successive shapes (${VAR:-<self>}, backticks, $((…)),
    $(printenv), an indented/exported reassignment, $'…'). Same reasoning as the two withdrawals
    already 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-mode
token (if not mode:) had no proof. test_a_hook_that_LOSES_its_instrumentation_is_DETECTED looks
like 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, never if False: on those two: the latter falls through to .group(1) on
None and reds with an AttributeError — a red for the wrong reason that reads like a proof, and
which 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 Blocks column
called 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 tests reds on the way in, both ersatztv#904 — a pre-existing flake in
test_docs_only_detector_clone_depth.py, not this branch. Measured on the runner in the CI image
under its real 1 GiB / 1 CPU caps, 30 runs of that file per sha: this head failed 2/30, main
failed 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-collision
hypothesis.

Docs

  • docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md — the SUBJECT/AUTHORITY
    boundary (a path a hook is asked to JUDGE is caller-supplied by design; pretooluse-bom-guard.sh
    and decisions-guard.sh are subjects, not authorities), "scope actually shipped", and the
    unsubsumed-arm list corrected to include PRESENCE.
  • docs/guard-inventory.md — the hand-maintained clause enumeration withdrawn.
## Closing record **Shipped** in PR #903, squashed to `8fd9eae0b` on `main`. Verified in git, not from the API response: all 13 tracked hooks carry the canonical self-located assignment, none remain on the `${CLAUDE_PROJECT_DIR:-…}` form. ### What was wrong `ETV_HOOK_FIRE_LIB` was assigned from an env-var-preferred path and then `. `-SOURCED. Sourcing is execution, 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 `allow` and exited 0, granting the merge 500 lines above the checks. Reachable without an attacker. The objection that `$CLAUDE_PROJECT_DIR` also selects the hook binary holds for the Claude `PreToolUse` hooks, where both roots agree by construction — but **husky is a different launcher**: `.husky/pre-push` invokes `./.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 - The remaining twelve hooks swept **together**, population derived from `git ls-files` (with `-F`; ugrep parses `{` in `${CLAUDE_PROJECT_DIR:-` as an interval expression, which is how this issue was first filed claiming 11 sites). - `etv_hook_fire_repo_root` self-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/hooks` would otherwise be reported on confidently. - Behavioural guards in BOTH launch constructs — absolute and husky-relative — each with a negative control, because the assertion is an ABSENCE and an inert fixture produces the same absence. - The static half pins the preamble **byte-for-byte**. That is a withdrawal, not a strengthening: a lexical rule was defeated by five successive shapes (`${VAR:-<self>}`, backticks, `$((…))`, `$(printenv)`, an indented/`export`ed reassignment, `$'…'`). Same reasoning as the two withdrawals already 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-mode token (`if not mode:`) had no proof. `test_a_hook_that_LOSES_its_instrumentation_is_DETECTED` looks like 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`, never `if False:`** on those two: the latter falls through to `.group(1)` on `None` and reds with an `AttributeError` — a red for the wrong reason that reads like a proof, and which 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 `Blocks` column called 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 tests` reds on the way in, both **ersatztv#904** — a pre-existing flake in `test_docs_only_detector_clone_depth.py`, not this branch. Measured on the runner in the CI image under its real 1 GiB / 1 CPU caps, 30 runs of that file per sha: **this head failed 2/30, `main` failed 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`-collision hypothesis. ### Docs - `docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md` — the SUBJECT/AUTHORITY boundary (a path a hook is asked to JUDGE is caller-supplied by design; `pretooluse-bom-guard.sh` and `decisions-guard.sh` are subjects, not authorities), "scope actually shipped", and the unsubsumed-arm list corrected to include PRESENCE. - `docs/guard-inventory.md` — the hand-maintained clause enumeration **withdrawn**.
timothy removed the in-progress label 2026-08-30 23:42:11 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#891