Skip to content

fix(scripts): give every throwaway-repo git child an environment of its own (#16644 Tier B) - #19168

Merged
os-try-charles merged 4 commits into
mainfrom
claude/issue-16644-git-env-isolation-tier-b
Sep 19, 2026
Merged

os-try-charles merged 4 commits into
mainfrom
claude/issue-16644-git-env-isolation-tier-b

Conversation

@os-try-charles

@os-try-charles os-try-charles commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Part of #16644

Clause-②: no

What this changes

Every git child these files spawn against a throwaway repository now carries an
environment of its own — gitFreeEnv() from scripts/git-env.mjs, the blanket GIT_*
strip #16624 landed and #16753 applied in Tier A. The judgement is made per spawn
point
, not per file: a child that operates on the repository its cwd and arguments
name gets the strip; a child that talks to a remote keeps the ambient environment and is
marked as doing so.

⚠️ This is prevention, not a repaired outage. No failure has been measured in any of
these files — the card is observation-class and says so. The mechanism was measured on
#16624: 8,190 paths staged as deleted in a shared index, and core.bare = true written
into the .git/config every linked worktree of that clone reads, from a self-test that
printed ticks throughout. What follows is that shape, reproduced against a disposable
stand-in and then closed.

Scope, and why it is wider than the card's Tier B

  • Tier B, 13 files under scripts/** — the card's enumeration, re-measured on
    origin/main @ 5d0ee8f60 as still bare.
  • packages/spec/scripts/build-schemas-check-mode.test.ts and
    packages/spec/scripts/sharded-artifacts.test.ts
    — added on the triage ruling
    (comment 5578727562, acceptance point 4): 「packages/spec 两个文件收敛到
    gitFreeEnv(),LEAKED_GIT_ENV 一并删除。⛔ 不要保留"两种都行"的过渡态」. Their
    hand-maintained ten-name allowlist of GIT_* location variables is gone; the reason it
    goes rather than gains an entry is the card's own: an allowlist has to be kept level
    with git's list, and the key it misses is the key that bites.
  • Not touched: Tier A (scripts/git-merge-regen.mjs,
    scripts/check-regen-pending.mjs) — already done by Tier A of the #16624 class: the merge=os-regen driver and the pre-commit/pre-push gate build throwaway git repos with no environment of their own — the two files git itself invokes #16753, which triage split out
    of this card as priority:p1; and Tier C, which contains scripts/pm/** — the
    domain:skills lane.
  • No repo-wide gate here, per triage acceptance point 5: it would land red and get
    weakened. It belongs after Tier C, on its own card.

Per spawn point: local-only or network-touching

gitFreeEnv() is applied ONLY to the local-only column. The boundary is triage's,
fixed verbatim from the card: this container really does carry GIT_CONFIG_COUNT with
GIT_CONFIG_KEY_0=credential.interactive, GIT_CONFIG_KEY_1/2=url.https://github.com/.insteadOf
and GIT_SSL_CAINFO=/root/.ccr/ca-bundle.crt (read off the box while writing this), so a
network child stripped of them loses its transport.

file local-only spawn points, now stripped left ambient, and why
scripts/ablation-dist-preflight.mjs status --porcelain -z; show HEAD:PATH; the self-test's git() over a mkdtemp corpus
scripts/check-adr-0087-registration.mjs the git(args, cwd) helper (every argv form); cat-file --batch — (the fetch --unshallow texts are remedy prose, not spawns)
scripts/check-bash32-floor.mjs listPopulation's ls-files -z; fixtureRepo's init -q and add -A; the index-vs-disk control's ls-files
scripts/check-changeset-no-major.mjs the one git() helper: init / config / add / commit / diff / show / rev-parse / merge-base, and the #4690 leg's fetch whose remote is a local mkdtemp PATH — no https/ssh child exists in this file
scripts/check-empty-changeset.mjs the one git() helper, same census, two local-path fetch legs
scripts/check-engine-split-ratio.mjs the main git helper; the self-test g helper (init/config/add/log + two clone from a file:// URL under its own mkdtemp); the dated commit; the --cwd re-entry child
scripts/check-nul-bytes.mjs lsFiles; repoRoot; the self-test's init, config, add -A -f, ls-files, add -A
scripts/check-skill-frame-freshness.mjs rev-parse / init / config / remote add / update-ref / commit, through a git() helper that strips by default fetch --quiet --no-tags origin main — NETWORK-TOUCHING. Marked ⛔ AMBIENT ENVIRONMENT ON PURPOSE, reached through an explicit network: true. Stripped, this gate would degrade to its offline rung on every run: a warning where a verdict belongs
scripts/check-type-check-coverage.mjs check-ignore --stdin -z; readIgnoredPaths' ls-files --others --ignored; the self-test's g — (tsc children are not git)
scripts/docs-audit/check-drift-comment.mjs GIT_ENV's base, so the fixture git() helper and the mapper child both inherit the strip — the identity and GIT_CONFIG_GLOBAL/SYSTEM pins are DELIBERATE and re-applied on top of it
scripts/objectui-changeset-digest.mjs the git(cwd, args) helper; the clone from a file:// mkdtemp source; rev-parse HEAD; merge-base --is-ancestor; and the six bash bump-objectui.sh fixture drivers, whose env BASE is now stripped because that script spawns git one frame down two bash capability probes carrying BASH_ENV — they spawn no git at all, so they are not spawn points for this card
scripts/objectui-range.mjs the git(cwd, args) helper; the end-to-end node re-entry child
scripts/collect-release-notes.sh one unset of every ambient GIT_* name, before any child: all four report sections read checkouts by path, and the self-test's init/add/commit/three clones/one fetch are all under its own mktemp root with a file:// remote — the per-command GIT_AUTHOR_DATE=… git commit prefixes in the fixture loop are applied per invocation, after that line, and are deliberately unaffected
packages/spec/scripts/build-schemas-check-mode.test.ts, …/sharded-artifacts.test.ts HERMETIC_ENV is now built from gitFreeEnv(); every fixture git and every generator run inside one operates on an fs.mkdtemp directory GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM / GIT_CONFIG_NOSYSTEM stay set on top: they are the file's own "read no config" pins, not inherited leakage

验收备注

Triage's six acceptance points (comment 5578727562), and what was measured against each.

1. 先红后绿, every batch. A one-time instrument built a disposable stand-in
repository (git init + a tracked file + a commit), pointed the hook-exported location
variables at it, ran each file's temp-repo path, and compared a fingerprint of the
stand-in's config, index (ls-files), refs (for-each-ref) and HEAD before and
after. ⭐ The stand-in is disposable on purpose: pointing those variables at this checkout
would not test the incident, it would be the incident, for every agent on the box — the
same discipline scripts/git-merge-regen.mjs states for its own probe.

Two leak shapes were run, because they are not interchangeable and scripts/git-env.mjs
records why: with GIT_WORK_TREE also set, git resolves the work tree to the stand-in and
add -A re-adds its own files, so the index comes back unchanged and a case can pass
proving nothing. GIT_DIR alone is the incident's shape.

  • Firing control, per probe: before any verdict, a plain git rev-parse --absolute-git-dir in an unrelated directory had to answer with the stand-in's git dir.
    It did, in all 52 probes. A control that does not fire measures nothing.
  • RED, the UNFIXED files (restored one at a time from the merge base 5d0ee8f60,
    each proven on disk by comparing git hash-object against that blob, then restored from
    HEAD and proven restored by an empty git diff HEAD): 12 of 13 wrote the
    stand-in
    under GIT_DIR alone — config in 10, index in 11, refs in 10, HEAD in 4.
    ⭐ Three of them (ablation-dist-preflight, check-adr-0087-registration,
    docs-audit/check-drift-comment) exited 0 while doing it[finding] a gate self-test's git init/git add -A inherited ambient GIT_* under pre-commit — staged 8,190 deletions in the real index and wrote core.bare = true into the SHARED .git/config, breaking the primary checkout for every agent on the box #16624's signature
    exactly: a green self-test writing another repository. The 13th,
    check-nul-bytes.mjs, did not write the stand-in; it exited 1, because the leaked
    init created no repository in the directory it was handed and the fixture could not be
    built. Red in a different key, and stated as such rather than counted as a write.
  • GREEN, on this branch: all 13 leave the stand-in byte-identical under BOTH leak
    shapes (26 probes), and all 26 child runs exit 0 — the isolation is complete enough
    that the self-tests still pass with a leaked git environment in the process.
  • Every probe also fingerprinted the real checkout (status --porcelain + the shared
    config) before and after. It never moved, in any of the 52 probes, and the whole-tree
    git status --porcelain after the red leg was empty.

2. Negative control. With those variables absent — ordinary CI and hand runs — every
changed file behaves as today: 19 invocations (each gate's bare call and its
--self-test, both batteries, never --self-test alone), all exit 0, real batteries
observed in the logs (check-nul-bytes 75 assertions, check-bash32-floor 177 cases,
check-type-check-coverage 55+97+56+28+19+18 cases, objectui-range all checks,
collect-release-notes all cases). The two packages/spec files: 2 files / 114 tests
passed
, 487s.

3. Per spawn point, local-only vs network-touching. The table above. One
network-touching child in the whole surface, and it keeps the ambient environment.

4. packages/spec converged, allowlist deleted. Both files now build HERMETIC_ENV
from gitFreeEnv(); the allowlist constant is gone from both and is not re-spelled in
either file, so a census of the retired shape does not match the paragraph that explains
it. ⛔ No fourth spelling was introduced. Equivalence is structural rather than argued: the
ten names the allowlist removed are a strict subset of "every GIT_-prefixed key", so the
new fixture environment is a subset of the old one, and the 114 tests above pass on it.

5. No repo-wide gate. None added.

6. Closing count, with the card's own classifier — and it is NOT zero. Reported in
the report and below: the classifier's spawn probe was run in the argv-array form
('git' plus a separate init/add/ls-files probe), never as a command string,
because triage's own first measurement was a false negative for exactly that reason and
「对照不发火的零什么都没测到」.

Acceptance notes

⚠️ One finding, filed rather than fixed here:
packages/create-objectstack/src/template-consistency.test.ts carries a THIRD copy of the
retired GIT_* allowlist
(same ten names, same for … delete env[key] shape). Triage's
census of that shape on 2026-09-08 found two carriers and named both; this one is not on
its list. It is left out of this PR deliberately — converging it would add a package, and
therefore a verification surface, that neither triage nor the dispatch declared — and it
is reported for the Tier C lap so the "one spelling, not two" ruling can actually close.
Until it lands, the allowlist shape survives in one file.

Noted, not filed: nothing else.

Local verification, and what is declared to CI

  • dispatch-gates --commands re-derived in this worktree against the real changed set
    (15 paths, merge base 5d0ee8f60, three-dot) → 82 commands; run, with results in the
    report. pnpm check:pm-dispatch-gates was detached and waited on with tail --pid, per
    its recorded 430–450s prescription.
  • ⛔ Declared to CI, not run locally: the 50 artifact-roster families, the 11
    wide-population families, the 5 workflow-valued families and the 5 path-scheduled CI
    jobs dispatch-gates names as outside its runnable list, and the repo-wide pnpm lint.
  • skip-changeset: nothing published moves. packages/spec's files[] does not list
    scripts/, and repo-root scripts/** is inside no package.

Generated by Claude Code


PM addendum — the declared surface, corrected (domain:devx 执行席, 2026-09-19T04:48Z)

⚠️ This body undercounts its own surface. The API reads 17 changed files; the body says 16 in one place and "15 paths" in the verification section. The author declined to PATCH the body under its write budget and put the reading in the commit message instead, so the PM records it here rather than leaving a reviewer to reconcile it.

The two files beyond the originally dispatched 13 scripts/** + 2 packages/spec fixtures, both forced rather than chosen:

  1. packages/spec/vitest.repo-tests.json (+1 line) — verified by the PM's own ablation at 2026-09-19T04:15:38Z: with the line removed check-cross-package-test-inputs exits 1 naming scripts/sharded-artifacts.test.ts; with it, 0. The blob was restored to its pristine hash and git diff HEAD was clean.
  2. scripts/git-env.d.mts (43 lines) — the sanctioned shape, not a new invention. PM control, read at 2026-09-19T04:47:44Z: packages/spec/scripts/build-schemas-check-mode.test.ts imports git-env.mjs at :57 and check-regen-pending.mjs at :58 — adjacent lines, same file, same TS program, same lane — and only the second had a sibling .d.mts. Nine such mirrors existed before this PR; there are now ten, and check-declaration-mirrors discovers its corpus (both batteries exit 0 on this head).

⛔ The PM has kept the mirror rather than reverting it: the two remedies that avoid a new file are a @ts-expect-error suppression and duplicating the retired allowlist back into the fixtures, and both are refused — the first trades a red for a suppression, the second reverses triage's 「收敛到 gitFreeEnv(),退役 allowlist」 ruling.


Generated by Claude Code

…ts own

Git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE into every child it runs
and those outrank `cwd`, so a `git init` / `add -A` / `ls-files` aimed at a temp
directory lands on the repository those variables name. Measured on #16624:
8,190 paths staged as deleted in a shared index and `core.bare = true` written
into the `.git/config` every linked worktree reads, from a self-test that
printed ticks throughout.

Applies the landed `gitFreeEnv()` strip (scripts/git-env.mjs, #16624, applied in
Tier A by #16753) to the Tier B spawn points, per spawn point rather than per
file, and converges the two `packages/spec` fixtures onto the same spelling by
retiring their hand-maintained GIT_* allowlist.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
…set-digest too

The `execFileSync('bash', [bump-objectui.sh, ...])` end-to-end leg still spread
`process.env`, so under a leaked GIT_DIR the driver's own `git rev-parse
<sha>^{commit}` resolved against the leaked repository and the self-test died.
Measured: with this spawn left ambient the `--self-test` exited 1 under the leak
probe while every other file exited 0.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
@os-try-charles os-try-charles added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 19, 2026 — with Claude
@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/spec/vitest.repo-tests.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/vitest.repo-tests.json) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4b21c8d1fbc3d20ba965723db17e51401748ee38packageMentionDocs.

Converging its fixture environment onto `gitFreeEnv()` makes the file import
`scripts/git-env.mjs`, so it now reads outside its package. `check:cross-package
-test-inputs` said so (exit 1, naming the file): an escaping test left in the
`local` project runs under a task whose hash never moves with what it reads, so
it can go red on main while every PR reports green. Verified after: the gate
prints OK, and `vitest list` puts the file in `repo` and no longer in `local`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
CI red on 6b398c3: `packages/spec` `pnpm run typecheck` exited 2 with TS7016
at both converted fixtures -- "Could not find a declaration file for module
'../../../scripts/git-env.mjs'". Reproduced locally against
`check:scripts-typecheck`, the lane that owns it: `tsconfig.scripts.json`
includes `scripts/**/*`, inherits `strict` from the root config and sets no
`allowJs`, so an untyped `.mjs` import from `packages/spec/scripts/` is TS7016.

The mechanical difference from the live precedent: `scripts/js-comment-mask.mjs`
and `scripts/check-regen-pending.mjs` each ship a hand-written `.d.mts` sibling
(#5475, #10398) -- nine of them existed before this commit -- and
`scripts/git-env.mjs` did not. That is also why the pre-existing
`check-regen-pending.mjs` import in the very same test file type-checks.

So this copies that shape and nothing else: one sibling declaration, PARTIAL on
purpose per `invoked-as.d.mts`, declaring only the export TypeScript consumes.
`check:declaration-mirrors` discovers the corpus rather than listing it and now
reports "OK: 10 hand-written declaration(s) agree with their modules on name,
kind and required arity" -- `gitFreeEnv`'s `Function.length` is 0 and the
declaration's required arity is 0.

⚠️ This is one file beyond the declared surface, and it is declared as such: it
is the mandatory accompaniment of the import triage ordered, and the two
remedies that avoid a new file are the two the dispatch refuses.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
@os-try-charles
os-try-charles marked this pull request as ready for review September 19, 2026 04:53
@os-try-charles
os-try-charles added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit c229223 Sep 19, 2026
45 checks passed
@os-try-charles
os-try-charles deleted the claude/issue-16644-git-env-isolation-tier-b branch September 19, 2026 05:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants