Sourcing is execution. Whatever that line names runs as code inside the hook, before stdin is read
and before the hook can decide anything — so a file of that name in an env-var-designated tree that
prints a decision and exits 0 is the decision. #858 fixed one hook (the merge gate, where
leaving a total bypass above the gate it was hardening would have made the rest decorative). This is
the sweep of the remaining twelve, plus the second resolution inside the sink itself.
Why it is reachable without an attacker. The obvious objection — $CLAUDE_PROJECT_DIR also
names the hook binary in .claude/settings.json, so a hostile value has already chosen which hook
runs — is correct for the Claude PreToolUse hooks, where the two roots agree by construction. It
does not hold for the prepush family, because husky is a different launcher: .husky/pre-push
invokes ./.claude/hooks/…, a path relative to the pushed tree and wholly 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 block or allow the push. Ordinary in a repo that runs several worktrees at
once, and it fails by returning a confident wrong answer rather than visibly.
What changed
All twelve remaining hooks, together, in one change. Population derived from git ls-files
(with -F — grep here is ugrep, and the same pattern without it matches nothing and exits 0,
which is how this issue was first filed claiming 11 sites). Every tracked hook now carries the
assignment byte-identically.
scripts/hook-fire-log.sh's own resolution reconciled, then hardened.etv_hook_fire_repo_root
preferred $CLAUDE_PROJECT_DIR when deriving the hook POPULATION for report. That 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. It now
self-locates and requires the root to own this file (-ef, not -e): self-location alone
still resolves somewhere, and a root that happens to hold a .claude/hooks would otherwise be
reported on confidently. A root that cannot be established refuses with exit 2, and the two
refusals state their own distinct causes.
A behavioural guard with negative controls, in both launch constructs. Parametrized over the
derived hook population: each hook is driven with $CLAUDE_PROJECT_DIR naming a decoy tree whose scripts/hook-fire-log.sh announces itself, and must not source it. Because that assertion is an
ABSENCE — which an inert fixture also produces — each has a control where the marker must appear.
The husky relative launch is tested separately from the absolute one: self-location under a
relative ${BASH_SOURCE[0]} resolves through the CWD, so it is a different resolution, and it is
the reachable one. Both run from repo-shaped scratch trees, so no test touches the live repo.
The static half pins BYTE-IDENTITY, and that is a withdrawal. A lexical rule over the line was
defeated three rounds running — ${VAR:-<self>} satisfies a containment test; then backticks and $((…)); then $(printenv VAR), $1, $?, an indented or exported reassignment a
column-anchored regex cannot see, and $'…' quoting. Each fix admitted the next shape, which is
this repo's recorded signal to stop rather than patch again: a guard was withdrawn from this
same file after four iterations of pattern-matching shell source. Every non-comment line
mentioning the variable must now be one of the two canonical lines, byte for byte; the lexical
arms survive only to say why a line is wrong.
The boundary this does NOT cross
A path the hook consumes as its subject — the tree it is asked to judge — is caller-supplied by
design. 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}; and decisions-guard.sh cds to git rev-parse --show-toplevel and executes that tree's validator, whose exit code is the
hook's decision. Both are admissible for the same reason, and the test is not data-versus-code but whose question the path answers. Written into the record so the next sweep does not "fix" them.
Verification
Red witnessed for every guard, never assumed. Reverting a hook to the pre-#891 form reddens the
static arm, the absolute-launch test, the husky relative-launch test and the mutation proof — each
for its stated reason, with the decoy's marker in the output; the other twelve stay green.
Disarming the byte-identity arm, the CLAUDE_PROJECT_DIR arm, the stray-expansion arm, the -ef
ownership clause (both as : and as the weaker -e) and the empty-population arm each reddens exactly its own proof.
Negative controls pass for every hook, which is what makes the absences evidence.
Two arms of the checker were unsubsumed AND unpinned, and now carry proofs. Adversarial review
found that the begin-call PRESENCE arm was defended only by a prose list: test_a_hook_that_LOSES_its_instrumentation_is_DETECTED looks like its proof and is not — it
strips the whole preamble, which trips four arms on every hook, and asserts only that the fault
list is NON-EMPTY, so deleting the arm leaves it green with three arms still answering. Sweeping
the neighbours found a second, if not mode:. Both now have proofs asserting their OWN fault
message (…_LOSES_its_begin_CALL_…, …_NAMING_NO_STDOUT_MODE_…); disarming either reddens
exactly its own test on 13/13 hooks and nothing else. All six unsubsumed arms are now pinned, so
the enumeration beside them is a map rather than the guard.
Disarm with pass, not if False:. On the two begin-call arms the latter falls through to a .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.
PYTHONPATH=. python3 -m pytest scripts/tests -q → 1565 passed, 3 skipped; ruff check / ruff format --check clean; decisions validator OK; catalog regenerated. Run on a FROZEN committed
tree (git status --porcelain empty), because editing under a running suite invalidates it.
Docs updated in this PR
docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md — the rule gains the
subject/authority boundary and both live cases; "scope actually shipped" records the sweep; the mechanics: frontmatter describes byte-identity and names the proofs. Catalog regenerated.
docs/guard-inventory.md — the row's Blocks column gains the third defect, and the row's
hand-maintained clause ENUMERATION is withdrawn. Every revision of that list was wrong in a new
way, and so was the paragraph written to replace it — it misattributed a machine check and
re-committed the scope error it had itself just recorded. Same shape as the two withdrawals already
in that file, withdrawn on the same reasoning. What remains is three short paragraphs, each
measured: read Proof ref and scripts/tests/mutation_manifest.py, which test_mutation_harness.py
holds against each other and EXECUTES (verified — swapping the row's ref leaves test_guard_inventory.py green at 10 passed and reddens test_every_declared_mutation_names_the_row_s_OWN_proof_ref); read the arms in the comments INSIDE instrumentation_faults; and one fact no artifact records — Blocks is not machine-checked at all,
because inventory_rows() parses that cell into a discard variable.
scripts/tests/test_hook_fire_log.py — the instrumentation_faults comment enumerating the arms
byte-identity does NOT subsume was missing the begin call's PRESENCE (if not m:), a live detector
pinned by test_a_hook_that_LOSES_its_instrumentation_is_DETECTED. That omission is the list's own
failure mode — an arm left off it reads as already covered — so it is now listed and the trap named.
scripts/tests/mutation_manifest.py — the why prose no longer pins an arm count.
Stale twins swept by subject across code, inventory, manifest and two decision records.
fixes #891
refs #858, #859, #776
## The defect
Every hook under `.claude/hooks/` opens by assigning a path and then `. `-SOURCING it:
```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
```
Sourcing is execution. Whatever that line names runs as code inside the hook, before stdin is read
and before the hook can decide anything — so a file of that name in an env-var-designated tree that
prints a decision and exits 0 **is** the decision. #858 fixed one hook (the merge gate, where
leaving a total bypass above the gate it was hardening would have made the rest decorative). This is
the sweep of the remaining twelve, plus the second resolution inside the sink itself.
**Why it is reachable without an attacker.** The obvious objection — `$CLAUDE_PROJECT_DIR` also
names the hook binary in `.claude/settings.json`, so a hostile value has already chosen which hook
runs — is correct for the Claude PreToolUse hooks, where the two roots agree by construction. It
does not hold for the prepush family, because **husky is a different launcher**: `.husky/pre-push`
invokes `./.claude/hooks/…`, a path relative to the pushed tree and wholly 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 block or allow the push. Ordinary in a repo that runs several worktrees at
once, and it fails by returning a confident wrong answer rather than visibly.
## What changed
1. **All twelve remaining hooks, together, in one change.** Population derived from `git ls-files`
(with `-F` — `grep` here is ugrep, and the same pattern without it matches nothing and exits 0,
which is how this issue was first filed claiming 11 sites). Every tracked hook now carries the
assignment byte-identically.
2. **`scripts/hook-fire-log.sh`'s own resolution reconciled, then hardened.** `etv_hook_fire_repo_root`
preferred `$CLAUDE_PROJECT_DIR` when deriving the hook POPULATION for `report`. That 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. It now
self-locates **and requires the root to own this file** (`-ef`, not `-e`): self-location alone
still resolves *somewhere*, and a root that happens to hold a `.claude/hooks` would otherwise be
reported on confidently. A root that cannot be established refuses with exit 2, and the two
refusals state their own distinct causes.
3. **A behavioural guard with negative controls, in both launch constructs.** Parametrized over the
derived hook population: each hook is driven with `$CLAUDE_PROJECT_DIR` naming a decoy tree whose
`scripts/hook-fire-log.sh` announces itself, and must not source it. Because that assertion is an
ABSENCE — which an inert fixture also produces — each has a control where the marker must appear.
The **husky relative launch** is tested separately from the absolute one: self-location under a
relative `${BASH_SOURCE[0]}` resolves through the CWD, so it is a different resolution, and it is
the reachable one. Both run from repo-shaped scratch trees, so no test touches the live repo.
4. **The static half pins BYTE-IDENTITY, and that is a withdrawal.** A lexical rule over the line was
defeated three rounds running — `${VAR:-<self>}` satisfies a containment test; then backticks and
`$((…))`; then `$(printenv VAR)`, `$1`, `$?`, an indented or `export`ed reassignment a
column-anchored regex cannot see, and `$'…'` quoting. Each fix admitted the next shape, which is
this repo's recorded signal to stop rather than patch again: a guard was **withdrawn from this
same file** after four iterations of pattern-matching shell source. Every non-comment line
mentioning the variable must now be one of the two canonical lines, byte for byte; the lexical
arms survive only to say *why* a line is wrong.
## The boundary this does NOT cross
A path the hook consumes as its **subject** — the tree it is asked to judge — is caller-supplied by
design. 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}`; and `decisions-guard.sh`
`cd`s to `git rev-parse --show-toplevel` and executes *that* tree's validator, whose exit code is the
hook's decision. Both are admissible for the same reason, and the test is not data-versus-code but
**whose question the path answers**. Written into the record so the next sweep does not "fix" them.
## Verification
- **Red witnessed for every guard, never assumed.** Reverting a hook to the pre-#891 form reddens the
static arm, the absolute-launch test, the husky relative-launch test and the mutation proof — each
for its stated reason, with the decoy's marker in the output; the other twelve stay green.
Disarming the byte-identity arm, the `CLAUDE_PROJECT_DIR` arm, the stray-expansion arm, the `-ef`
ownership clause (both as `:` and as the weaker `-e`) and the empty-population arm each reddens
**exactly its own proof**.
- Negative controls pass for every hook, which is what makes the absences evidence.
- **Two arms of the checker were unsubsumed AND unpinned, and now carry proofs.** Adversarial review
found that the begin-call PRESENCE arm was defended only by a prose list:
`test_a_hook_that_LOSES_its_instrumentation_is_DETECTED` looks like its proof and is not — it
strips the whole preamble, which trips four arms on every hook, and asserts only that the fault
list is NON-EMPTY, so deleting the arm leaves it green with three arms still answering. Sweeping
the neighbours found a second, `if not mode:`. Both now have proofs asserting their OWN fault
message (`…_LOSES_its_begin_CALL_…`, `…_NAMING_NO_STDOUT_MODE_…`); disarming either reddens
exactly its own test on 13/13 hooks and nothing else. All six unsubsumed arms are now pinned, so
the enumeration beside them is a map rather than the guard.
- **Disarm with `pass`, not `if False:`.** On the two begin-call arms the latter falls through to a
`.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.
- `PYTHONPATH=. python3 -m pytest scripts/tests -q` → **1565 passed, 3 skipped**; `ruff check` /
`ruff format --check` clean; decisions validator OK; catalog regenerated. Run on a FROZEN committed
tree (`git status --porcelain` empty), because editing under a running suite invalidates it.
## Docs updated in this PR
- `docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md` — the rule gains the
subject/authority boundary and both live cases; "scope actually shipped" records the sweep; the
`mechanics:` frontmatter describes byte-identity and names the proofs. Catalog regenerated.
- `docs/guard-inventory.md` — the row's `Blocks` column gains the third defect, and the row's
hand-maintained clause ENUMERATION is **withdrawn**. Every revision of that list was wrong in a new
way, and so was the paragraph written to replace it — it misattributed a machine check and
re-committed the scope error it had itself just recorded. Same shape as the two withdrawals already
in that file, withdrawn on the same reasoning. What remains is three short paragraphs, each
measured: read `Proof ref` and `scripts/tests/mutation_manifest.py`, which `test_mutation_harness.py`
holds against each other and EXECUTES (verified — swapping the row's ref leaves
`test_guard_inventory.py` green at 10 passed and reddens
`test_every_declared_mutation_names_the_row_s_OWN_proof_ref`); read the arms in the comments INSIDE
`instrumentation_faults`; and one fact no artifact records — `Blocks` is not machine-checked at all,
because `inventory_rows()` parses that cell into a discard variable.
- `scripts/tests/test_hook_fire_log.py` — the `instrumentation_faults` comment enumerating the arms
byte-identity does NOT subsume was missing the begin call's PRESENCE (`if not m:`), a live detector
pinned by `test_a_hook_that_LOSES_its_instrumentation_is_DETECTED`. That omission is the list's own
failure mode — an arm left off it reads as already covered — so it is now listed and the trap named.
- `scripts/tests/mutation_manifest.py` — the `why` prose no longer pins an arm count.
- Stale twins swept by subject across code, inventory, manifest and two decision records.
Seven rounds of prose defects around one guard, each fix spawning the next. The
mechanism was an arm defended by a hand-maintained list rather than by a test, so
the list had to be believed and kept being wrong.
Both unpinned arms now carry a proof asserting their OWN fault message:
test_a_hook_that_LOSES_its_begin_CALL_is_DETECTED and
test_a_begin_call_NAMING_NO_STDOUT_MODE_is_DETECTED. Disarming either with `pass`
reddens exactly its own test on 13/13 hooks and nothing else.
Decisions-Edit: yes
15 review rounds. Rounds 1-5 functional; 6-13 prose only, each fix spawning the next until the mechanism (an arm defended by a prose list rather than a test) was removed by pinning both unpinned arms. Rounds 14 and 15 clean; 15 found nothing and independently ran the full suite at this sha (1565 passed, 3 skipped, 0 failed) and verified the rebase is exactly the union of both parents. The two script-suite reds were ersatztv#904, measured pre-existing: 30 runs of that file at this sha failed 2, at main 3.
Review-verdict: MERGEABLE @ b1b478f
15 review rounds. Rounds 1-5 functional; 6-13 prose only, each fix spawning the next until the mechanism (an arm defended by a prose list rather than a test) was removed by pinning both unpinned arms. Rounds 14 and 15 clean; 15 found nothing and independently ran the full suite at this sha (1565 passed, 3 skipped, 0 failed) and verified the rebase is exactly the union of both parents. The two script-suite reds were ersatztv#904, measured pre-existing: 30 runs of that file at this sha failed 2, at main 3.
timothy
merged commit 8fd9eae0bf into main2026-08-30 23:41:20 +02:00
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.
fixes #891
refs #858, #859, #776
The defect
Every hook under
.claude/hooks/opens by assigning a path and then.-SOURCING it:Sourcing is execution. Whatever that line names runs as code inside the hook, before stdin is read
and before the hook can decide anything — so a file of that name in an env-var-designated tree that
prints a decision and exits 0 is the decision. #858 fixed one hook (the merge gate, where
leaving a total bypass above the gate it was hardening would have made the rest decorative). This is
the sweep of the remaining twelve, plus the second resolution inside the sink itself.
Why it is reachable without an attacker. The obvious objection —
$CLAUDE_PROJECT_DIRalsonames the hook binary in
.claude/settings.json, so a hostile value has already chosen which hookruns — is correct for the Claude PreToolUse hooks, where the two roots agree by construction. It
does not hold for the prepush family, because husky is a different launcher:
.husky/pre-pushinvokes
./.claude/hooks/…, a path relative to the pushed tree and wholly independent of thevariable. 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. Ordinary in a repo that runs several worktrees at
once, and it fails by returning a confident wrong answer rather than visibly.
What changed
All twelve remaining hooks, together, in one change. Population derived from
git ls-files(with
-F—grephere is ugrep, and the same pattern without it matches nothing and exits 0,which is how this issue was first filed claiming 11 sites). Every tracked hook now carries the
assignment byte-identically.
scripts/hook-fire-log.sh's own resolution reconciled, then hardened.etv_hook_fire_repo_rootpreferred
$CLAUDE_PROJECT_DIRwhen deriving the hook POPULATION forreport. That reads asharmless 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. It now
self-locates and requires the root to own this file (
-ef, not-e): self-location alonestill resolves somewhere, and a root that happens to hold a
.claude/hookswould otherwise bereported on confidently. A root that cannot be established refuses with exit 2, and the two
refusals state their own distinct causes.
A behavioural guard with negative controls, in both launch constructs. Parametrized over the
derived hook population: each hook is driven with
$CLAUDE_PROJECT_DIRnaming a decoy tree whosescripts/hook-fire-log.shannounces itself, and must not source it. Because that assertion is anABSENCE — which an inert fixture also produces — each has a control where the marker must appear.
The husky relative launch is tested separately from the absolute one: self-location under a
relative
${BASH_SOURCE[0]}resolves through the CWD, so it is a different resolution, and it isthe reachable one. Both run from repo-shaped scratch trees, so no test touches the live repo.
The static half pins BYTE-IDENTITY, and that is a withdrawal. A lexical rule over the line was
defeated three rounds running —
${VAR:-<self>}satisfies a containment test; then backticks and$((…)); then$(printenv VAR),$1,$?, an indented orexported reassignment acolumn-anchored regex cannot see, and
$'…'quoting. Each fix admitted the next shape, which isthis repo's recorded signal to stop rather than patch again: a guard was withdrawn from this
same file after four iterations of pattern-matching shell source. Every non-comment line
mentioning the variable must now be one of the two canonical lines, byte for byte; the lexical
arms survive only to say why a line is wrong.
The boundary this does NOT cross
A path the hook consumes as its subject — the tree it is asked to judge — is caller-supplied by
design. Two live cases, both deliberate:
pretooluse-bom-guard.shtakes the committed tree from thecommand's
cd, then the payloadcwd, then${CLAUDE_PROJECT_DIR:-$PWD}; anddecisions-guard.shcds togit rev-parse --show-topleveland executes that tree's validator, whose exit code is thehook's decision. Both are admissible for the same reason, and the test is not data-versus-code but
whose question the path answers. Written into the record so the next sweep does not "fix" them.
Verification
static arm, the absolute-launch test, the husky relative-launch test and the mutation proof — each
for its stated reason, with the decoy's marker in the output; the other twelve stay green.
Disarming the byte-identity arm, the
CLAUDE_PROJECT_DIRarm, the stray-expansion arm, the-efownership clause (both as
:and as the weaker-e) and the empty-population arm each reddensexactly its own proof.
found that the begin-call PRESENCE arm was defended only by a prose list:
test_a_hook_that_LOSES_its_instrumentation_is_DETECTEDlooks like its proof and is not — itstrips the whole preamble, which trips four arms on every hook, and asserts only that the fault
list is NON-EMPTY, so deleting the arm leaves it green with three arms still answering. Sweeping
the neighbours found a second,
if not mode:. Both now have proofs asserting their OWN faultmessage (
…_LOSES_its_begin_CALL_…,…_NAMING_NO_STDOUT_MODE_…); disarming either reddensexactly its own test on 13/13 hooks and nothing else. All six unsubsumed arms are now pinned, so
the enumeration beside them is a map rather than the guard.
pass, notif False:. On the two begin-call arms the latter falls through to a.group(1)onNoneand reds with anAttributeError— a red for the wrong reason that readslike a proof, and which briefly convinced a review round that an unpinned arm was pinned.
PYTHONPATH=. python3 -m pytest scripts/tests -q→ 1565 passed, 3 skipped;ruff check/ruff format --checkclean; decisions validator OK; catalog regenerated. Run on a FROZEN committedtree (
git status --porcelainempty), because editing under a running suite invalidates it.Docs updated in this PR
docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md— the rule gains thesubject/authority boundary and both live cases; "scope actually shipped" records the sweep; the
mechanics:frontmatter describes byte-identity and names the proofs. Catalog regenerated.docs/guard-inventory.md— the row'sBlockscolumn gains the third defect, and the row'shand-maintained clause ENUMERATION is withdrawn. Every revision of that list was wrong in a new
way, and so was the paragraph written to replace it — it misattributed a machine check and
re-committed the scope error it had itself just recorded. Same shape as the two withdrawals already
in that file, withdrawn on the same reasoning. What remains is three short paragraphs, each
measured: read
Proof refandscripts/tests/mutation_manifest.py, whichtest_mutation_harness.pyholds against each other and EXECUTES (verified — swapping the row's ref leaves
test_guard_inventory.pygreen at 10 passed and reddenstest_every_declared_mutation_names_the_row_s_OWN_proof_ref); read the arms in the comments INSIDEinstrumentation_faults; and one fact no artifact records —Blocksis not machine-checked at all,because
inventory_rows()parses that cell into a discard variable.scripts/tests/test_hook_fire_log.py— theinstrumentation_faultscomment enumerating the armsbyte-identity does NOT subsume was missing the begin call's PRESENCE (
if not m:), a live detectorpinned by
test_a_hook_that_LOSES_its_instrumentation_is_DETECTED. That omission is the list's ownfailure mode — an arm left off it reads as already covered — so it is now listed and the trap named.
scripts/tests/mutation_manifest.py— thewhyprose no longer pins an arm count.Review-verdict: MERGEABLE @
b1b478f15 review rounds. Rounds 1-5 functional; 6-13 prose only, each fix spawning the next until the mechanism (an arm defended by a prose list rather than a test) was removed by pinning both unpinned arms. Rounds 14 and 15 clean; 15 found nothing and independently ran the full suite at this sha (1565 passed, 3 skipped, 0 failed) and verified the rebase is exactly the union of both parents. The two script-suite reds were ersatztv#904, measured pre-existing: 30 runs of that file at this sha failed 2, at main 3.