fix(tooling): isolate git children from ambient GIT_*, and make a shared core.bare flip loud - #16646
Merged
baozhoutao merged 1 commit intoSep 7, 2026
Conversation
…ed core.bare flip loud A gate self-test that shells out to `git` inherited the repository location a hook exports (`GIT_DIR` / `GIT_WORK_TREE` / `GIT_INDEX_FILE`), which outranks `cwd`: its `git add -A` staged 8,190 paths as deleted in the real index and its `git init` wrote `core.bare = true` into the SHARED `.git/config`, breaking the primary checkout for every agent on the box. Every case still printed `ok`. - scripts/git-env.mjs: the class rule in one place. `gitFreeEnv()` for a git child that must stay inside its own `cwd`, `withoutGitEnv()` for a call one frame down that passes no environment of its own, and the boundary stated where it can be read: never strip for a child that fetches or pushes, because GIT_CONFIG_* and GIT_SSL_* carry the transport configuration. Its self-test reproduces the leak against real git (red) before proving the strip (green). - scripts/symbol-anchors.mjs: `trackedFiles` passes an explicit stripped environment, so a sweep of a synthetic root can no longer answer with the real repository's file list. Pinned by a self-test case that injects a bogus GIT_DIR and requires the sweep to resolve its own tree; before the change that case threw. - The two corpus gates built on that resolver build their fixture repositories the same way, so both spawn stripped too. - scripts/check-system-context-census.mjs drops its local copy of the strip and imports the shared one; its regression pin is unchanged. - scripts/setup-git-hooks.mjs carries the tripwire. In the flipped state no commit is possible, so no hook can be the alarm; `pnpm install` is the first thing that runs after the damage. It warns there and refuses under `--self-test`. Measured: `rev-parse --is-inside-work-tree` prints `false` and exits 0 under the flip, so the predicate reads `core.bare` on the shared common dir, which is also the only reading a linked worktree can take. Refs #16624 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
This was referenced Sep 7, 2026
baozhoutao
marked this pull request as ready for review
September 7, 2026 16:18
baozhoutao
enabled auto-merge
September 7, 2026 16:18
baozhoutao
deleted the
claude/issue-16624-git-env-isolation-for-gate-self-tests
branch
September 7, 2026 16:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #16624
A gate self-test that shells out to
gitinherited the repository location a hook exports —GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE, which outrankcwd. Underpre-commititsgit add -Astaged 8,190 paths as deleted in the real index and itsgit initwrotecore.bare = trueinto the shared.git/config, breaking the primary checkout for every agent on the box. Every case still printedok.Triage graded this p1 for three properties: machine-wide blast radius from one agent's test, a symptom that appeared far from the cause, and an instrument whose entire output said nothing had happened. This PR lands remedies 1 and 3, plus the mechanism half of remedy 2; the migration half is split out, see below.
What changed
scripts/git-env.mjs(new). The class rule with one spelling:gitFreeEnv()for a git child that must stay inside its owncwd,withoutGitEnv()for a call one frame down that passes no environment of its own. The header carries the measured incident and one boundary, below.scripts/symbol-anchors.mjs— the graded deliverable (remedy 1).trackedFilesresolves throughgit ls-filesand passed no environment of its own, so a sweep of a synthetic root inherited the caller'sGIT_DIRand would answer with the real repository's file list. It now passes an explicit stripped environment. Pinned by a self-test case that injects a bogusGIT_DIR, builds a real two-file corpus and requires the sweep to resolve its own tree — before the change that case threw.The two corpus gates built on that resolver (
check-adr-symbol-anchors,check-scripts-symbol-anchors) build their fixture repositories withgit init/git add -A, one frame away from the resolver just hardened, so both now spawn stripped too. Bounded in-place fix: same defect class, mechanical, same gate family, no new verification surface.scripts/check-system-context-census.mjsdrops its local copy of the strip and imports the shared one. Its regression pin — the one that injects a bogusGIT_DIRand requires the throwaway tree to come back with its own files staged — is unchanged and still green.scripts/setup-git-hooks.mjs— remedy 3, the loudness. In the flipped state a commit is impossible, so no hook can be the tripwire;pnpm installis the first thing that runs after the damage and every agent runs it when it creates a worktree. It warns there (this script's "failing is not an option" contract is unchanged — a broken shared config must not also break every install on the box) and refuses under--self-test, whose whole job is to answer "is this clone wired". No existing gate's refusal set moves, and nothing was weakened or slowed to add it.Two measurements worth keeping
rev-parse --is-inside-work-treeprintsfalseand exits 0 under the flip. An exit-code check reports a healthy clone.git statusis what fails, with exit 128, long after the useful moment — so the predicate readscore.baredirectly.The predicate must read the SHARED common dir, not the git dir it happens to be standing in.
--absolute-git-dirfrom a linked worktree answers with that worktree's own private directory, so the layout test read every linked worktree as a legitimately bare repository and the alarm never fired from the very place agents work. This was a real defect in the first spelling, caught by its own self-test case. It matters because under the flip the linked worktrees keep working and only the primary checkout dies: the session that can still ask the question is usually not the one suffering the damage.⛔ The boundary a blanket strip has, measured
gitFreeEnv()removes everyGIT_-prefixed key, which is blunter than a list of the location variables on purpose — a list has to be maintained against git's, and the one it misses is the one that bites. But it is correct only for a child that operates on the repository itscwdand arguments name. It is wrong for a child that talks to a remote: the containers this repo is worked in carryGIT_CONFIG_COUNTwithGIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_*pairs that rewrite GitHub remotes and disable interactive credentials, plusGIT_SSL_CAINFOnaming the proxy CA bundle. Strip those andfetch/clone/pushin the child loses its transport configuration. The rule is stated by what the child does, never by which script it lives in.Remedy 2 — the enumeration triage asked for
"Several gates build throwaway repos; this is the one that was caught" — measured at
8ae3b8d2ccover every tracked.mjs/.mts/.js/.ts/.shfile that both creates a temp directory and spawnsgit initagainst one. 32 files, in three groups:scripts/git-env.mjs·symbol-anchors.mjs·check-system-context-census.mjs·check-adr-symbol-anchors.mjs·check-scripts-symbol-anchors.mjsLEAKED_GIT_ENVallowlistpackages/spec/scripts/build-schemas-check-mode.test.ts·packages/spec/scripts/sharded-artifacts.test.ts— a different spelling, landed earlier by #9068 / #9109, both of which are no longer openTier A — invoked BY git, or from a git hook.
scripts/git-merge-regen.mjs(3 init sites; it is themerge=os-regendriver, so git runs it andGIT_DIRis exported by construction) ·scripts/check-regen-pending.mjs(1; run bypre-commitandpre-push— the exact path this incident travelled).Tier B — repo gates under
scripts/**.ablation-dist-preflight.mjs·check-adr-0087-registration.mjs(6) ·check-bash32-floor.mjs·check-changeset-no-major.mjs·check-empty-changeset.mjs(4) ·check-engine-split-ratio.mjs·check-nul-bytes.mjs·check-skill-frame-freshness.mjs·check-type-check-coverage.mjs·docs-audit/check-drift-comment.mjs·objectui-changeset-digest.mjs(9) ·objectui-range.mjs·collect-release-notes.sh.Tier C — PM tooling, package tests, agent-hook self-tests.
pm/dispatch-gates.mjs(8) ·pm/check-governed-merges.mjs·pm/git-history.mjs·pm/release-rehearsal-clone.mjs·pm/os-regen-merge.sh(2) ·bump-objectui.selftest.sh(2) · the two.claude/hooks/*.selftest.sh·packages/spec/scripts/check-skill-examples.ts·packages/create-objectstack/src/scaffold-skills-single-copy.test.ts.⇒ Twenty-five is past the "handful" the dispatch set as the split threshold, and one of the tiers touches a governed surface, so migrating them here would make a p1 land slowly or not at all. Enumerated, tiered and filed unassigned as #16644, which also records the second finding: the repo now states one rule three ways, and the hand-maintained key list is the spelling worth retiring. ⛔ A repo-wide gate refusing a new inheriting spawn is deliberately not proposed until the population is at zero — it would land red and get weakened.
Red before green, per remedy
Every ablation ran from the committed state, proved the mutation on disk by occurrence count before taking a reading, restored under a
trapwithgit checkout HEAD --, and proved each restore by blob hash equal toHEAD:pathplus an emptygit diff HEAD. Final tree:git diff HEAD0 lines,git status --porcelain0 lines.env: gitFreeEnv()fromtrackedFilessymbol-anchors --self-testexit 1: "⛔ ENV LEAK:sweepCorpusmust not inherit a hook's GIT_DIR — with one injected it threw: Command failed: git ls-files". Unmutated baseline in the same run: exit 0.startsWith('GIT_')delete unreachablegit-env --self-testexit 1, 2 of 21 cases: the strip itself, and "with gitFreeEnv the corpus gets its OWN repository".core.bareread tofalsegit-env --self-testexit 1, 3 of 21 cases, including the linked-worktree one.setup-git-hooks --self-testexit 1, printing the alarm and the repair line.The
git-envself-test does not only ablate: it reproduces the incident against real git as a passing red-side case — a victim repository with a commit, a leaky child spawned withGIT_DIRalone (the incident's shape; addingGIT_WORK_TREEmakes the case pass while proving nothing, which was measured before the spelling was settled), then the assertion that the victim's own file comes back staged asDand that the leakygit initcreated no repository where it was pointed. The green side runs the same two commands with the same environment and one difference.Gates
41 commands derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackagainst the real change set, every exit code captured before any pipe. All green except:check:dts-closure,check:dual-build-cjs-loads,check:sourcemap-no-sources-content— exit 3, PREREQUISITE NOT MET (they read builtdist/, and this worktree has none). NOT MEASURED, not a pass and not a finding. This diff touches no package source, so no package's built output can move; CI builds and runs them.check:pm-dispatch-gates— its self-test exceeds a foreground budget on a contended box; run to completion out of band, result in the report comment on [finding] a gate self-test'sgit init/git add -Ainherited ambient GIT_* under pre-commit — staged 8,190 deletions in the real index and wrotecore.bare = trueinto the SHARED .git/config, breaking the primary checkout for every agent on the box #16624.pnpm installwas run in this worktree and itspreparestep executed the modifiedsetup-git-hooks.mjswithout incident.Shared-checkout readings
Verified before starting, after every git-spawning experiment, and again at PR time, on
/home/user/objectstack:core.barefalse ·git status --porcelainempty ·git ls-files7947 ·git worktree listshows it onclaude/pm-dispatch-devx-956ft5, not(bare). Nothing in this branch's work touched the shared checkout or the shared.git/config; everygitthis branch's tests spawn is itself spawned with a stripped environment.Changeset
Nothing here is published from any package — root tooling under
scripts/plus one rootpackage.jsonscript line — soskip-changesetis the right disposition. The dispatch reserves repository labels for the PM, so this PR applies none; the standing dev clause says the label is the dev's step. Flagged rather than decided, and recorded in the report'sopen_questions.Generated by Claude Code