fix(891): a sourced path is code, so every hook resolves it from its own tree (#903)
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
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
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>
This commit was merged in pull request #903.
This commit is contained in:
@@ -7,7 +7,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin decisions-guard "" stream || true
|
||||
|
||||
@@ -19,7 +19,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin design-sync-reminder "${1:-}" capture || true
|
||||
|
||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin posttooluse-worktree-marker "" capture || true
|
||||
|
||||
@@ -17,7 +17,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-clean-worktree-check "" stream || true
|
||||
|
||||
@@ -14,7 +14,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-donewhen "" stream || true
|
||||
|
||||
@@ -11,7 +11,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-rebase-check "" stream || true
|
||||
|
||||
@@ -42,7 +42,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-agent-model "" capture || true
|
||||
|
||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-agent-ram "" capture || true
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-bash-guard "" capture || true
|
||||
|
||||
@@ -20,7 +20,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-bom-guard "" capture || true
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
|
||||
set -euo pipefail
|
||||
|
||||
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` (ersatztv#858, #891).
|
||||
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` — as is every other
|
||||
# tracked hook's since ersatztv#891, byte-identically (`process.hook-resolves-inputs-from-repo-root`).
|
||||
# Written here rather than beside the assignment because the instrumentation preamble that follows is
|
||||
# machine-compared: `test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else`
|
||||
# permits only its own recognised lines in that block, so a comment inside it fails the suite.
|
||||
@@ -55,12 +56,6 @@ set -euo pipefail
|
||||
# MEASURED 2026-08-30: with the env-var-first form, a `hook-fire-log.sh` in an env-var-named tree
|
||||
# that prints an `allow` decision and exits 0 GRANTS THE MERGE outright, having bypassed every check
|
||||
# below. Self-locating binds it to the tree this hook was loaded from and closes that.
|
||||
#
|
||||
# The other twelve tracked hooks still carry the env-var-first form and are deliberately NOT changed
|
||||
# here; that sweep needs its own population and review (ersatztv#891, where the reachable case is
|
||||
# measured — husky launches the prepush hooks as `./.claude/hooks/...`, a RELATIVE path independent
|
||||
# of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely diverge there). This copy is fixed because
|
||||
# leaving a total gate bypass 500 lines above the gate this PR hardens would make the rest decorative.
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
@@ -706,7 +701,7 @@ fi
|
||||
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
|
||||
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
|
||||
# RESOLVED FROM `$repo_root`, never `$CLAUDE_PROJECT_DIR` — the rule, the threat model and the
|
||||
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858). Written once there
|
||||
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). Written once there
|
||||
# rather than twice here: this file carried two resolutions of the same question, and the guard-scope
|
||||
# arm below is the other one. Two answers in one file is the state most likely to be "tidied" toward
|
||||
# the weaker side, so neither site restates the argument now.
|
||||
@@ -715,8 +710,8 @@ fi
|
||||
# would classify THIS PR's comments with THAT tree's copy of the H10 grammar.
|
||||
#
|
||||
# `ETV_HOOK_FIRE_LIB` at the top of this file is bound the same way, and for a STRONGER reason — it
|
||||
# is sourced, so it is code. See the block above it. The twelve other hooks still resolve it from
|
||||
# the env var and are ersatztv#891.
|
||||
# is sourced, so it is code. See the block above it. Since #891 every tracked hook binds it
|
||||
# identically, and `test_hook_fire_log.py` fails any that stops doing so.
|
||||
verdict_script="$repo_root/scripts/check-review-verdict.sh"
|
||||
if [ ! -x "$verdict_script" ]; then
|
||||
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-nav-guard "" capture || true
|
||||
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-worktree-guard "" capture || true
|
||||
|
||||
@@ -126,7 +126,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `process.ersatztv-owns-code-not-operations` | This repo owns *developing the fork* — code, the `/api/v1` surface, the MCP server, CI, releases and the canonical `ersatztv` skill. Channel/collection/schedule/playout OPERATIONS against a running instance belong to `media-management`; driving prod from here is in scope only as verification of a change this repo is shipping. | 2026-08-26 | [link](records/process/ersatztv-owns-code-not-operations.md) |
|
||||
| `process.foreign-worktree-plumbing-merge` | Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead. | 2026-07-21 | [link](records/process/foreign-worktree-plumbing-merge.md) |
|
||||
| `process.harden-with-runtime-posture-not-clamp` | When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first. | 2026-07-21 | [link](records/process/harden-with-runtime-posture-not-clamp.md) |
|
||||
| `process.hook-resolves-inputs-from-repo-root` | 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 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. | 2026-08-30 | [link](records/process/hook-resolves-inputs-from-repo-root.md) |
|
||||
| `process.hook-resolves-inputs-from-repo-root` | 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. | 2026-08-30 | [link](records/process/hook-resolves-inputs-from-repo-root.md) |
|
||||
| `process.independent-review-rubric` | Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment. | 2026-07-21 | [link](records/process/independent-review-rubric.md) |
|
||||
| `process.issue-qualification-audit` | Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session. | 2026-07-21 | [link](records/process/issue-qualification-audit.md) |
|
||||
| `process.local-gate-before-push` | Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | [link](records/process/local-gate-before-push.md) |
|
||||
|
||||
@@ -5,9 +5,9 @@ status: active
|
||||
since: '2026-08-30'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: '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 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.'
|
||||
rule: '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.'
|
||||
signals: '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'
|
||||
mechanics: '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. Two worked 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.'
|
||||
mechanics: '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
|
||||
@@ -40,7 +40,40 @@ tree's code into a gate that can block or allow the push. That is ordinary, non-
|
||||
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**. The other twelve tracked hooks still
|
||||
carry the env-var-first fire-log line; sweeping them needs its own derived population and its own
|
||||
review, and is #891. One copy was fixed 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.
|
||||
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 `export`ed 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.
|
||||
|
||||
@@ -41,15 +41,23 @@ one's declared diagnostic says which.
|
||||
**The `DETECTOR` grade is the honest half, and it exists because measuring found a case.** #775's
|
||||
record concedes that neutering `pin_population_faults` wholesale is "coarser than disarming one
|
||||
clause at a time — coarse enough that a single surviving clause would not be noticed". Running the
|
||||
finer mutations settled which guards actually admit clause-level proof: all but one do,
|
||||
including
|
||||
`pin_population_faults`, where disarming the against-the-registry comparison alone reddens the proof
|
||||
because a job that loses its `container:` block leaves the two derived sets equal. One does not.
|
||||
`instrumentation_faults` accumulates from four independent arms and a stripped hook trips three of
|
||||
them at once, so disarming any single arm leaves the proof test green — measured, by running
|
||||
`if not _SOURCES_SINK.search(text):` → `if False:` and watching it pass. That surviving mutation is
|
||||
carried as data and re-run on every suite: if it ever starts reddening, the guard has become
|
||||
clause-provable and the entry must be regraded. A grade that cannot decay quietly is the point.
|
||||
finer mutations settled which guards actually admit clause-level proof: all but one did at the time,
|
||||
including `pin_population_faults`, where disarming the against-the-registry comparison alone reddens
|
||||
the proof because a job that loses its `container:` block leaves the two derived sets equal — and
|
||||
since ersatztv#891 regraded the exception, all of them do. That one did not, and
|
||||
its history is the worked example. `instrumentation_faults` accumulated from independent arms a
|
||||
stripped hook trips several of at once, so disarming any single arm left the proof test green —
|
||||
measured, by running `if not _SOURCES_SINK.search(text):` → `if False:` and watching it pass. The
|
||||
surviving mutation was carried as data and re-run every suite, required to keep surviving.
|
||||
|
||||
**It stopped being able to, and that is the mechanism working rather than failing.** ersatztv#891
|
||||
replaced the lexical rule over the hook preamble with byte-identity, a clause that decides alone; the
|
||||
entry was regraded to `CLAUSE` and the survivor dropped. Note the direction: the contract says a
|
||||
survivor that STARTS REDDENING forces a regrade, and this survivor could no longer redden at all —
|
||||
byte-identity necessarily matches the line it tested. A widened clause can retire its own evidence,
|
||||
and a canary whose precondition the fix removes does not fail, it becomes a tautology that reads
|
||||
exactly like a passing proof. So re-derive every proof calibrated against a clause you widen. A grade
|
||||
that cannot decay quietly is the point — including decaying into vacuity.
|
||||
|
||||
**Three shapes that were tried and rejected, because each looks like verification and is not.**
|
||||
Emptying a checker's own population reddens its proof with an `IndexError` — a crash, not a
|
||||
|
||||
+80
-26
@@ -252,7 +252,7 @@ recorded as unexamined rather than as cleared.
|
||||
| `scripts/tests/test_docs_only_detector_clone_depth.py` | the `script-tests` job | PROOF | NONE | — |
|
||||
| `scripts/tests/test_guard_inventory.py` | the `script-tests` job, on an unclassified guard or a stale proof ref | GUARD | MUTATION | `test_guard_inventory.py::test_the_inventory_covers_exactly_the_guards_that_exist` |
|
||||
| `scripts/tests/test_guard_populations_derive_from_git.py` | the `script-tests` job, on a guard whose file population admits a file git does not track | GUARD | MUTATION | `test_guard_populations_derive_from_git.py::test_no_derivation_admits_an_untracked_file` |
|
||||
| `scripts/tests/test_hook_fire_log.py` | the `script-tests` job, on a hook that stops reporting that it fired, or whose reporting changes what the harness sees | GUARD | MUTATION | `test_hook_fire_log.py::test_a_hook_that_LOSES_its_instrumentation_is_DETECTED` |
|
||||
| `scripts/tests/test_hook_fire_log.py` | the `script-tests` job, on a hook that stops reporting that it fired, whose reporting changes what the harness sees, or that resolves the SOURCED sink from `$CLAUDE_PROJECT_DIR` instead of its own location | GUARD | MUTATION | `test_hook_fire_log.py::test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED` |
|
||||
| `scripts/tests/test_image_build_delegates_the_spa_suite.py` | the `script-tests` job, on ANY change to the commands an SPA-carrying Dockerfile stage runs, on ANY change to the gating `Test SPA` step (its `run:` body, its `if:`, or a `continue-on-error` in any spelling), on the publish step losing its `docs_only` gate, on an image-publishing job that stops being gated on the job holding that step, or on any other step mentioning the suite | GUARD | MUTATION | `test_image_build_delegates_the_spa_suite.py::test_every_SPA_CARRYING_STAGE_runs_exactly_its_pinned_commands` |
|
||||
| `scripts/tests/test_jq_preflight.py` | the `script-tests` job | PROOF | NONE | — |
|
||||
| `scripts/tests/test_mcp_smoke.py` | the `script-tests` job, when the MCP smoke checker accepts a server it should refuse — an impostor identity, a reply carrying only an id, a missing expected tool, or an `initialize` pre-answered before the request (the `tools/list` twin is held by two mechanisms jointly and is covered behaviourally, not by the declared clause) | GUARD | MUTATION | `test_mcp_smoke.py::test_MUTATION_a_PRE_ANSWERED_id_is_refused_because_the_request_ids_are_UNGUESSABLE` |
|
||||
@@ -369,14 +369,28 @@ set equality in both directions, so the grade and the mutation cannot drift apar
|
||||
graded `MUTATION` without declaring its clause fails the suite. Full contract and its limits:
|
||||
`docs/decisions/records/testing/mutation-claims-are-executed.md`.
|
||||
|
||||
Two things that column still does not say. It does not say the declared clause is the ONLY thing the
|
||||
One thing that column still does not say: it does not say the declared clause is the ONLY thing the
|
||||
guard hangs on — for three rows (measured 2026-08-22) the redness arrives through the
|
||||
proof test's own "this clause has moved, RETARGET it" assertion rather than through changed
|
||||
behaviour, which catches the recorded proof going stale but not much else. And one row is graded `DETECTOR` rather than `CLAUSE` in the
|
||||
manifest: `instrumentation_faults` accumulates from four independent arms and a stripped hook trips
|
||||
three at once, so no single-arm disarm reddens its proof. That finer mutation is carried as data and
|
||||
re-run every suite, and must keep surviving — if it starts reddening, the guard has become
|
||||
clause-provable and the entry is regraded.
|
||||
behaviour, which catches the recorded proof going stale but not much else.
|
||||
|
||||
**No row is graded `DETECTOR` (measured 2026-08-30, and asserted every run — see below), and the
|
||||
one that was is the worked example of the grade doing its job.** `instrumentation_faults` held it: the arms a stripped hook trips several of at once meant
|
||||
no single-arm disarm reddened its proof, so the entry carried the finest mutation that survived
|
||||
(`if not _SOURCES_SINK.search(text):` -> `if False:`) as data, re-run every suite and required to
|
||||
KEEP surviving. ersatztv#891 gave the guard byte-identity of the hook preamble, which decides alone —
|
||||
so the entry was regraded to `CLAUSE` and the survivor dropped. NOTE THE DIRECTION, because it is
|
||||
not the one the contract anticipates: the contract regrades when a survivor STARTS REDDENING, and
|
||||
this survivor could no longer redden AT ALL — byte-identity necessarily matches the line it tested.
|
||||
A mutation required to keep surviving that is guaranteed to survive is a tautology, not a canary, so
|
||||
it had to go rather than be carried on. Because the set is now empty, TWO things are unexercised:
|
||||
the survivor runner `test_every_SURVIVING_clause_mutation_still_does`, and the `unevidenced` arm of
|
||||
`test_every_entry_declares_a_known_granularity_and_DETECTOR_entries_CARRY_their_survivor`. Both
|
||||
self-arm the moment an entry is graded `DETECTOR`. `verify_mutation`'s "still PASSED" verdict path is
|
||||
NOT one of them, though it looks like it belongs: it is not `DETECTOR`-gated, and
|
||||
`test_an_INERT_mutation_is_REPORTED_rather_than_passed` drives it every run. Recorded here rather
|
||||
than implied to be covered, and asserted by `test_the_DETECTOR_survivor_set_is_empty_ON_PURPOSE` so
|
||||
the empty set stays a decision rather than an unread skip.
|
||||
|
||||
## What the numbers say
|
||||
|
||||
@@ -435,17 +449,55 @@ lines flagged wrongly — and cold review then constructed more of both (`[[ "$x
|
||||
a backslash continuation, a `>` inside a quoted string). Deleted rather than patched a fifth time,
|
||||
on the same reasoning as the vocabulary-parity withdrawal above.
|
||||
|
||||
**`test_hook_fire_log.py` proves FOUR clauses, and its row claims two of them.** The fourth is
|
||||
suite isolation, added in ersatztv#809/#822 and described in the section below; the three that
|
||||
predate it are as follows. Coverage (every
|
||||
hook reports that it fired) via `test_a_hook_that_LOSES_its_instrumentation_is_DETECTED`;
|
||||
transparency (the wrapper changes nothing the harness can see) via
|
||||
`test_instrumentation_changes_NOTHING_the_harness_can_see` with its mutation proof
|
||||
`test_DELETING_the_replay_makes_the_differential_go_RED`; and placement (`etv_hook_fire_begin` must
|
||||
precede the stdin read) via `test_begin_placed_AFTER_the_stdin_read_is_DETECTED`. The row's `Blocks`
|
||||
column covers the first two — "stops reporting that it fired, **or whose reporting changes what the
|
||||
harness sees**" — and the `Proof ref` column holds one ref because the column holds one, not because
|
||||
the second is unproven. Placement is proved and unclaimed.
|
||||
**`test_hook_fire_log.py` proves more clauses than its row claims, and this file no longer tries to
|
||||
enumerate them.** Every revision of the hand-written list was wrong in a new way: a count that
|
||||
drifted from the proofs, an arm credited with proofs it does not exercise, an unclaimed list missing
|
||||
members, and a scope sentence that did not partition the file. Successive review rounds fixed one and
|
||||
introduced the next — the paragraph written to REPLACE the list then misattributed a machine check
|
||||
and re-committed the scope error it had itself just recorded. Same shape as the two withdrawals
|
||||
above, withdrawn on the same reasoning: this file now says deliberately LESS about that guard than it
|
||||
could, and what remains is only what nothing beside the code already says.
|
||||
|
||||
Read the artifacts instead. The row's `Proof ref` and `scripts/tests/mutation_manifest.py` are held
|
||||
against each other and EXECUTED by `test_mutation_harness.py`, so a stale ref there is a red rather
|
||||
than a reading error. The arms are explained in `scripts/tests/test_hook_fire_log.py` itself, in the
|
||||
comments through `instrumentation_faults`; the list partway down that function enumerates the arms
|
||||
byte-identity does NOT subsume.
|
||||
|
||||
One thing no artifact records, so it is written here: **the `Blocks` column is not machine-checked at
|
||||
all.** `inventory_rows()` parses that cell into a discard variable and nothing else reads it, so a
|
||||
reader is the only check. Read it as the DEFECT MECHANISM, not the symptom: a self-disabled hook and
|
||||
a hook reporting under another hook's name both end in "stops reporting that it fired", but neither
|
||||
is the mechanism that column names (losing the preamble), so neither is claimed by it.
|
||||
|
||||
**The SELF-DISABLEMENT arm is narrow, and that is recorded rather than implied.** It matches
|
||||
`^\s*(export\s+)?ETV_HOOK_FIRE_DISABLE=1`, so it sees the bare and `export`ed forms. Measured
|
||||
2026-08-30, four other spellings disable reporting and pass clean: `ETV_HOOK_FIRE_DISABLE="1"`,
|
||||
`…='1'`, `: ${ETV_HOOK_FIRE_DISABLE:=1}` and `readonly ETV_HOOK_FIRE_DISABLE=1`. No hook uses any of
|
||||
them — no tracked hook mentions `ETV_HOOK_FIRE_DISABLE` at all. It is left narrow deliberately, but
|
||||
NOT because widening is impossible: the whole-artifact pin already shipped by ROOT — the clause that
|
||||
a hook must source the sink from its OWN tree (ersatztv#891) — transfers verbatim: collect
|
||||
the non-comment lines mentioning the variable and require the list EMPTY, which refuses all four
|
||||
spellings and anything not yet imagined. That is a whole-artifact pin, not a fifth alternation, and
|
||||
it is the shape ersatztv#891 withdrew a lexical predicate in favour of. It is not done here only
|
||||
because ersatztv#891 was already large and the hole is recorded; whoever needs the net should add
|
||||
that check rather than another `|`-arm.
|
||||
|
||||
ROOT carries a NEGATIVE CONTROL because its assertion is an ABSENCE — the decoy's marker must not
|
||||
appear — which is also exactly what an inert fixture produces. (That is the distinguishing property,
|
||||
not "the only clause with a control": transparency's `test_the_stripper_actually_strips` and
|
||||
coverage's `test_the_population_is_not_empty` are anti-vacuity arguments of the same family.) The
|
||||
control runs the same decoy with the hook copied into it — making the decoy genuinely `$repo_root` —
|
||||
and requires the marker to appear.
|
||||
|
||||
ROOT is also proved in BOTH LAUNCH CONSTRUCTS, because they resolve differently and only one of them
|
||||
is reachable. The Claude harness starts a hook by ABSOLUTE path, where `$CLAUDE_PROJECT_DIR` names
|
||||
the same tree by construction; husky starts `./.claude/hooks/…` by a path RELATIVE to the pushed
|
||||
tree, so self-location resolves through the CWD and the two roots can genuinely disagree. The husky
|
||||
half (`test_the_HUSKY_RELATIVE_launch_resolves_to_the_pushed_tree`, with its own control) derives
|
||||
its population from the `.husky/*` wiring and is guarded against an empty one by
|
||||
`test_the_husky_launched_population_is_not_empty` — a `@parametrize` over nothing does not fail, it
|
||||
collects one SKIPPED placeholder that is indistinguishable from coverage in a full run.
|
||||
|
||||
**`test_the_suite_does_not_write_to_the_PRODUCTION_log` is a structural check, and deliberately
|
||||
reads nothing under `$HOME`.** It asserts that the environment a module sees at IMPORT time already
|
||||
@@ -497,15 +549,17 @@ The reproduction is the part worth keeping: restore a module-level `{**os.enviro
|
||||
leave the helper correct and point a single `env=` argument at one — then run that file and count
|
||||
records for its synthetic session ids. **58 per run**, measured identically on macOS/git 2.55 and
|
||||
Linux/git 2.47.3. (The accumulated total observed before the fix ran to four figures across many
|
||||
runs; that is an observation rather than a reproducible measurement, so check the per-run figure.) Since the pre-collection isolation those 58 land in the session's own
|
||||
temporary directory, so the figure now measures this file's hook traffic rather than a leak — the
|
||||
reproduction still shows the mistake, not the consequence it used to have.
|
||||
runs; that is an observation rather than a reproducible measurement, so check the per-run figure.)
|
||||
Since the pre-collection isolation those 58 land in the session's own temporary directory, so the
|
||||
figure now measures this file's hook traffic rather than a leak — the reproduction still shows the
|
||||
mistake, not the consequence it used to have.
|
||||
|
||||
The pin is `test_worktree_ownership_guard.py::test_driving_a_hook_LANDS_its_records_in_the_ISOLATED_dir`,
|
||||
which asserts the EFFECT — records land in the fixture's dir — rather than the shape of the fix. Its
|
||||
predecessor asserted the helper's return value, which leaves the call site
|
||||
unguarded: `_env()` correct, one `env=` reverted, all tests green, records still leaking. Pinning to
|
||||
a hand-written revert rather than to the property is `verify-against-the-REAL-predecessor`.
|
||||
The pin is
|
||||
`test_worktree_ownership_guard.py::test_driving_a_hook_LANDS_its_records_in_the_ISOLATED_dir`, which
|
||||
asserts the EFFECT — records land in the fixture's dir — rather than the shape of the fix. Its
|
||||
predecessor asserted the helper's return value, which leaves the call site unguarded: `_env()`
|
||||
correct, one `env=` reverted, all tests green, records still leaking. Pinning to a hand-written
|
||||
revert rather than to the property is `verify-against-the-REAL-predecessor`.
|
||||
|
||||
It is narrower than the property: it guards the launch path it drives. A second launcher in the
|
||||
same file that passed a stale environment would leave it green — measured, 18 records to the wrong
|
||||
|
||||
+59
-13
@@ -37,12 +37,14 @@
|
||||
# etv_hook_fire_begin <hook-name> [label]
|
||||
#
|
||||
# SELF-LOCATED, and the `${CLAUDE_PROJECT_DIR:-...}` form this recipe used to show is BANNED —
|
||||
# `process.hook-resolves-inputs-from-repo-root` (ersatztv#858). This line is SOURCED, so whatever it
|
||||
# names runs as code inside the hook before the hook decides anything; a file of this name in an
|
||||
# env-var-designated tree can print a decision and exit 0. Measured on the merge-consent hook: it
|
||||
# granted the merge outright. The recipe is corrected here because the header is what a new hook
|
||||
# copies, so leaving it would keep minting the defect after ersatztv#891 sweeps the existing hooks.
|
||||
# Twelve hooks still carry the old form; that sweep is #891, not this line.
|
||||
# `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). This line is SOURCED, so
|
||||
# whatever it names runs as code inside the hook before the hook decides anything; a file of this
|
||||
# name in an env-var-designated tree can print a decision and exit 0. Measured on the merge-consent
|
||||
# hook: it granted the merge outright. The recipe matters because the header is what a new hook
|
||||
# copies, so a wrong one here keeps minting the defect. #891 swept the rest: every tracked hook now
|
||||
# carries this line byte-identically, and `test_hook_fire_log.py` — whose population comes from
|
||||
# `git ls-files`, so a new hook is covered the moment it exists — fails any that does not. The read
|
||||
# side below self-locates for the same reason.
|
||||
#
|
||||
# USAGE (read side):
|
||||
# scripts/hook-fire-log.sh report # this session (or every session, see below)
|
||||
@@ -452,9 +454,16 @@ etv_hook_fire_end() {
|
||||
# Read side: the report
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
# The hook population, DERIVED from the filesystem, never listed
|
||||
# (`testing.guard-derives-population-from-source`). A hook added tomorrow appears in the report as
|
||||
# The hook population, DERIVED rather than listed. A hook added tomorrow appears in the report as
|
||||
# NEVER-FIRED the moment it exists, rather than being invisible because nobody updated an array.
|
||||
#
|
||||
# A FILESYSTEM WALK, and that is the carve-out rather than the rule.
|
||||
# `testing.guard-derives-population-from-source` requires the GIT INDEX for a guard's file
|
||||
# population and allows a walk only with its reason written down, so: this is the REPORT, not a
|
||||
# gate — it describes what is on disk in the tree it was run from, and an untracked scratch hook
|
||||
# that really does fire is a row a reader wants to see. The guard over these same hooks
|
||||
# (`test_hook_fire_log.py`) uses `git ls-files`, and the two populations are deliberately not the
|
||||
# same one.
|
||||
etv_hook_fire_population() {
|
||||
local root="${1:-}" f
|
||||
[ -d "$root/.claude/hooks" ] || return 0
|
||||
@@ -464,12 +473,41 @@ etv_hook_fire_population() {
|
||||
done
|
||||
}
|
||||
|
||||
# SELF-LOCATED, for the same reason the write side is (ersatztv#891,
|
||||
# `process.hook-resolves-inputs-from-repo-root`). This used to prefer `$CLAUDE_PROJECT_DIR` when it
|
||||
# named a tree with a `.claude/hooks`, which read as harmless because the report decides nothing.
|
||||
# It is not: the report's two halves are the POPULATION (from this root) and the LOG (from
|
||||
# `$ETV_HOOK_FIRE_LOG_DIR`). Resolving them from two different roots is how a report earns a
|
||||
# NEVER-FIRED row for a hook that fired, or omits a hook that exists — a confident wrong answer
|
||||
# about another checkout, in a table whose entire purpose is to replace inference with measurement.
|
||||
# The env var bought nothing the fallback did not: self-location is what already makes
|
||||
# `scripts/hook-fire-log.sh report` work from any directory.
|
||||
#
|
||||
# A WRONG ROOT CANNOT BECOME A PLAUSIBLE REPORT, and that is enforced below rather than hoped for:
|
||||
# the root must own this file. Without that, self-location alone still resolves somewhere, and a
|
||||
# root that happens to hold a `.claude/hooks` would be reported on confidently. (`$PATH` invocation
|
||||
# is NOT such a case, measured 2026-08-30: bash sets `${BASH_SOURCE[0]}` to the resolved path, so
|
||||
# `PATH=…/scripts hook-fire-log.sh report` self-locates correctly.)
|
||||
etv_hook_fire_repo_root() {
|
||||
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "$CLAUDE_PROJECT_DIR/.claude/hooks" ]; then
|
||||
printf '%s' "$CLAUDE_PROJECT_DIR"
|
||||
return 0
|
||||
fi
|
||||
( cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd )
|
||||
local self root
|
||||
self="${BASH_SOURCE[0]}"
|
||||
root="$( cd -- "$(dirname -- "$self")/.." >/dev/null 2>&1 && pwd )" || return 0
|
||||
# AND THE ROOT MUST OWN THIS FILE. Self-location alone answers "two directories up from wherever
|
||||
# I am", which is this repo only when this file sits in it. A copy or symlink whose grandparent
|
||||
# happens to hold a `.claude/hooks` otherwise roots there and prints a confident table about
|
||||
# another tree's hooks. The shape is ordinary — `$HOME` commonly holds a `.claude/hooks` (it does
|
||||
# on this machine), so anything dropped in a `$HOME/<dir>/` roots at `$HOME` — and it is the
|
||||
# SILENT direction, since the wrong answer is a plausible one. Requiring the root's own sink to BE
|
||||
# this file makes that case return nothing, which `etv_hook_fire_report` refuses with exit 2.
|
||||
# `-ef` AND NOT `-e`, because identity is strictly stronger than presence. A mere `-e` already
|
||||
# rejects the common shape above — `$HOME` holds no `scripts/hook-fire-log.sh`, so presence fails
|
||||
# and the report refuses. What only `-ef` rejects is the narrower shape where the wrong root DOES
|
||||
# hold a sink of that name: `-e` accepts it and prints a confident table about that other tree.
|
||||
# Proven by `test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink`. Of its three arms only
|
||||
# `other-sink` distinguishes the two operators; the copy and symlink arms are the shapes that
|
||||
# actually occur, and a weakened `-e` still passes them — so they cannot be the proof.
|
||||
[ "$root/scripts/hook-fire-log.sh" -ef "$self" ] || return 0
|
||||
printf '%s' "$root"
|
||||
}
|
||||
|
||||
etv_hook_fire_report() {
|
||||
@@ -502,6 +540,14 @@ etv_hook_fire_report() {
|
||||
|
||||
# ANTI-VACUITY. A report over an empty population would print a clean table of nothing and read
|
||||
# as "all hooks accounted for" — the exact failure this file exists to end.
|
||||
#
|
||||
# THE TWO REASONS ARE REPORTED SEPARATELY. An empty `$root` means the ownership check above
|
||||
# declined to name a checkout, not that a checkout was searched and found bare; saying "no hooks
|
||||
# under /.claude/hooks" for that case is a refusal stating a finding it never made.
|
||||
if [ -z "$root" ]; then
|
||||
printf 'hook-fire-log: could not establish which checkout this sink belongs to (%s is not the `scripts/hook-fire-log.sh` of the tree two levels above it) — refusing to report rather than describing some other tree.\n' "${BASH_SOURCE[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
local pop
|
||||
pop=$(etv_hook_fire_population "$root")
|
||||
if [ -z "$pop" ]; then
|
||||
|
||||
@@ -83,9 +83,12 @@ class Mutation:
|
||||
|
||||
A `DETECTOR` entry does not merely SAY a finer mutation was tried; it carries that mutation in
|
||||
`survived_clause`/`survived_replacement`, and `test_every_SURVIVING_clause_mutation_still_does`
|
||||
re-runs it and requires the proof test to stay GREEN. The justification for the coarse grade is
|
||||
therefore executed on every run, exactly like the grade it justifies — a prose claim would decay
|
||||
the same way the hand-run witnessing this whole harness replaces did.
|
||||
re-runs it and requires the proof test to stay GREEN. WHERE SUCH AN ENTRY EXISTS, the
|
||||
justification for the coarse grade is therefore executed on every run, exactly like the grade it
|
||||
justifies — a prose claim would decay the same way the hand-run witnessing this harness replaces
|
||||
did. No entry is graded `DETECTOR` since ersatztv#891, so that runner currently executes nothing;
|
||||
`test_the_DETECTOR_survivor_set_is_empty_ON_PURPOSE` asserts the emptiness so it stays a recorded
|
||||
decision rather than an unread skip.
|
||||
"""
|
||||
|
||||
CLAUSE = "CLAUSE"
|
||||
|
||||
@@ -264,19 +264,28 @@ MUTATIONS: tuple[Mutation, ...] = (
|
||||
Mutation(
|
||||
guard="scripts/tests/test_hook_fire_log.py",
|
||||
target="scripts/tests/test_hook_fire_log.py",
|
||||
clause=" return faults\n\n\ndef strip_instrumentation",
|
||||
replacement=" return []\n\n\ndef strip_instrumentation",
|
||||
proof="test_hook_fire_log.py::test_a_hook_that_LOSES_its_instrumentation_is_DETECTED",
|
||||
granularity=DETECTOR,
|
||||
expect="left the check GREEN. The check is not load-bearing",
|
||||
why="NO CLAUSE-LEVEL MUTATION REDDENS THIS ONE, and that is a finding rather than a shortcut. "
|
||||
"`instrumentation_faults` accumulates from four independent arms and a stripped hook trips "
|
||||
"three of them at once (no sink source, no ETV_HOOK_FIRE_LIB assignment, no begin call), so "
|
||||
"disarming any single arm leaves the other two answering and the proof test stays green. The "
|
||||
"whole detector is therefore the smallest mutation this proof can witness — and the surviving "
|
||||
"single-arm mutation below is re-run every time so that claim is checked, not recited.",
|
||||
survived_clause=" if not _SOURCES_SINK.search(text):",
|
||||
survived_replacement=" if False:",
|
||||
clause=" if mentions != [CANONICAL_SINK_ASSIGNMENT, CANONICAL_SINK_SOURCE]:",
|
||||
replacement=" if False:",
|
||||
proof="test_hook_fire_log.py::test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED",
|
||||
granularity=CLAUSE,
|
||||
expect="reassignment of the SOURCED sink passed clean",
|
||||
why="REGRADED FROM `DETECTOR` BY ersatztv#891, because the guard acquired a clause that IS "
|
||||
"load-bearing on its own. `instrumentation_faults` used to accumulate only from arms a "
|
||||
"stripped hook trips several of at once, so disarming any one left the rest answering and no "
|
||||
"clause-level mutation could redden the proof; the entry carried the finest surviving "
|
||||
"mutation (`if not _SOURCES_SINK.search(text):` -> `if False:`) as its honesty check. #891 "
|
||||
"replaced the lexical rule over the sink assignment with BYTE-IDENTITY of the preamble's two "
|
||||
"lines, and that comparison decides alone: disarming it reddens this proof on every hook, "
|
||||
"while nothing else catches an indented or `export`ed reassignment. The old canary had to go "
|
||||
"with it rather than be carried on: byte-identity necessarily matches the sourcing line, so "
|
||||
"`_SOURCES_SINK` can no longer decide anything (measured: 0 cases in 13 hooks x 8 "
|
||||
"perturbations) and a mutation required to KEEP surviving would have been guaranteed to. "
|
||||
"THE GENERAL SHAPE, because it is not specific to this entry: a `survived_clause` asserts "
|
||||
"that a finer mutation still survives, so its precondition is that the guard is deliberately "
|
||||
"coarse there. WIDENING the main clause can remove that precondition, and the canary then "
|
||||
"does not fail — it becomes a tautology, which reads exactly like a passing proof. When a "
|
||||
"clause is widened, re-derive every proof calibrated against the narrow one; a widened clause "
|
||||
"can retire its own evidence.",
|
||||
),
|
||||
Mutation(
|
||||
guard="scripts/tests/test_remote_state_inventory.py",
|
||||
|
||||
@@ -177,8 +177,20 @@ def test_DISARMING_the_BOM_comparison_stops_detection(tmp_path):
|
||||
"the BOM comparison has moved; retarget this mutation rather than loosening it — a mutation "
|
||||
"that silently stops mutating is the failure this file is about"
|
||||
)
|
||||
mutated = tmp_path / "mutated-bom-guard.sh"
|
||||
# REPO-SHAPED, so the mutant differs from its subject in ONE clause and not two. The hooks
|
||||
# self-locate `scripts/hook-fire-log.sh` from `${BASH_SOURCE[0]}` (ersatztv#891,
|
||||
# `process.hook-resolves-inputs-from-repo-root`); a copy dropped at the root of `tmp_path`
|
||||
# resolves two directories above a temporary filename, finds no sink, and silently runs
|
||||
# UNINSTRUMENTED while the subject runs instrumented. Measured: 2 fire records for the subject,
|
||||
# 0 for a tmp-root copy. The confound points toward a false RED here rather than a false green,
|
||||
# but "differs in one clause" is the property this file's whole argument rests on.
|
||||
mutant_root = tmp_path / "mutant-root-bom-guard"
|
||||
(mutant_root / ".claude" / "hooks").mkdir(parents=True, exist_ok=True)
|
||||
(mutant_root / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(REPO_ROOT / "scripts" / "hook-fire-log.sh", mutant_root / "scripts" / "hook-fire-log.sh")
|
||||
mutated = mutant_root / ".claude" / "hooks" / HOOK.name
|
||||
mutated.write_text(text.replace(marker, '= "deadbeef" ]; then', 1))
|
||||
mutated.chmod(0o755)
|
||||
|
||||
repo = _repo_with(tmp_path, "Bad.cs", BOM + b"class A {}\n")
|
||||
|
||||
|
||||
@@ -68,10 +68,23 @@ SINK = REPO_ROOT / "scripts" / "hook-fire-log.sh"
|
||||
# the cross-suite claim that test has always made in its docstring and never checked in its body.
|
||||
_IMPORT_TIME_ENV = {**os.environ}
|
||||
|
||||
# The three lines a hook must carry. Matched structurally rather than as one frozen blob so that a
|
||||
# comment reflow does not redden the suite, but the two things that matter — sourcing the single
|
||||
# shared sink, and calling begin with this hook's own name — are both pinned.
|
||||
# The lines a hook must carry. `_BEGINS` is matched structurally, so a comment reflow around the
|
||||
# begin call does not redden the suite. The SINK PREAMBLE is matched as a frozen blob instead —
|
||||
# byte-identity against the two constants below — because every lexical rule tried over it was
|
||||
# defeated by a shape it did not anticipate (ersatztv#891). `_SOURCES_SINK` predates that and is now
|
||||
# subsumed: byte-identity necessarily matches the sourcing line, so that pattern can no longer decide
|
||||
# anything alone. It is kept for the diagnostic it produces, not for a verdict.
|
||||
_SOURCES_SINK = re.compile(r'^\[ -r "\$ETV_HOOK_FIRE_LIB" \] && \. "\$ETV_HOOK_FIRE_LIB" \|\| true$', re.M)
|
||||
|
||||
# THE TWO LINES, byte for byte. Named once so the checker, the mutation proofs and the hooks all mean
|
||||
# the same string; a deliberate change to the preamble edits these and the suite tells you which
|
||||
# hooks disagree.
|
||||
CANONICAL_SINK_ASSIGNMENT = (
|
||||
'ETV_HOOK_FIRE_LIB="'
|
||||
'$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)'
|
||||
'/scripts/hook-fire-log.sh" || true'
|
||||
)
|
||||
CANONICAL_SINK_SOURCE = '[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true'
|
||||
_BEGINS = re.compile(r"^etv_hook_fire_begin (\S+) .*\|\| true$", re.M)
|
||||
|
||||
|
||||
@@ -116,11 +129,120 @@ def instrumentation_faults(text: str, name: str) -> list[str]:
|
||||
# The sink PATH, not just the sourcing line. Repointing `ETV_HOOK_FIRE_LIB` at /dev/null leaves
|
||||
# the `[ -r ] && .` line untouched and disables all reporting silently — a mutation the first
|
||||
# version of this checker passed clean.
|
||||
lib = re.search(r"^ETV_HOOK_FIRE_LIB=(.*)$", text, re.M)
|
||||
if not lib:
|
||||
#
|
||||
# EVERY assignment, and EXACTLY ONE of them. Reading only the first is a hole cold review found
|
||||
# and measured: a hook carrying the canonical self-located line and then a SECOND
|
||||
# `ETV_HOOK_FIRE_LIB=${SOME_OTHER_VAR:-…}/scripts/hook-fire-log.sh` passed this checker clean on
|
||||
# all 13 hooks, and the shell would source the LAST value. The sourcing line reads the variable's
|
||||
# final value, so the checker must judge every writer of it — checking the first is checking the
|
||||
# one the defect does not use.
|
||||
# THE AUTHORITY IS BYTE-IDENTITY, not a pattern — and that is a withdrawal, recorded rather than
|
||||
# quietly performed. Three review rounds attacked a lexical rule over this line: `${VAR:-<self>}`
|
||||
# satisfied a containment test; then backticks and `$((…))`; then `$(printenv VAR)`, `$1`, `$?`,
|
||||
# an INDENTED or `export`ed reassignment further down, and `$'…'` quoting that makes the required
|
||||
# token literal rather than expanded. Every fix admitted the next shape, which is this repo's own
|
||||
# signal to stop: a guard was WITHDRAWN from THIS FILE after four iterations of pattern-matching
|
||||
# shell source (`docs/guard-inventory.md`). A shell assignment cannot be recognised by regex, so
|
||||
# this stops trying to recognise one and pins the property that is actually true and actually
|
||||
# wanted — every hook carries THE SAME LINE.
|
||||
#
|
||||
# Comment lines are excluded, which needs no parsing: a line whose first non-blank character is
|
||||
# `#` is a comment in every shell. `pretooluse-merge-consent.sh` legitimately discusses this
|
||||
# variable in prose. Every other line mentioning it must be one of the two canonical lines, in
|
||||
# order. A heredoc carrying the token would fault; no hook has one, and refusing is the safe
|
||||
# direction — this arm's errors are refusals, never acceptances.
|
||||
# `split("\n")`, not `splitlines()`: the latter also breaks on \x0b, \x0c, \x1c-\x1e, \x85 and the
|
||||
# Unicode separators, none of which ends a line for the shell. The divergence runs BOTH ways —
|
||||
# a canonical assignment followed by `\x0c` and garbage on one physical line parses clean under
|
||||
# `splitlines()` and faults under `split("\n")`, so it is an acceptance hole, not only a
|
||||
# stricter refusal. No tracked hook changes its `mentions` either way (measured, 13 of 13); the
|
||||
# point is that the checker should split lines the way the shell does.
|
||||
mentions = [line for line in text.split("\n") if "ETV_HOOK_FIRE_LIB" in line and not line.lstrip().startswith("#")]
|
||||
if mentions != [CANONICAL_SINK_ASSIGNMENT, CANONICAL_SINK_SOURCE]:
|
||||
faults.append(
|
||||
f"{name}: the sink preamble is not the canonical two lines. Every hook carries them "
|
||||
f"byte-identically, so anything else — a second or indented or exported assignment, a "
|
||||
f"trailing comment, a rewritten expansion — is a divergence, not a variant. got={mentions}"
|
||||
)
|
||||
|
||||
# THE ARMS OVER THE SINK PREAMBLE no longer decide anything — the `libs` arms below AND
|
||||
# `_SOURCES_SINK` above — because byte-identity already refuses every line they catch. They
|
||||
# survive to say WHY a line is wrong, since "not byte-identical" is true but useless for the
|
||||
# failure that recurs.
|
||||
#
|
||||
# ENUMERATED, not counted: a count here has been wrong twice. The arms judging OTHER THINGS are
|
||||
# NOT subsumed — `ETV_HOOK_FIRE_DISABLE`, and the begin call's PRESENCE, name, mode and stdin
|
||||
# ordering. Every one of them is now pinned by a test asserting ITS OWN fault message, so this
|
||||
# list no longer has to be believed — delete an arm and the suite reddens. It did have to be
|
||||
# believed: PRESENCE and the no-mode-token arm were unsubsumed AND unpinned, because
|
||||
# `…_LOSES_its_instrumentation_…` looks like their proof and is not (it asserts only that the
|
||||
# fault list is NON-EMPTY, and a stripped hook trips four arms, so removing one leaves three
|
||||
# answering). Disarm an arm here with `pass`. `if False:` is safe on an arm with no `elif` after
|
||||
# it — `mutation_manifest.py` disarms byte-identity that way every run — but on the two begin-call
|
||||
# arms below it 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 cost this branch two review rounds. A
|
||||
# hook carrying both canonical lines plus `ETV_HOOK_FIRE_DISABLE=1` trips exactly one fault, and
|
||||
# it is not byte-identity's, so a reader who sorts those into "already covered" deletes a live
|
||||
# detector.
|
||||
libs = re.findall(r"^ETV_HOOK_FIRE_LIB=(.*)$", text, re.M)
|
||||
if not libs:
|
||||
faults.append(f"{name}: no ETV_HOOK_FIRE_LIB assignment")
|
||||
elif "/scripts/hook-fire-log.sh" not in lib.group(1):
|
||||
faults.append(f"{name}: ETV_HOOK_FIRE_LIB does not point at the shared sink: {lib.group(1)}")
|
||||
elif len(libs) > 1:
|
||||
faults.append(
|
||||
f"{name}: {len(libs)} ETV_HOOK_FIRE_LIB assignments; the sourcing line takes the LAST, so "
|
||||
f"only one may exist: {libs}"
|
||||
)
|
||||
|
||||
for value in libs:
|
||||
if "/scripts/hook-fire-log.sh" not in value:
|
||||
faults.append(f"{name}: ETV_HOOK_FIRE_LIB does not point at the shared sink: {value}")
|
||||
|
||||
# The ROOT the path is resolved FROM, not only the leaf it ends in. The arm above is
|
||||
# satisfied by `${CLAUDE_PROJECT_DIR:-<self>}/scripts/hook-fire-log.sh` — the form all
|
||||
# thirteen hooks carried before ersatztv#891 — and this line is `. `-SOURCED, so an
|
||||
# environment variable chose which tree's CODE ran inside the hook, before stdin was read and
|
||||
# before its decision helper existed. Measured on the merge-consent gate: a decoy tree's copy
|
||||
# printed an `allow` and exited 0, granting the merge 500 lines above the checks.
|
||||
# `process.hook-resolves-inputs-from-repo-root`.
|
||||
#
|
||||
# NARROW BY CONSTRUCTION — only these assignments are judged, never every mention of the
|
||||
# variable in a hook. `pretooluse-bom-guard.sh` reads it to locate the tree a commit ACTS ON:
|
||||
# a SUBJECT the caller supplies, not the AUTHORITY that decides, and binding that one to
|
||||
# `$repo_root` would break it. Classify a path by how it is consumed, not by what it is
|
||||
# called.
|
||||
if "CLAUDE_PROJECT_DIR" in value:
|
||||
faults.append(
|
||||
f"{name}: resolves the SOURCED sink from $CLAUDE_PROJECT_DIR, so an environment "
|
||||
f"variable picks which tree's code runs inside this hook: {value}"
|
||||
)
|
||||
# A POSITIVE requirement, not a ban on one variable name — banning `CLAUDE_PROJECT_DIR` alone
|
||||
# waves through the next `${SOME_OTHER_DIR:-…}` to be invented, and "could not tell where
|
||||
# this resolves from" must fail rather than pass quietly.
|
||||
if "${BASH_SOURCE[0]}" not in value:
|
||||
faults.append(f"{name}: the sink path is not self-located from ${{BASH_SOURCE[0]}}: {value}")
|
||||
|
||||
# ...and MENTIONING `${BASH_SOURCE[0]}` is not the same as being DECIDED by it. Cold review
|
||||
# measured the gap: `${ETV_HOOKS_ROOT:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../..")}`
|
||||
# — literally "the next `${SOME_OTHER_DIR:-…}`" the comment above says it stops — passed on
|
||||
# all 13 hooks, because the self-location sitting in the `:-` FALLBACK satisfied a
|
||||
# containment test while the environment still won whenever the variable was set. A
|
||||
# containment test cannot see which branch runs.
|
||||
#
|
||||
# So the value must contain NO expansion at all beyond `${BASH_SOURCE[0]}` itself and the
|
||||
# `$(…)` command substitution the canonical line is MADE of. Four spellings can let something
|
||||
# outside this file choose the tree, and all four fail: `${…}`, a bare `$NAME`, an arithmetic
|
||||
# `$((…))`, and a BACKTICK substitution — the last two found by self-audit rather than by a
|
||||
# reviewer, and both slip past a `${…}`/`$NAME` test when `${BASH_SOURCE[0]}` also appears
|
||||
# (`` `cat /tmp/root` `` names a tree while containing no `$` at all). Neither has any use in
|
||||
# this line, so refusing them costs nothing and "could not tell where this resolves from"
|
||||
# fails rather than passing quietly.
|
||||
residue = value.replace("${BASH_SOURCE[0]}", "")
|
||||
stray = re.search(r"\$\{|\$\(\(|\$[A-Za-z_]|`", residue)
|
||||
if stray:
|
||||
faults.append(
|
||||
f"{name}: the sink path expands {stray.group(0)!r} besides ${{BASH_SOURCE[0]}}, so "
|
||||
f"something outside this hook can still choose the tree whose code it sources: {value}"
|
||||
)
|
||||
|
||||
# A hook that disables its own reporting reads as instrumented and reports nothing.
|
||||
if re.search(r"^\s*(export\s+)?ETV_HOOK_FIRE_DISABLE=1", text, re.M):
|
||||
@@ -158,7 +280,9 @@ def strip_instrumentation(text: str) -> str:
|
||||
made the reconstruction differ from the original by one blank line in all 13 hooks — harmless
|
||||
behaviourally, but the A/B control is only trustworthy insofar as it IS the original, and
|
||||
"differs only in ways I judged harmless" is a claim, not a property.
|
||||
`test_the_stripper_reproduces_the_ORIGINAL_byte_for_byte` turns it into a property.
|
||||
`test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else` turns it into a property,
|
||||
checked structurally — every removed line is a preamble line and nothing is added — rather than
|
||||
against a historical copy.
|
||||
"""
|
||||
out, skipping = [], False
|
||||
for line in text.splitlines(keepends=True):
|
||||
@@ -277,6 +401,95 @@ def test_a_hook_that_LOSES_its_instrumentation_is_DETECTED():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_a_hook_that_DISABLES_its_own_reporting_is_DETECTED(hook):
|
||||
"""An arm byte-identity does NOT subsume, and it had no proof until #891 said so.
|
||||
|
||||
A hook can carry both canonical lines and still report nothing by setting `ETV_HOOK_FIRE_DISABLE`
|
||||
— it reads as fully instrumented and is silent, which is #776's defect wearing the fix's clothes.
|
||||
Byte-identity cannot see it (the preamble is untouched), so this arm decides alone.
|
||||
"""
|
||||
mutated = hook.read_text() + "\nexport ETV_HOOK_FIRE_DISABLE=1\n"
|
||||
faults = instrumentation_faults(mutated, hook.stem)
|
||||
assert any("never reports" in f for f in faults), (
|
||||
f"{hook.name} disabled its own reporting and the check stayed green. faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_a_hook_that_BEGINS_UNDER_ANOTHER_HOOKS_NAME_is_DETECTED(hook):
|
||||
"""Another unsubsumed arm. A copy-paste that keeps the donor's name logs every fire under the
|
||||
wrong hook: the report shows the donor firing twice and this one never, which is precisely the
|
||||
NEVER-FIRED row #776 exists to make trustworthy."""
|
||||
mutated = re.sub(
|
||||
rf"^etv_hook_fire_begin {re.escape(hook.stem)} ",
|
||||
"etv_hook_fire_begin some-other-hook ",
|
||||
hook.read_text(),
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
faults = instrumentation_faults(mutated, hook.stem)
|
||||
assert any("not for itself" in f for f in faults), (
|
||||
f"{hook.name} reported under another hook's name and the check stayed green. faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_a_hook_that_LOSES_its_begin_CALL_is_DETECTED(hook):
|
||||
"""PINS the begin-call PRESENCE arm, which was unsubsumed AND unpinned until ersatztv#891.
|
||||
|
||||
`…_LOSES_its_instrumentation_…` above looks like this arm's proof and is not: it strips the whole
|
||||
preamble, which trips FOUR arms on every hook, and it asserts only that the fault list is
|
||||
non-empty — so deleting this arm leaves it green with three arms still answering. An arm nothing
|
||||
reddens for is one a reader can delete on the strength of a prose list, which is how the
|
||||
enumeration beside the arms became load-bearing. This asserts the arm's OWN message instead.
|
||||
"""
|
||||
mutated = re.sub(r"^etv_hook_fire_begin .*\n", "", hook.read_text(), count=1, flags=re.M)
|
||||
faults = instrumentation_faults(mutated, hook.stem)
|
||||
assert any("never calls etv_hook_fire_begin" in f for f in faults), (
|
||||
f"{hook.name} lost its begin call and the check stayed green, so nothing would notice this "
|
||||
f"arm being deleted. faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_a_begin_call_NAMING_NO_STDOUT_MODE_is_DETECTED(hook):
|
||||
"""PINS the arm beside the one above, found unpinned by the same sweep rather than by a report.
|
||||
|
||||
The MODE MISMATCH arm has a proof; the arm that fires when there is no mode token to compare had
|
||||
none, and the two fail differently — a mismatch is a wrong mode, this is no mode at all. The
|
||||
mutation is a DOUBLED SPACE, so the token count changes without the line looking edited; that is
|
||||
the shape a reflow or a careless sed produces, and it silently disables the mode check below it.
|
||||
"""
|
||||
mutated = re.sub(rf"^(etv_hook_fire_begin {re.escape(hook.stem)}) ", r"\1 ", hook.read_text(), count=1, flags=re.M)
|
||||
faults = instrumentation_faults(mutated, hook.stem)
|
||||
assert any("names no stdout mode" in f for f in faults), (
|
||||
f"{hook.name}'s begin call lost its stdout mode token and the check stayed green. faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_a_hook_that_BEGINS_IN_THE_WRONG_STDOUT_MODE_is_DETECTED(hook):
|
||||
"""And another. Flipping a Claude hook to `stream` stops its stdout being read, so every decision
|
||||
is recorded from an exit status it never uses — the log stays full and becomes wrong, which is
|
||||
worse than empty. The expectation is DERIVED from the wiring, so the mutation is "the other mode"
|
||||
rather than a fixed word."""
|
||||
want = expected_mode(hook.stem)
|
||||
other = "stream" if want == "capture" else "capture"
|
||||
mutated = re.sub(
|
||||
rf"^(etv_hook_fire_begin {re.escape(hook.stem)} \S+ ){re.escape(want)}",
|
||||
rf"\g<1>{other}",
|
||||
hook.read_text(),
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
assert mutated != hook.read_text(), f"could not flip {hook.name}'s stdout mode; the begin line moved"
|
||||
faults = instrumentation_faults(mutated, hook.stem)
|
||||
assert any("but its wiring implies" in f for f in faults), (
|
||||
f"{hook.name} begins in the wrong stdout mode and the check stayed green. faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
def test_begin_placed_AFTER_the_stdin_read_is_DETECTED():
|
||||
"""The subtler mutation: present but too late. Ordering is the property that makes it work."""
|
||||
late = 'set -euo pipefail\ninput=$(cat)\nETV_HOOK_FIRE_LIB="x"\n[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true\netv_hook_fire_begin demo "" capture || true\n' # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
|
||||
@@ -287,6 +500,305 @@ def test_begin_placed_AFTER_the_stdin_read_is_DETECTED():
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# CLAIM 1b — THE SOURCED SINK IS THE ONE IN *THIS* TREE (ersatztv#858, #891)
|
||||
#
|
||||
# `instrumentation_faults` reads the assignment; these run it. The static arm can only see the shape
|
||||
# of a line, and a shape is a claim about behaviour until something executes it — so each hook is
|
||||
# driven with `$CLAUDE_PROJECT_DIR` naming a tree whose `scripts/hook-fire-log.sh` announces itself,
|
||||
# and must not source it. Every hook, from the derived population: the interesting one is whichever
|
||||
# file someone edits next.
|
||||
#
|
||||
# WHY THIS IS REACHABLE WITHOUT AN ATTACKER, since the obvious objection is that the same variable
|
||||
# also names the hook binary in `.claude/settings.json`. That objection holds for the Claude hooks
|
||||
# and not for the husky ones: `.husky/pre-push` invokes `./.claude/hooks/…`, a RELATIVE path from
|
||||
# the pushed tree, wholly independent of the variable. A push from one worktree while the
|
||||
# environment names another then 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.
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
DECOY_SINK_MARKER = "DECOY-SINK-IN-AN-ENV-VAR-NAMED-TREE"
|
||||
# Prints and EXITS. A sourced file is code at the hook's own top level, so `exit` ends the hook —
|
||||
# which is precisely the authority the measured merge-consent bypass used, reduced to a marker.
|
||||
DECOY_SINK_BODY = f"printf '%s\\n' '{DECOY_SINK_MARKER}'\nexit 0\n"
|
||||
|
||||
|
||||
def _sink_decoy(tmp_path: Path) -> Path:
|
||||
"""A tree whose only content is a `scripts/hook-fire-log.sh` that announces it ran."""
|
||||
project = tmp_path / "decoy"
|
||||
(project / "scripts").mkdir(parents=True)
|
||||
(project / "scripts" / "hook-fire-log.sh").write_text(DECOY_SINK_BODY)
|
||||
return project
|
||||
|
||||
|
||||
def _drive_with_project_dir(hook: Path, sandbox, project: Path, tmp_path: Path) -> bytes:
|
||||
"""Run `hook` with `$CLAUDE_PROJECT_DIR` naming `project`. Returns stdout+stderr together.
|
||||
|
||||
Both channels, because the question is only whether the decoy's code ran at all; which stream it
|
||||
reached is the hook's stdout mode, not the property under test.
|
||||
|
||||
The cwd is a scratch directory that is deliberately NOT a git repository, so every hook takes an
|
||||
early exit instead of inspecting a real tree. Their deciding branches are covered by the A/B
|
||||
matrix below; this test needs them to reach their PREAMBLE and nothing more.
|
||||
"""
|
||||
_root, base_env = sandbox
|
||||
env = dict(base_env)
|
||||
env["CLAUDE_PROJECT_DIR"] = str(project)
|
||||
env["TMPDIR"] = str(tmp_path / "tmp")
|
||||
Path(env["TMPDIR"]).mkdir(parents=True, exist_ok=True)
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir(parents=True, exist_ok=True)
|
||||
p = subprocess.run(["bash", str(hook)], input=b"", capture_output=True, cwd=str(cwd), env=env, timeout=90)
|
||||
return p.stdout + p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_CLAUDE_PROJECT_DIR_cannot_choose_the_SOURCED_sink(hook, sandbox, tmp_path):
|
||||
"""The defect ersatztv#891 swept, executed against every hook rather than argued about.
|
||||
|
||||
`ETV_HOOK_FIRE_LIB` was `${CLAUDE_PROJECT_DIR:-<self>}/scripts/hook-fire-log.sh` and is
|
||||
`. `-SOURCED at the top of the hook — so it is not a log DESTINATION, it is CODE, executed before
|
||||
stdin is read and before the hook can decide anything. Measured on `pretooluse-merge-consent.sh`
|
||||
before #858: a decoy printing an `allow` and exiting 0 granted the merge outright.
|
||||
"""
|
||||
project = _sink_decoy(tmp_path)
|
||||
seen = _drive_with_project_dir(hook, sandbox, project, tmp_path)
|
||||
assert DECOY_SINK_MARKER.encode() not in seen, (
|
||||
f"{hook.name} sourced a hook-fire-log.sh chosen by $CLAUDE_PROJECT_DIR — another tree's code "
|
||||
f"ran inside this hook before it read stdin: {seen[:300]!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_the_sink_decoy_IS_sourced_when_it_genuinely_is_the_repo_root(hook, sandbox, tmp_path):
|
||||
"""NEGATIVE CONTROL for the test above, and it is not optional.
|
||||
|
||||
That test asserts an ABSENCE, which is also exactly what an inert decoy produces — mis-copied,
|
||||
unreadable, or a fixture that quietly stopped being built. A green there cannot on its own
|
||||
distinguish "the environment variable was ignored" from "nothing was ever there to source".
|
||||
|
||||
So the SAME decoy is run again with the hook COPIED INTO it, making the decoy genuinely
|
||||
`$repo_root`. Now the marker MUST appear. That is what makes the absence above evidence.
|
||||
"""
|
||||
project = _sink_decoy(tmp_path)
|
||||
(project / ".claude" / "hooks").mkdir(parents=True)
|
||||
copied = project / ".claude" / "hooks" / hook.name
|
||||
shutil.copy2(hook, copied)
|
||||
seen = _drive_with_project_dir(copied, sandbox, project, tmp_path)
|
||||
assert DECOY_SINK_MARKER.encode() in seen, (
|
||||
f"the decoy sink was inert even as {hook.name}'s own $repo_root, so its sibling test proves "
|
||||
f"nothing: {seen[:300]!r}"
|
||||
)
|
||||
|
||||
|
||||
def husky_launched_hooks() -> list[Path]:
|
||||
"""DERIVED from the `.husky/*` wiring, never listed. These are the hooks a DIFFERENT launcher
|
||||
starts — the whole reason the two roots can disagree in ordinary use."""
|
||||
wiring = "\n".join(h.read_text() for h in tracked_paths(*HUSKY))
|
||||
return [h for h in hook_scripts() if h.name in wiring]
|
||||
|
||||
|
||||
def test_the_husky_launched_population_is_not_empty():
|
||||
"""ANTI-VACUITY for the pair below.
|
||||
|
||||
`@parametrize` over an empty list does not fail. Measured: pytest's default
|
||||
`empty_parameter_set_mark` collects one placeholder reported as SKIPPED, which in a full run is a
|
||||
single `s` among the dots — not a false green, but indistinguishable from coverage unless someone
|
||||
reads the skip list. That is the characteristic failure of a derived population that stops
|
||||
matching, and this file has already shipped it once (#631, #751).
|
||||
"""
|
||||
found = husky_launched_hooks()
|
||||
assert found, (
|
||||
f"no tracked hook under {HOOKS_DIR} is named by any file in .husky/, so the husky-launch "
|
||||
"pair below is parametrized over nothing and asserts nothing. The wiring derivation has "
|
||||
"stopped matching."
|
||||
)
|
||||
|
||||
|
||||
def _pushed_tree(tmp_path: Path, hook: Path, sink_body: str) -> Path:
|
||||
"""A repo-SHAPED scratch tree holding this hook and a `scripts/hook-fire-log.sh` of `sink_body`.
|
||||
|
||||
The pair below used to make its relative launch from REPO_ROOT itself, which was wrong twice
|
||||
over and cold review measured both: `prepush-rebase-check.sh` reaches `git fetch origin main` on
|
||||
an empty ref list, so every suite run made a NETWORK CALL and rewrote `FETCH_HEAD` in the very
|
||||
checkout under test — the one ref this repo's process treats as the baseline — and
|
||||
`decisions-guard.sh` ran the full decisions validator over the live tree. A unit test must not
|
||||
move the state its own repository is judged against.
|
||||
|
||||
Running from a scratch tree also makes the pair a true differential: both halves use the same
|
||||
layout and the same launch, and differ only in which sink the pushed tree holds.
|
||||
"""
|
||||
root = tmp_path / f"pushed-{hook.stem}"
|
||||
(root / ".claude" / "hooks").mkdir(parents=True, exist_ok=True)
|
||||
(root / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
(root / "scripts" / "hook-fire-log.sh").write_text(sink_body)
|
||||
shutil.copy2(hook, root / ".claude" / "hooks" / hook.name)
|
||||
return root
|
||||
|
||||
|
||||
def _relative_launch(hook: Path, sandbox, pushed: Path, decoy: Path, tmp_path: Path):
|
||||
"""Start `hook` exactly as husky does — `./.claude/hooks/<name>.sh`, relative to the pushed tree
|
||||
— while `$CLAUDE_PROJECT_DIR` names a different tree entirely."""
|
||||
_root, base_env = sandbox
|
||||
env = dict(base_env)
|
||||
env["CLAUDE_PROJECT_DIR"] = str(decoy)
|
||||
env["TMPDIR"] = str(tmp_path / f"tmp-{hook.stem}")
|
||||
Path(env["TMPDIR"]).mkdir(parents=True, exist_ok=True)
|
||||
p = subprocess.run(
|
||||
["bash", f"./.claude/hooks/{hook.name}"],
|
||||
input=b"",
|
||||
capture_output=True,
|
||||
cwd=str(pushed),
|
||||
env=env,
|
||||
timeout=90,
|
||||
)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", husky_launched_hooks(), ids=lambda h: h.stem)
|
||||
def test_the_HUSKY_RELATIVE_launch_resolves_to_the_pushed_tree(hook, sandbox, tmp_path):
|
||||
"""THE REACHABLE CASE, in the construct it actually occurs in.
|
||||
|
||||
The sibling tests launch each hook by ABSOLUTE path, which is how the Claude harness starts them
|
||||
— and there `$CLAUDE_PROJECT_DIR` names the same tree by construction, so binding to `$repo_root`
|
||||
is consistency rather than repair. Husky is what makes the two roots genuinely diverge:
|
||||
`.husky/pre-push` runs `./.claude/hooks/…`, a RELATIVE path from the pushed tree, wholly
|
||||
independent of the variable. Self-location under a relative `${BASH_SOURCE[0]}` resolves through
|
||||
the CWD, so it is a different resolution to exercise, not the same one at another spelling.
|
||||
|
||||
Scenario: a push from one worktree while the environment names another. Ordinary here. The
|
||||
hook must source the PUSHED tree's sink, not the one the variable points at.
|
||||
"""
|
||||
pushed = _pushed_tree(tmp_path, hook, SINK.read_text())
|
||||
decoy = _sink_decoy(tmp_path)
|
||||
rc, seen = _relative_launch(hook, sandbox, pushed, decoy, tmp_path)
|
||||
assert rc != 127, f"the relative launch did not find the hook (exit 127): {seen[:200]!r}"
|
||||
assert DECOY_SINK_MARKER.encode() not in seen, (
|
||||
f"{hook.name}, launched the way husky launches it, sourced a hook-fire-log.sh chosen by "
|
||||
f"$CLAUDE_PROJECT_DIR: {seen[:300]!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", husky_launched_hooks(), ids=lambda h: h.stem)
|
||||
def test_the_HUSKY_RELATIVE_launch_DOES_source_its_own_trees_sink(hook, sandbox, tmp_path):
|
||||
"""NEGATIVE CONTROL for the test above, in the same construct.
|
||||
|
||||
Its sibling asserts an ABSENCE under a relative launch, and a relative launch has an extra way to
|
||||
produce one: resolve to nothing at all. So the same relative spelling is run against a pushed
|
||||
tree whose OWN sink is the announcing one, where the marker MUST appear. That separates "the
|
||||
variable was ignored" from "the self-location silently resolved nowhere". The two arms differ in
|
||||
exactly one thing — which sink the pushed tree holds — and `$CLAUDE_PROJECT_DIR` names the
|
||||
unrelated decoy in both.
|
||||
"""
|
||||
pushed = _pushed_tree(tmp_path, hook, DECOY_SINK_BODY)
|
||||
decoy = _sink_decoy(tmp_path)
|
||||
_rc, seen = _relative_launch(hook, sandbox, pushed, decoy, tmp_path)
|
||||
assert DECOY_SINK_MARKER.encode() in seen, (
|
||||
f"the relative launch of {hook.name} sourced nothing even when its own tree held the decoy, "
|
||||
f"so its sibling's absence proves nothing: {seen[:300]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_an_ENV_VAR_resolved_sink_path_is_DETECTED():
|
||||
"""THE MUTATION PROOF for the `CLAUDE_PROJECT_DIR` DIAGNOSTIC arm — not for byte-identity, which
|
||||
has its own in `test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED`. Reintroduce the
|
||||
pre-#891 line into each real hook in turn and the checker must report it. Built by editing the
|
||||
hook's OWN assignment rather than from a hand-written fixture, so the check is measured against
|
||||
the shape the repo actually shipped (`verify against the REAL predecessor`).
|
||||
"""
|
||||
predecessor = (
|
||||
'ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-'
|
||||
'$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}'
|
||||
'/scripts/hook-fire-log.sh" || true'
|
||||
)
|
||||
current = (
|
||||
'ETV_HOOK_FIRE_LIB="'
|
||||
'$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)'
|
||||
'/scripts/hook-fire-log.sh" || true'
|
||||
)
|
||||
for hook in hook_scripts():
|
||||
text = hook.read_text()
|
||||
assert current in text, (
|
||||
f"{hook.name} no longer carries the self-located line this mutation reverts, so the "
|
||||
"proof below would mutate nothing. All hooks must resolve the sink identically."
|
||||
)
|
||||
mutated = text.replace(current, predecessor)
|
||||
faults = instrumentation_faults(mutated, hook.stem)
|
||||
# THE ARM'S OWN PHRASE, never the variable name. The byte-identity fault echoes the
|
||||
# offending line verbatim, so a substring assertion on `CLAUDE_PROJECT_DIR` is satisfied by
|
||||
# the ECHO alone — measured: with the dedicated arm deleted, this test still passed.
|
||||
# Asserting that a MESSAGE mentions the defect is not asserting the DETECTOR ran.
|
||||
assert any("resolves the SOURCED sink from" in f for f in faults), (
|
||||
f"restoring the env-var-first sink path in {hook.name} did not trip the arm that names "
|
||||
f"it, so nothing stops it being reintroduced one hook at a time. faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED(hook):
|
||||
"""THE MUTATION PROOF for the byte-identity arm — the shape only IT catches.
|
||||
|
||||
The diagnostic arms below it read `^ETV_HOOK_FIRE_LIB=(.*)$`, anchored at column zero, so an
|
||||
INDENTED reassignment inside an `if`, or an `export`ed one, is invisible to them while bash
|
||||
executes it and the `. `-source line takes the LAST value. Cold review measured both passing a
|
||||
checker that had just been "fixed" to read every assignment — which is what retired the pattern
|
||||
approach in favour of pinning the two canonical lines.
|
||||
|
||||
Both spellings are asserted, because they fail the regex for different reasons (leading
|
||||
whitespace, and a leading keyword) and a proof of one is not a proof of the other.
|
||||
"""
|
||||
text = hook.read_text()
|
||||
assert CANONICAL_SINK_ASSIGNMENT in text, f"{hook.name} does not carry the canonical line"
|
||||
steered = '"$ETV_HOOKS_ROOT/scripts/hook-fire-log.sh"'
|
||||
for label, later in (
|
||||
("indented, inside an if", f'\nif [ -n "${{ETV_HOOKS_ROOT:-}}" ]; then\n ETV_HOOK_FIRE_LIB={steered}\nfi'),
|
||||
("exported", f"\nexport ETV_HOOK_FIRE_LIB={steered}"),
|
||||
):
|
||||
mutated = text.replace(CANONICAL_SINK_ASSIGNMENT, CANONICAL_SINK_ASSIGNMENT + later)
|
||||
faults = instrumentation_faults(mutated, hook.stem)
|
||||
assert any("canonical two lines" in f for f in faults), (
|
||||
f"{hook.name}: a {label} reassignment of the SOURCED sink passed clean, so an "
|
||||
f"environment variable still picks whose code runs inside this hook. faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", hook_scripts(), ids=lambda h: h.stem)
|
||||
def test_the_NEXT_env_var_to_be_invented_is_DETECTED(hook):
|
||||
"""THE MUTATION PROOF for the no-other-expansion arm, and the reason that arm exists.
|
||||
|
||||
Banning `CLAUDE_PROJECT_DIR` by name only moves the defect to the next name. This is that shape,
|
||||
and cold review measured it passing on all 13 hooks against the arm that merely required
|
||||
`${BASH_SOURCE[0]}` to APPEAR: the self-location sits in the `:-` fallback, so the containment
|
||||
test is satisfied while the environment still decides whenever `$ETV_HOOKS_ROOT` is set.
|
||||
|
||||
Parametrized over the whole population rather than one specimen, because the interesting hook is
|
||||
whichever one someone edits — and a single-specimen proof of a per-hook checker is a claim about
|
||||
one file dressed as a claim about the population.
|
||||
"""
|
||||
successor = (
|
||||
'ETV_HOOK_FIRE_LIB="${ETV_HOOKS_ROOT:-'
|
||||
'$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}'
|
||||
'/scripts/hook-fire-log.sh" || true'
|
||||
)
|
||||
current = (
|
||||
'ETV_HOOK_FIRE_LIB="'
|
||||
'$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)'
|
||||
'/scripts/hook-fire-log.sh" || true'
|
||||
)
|
||||
text = hook.read_text()
|
||||
assert current in text, f"{hook.name} no longer carries the line this mutation replaces"
|
||||
faults = instrumentation_faults(text.replace(current, successor), hook.stem)
|
||||
# Its own phrase too — prudence here rather than repair: `besides` never appears in the
|
||||
# byte-identity fault, so this assertion was already non-vacuous. Pinned anyway, because an arm
|
||||
# added later can de-pin a shared substring silently.
|
||||
assert any("expands" in f and "besides" in f for f in faults), (
|
||||
f"a sink path steered by a NEW environment variable passed clean on {hook.name}. The arm is "
|
||||
f"a containment test, not a resolution test, and the ban is worth only the one name in it. "
|
||||
f"faults={faults}"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# CLAIM 2 — TRANSPARENCY. The wrapper must be invisible to the harness.
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
@@ -925,16 +1437,143 @@ def test_the_report_names_hooks_that_NEVER_fired(sandbox):
|
||||
assert zero_rows, "no never-fired rows shown despite only one hook appearing in the log"
|
||||
|
||||
|
||||
def _report_decoy_tree(tmp_path: Path) -> Path:
|
||||
"""A tree that looks like a checkout and holds a hook found NOWHERE in the real repo."""
|
||||
project = tmp_path / "report-decoy"
|
||||
(project / ".claude" / "hooks").mkdir(parents=True)
|
||||
(project / ".claude" / "hooks" / "someone-elses-hook.sh").write_text("#!/bin/sh\nexit 0\n")
|
||||
return project
|
||||
|
||||
|
||||
def test_CLAUDE_PROJECT_DIR_cannot_choose_which_trees_hooks_the_REPORT_describes(tmp_path, sandbox):
|
||||
"""THE READ SIDE'S half of ersatztv#891, which is the change this file makes to the sink itself.
|
||||
|
||||
`etv_hook_fire_repo_root` used to prefer `$CLAUDE_PROJECT_DIR` whenever it named a tree with a
|
||||
`.claude/hooks`. That reads as harmless — a report decides nothing — but the report's two halves
|
||||
ARE the population and the log, so resolving them from two roots produces a confident table about
|
||||
another checkout: NEVER-FIRED rows for hooks that fired, and rows for hooks this tree does not
|
||||
have. `process.hook-resolves-inputs-from-repo-root` requires a behavioural test PER SITE, and
|
||||
this is that test for this site — cold review measured its absence by restoring the preference
|
||||
verbatim and watching the whole file stay green.
|
||||
"""
|
||||
project = _report_decoy_tree(tmp_path)
|
||||
_root, env = sandbox
|
||||
runenv = dict(env)
|
||||
runenv["CLAUDE_PROJECT_DIR"] = str(project)
|
||||
p = subprocess.run(
|
||||
["bash", str(SINK), "report", "--all", "--dir", str(tmp_path / "nolog")],
|
||||
capture_output=True,
|
||||
cwd=str(tmp_path),
|
||||
env=runenv,
|
||||
timeout=60,
|
||||
)
|
||||
out = p.stdout.decode()
|
||||
assert b"someone-elses-hook" not in p.stdout, (
|
||||
f"$CLAUDE_PROJECT_DIR chose which tree the report describes — it listed a hook that exists "
|
||||
f"only in the decoy: {out[:400]!r}"
|
||||
)
|
||||
# ...and it must still have described THIS tree, or the absence above is just an empty report.
|
||||
for hook in hook_scripts():
|
||||
assert hook.stem in out, (
|
||||
f"the report did not describe this checkout either ({hook.stem} missing), so the "
|
||||
f"assertion above is vacuous: {out[:400]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_report_decoy_IS_described_when_it_genuinely_is_the_repo_root(tmp_path, sandbox):
|
||||
"""NEGATIVE CONTROL for the test above; its assertion is an ABSENCE, which an inert fixture also
|
||||
produces. Copy the sink into the decoy so the decoy genuinely IS its `$repo_root`, and the
|
||||
decoy's hook must now be the one described."""
|
||||
project = _report_decoy_tree(tmp_path)
|
||||
(project / "scripts").mkdir()
|
||||
copied = project / "scripts" / "hook-fire-log.sh"
|
||||
shutil.copy2(SINK, copied)
|
||||
_root, env = sandbox
|
||||
runenv = dict(env)
|
||||
runenv["CLAUDE_PROJECT_DIR"] = str(project)
|
||||
p = subprocess.run(
|
||||
["bash", str(copied), "report", "--all", "--dir", str(tmp_path / "nolog")],
|
||||
capture_output=True,
|
||||
cwd=str(tmp_path),
|
||||
env=runenv,
|
||||
timeout=60,
|
||||
)
|
||||
assert b"someone-elses-hook" in p.stdout, (
|
||||
f"the decoy tree was never described even as its own $repo_root, so its sibling's absence "
|
||||
f"proves nothing: {p.stdout.decode()[:400]!r} / {p.stderr.decode()[:200]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink(tmp_path, sandbox):
|
||||
"""THE PROOF for the ownership clause in `etv_hook_fire_repo_root` (ersatztv#891).
|
||||
|
||||
Self-location answers "two directories above wherever I am", which is this repo only when this
|
||||
file sits in it. A copy or symlink whose grandparent HAPPENS to hold a `.claude/hooks` otherwise
|
||||
roots there and prints a confident table about another tree — the wrong-root case the sink's own
|
||||
comment claims is enforced rather than hoped for. It was shipped unexecuted: cold review measured
|
||||
that replacing the clause with `:` left this whole file green, because the sibling test below
|
||||
copies the sink into `<tmp>/scripts/`, where the root DOES own it.
|
||||
|
||||
THREE arms, and the third is the one that tests OWNERSHIP rather than mere presence. The first
|
||||
two roots hold no `scripts/hook-fire-log.sh` at all, so `-ef` never evaluates against an existing
|
||||
left-hand side and a strictly weaker `[ -e … ]` would pass them both — measured by cold review,
|
||||
which left the whole file green on that mutant. The `other-sink` arm gives the fake root a
|
||||
DIFFERENT `scripts/hook-fire-log.sh`, where presence and identity finally disagree: `-e` accepts
|
||||
and prints a confident table about the other tree, `-ef` refuses.
|
||||
|
||||
The copy and symlink arms are kept because they are the shapes that actually occur, not because
|
||||
they exercise different branches — measured, they do not: both pass a weaker `[ -e … ]`.
|
||||
"""
|
||||
_root, env = sandbox
|
||||
for label in ("copy", "symlink", "other-sink"):
|
||||
fake = tmp_path / f"fake-{label}"
|
||||
(fake / "bin").mkdir(parents=True)
|
||||
# A root that WOULD look plausible: it really does hold hooks, just not this sink's.
|
||||
(fake / ".claude" / "hooks").mkdir(parents=True)
|
||||
(fake / ".claude" / "hooks" / "someone-elses-hook.sh").write_text("#!/bin/sh\nexit 0\n")
|
||||
target = fake / "bin" / "hook-fire-log.sh"
|
||||
if label == "symlink":
|
||||
target.symlink_to(SINK)
|
||||
else:
|
||||
shutil.copy2(SINK, target)
|
||||
if label == "other-sink":
|
||||
# PRESENT but NOT THIS FILE — the only arm where `-e` and `-ef` disagree.
|
||||
(fake / "scripts").mkdir(parents=True)
|
||||
shutil.copy2(SINK, fake / "scripts" / "hook-fire-log.sh")
|
||||
|
||||
runenv = dict(env)
|
||||
runenv.pop("CLAUDE_PROJECT_DIR", None)
|
||||
p = subprocess.run(
|
||||
["bash", str(target), "report", "--all", "--dir", str(tmp_path / "nolog")],
|
||||
capture_output=True,
|
||||
cwd=str(tmp_path),
|
||||
env=runenv,
|
||||
timeout=60,
|
||||
)
|
||||
assert p.returncode == 2, (
|
||||
f"the {label} reported from a root it does not belong to (exit {p.returncode}). A wrong "
|
||||
f"root must refuse, not describe another tree. stdout={p.stdout.decode()!r}"
|
||||
)
|
||||
assert b"could not establish which checkout" in p.stderr, (
|
||||
f"the {label} refused for some other reason, so the ownership clause is not what "
|
||||
f"stopped it: {p.stderr.decode()[:300]!r}"
|
||||
)
|
||||
assert b"someone-elses-hook" not in p.stdout, "it listed the other tree's hooks"
|
||||
|
||||
|
||||
def test_the_report_REFUSES_an_empty_population(tmp_path, sandbox):
|
||||
"""Anti-vacuity on the read side. A report over zero hooks would print a tidy empty table and
|
||||
read as 'all hooks accounted for' — the shape of #751's 6-second green.
|
||||
|
||||
The sink must be COPIED into a hook-less tree, not merely pointed at one via
|
||||
`CLAUDE_PROJECT_DIR`. The script deliberately falls back to its own location when that variable
|
||||
names a tree with no hooks — that fallback is correct, it is what makes `hook-fire-log.sh
|
||||
report` work from any directory — but it means setting the variable alone lands the report back
|
||||
on the real repo's 13 live hooks, and the test would then assert against a full population while
|
||||
claiming to exercise the empty one.
|
||||
The sink must be COPIED into a hook-less tree; pointing at one via `CLAUDE_PROJECT_DIR` does
|
||||
nothing at all since ersatztv#891, because the report now SELF-LOCATES its population
|
||||
unconditionally (`process.hook-resolves-inputs-from-repo-root`) — setting the variable alone
|
||||
would land the report back on the real repo's live hooks, and the test would then assert
|
||||
against a full population while claiming to exercise the empty one.
|
||||
|
||||
`runenv.pop("CLAUDE_PROJECT_DIR")` below is kept deliberately. It no longer changes where the
|
||||
population comes from, and that is the point: this test must exercise the empty tree whether or
|
||||
not some later change reintroduces a variable that could steer it.
|
||||
"""
|
||||
_, env = sandbox
|
||||
fake_scripts = tmp_path / "scripts"
|
||||
@@ -956,7 +1595,10 @@ def test_the_report_REFUSES_an_empty_population(tmp_path, sandbox):
|
||||
"the report exited 0 over an empty hook population. It must refuse rather than print a "
|
||||
f"clean table of nothing. stdout={p.stdout.decode()!r}"
|
||||
)
|
||||
assert b"refusing to report" in p.stderr
|
||||
# ITS OWN PHRASE. Both refusals say "refusing to report", so asserting on that shared text
|
||||
# cannot distinguish which arm fired — measured: forcing the ownership arm to always fire left
|
||||
# this test green. A refusal message shared by two arms pins neither.
|
||||
assert b"empty population" in p.stderr
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -28,8 +28,11 @@ the same defect one level up:
|
||||
`docs/decisions/records/testing/mutation-claims-are-executed.md`, not here.
|
||||
|
||||
The `granularity` column is where this file refuses to flatter itself. See `Mutation` in
|
||||
`mutation_harness_lib.py`: all but one guard admits a single-clause mutation, and the one that does
|
||||
not CARRIES the finer mutation that survived, which is re-run and required to keep surviving.
|
||||
`mutation_harness_lib.py`: a `DETECTOR` entry — one whose detector accumulates faults from arms that
|
||||
a single mutation trips several of at once — must CARRY the finer mutation that survived, re-run
|
||||
every suite and required to keep surviving. EVERY entry is `CLAUSE` today and the survivor set is
|
||||
EMPTY, so that machinery is currently unexercised; `test_the_DETECTOR_survivor_set_is_empty_ON_PURPOSE`
|
||||
says so out loud rather than leaving it as a lone SKIPPED placeholder nobody reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -280,6 +283,41 @@ def test_MUTATION_the_declared_clause_reddens_the_named_proof(sandbox, mutation)
|
||||
_SURVIVORS = tuple(m for m in MUTATIONS if m.granularity == Mutation.DETECTOR)
|
||||
|
||||
|
||||
def test_the_DETECTOR_survivor_set_is_empty_ON_PURPOSE():
|
||||
"""ANTI-VACUITY for the parametrized proof below, which currently runs over NOTHING.
|
||||
|
||||
`@parametrize` over an empty tuple does not fail and does not collect a real case: with pytest's
|
||||
default `empty_parameter_set_mark=skip` (and this repo sets no ini), it collects ONE placeholder
|
||||
reported as `SKIPPED … got empty parameter set`. Measured. In a 1500-test run that is one `s`
|
||||
among the dots — not a false green, but indistinguishable from coverage unless someone reads the
|
||||
skip list, which is the same reason `test_the_husky_launched_population_is_not_empty` exists in
|
||||
`test_hook_fire_log.py`.
|
||||
|
||||
`_SURVIVORS` went empty when ersatztv#891 regraded the last `DETECTOR` row to `CLAUSE`. TWO
|
||||
things are consequently unexercised, enumerated rather than counted:
|
||||
|
||||
1. the survivor runner below, `test_every_SURVIVING_clause_mutation_still_does`; and
|
||||
2. the `unevidenced` arm of
|
||||
`test_every_entry_declares_a_known_granularity_and_DETECTOR_entries_CARRY_their_survivor`,
|
||||
which filters on `granularity == DETECTOR`.
|
||||
|
||||
Both self-arm the moment an entry is graded `DETECTOR`. `verify_mutation`'s "still PASSED"
|
||||
verdict path is NOT in this list, though an earlier revision put it there: it is not
|
||||
DETECTOR-gated and fires for any declared mutation that fails to redden, and
|
||||
`test_an_INERT_mutation_is_REPORTED_rather_than_passed` drives it every run — measured by
|
||||
retargeting its diagnostic string and watching that test go red.
|
||||
|
||||
This test states the expected size, so the day someone adds a `DETECTOR` entry it goes red and
|
||||
names the lines to update; the empty set becomes a decision rather than a skip nobody reads.
|
||||
"""
|
||||
assert len(_SURVIVORS) == 0, (
|
||||
f"a DETECTOR entry exists again ({[m.guard for m in _SURVIVORS]}), so the survivor runner "
|
||||
"below is no longer unexercised. Update this expectation — and note that the machinery it "
|
||||
"drives has had no coverage since ersatztv#891, so exercise it deliberately rather than "
|
||||
"assuming it still works."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", _SURVIVORS, ids=lambda m: m.guard)
|
||||
def test_every_SURVIVING_clause_mutation_still_does(sandbox, mutation):
|
||||
"""The DETECTOR grade, executed rather than recited.
|
||||
|
||||
@@ -34,6 +34,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -53,10 +54,14 @@ def _env() -> dict:
|
||||
|
||||
Two things it must get right.
|
||||
|
||||
`CLAUDE_PROJECT_DIR` is pinned because the hooks resolve `scripts/hook-fire-log.sh` from it,
|
||||
falling back to a path relative to their own location; a MUTATED copy lives in tmp_path, where
|
||||
that fallback finds nothing. Without the pin the mutant differs from the subject in a second way
|
||||
and the comparison stops being about the mutated clause.
|
||||
`CLAUDE_PROJECT_DIR` is NOT pinned, and that is deliberate. It used to be, because the hooks
|
||||
resolved `scripts/hook-fire-log.sh` from it and a mutated copy in `tmp_path` would otherwise find
|
||||
no sink and lose its instrumentation — a second difference from its subject. Since ersatztv#891
|
||||
the hooks self-locate THAT path, so the pin steered nothing while still reading as a control. It
|
||||
is scoped to the sink deliberately: `pretooluse-bom-guard.sh` still reads `$CLAUDE_PROJECT_DIR`
|
||||
to find the tree a commit acts on, which the rule preserves on purpose — neither hook this file
|
||||
drives is that one. `_mutate` now gives each mutant a repo-shaped root instead,
|
||||
which is what actually keeps the difference to one clause.
|
||||
|
||||
And it is a FUNCTION rather than a module-level dict because the autouse
|
||||
`isolate_hook_fire_log` fixture monkeypatches `ETV_HOOK_FIRE_LOG_DIR` into `os.environ` at test
|
||||
@@ -75,7 +80,7 @@ def _env() -> dict:
|
||||
run, on macOS and Linux alike. Those 58 now land in the session's own temporary directory
|
||||
instead, so the figure is a measure of this file's hook traffic and no longer of a leak.
|
||||
"""
|
||||
return {**os.environ, "CLAUDE_PROJECT_DIR": str(REPO_ROOT)}
|
||||
return {**os.environ}
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
@@ -371,12 +376,33 @@ def test_a_git_C_into_a_foreign_worktree_is_DENIED_from_the_main_tree(tmp_path):
|
||||
|
||||
|
||||
def _mutate(src: Path, tmp_path: Path, old: str, new: str, why: str) -> Path:
|
||||
"""An isolated copy with ONE clause disarmed — and the copy is placed in a REPO-SHAPED tree so
|
||||
that stays true of the instrumentation as well.
|
||||
|
||||
The hooks resolve `scripts/hook-fire-log.sh` by self-locating from `${BASH_SOURCE[0]}`
|
||||
(ersatztv#891, `process.hook-resolves-inputs-from-repo-root`). A mutant dropped at the root of
|
||||
`tmp_path` therefore resolves two directories above a temporary filename, finds no sink, and
|
||||
silently loses its instrumentation — so it would differ from its subject in TWO ways and the
|
||||
differential would no longer be about the mutated clause. Laying the copy out as
|
||||
`<root>/.claude/hooks/<name>.sh` beside a copy of the real sink at `<root>/scripts/` makes the
|
||||
self-location land, so the only difference is the clause.
|
||||
|
||||
This used to be arranged by pinning `$CLAUDE_PROJECT_DIR` at the real repository, which worked
|
||||
only while the hooks read that variable. They no longer do, and an env var that no longer steers
|
||||
anything is worse than no arrangement at all: it reads as a control that is really a no-op.
|
||||
"""
|
||||
assert old in src.read_text(), (
|
||||
f"the clause {old!r} has moved in {src.name}; RETARGET this mutation rather than loosening "
|
||||
f"it — a mutation that silently stops mutating is the failure this file is about ({why})"
|
||||
)
|
||||
dst = tmp_path / f"mutated-{src.name}"
|
||||
root = tmp_path / f"mutant-root-{src.stem}"
|
||||
(root / ".claude" / "hooks").mkdir(parents=True, exist_ok=True)
|
||||
(root / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
sink = REPO_ROOT / "scripts" / "hook-fire-log.sh"
|
||||
shutil.copy2(sink, root / "scripts" / sink.name)
|
||||
dst = root / ".claude" / "hooks" / src.name
|
||||
dst.write_text(src.read_text().replace(old, new, 1))
|
||||
dst.chmod(0o755)
|
||||
return dst
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user