Skip to content

fix(hooks): clear git's repo-relocating env once, at the pre-push entry - #960

Merged
IvanWng97 merged 3 commits into
mainfrom
fix/hook-env-scrub
Aug 31, 2026
Merged

fix(hooks): clear git's repo-relocating env once, at the pre-push entry#960
IvanWng97 merged 3 commits into
mainfrom
fix/hook-env-scrub

Conversation

@IvanWng97

Copy link
Copy Markdown
Owner

Follow-on to #958. That PR scrubbed git's repo-relocating env inside e2e_init_repo; this moves the same scrub to the one place the pollution comes from.

Why the helper is not enough

#958 makes the correct thing easy. It does not make the incorrect thing impossible — any future script that writes a bare git init bypasses the helper entirely.

That is not hypothetical. #958's own selftest did exactly that: it built its fixture with the naive form, committed into the developer's repo, and printed OK. The author knew the trap, was actively writing the fix for it, and still bypassed it in the same file. A mechanism that the author bypasses at their most alert moment is not a mechanism.

Why the hook entry is the right place

The pollution has exactly one source: a git hook. Measured both ways on git 2.54.0:

push from a plain checkout   → GIT_DIR=<unset>
push from a LINKED worktree  → GIT_DIR=…/main/.git/worktrees/<name>

and GIT_DIR outranks git -C <dir>. Chain: .githooks/pre-pushjust preflightlint → every selftest and script it fans out to.

Clearing it at the entry means every downstream child — preflight, lint, every selftest, every script not yet written — is immune without its author knowing the trap exists.

This is the shape pre-commit uses: its no_git_env() filters the environment in the framework, so third-party hooks cannot get this wrong. Its comment is blunt — "Too many bugs dealing with environment variables and GIT".

githooks(5) prescribes the idiom verbatim (fetched in-session from man githooks, git 2.54.0). Deliberately not a GIT_* prefix match — that would strip GIT_SSH_COMMAND and the GIT_COMMITTER_* identity, neither of which relocates a repo.

Measured

Real hook, a probe standing in for preflight, pushing from a linked worktree:

hook real repo core.bare
before this change true polluted
after false clean

Side effects checked, not assumed: after the unset, --show-toplevel, --abbrev-ref HEAD, --git-dir and status all still resolve the worktree correctly — git falls back to the working directory it sets for the hook, exactly as githooks(5) describes.

Over-reach checked: the only GIT_CONFIG_COUNT user in this repo is star-history.yml, which runs on a fresh runner checkout with no core.hooksPath, so it never reaches this hook.

No selftest, deliberately

CI structurally cannot execute a hook test — the runner uses a fresh plain checkout and invokes recipes directly, never from a hook and never from a linked worktree. That blindness is exactly why #893 landed twice and why #958's selftest bug survived 47 green checks.

A hook selftest would therefore be green locally and never run in CI — the trap #958 had to fix in its own wiring (just lint alone would have been a no-op). The primary defense, the helper's scrub, keeps its CI-covered selftest. This is depth.

Note

core.hooksPath is an absolute path to the main checkout, so this takes effect for every worktree the moment it lands — and correspondingly it does not self-verify on its own push, which is why the before/after control above was run explicitly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N4W4HtEmnF7Bu8jsqafJML

#958 gave `e2e_init_repo` a scrub, which makes the correct thing easy but not
the incorrect thing impossible: any future script that writes a bare `git init`
bypasses the helper entirely. That is not hypothetical — #958's OWN selftest
did exactly that, and committed into the developer's repo while printing OK.
The author knew the trap, was writing the fix, and still bypassed it.

The pollution has exactly one source: a git hook. A LINKED worktree's pre-push
exports GIT_DIR (a plain checkout's does not — measured both ways), and it
outranks `git -C <dir>`. Clearing it at the entry means every downstream
child — preflight, lint, every selftest, every script not yet written — is
immune without its author knowing the trap exists. That is the shape pre-commit
uses: it filters the environment in the framework so third-party hooks cannot
get this wrong.

githooks(5) prescribes the idiom verbatim, fetched in-session from `man
githooks` on git 2.54.0. Not a GIT_* prefix match, which would strip
GIT_SSH_COMMAND and the GIT_COMMITTER_* identity.

Measured, with the real hook and a probe standing in for preflight, pushing
from a linked worktree:

  before this change: real repo core.bare = true   (polluted)
  after:              real repo core.bare = false  (clean)

Also measured: after the unset, `--show-toplevel`, `--abbrev-ref HEAD`,
`--git-dir` and `status` all still resolve the worktree correctly — git falls
back to the working directory it set for the hook, so nothing below loses its
bearings.

Checked for over-reach: the one GIT_CONFIG_COUNT user in this repo is
star-history.yml, which runs on a fresh runner checkout with no core.hooksPath,
so it never reaches this hook.

No selftest, deliberately. CI cannot execute a hook test — the runner uses a
fresh plain checkout and invokes recipes directly, never from a hook and never
from a linked worktree. A hook selftest would be green locally and never run in
CI, which is the trap #958 had to fix in its own wiring. The primary defense
(the helper's scrub) keeps its CI-covered selftest; this is depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4W4HtEmnF7Bu8jsqafJML
@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 7380ff23e23ac045166f321c4965206fda7cc272

Findings: 0 — No findings

This diff only touches the repo's local .githooks/pre-push dev-tooling script (unsetting git's local env vars before preflight runs) and does not touch the hook shim, socket/pipe transport, config writes, or ingestion of untrusted transcript/JSONL/pack data, so there are no security-relevant changes.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Review

Head: 7380ff23e23ac045166f321c4965206fda7cc272

Findings: 1 (1 high, 0 medium)

Small, well-justified fix (scrubs git's repo-relocating env vars at the top of the pre-push hook to stop preflight/e2e helpers writing into the real repo from a linked worktree), but the new hook behavior — for a defect class (#893) that has already recurred twice — ships with no automated regression test, unlike the companion fix in #958 which got its own selftest.

1. HIGH — .githooks/pre-push:18

The new unset $(git rev-parse --local-env-vars) line is the production fix for a defect (#893) that the PR's own comment says has already shipped silently twice, most recently patched via scripts/lib/e2e-common-selftest.sh (added in #958, wired to just e2e-scrub-selftest). That selftest only exercises e2e_init_repo's per-command env -u scrub in e2e-common.sh — it does not invoke .githooks/pre-push itself, and no CI workflow or justfile recipe execs .githooks/pre-push (grep confirms no matches under .github/ and no recipe shells out to the hook file). So this specific line — the actual fix being shipped — has zero automated coverage: a future edit that reorders this unset after another command, or a rebase that drops it, will reintroduce the exact silent-corruption bug (exit 0, no visible failure) with nothing in CI or just preflight to catch it, repeating the #893 pattern a third time. Given the repo's TDD-first convention and that the sibling fix in #958 explicitly added a regression test for the same bug class, this fix should get an analogous selftest (e.g. simulate a linked-worktree hook invocation with GIT_DIR/GIT_INDEX_FILE exported and assert pre-push does not touch a stand-in 'real' repo) before merge.


Automated read-only review by Claude Code

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.69%. Comparing base (9bd4ac6) to head (2b312e5).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #960      +/-   ##
==========================================
- Coverage   95.70%   95.69%   -0.01%     
==========================================
  Files         196      196              
  Lines       57587    57587              
==========================================
- Hits        55113    55109       -4     
- Misses       2474     2478       +4     
Components Coverage Δ
pixtuoid-core 97.66% <ø> (-0.03%) ⬇️
pixtuoid-scene 95.40% <ø> (ø)
pixtuoid 94.12% <ø> (ø)
pixtuoid-hook 96.54% <ø> (ø)
pixtuoid-web 95.78% <ø> (ø)
see 2 files with indirect coverage changes
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…o paths

Three lenses. The 10-line change had a defect in the one line that mattered,
introduced two regressions, and shipped without the test I argued was
impossible. All three of those were my calls.

**The guard did not guard.** `unset $(git rev-parse --local-env-vars)` discards
the substitution's status — only `unset`'s own counts, and bare `unset` returns
0 — so under `set -euo pipefail` a failing git left the scrub silently not
happening and the push proceeded. Measured three ways. This is the same defect
as #958 round 1's `return 1`, in a second disguise: I wrote a guard, asserted
it was fail-closed, and did not run it. Now an explicit `if ! scrub=$(...) ||
[ -z "$scrub" ]` with `exit 1`.

**Two regressions the change introduced, both found by an adversarial lens:**

- `cd "$(git rev-parse --show-toplevel)"` sits after the scrub, which removed
  the only locator when the hook's CWD is outside the worktree
  (`git --git-dir=… push` from elsewhere). And `cd "$(...)"` swallows the
  failure by the same mechanic as `unset $(...)` — nine lines apart, and I
  fixed one without seeing the other. `toplevel` is now resolved before the
  scrub. Measured: OLD exit 0, NEW-before-fix exit 1 in /tmp, NEW-after 0.
- `SKIP_PREFLIGHT=1` no longer bypassed the new failure, breaking the contract
  line 8 advertises. The early exit now precedes the scrub, which costs
  nothing since it exits before anything uses the scrubbed env.

**The test I said was impossible.** I argued CI structurally cannot execute a
hook test, so shipped none. That conflated testing a push with testing the
hook's env contract, which is observable at its `exec`: stub `just`, hand the
hook the GIT_DIR a linked worktree exports, read what the child saw. 184ms, no
push, no worktree, no toolchain. The failure it now catches is silent by
construction — deleting the line reds nothing otherwise.

It enumerates `.githooks/*` rather than naming pre-push, so a hook added later
fails here instead of inheriting the gap; `pre-commit` is exempt by name while
it only runs `cargo fmt`. The control is a literal two-line hook, not one
derived by `grep -v` from a real one — deriving it orphans the guard's `if`
body, so the mutant exits before `just` and the control silently stops
controlling.

**The test's own first version was vacuous, caught by running the mutation.**
Its `git init` for the fixture ran under the GIT_DIR this suite exports at its
top, so `$s/repo/.git` was never created; the hook then died at `toplevel=` and
never reached `exec`, `seen` never existed, and `test -z ""` passed against the
real hook while the control still fired. The harness was bitten by the bug it
tests for. Cleared the list around that init.

Both copies now have teeth, verified by mutation: deleting the hook's scrub
block reds; a behavioural mutation that keeps the block but unsets nothing also
reds; emptying the helper's scrub reds.

Also: `LINKED` restored to the hook comment — trimming it on owner feedback
turned a true sentence false, since a plain checkout's pre-push exports nothing.
And `e2e-common-selftest.sh`'s scrub rationale named the pre-push chain as its
cause, which this commit makes false; it now names direct runs and `submodule
foreach` (verified in-session: exports GIT_DIR=.git to children).

shellcheck 0 · shfmt-check 0 · just lint 0 (19/19) · selftest 9 checks.

plan-miss: I scoped this PR as "3 lines, low risk, no test" and was wrong on
both halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4W4HtEmnF7Bu8jsqafJML
@IvanWng97

Copy link
Copy Markdown
Owner Author

Two-lens review — round 1 disposition (head 3e4b0d55)

Three differentiated lenses: correctness/grounding · design/blast-radius · adversarial (dispatched because this file runs on every contributor's every push — its brief was literally "try to brick git push").

A 10-line change. The review found a defect in the one line that mattered, two regressions it introduced, and a missing test I had argued was impossible. All three were my calls.

HIGH — the guard did not guard — FIXED

unset $(git rev-parse --local-env-vars) discards the substitution's status; only unset's own counts, and bare unset returns 0. So under set -euo pipefail a failing git left the scrub silently not happening and the push proceeded. Found independently by lens 1 and lens 3; reproduced three ways.

This is #958 round 1's return 1 in a second disguise — a guard asserted rather than run. Now if ! scrub=$(...) || [ -z "$scrub" ] with exit 1.

MEDIUM ×2 — regressions this change introduced — FIXED

cd "$(git rev-parse --show-toplevel)" sat after the scrub, which removed the only locator when the hook's CWD is outside the worktree (git --git-dir=… push from elsewhere). And cd "$(...)" swallows the failure by the same mechanic as unset $(...) — nine lines apart, and I fixed one without seeing the other. toplevel now resolved before the scrub. OLD exit 0 · NEW-before-fix exit 1 in /tmp · NEW-after 0
SKIP_PREFLIGHT=1 no longer bypassed the new failure, breaking the contract line 8 advertises early exit now precedes the scrub

HIGH — "CI structurally cannot execute a hook test" was false — FIXED

I shipped no test on that reasoning. It conflated testing a push with testing the hook's env contract, which is observable at its exec: stub just, hand the hook the GIT_DIR a linked worktree exports, read what the child saw. 184 ms, no push, no worktree, no toolchain.

The failure it guards is silent by construction — delete the line and nothing else reds.

Design details that earned their place:

  • Enumerates .githooks/* rather than naming pre-push, so a hook added later fails here instead of inheriting the gap. pre-commit is exempt by name while it only runs cargo fmt.
  • The control is a literal two-line hook, not one derived by grep -v from a real one — deriving it orphans the guard's if body, so the mutant exits before just and the control silently stops controlling.

The test's own first version was vacuous — FIXED (caught by running the mutation)

Its fixture git init ran under the GIT_DIR this suite exports at its top, so $s/repo/.git was never created; the hook then died at toplevel= under set -e, never reached exec, seen never existed, and test -z "" passed against the real hook while the control still fired. The harness was bitten by the bug it tests for. Cleared the list around that init.

MEDIUM — e2e-common-selftest.sh's scrub rationale — FIXED

It named the pre-push chain as its cause, which this commit makes false (the hook now scrubs upstream). The line still earns its place, so the cause was replaced: direct just e2e-scrub-selftest runs, and submodule foreach — verified in-session, it exports GIT_DIR=.git to children with no hook involved.

REFUTED — keep exit 1, keep [ -z "$scrub" ]

Lens 1 measured that git prepends its exec-path to a hook's PATH, so a hook invoked by git push cannot fail to find git; --local-env-vars has existed since v1.7.1 (2010). So the guard's population is a git shim that exits 0 printing nothing. Not dead — the trigger can fire — and the alternative is the silent corruption this PR exists to prevent. Blocking is proportionate.

Sharp-edge check on the sibling: e2e-common.sh:47's -z test looks identical but is not the same claim — there the value comes from a pipeline whose status belongs to tr, so -z is the only guard.

Comment

Owner feedback: too long. Trimmed 7 → 4 lines. Trimming also dropped LINKED, turning a true sentence false — a plain checkout's pre-push exports nothing. Restored, still 4 lines.

Teeth, verified by mutation

  • delete the hook's scrub block → reds
  • keep the block, unset nothing (behavioural) → reds
  • empty the helper's scrub → reds

Gates: shellcheck -x 0 · just shfmt-check 0 · just actionlint 0 · just lint 0 (19/19) · selftest 9 checks · full preflight on push, 3174 passed.

Zero open confirmed HIGH at this head. A human merges.

@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 3e4b0d55958a6fb469538bea1d19b913cf79bb1f

Findings: 0 — No findings

The diff hardens the pre-push git hook to scrub git's repo-relocating env vars (GIT_DIR, GIT_INDEX_FILE, etc.) before running preflight, correctly resolving toplevel first and unsetting only git's own fixed local-env-var names, with no exploitable gap found.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Review

Head: 3e4b0d55958a6fb469538bea1d19b913cf79bb1f

Findings: 1 (0 high, 1 medium)

The PR extends the git-env scrub (from #893) to the pre-push hook and adds a selftest that exec's every non-exempt .githooks/* script through a stubbed just to check for a leaked GIT_DIR; the pre-push hook logic itself (resolve toplevel before scrubbing, refuse-on-empty-list, then unset the whole --local-env-vars list before cd+exec just preflight) is sound, but the new regression test's assertion is narrower than the vulnerability it's meant to pin.

1. MEDIUM — scripts/lib/e2e-common-selftest.sh:143

The new hook_hands_just stub only records ${GIT_DIR:-} (printf "%s" "${GIT_DIR:-}" >"$s/seen"), so the loop's check (test -z "$(hook_hands_just "$hook")", line 161) only proves GIT_DIR gets scrubbed before exec just preflight. This file's own header/comments (and e2e_init_repo's doc comment in e2e-common.sh) describe the #893 leak as GIT_DIR and GIT_INDEX_FILE together outranking git -C <dir>. A future regression that scrubs GIT_DIR but drops GIT_INDEX_FILE (or any other name from git rev-parse --local-env-vars) from the unset list in .githooks/pre-push would pass this new selftest undetected, since the stub never inspects GIT_INDEX_FILE (or GIT_WORK_TREE / GIT_COMMON_DIR / etc.). Widening the stub to also capture and assert on GIT_INDEX_FILE would close the gap and match the severity this test is meant to pin.


Automated read-only review by Claude Code

The online review bot found it, and it is right: `hook_hands_just`'s stub only
recorded `${GIT_DIR:-}`, so the check proved GIT_DIR gets scrubbed and nothing
else. A scrub narrowed to `unset GIT_DIR` would leave GIT_INDEX_FILE relocating
the index — the other half of what outranks `git -C <dir>` in #893 — and pass.

The harness was already injecting GIT_INDEX_FILE and never looking at it.

The stub now reports EVERY name from `git rev-parse --local-env-vars` that is
still set at the `exec`, so the assertion covers whatever git's list covers
rather than one name I picked. GIT_WORK_TREE is injected too, since a hook's
children can carry it.

Measured, with the mutation the bot described:

  unset $scrub  ->  unset GIT_DIR   : FAIL (green before this commit)
  whole scrub block deleted          : FAIL
  unmutated                          : 9 checks, exit 0

The check's message named GIT_DIR while testing the whole list; renamed to
"leaks no repo-relocating git env to preflight" so a failure names the real
requirement.

shellcheck 0 · shfmt-check 0 · just lint 0 (19/19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4W4HtEmnF7Bu8jsqafJML
@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 2b312e586ba3dffbd0704ca411df6db2251bf4a9

Findings: 0 — No findings

The diff hardens the pre-push git hook's env scrub (fail-closed on missing/empty git-reported local-env-vars) and adds a selftest covering it; the scrub still only unsets names sourced from trusted git rev-parse --local-env-vars output, so no exploitable issue was found.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Review

Head: 2b312e586ba3dffbd0704ca411df6db2251bf4a9

Findings: 0 — No findings

Clean, well-scoped fix: pre-push now scrubs the same repo-relocating git env vars (GIT_DIR/GIT_INDEX_FILE/GIT_WORK_TREE) that e2e_init_repo already scrubbed, resolving toplevel before the scrub and failing closed (exit 1) if git's local-env-vars list is empty; the new selftest section drives the actual .githooks/* files (not a copy) through a stubbed just, with a negative control proving the harness detects leakage, so a regression in either scrub copy fails the suite. Traced the printf-generated stub script, the word-splitting unset $scrub, and the exempt-hook matching logic in detail and found no functional defect; no architecture-invariant, Source-trait, or hook-shim-crate code is touched by this diff.


Automated read-only review by Claude Code

@IvanWng97

Copy link
Copy Markdown
Owner Author

Round 2 + verification disposition (final head 2b312e58)

Round 2 was the last behavior-changing commit; an independent verification lens then re-derived everything at this head. Verdict: APPROVE-WITH-NITS, zero open HIGH.

Round 2 — the bot's MEDIUM — FIXED, with a green-before/red-after pair

The online review bot found that the new hook test's stub recorded only ${GIT_DIR:-}, so a scrub narrowed to unset GIT_DIR passed while GIT_INDEX_FILE still relocated the index — half of the #893 mechanism, and the harness was already injecting it without ever looking.

The stub now reports every name from git rev-parse --local-env-vars still set at the exec. Verified independently by the verification lens, both directions: the round-1 test is green against the bot's exact mutant (proving the finding real); the round-2 test reds it. A stronger mutant (unset GIT_DIR GIT_INDEX_FILE, leaving only GIT_WORK_TREE) also reds — the assertion is general over git's list, not over names anyone picked.

Contract shape: a change shipping a test that fails without it (+7/−3, one test file, production untouched).

Verification at 2b312e58 (default REFUTE — attacks that came back clean)

${!v:-} indirection on bash 3.2.57 · the generating printf's quoting (byte-exact stub) · rev-parse --local-env-vars with no repo / a nonexistent GIT_DIR · word-splitting, IFS poisoning, glob-named files in CWD · the trailing exit 0 on every path · shellcheck 0 · shfmt 0. All seven mutation rows behaved. The round-1 disposition's claims were re-audited item by item against this head: no mismatches. The real repository was bookended before/after: untouched.

SURFACED — one line for the owner (V-1, MEDIUM, test blind spot; the hook itself is unaffected)

A hook that dies before exec passes the per-hook check vacuously: :150 discards the hook's exit status and :165 asserts an empty string, indistinguishable from a seen file never written. Live instance: SKIP_PREFLIGHT=1 ambient in a developer's shell → the hook takes its documented early exit → the check is green even against a hook with no scrub at all. Measured both ways (same mutant reds with the var unset). It falsifies none of the mutation evidence above — every reported red runs through a path that reaches exec — it only names a mutant class that would slip through.

Follow-up one-liner when the owner wants it: assert the seen file exists (catches every died-before-exec cause), plus SKIP_PREFLIGHT= in the injected env as defense-in-depth. Not folded here: the two-round cap is spent, and this is a test-coverage nit, not a defect in the change.

V-2 (LOW): the injected GIT_WORK_TREE duplicates a file-scope export — inert, self-documenting, left as is.

Gates at this head: CI CLEAN (47 checks, 0 failing) · claude-auto-review 0 findings · claude-security-review 0 findings · just lint 0 (19/19) · selftest 9 checks · preflight on push 3174 passed.

A human merges.

@IvanWng97
IvanWng97 merged commit e6bdf5c into main Aug 31, 2026
47 checks passed
@IvanWng97
IvanWng97 deleted the fix/hook-env-scrub branch August 31, 2026 03:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant