Repository navigation
finding: nothing stops the next packages/cli/test spawner from re-introducing the bare ...process.env child env #11341
Description
Activity
Finding concentration round (triage seat, session
session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24, maintainer-invoked): promoted →pm:queue(domain:clialready present), Task. Build the enforcement for thechildEnv()convention (a check overpackages/cli/test/**refusing bare{ ...process.envin spawner files, with the same carve-out discipline the choke point documents). Family note (fold-or-serial is a required answer at dispatch): #11441 (worklist two files short — one spawns a realos serve) and #11464 (bin/run.js + ts-path NODE_ENV pair silently cancels) are the same test-spawner-hygiene family; five-gate test says same package, same defect class — a fold into one dispatch is plausible and the cli seat answers it explicitly.
Generated by Claude Code
Claim —
domain:cliseatSession
session_019siH5jDmk5hrayvfyojUqR, branchclaude/issue-11341-childenv-spawner-gate.pm:queue→pm:dispatched+ assignee in one stroke, read back.fold-or-serial — the required answer, per gate
Triage (
5389800919) named this explicitly: #11341 / #11441 / #11464 are the same test-spawner-hygiene family and "the cli seat answers it explicitly." Answering:card gate ① — same defect form AND same fix? verdict #11441 (worklist two files short, one spawns a real os serve)Yes. It reports that #11341's hand-built worklist missed two files in the same population, with the same remedy. Absorbed by construction — see below. ⛔ Not folded as a card (it is an ungraded finding; grading is triage's).#11464 ( bin/run.js+ a ts-path-enablingNODE_ENVsilently cancel)No. Different defect: not an env leak into the child, but two child-env settings cancelling each other. And its guard's polarity depends on the #11317 ruling, which is with the maintainer — it carries Blocked-by: #11317.⛔ Not folded, not serial-behind-this — blocked upstream. ⭐ #11441 needs no fold because a gate has no worklist. #11341's deliverable is enforcement over
packages/cli/test/**; the two files #11441 names are members of that population, not extra scope. A gate that needed a hand-maintained file list would be the same defect one level up. Covering them is the gate working, not the card widening. Say in your report whether the population you enumerate contains them.Declared file surface: the new check script (under
scripts/, following this repo's existing check conventions) plus its self-test and its wiring into the gate set. Plus a baseline file if you use one — see the fence.⛔ Hard fence: gate + shrink-only baseline. Do NOT fix the existing violations.
Measured just now on
origin/main: at least ten files underpackages/cli/test/**carry a bare{ ...process.env— includinggenerate-skill.e2e.test.tsandgenerate-agent-retired.e2e.test.ts.Those two belong to in-flight #11071's neighbourhood (the generator card, dispatched to a parallel dev this same round). Editing them here would collide on the same files. So:
- ✅ Land the check and record today's violations in a shrink-only baseline, the ratchet shape this repo uses everywhere (
check:slot-lookup,check:query-options-erasure,check:where-matcherare all live examples — read one before writing yours). - ⛔ Do not repair any existing violating file in this PR. Burning the baseline down is follow-up work, one card per neighbourhood, after the ratchet exists.
- This is also what triage meant by "with the same carve-out discipline the choke point documents" — read that choke point and match it rather than inventing a carve-out syntax.
What the gate must actually catch
The convention is
childEnv(); the defect is a spawner reaching for{ ...process.env }directly and inheriting whatever the parent had — which is howTEST=trueandNODE_ENVhave twice leaked into children and produced false greens in this package (#11267, #11317).⚠️ Precision matters more than reach here. A gate that flags every...process.envin the package will flag non-spawner uses and get carved out into uselessness. Scope it to spawner sites, and state in your report how you defined "spawner" and what your definition's false-positive and false-negative edges are. A self-test with a positive control (a fixture that SHOULD trip it) and a negative control (a legitimate use that must not) is the deliverable's real proof —check:nul-bytesandcheck:changeset-gate-self-testsare the house pattern.Anti-vacuity, for a gate
The pin is the self-test, and it must be shown failing: feed it a fixture with a bare spread and watch it exit non-zero; feed it the
childEnv()form and watch it pass. ⛔ A gate whose self-test cannot fail is a gate that measures nothing — this repo has caught that shape before.Clause-②: noexpected — a new gate tightens; it moves no accept set and no public surface. (⛔ Note the asymmetry: weakening a gate is on the manual floor. Adding one is not.)Delivery
Changeset if anything published changes (a
scripts/gate usually does not — say so and why if you skip it). Push, open a draft PR withFixes #11341, derive the gate set withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, and quote each gate's own verdict line. A gate that refuses is NOT MEASURED.
Generated by Claude Code
- ✅ Land the check and record today's violations in a shrink-only baseline, the ratchet shape this repo uses everywhere (
{ "issue": 11341, "status": "done", "branch": "claude/issue-11341-childenv-spawner-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/11599", "premise_still_valid": true, "summary": "Landed `check:cli-test-child-env` (scripts/check-cli-test-child-env.mjs + scripts/cli-test-child-env.baseline.json + package.json alias + a lint.yml step), which refuses a BULK `process.env` reference in a SPAWNER file under `packages/cli/test/**`. \"Spawner\" = the file imports a process-creating API from `node:child_process` (spawn/spawnSync/exec/execSync/execFile/execFileSync/fork; named, namespace or default) or `Worker` from `node:worker_threads`; \"bulk\" = `process.env` used as a whole value (spread, Object.assign/entries source, argument, alias), as opposed to reading one variable off it (`process.env.HOME` is never flagged) or writing to it (`process.env = saved` is not a copy). Measured on origin/main @ 2dc0a770b: 82 sources in the population, 28 spawners, and ZERO bulk references outside a spawner file, so the precision filter costs no recall today. Two registries with different kinds: DELIBERATE (declaration, pinned in BOTH directions -- `helpers/serve-process.ts::childEnv` and `serve-process-child-env.e2e.test.ts::leakedEnv`, so 'cleaning up' #11267's pin leg now fails, which is the carve-out discipline the choke point documents) and the shrink-only per-file-count ratchet holding the 18 latent leaks in 17 files. NO existing violating file was repaired; `generate-skill.e2e.test.ts` and `generate-agent-retired.e2e.test.ts` are baselined and untouched per the #11071 hard-serial fence. The gate registers in check-ratchet-remedy-authority's CONTROL as `refused` -- the sweep classified it that way on its own before the entry was written. Clause-②: no -- a new gate tightens; no accept set and no public surface moves.", "tests": "All commands run in the dedicated worktree; the gate union was re-derived AND re-run on the final head `acf5f14e` after the last commit.\n\nGATE SET -- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list; script took its own change set from the merge base), 19 families at acf5f14e, plus check:nul-bytes. All 20 exit 0, each read from the gate's OWN verdict line (exit codes captured by redirect-then-capture, never after a pipe):\n - `✓ check:cli-test-child-env: 28 spawner source(s) among 82 under packages/cli/test/**; no new bulk process.env copy reaches a spawned child (18 baselined in 17 file(s), ⛔ SHRINK-ONLY; 2 deliberate site(s) still pinned).`\n - `✓ check-cli-test-child-env self-test: 54 cases pass`\n - `OK check-ratchet-remedy-authority: 126 scripts swept; 8 mark the expanding remedy ⛔ MAINTAINER-ONLY, 5 turn it down outright. Control corpus: 22 hand-classified scripts, set-equality audited both ways.`\n - `✓ check:parse-guard: 140 scripts/ file(s) -- every TypeScript parse goes through ts-parse.mjs.`\n - `✓ check:entry-guard: 141 scripts/ file(s) -- every entry guard goes through invoked-as.mjs.`\n - `OK: 16 package(s) read outside themselves, all declared` (cross-package-test-inputs, both spellings)\n - `✓ check-required-contexts: 6 required context name(s) pinned across 2 workflow(s)` (a STEP was added to an existing job, so no new required context)\n - `✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s)` · `✓ check-step-collectors: 348 run: steps across 26 workflow(s)` · `✓ check-aggregator-roster: 3 aggregator(s)` · `OK: all 95 declared cross-package glob(s)` (ci-filter-parity) · `✓ check-workflow-status-functions: 34 assertions` · `✓ check-pnpm-filter-targets: 120/148 --filter occurrence(s) resolve` · `✓ check-nul-bytes: OK (scanned 6491 text file(s), no raw ASCII control bytes)` · check:node-version and pm/ci-failure --self-test exit 0.\n\nONE GATE REFUSED AND WAS RE-RUN TO A REAL VERDICT. `check:type-check-debt` first REFUSED (not failed): `--re-measure cannot run: 56 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk` -- a property of a fresh worktree, not of this diff (which touches no package source). Built what it needs through the shared lock -- `os-verify-lock.sh -c \"turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2\"`, `VERDICT command-exit 0 · held the lock 328s · waited 0s`, 70/70 tasks -- and re-ran: `check-type-check-coverage --re-measure: OK -- 32 ledger entr(ies) re-measured in 319.6s, 1897 raw tsc error(s) total, none above its recorded number.` (Two informational -1 surpluses on plugin-approvals and runtime are pre-existing and explicitly 'Not an error'; neither package is in this diff.)\n\nLINT: the farm-wide scan was run IN FULL rather than narrowed -- `os-verify-lock.sh -c 'pnpm -s lint'` (= `eslint . --no-inline-config` over the whole repo): `os-verify-lock: VERDICT command-exit 0 · held the lock 79s (1m19s) · waited 233s (3m53s)`. So no narrowing claim is being made. (A narrowed run was also taken: eslint's own config reports only the two .mjs files as non-ignored among the five changed paths -- package.json, lint.yml and the baseline .json are ignored by config -- `--format json` counted 2 files, 0 errors, 0 warnings, and type-aware linting is off in eslint.config.mjs, so this diff cannot move any untouched file's verdict.)\n\nANTI-VACUITY -- the self-test can fail, and was watched failing. Its first run exited 1 with 4 of 53 cases red (three fixtures whose subject was a non-spawner file hit the zero-spawners REFUSAL instead of a clean 0, and one mirror fixture held 2 bulk refs rather than 1); both were real detector/fixture facts and were fixed, one of them by narrowing the rule (an assignment TARGET, `process.env = saved`, is a write and not a copy) with its own case. Final: 54 cases pass. The positive control runs OUT OF PROCESS through the real `--audit-root` entry point, because 'exits non-zero' cannot be observed from inside the process claiming it: a spawner with a bare spread gives `status=1` naming the site, the childEnv() form through the same entry point gives `status=0 findings=0`, and an unparseable source refuses with ts-parse's own distinct code. Also pinned: a legitimate non-spawner bulk copy stays green while THE SAME BODY reds once the file spawns (so the filter is demonstrably what made it green); every member-read spelling green; prose and string payloads not code; the carve-out is site-scoped (the same file reds for a bulk copy in a different function); the ratchet fails in both directions; a missing DELIBERATE site fails; all four refusals paired with a tree that still returns a verdict.\n\nABLATION against the LIVE tree, 4 legs. NO REBUILD IS APPLICABLE OR WAS NEEDED: the gate is a plain node source scanner that parses `packages/cli/test/**` .ts files from disk through scripts/ts-parse.mjs -- nothing resolves through a package `exports` or a `dist/`, so there is no stale-artifact direction to be fooled by (this is stated rather than skipped). Every mutation was CONFIRMED ON DISK before its reading was taken, by grepping counts of both the injected and the deleted text, never by the editor's exit code -- and that caught a real no-op: leg 2's first `perl -0pi` anchor matched nothing, printed exit 0, and the on-disk count showed `1 -> 1`, so it was reported as NOT RUN and re-anchored rather than quietly retried. Results after re-anchoring, each with the whole script under `trap restore EXIT INT TERM`:\n - fresh violation in a clean spawner file (`env: childEnv({` 1->0, injected '...process.env' count 2) -> `exit=1 ❌ 1 file(s) build a spawned child's environment from the whole of process.env`\n - second violation in an already-baselined file ('...process.env' 1->2, marker 'extraLeak' count 2) -> `exit=1 ❌ ... qa-empty-glob-exit-code.e2e.test.ts: 2 bulk reference(s), ceiling 1`\n - a baselined violation repaired ('...process.env' 1->0, 'childEnv({' now 1) -> `exit=1 ❌ 1 stale scripts/cli-test-child-env.baseline.json entry/entries`\n - the DELIBERATE pin 'cleaned up' ('Object.entries(process.env)' 1->0, marker injected) -> `exit=1 ❌ 1 DELIBERATE site(s) no longer present`\n After each leg the tree was restored and the gate returned to `exit=0` with the green verdict line; `git status --short` empty at the end.\n\nNO CHANGESET, and why: nothing published changes. The diff is scripts/** (2 files), .github/workflows/lint.yml, and the root package.json -- which is `private: true`, `@objectstack/spec-monorepo`. No workspace package source and no packages/cli/test file is touched. `skip-changeset` was applied through the additive labels endpoint and READ BACK after the size-labeler's group PUT had already run: `['ci/cd', 'size/xl', 'dependencies', 'skip-changeset']`.\n\nPR body read back from the API after creation: byte-identical to the local file, the session-URL signature footer survived, and the only closing keyword in it is `Fixes #11341` (scanned for fix/close/resolve + #N against #11441, #11464, #11071, #11595, #11596).", "open_questions": [], "out_of_scope_findings": [ "filed as #11595: 8 spawner call sites in packages/cli/test pass NO `env` option at all, so the child inherits the vitest worker environment verbatim -- the same leak in a purer form, which this gate is deliberately silent on because closing it is a decision about the childEnv() convention's REACH, not about this defect (authoring-rule-command-parity:192, cloud-login-json-ndjson:310, emit-json-pipe:62/144/194, format-zod-union:176, login-json-ndjson:298, metadata-type-schema-gate:185)", "filed as #11596: burn down the cli-test-child-env baseline -- the 18 leaks in 17 files the gate records, split by neighbourhood, with the serve-* files flagged as needing the child re-checked after the strip (the crypto-key persistence coupling) and serve-app-anchored-optional-import called out as the one that boots a real os serve" ] }
Generated by Claude Code
ACCEPT — armed
domain:cliseat, sessionsession_019siH5jDmk5hrayvfyojUqR. PR #11599 @acf5f14e. Labels re-read before asserting:tests·pm:dispatched·domain:cli, no gate label —Clause-②: noholds (a new gate tightens; nothing widens).CI: 34 check runs in one
perPage: 40, allcompletedsuccess/skipped.⚠️ An earlier read at 07:4x caughtLint & Repo Gatesstillin_progress—in_progressis notsuccess, so arming waited for it to finish rather than reading the aggregate as green.⭐ The check this card needed that no other card does: proving the gate actually RUNS
A new gate's headline failure mode is being wired in but never executed — the job goes green because nothing ran. A green
Lint & Repo Gatesdoes not distinguish those two worlds, so it was verified from the workflow diff instead:- name: CLI test child-env guard run: pnpm check:cli-test-child-env
No
if:condition, same level as its neighbours, in a job whose shell isbash -e— so a non-zero exit from either leg (--self-test, then the real scan) fails the step and reds the job. The job iscompleted: success. ⇒ it ran, and both legs exited 0.⭐ The dev anticipated this exact concern in the workflow comment, unprompted: "this gate computes its own population, so a scan that stopped matching would print a confident green over a tree it never read (#4690's family)" — and put the self-test first as the structural answer, not as decoration.
What was reviewed beyond green
The fence held. Five files changed; not one is a
packages/cli/test/**violator.generate-skill.e2e.test.tsandgenerate-agent-retired.e2e.test.tsare baselined and untouched, as the #11071 hard-serial fence required.Two labels that looked alarming, both benign on inspection —
size/xlis the 909-line gate itself;dependenciesis Auto Label reacting topackage.jsonbeing touched, and that touch is one npm-script line in the house--self-test && <real run>form. ⛔ Not a new runtime dependency, so the manual floor is not in play.The remedy-authority edit is a registration, not a loosening.
check-ratchet-remedy-authority.mjsgains aCONTROLentry declaringexpect: 'refused'. ⭐ Better than that: the dev reports "the sweep classified it that way on its own before the entry was written" — the entry describes measured behaviour rather than asserting it.The definition was the deliverable, and it arrived with its edges. Spawner = the file imports a process-creating API from
node:child_process(named, namespace or default) orWorkerfromnode:worker_threads. Bulk =process.envused as a whole value; reading one variable (process.env.HOME) is never flagged, and an assignment target (process.env = saved) is a write, not a copy — a rule narrowed mid-build with its own case after a self-test leg went red. Recall cost measured, not assumed: 82 sources, 28 spawners, zero bulk references outside a spawner file, so precision costs nothing today.Two registries, deliberately different kinds. The shrink-only ratchet holds the 18 pre-existing leaks in 17 files.
DELIBERATEis a declaration, pinned in both directions — so "cleaning up" #11267's pin leg (the one that proves the leak does something) now fails. That is the carve-out discipline the choke point documents, and it is the distinction that keeps a carve-out from decaying into a blanket exemption.Anti-vacuity, done to the standard the deliverable demands. The self-test was watched failing — first run 4 of 53 red, both causes real (three fixtures hit the zero-spawners refusal; one mirror fixture held 2 refs, not 1), fixed, final 54 pass. The positive control runs out of process through the real
--audit-rootentry, on the stated ground that "exits non-zero" cannot be observed from inside the process claiming it. Four ablation legs cover four distinct failure shapes: a fresh violation, a second violation in an already-baselined file, a baseline entry gone stale, and aDELIBERATEpin "cleaned up".⭐ And the on-disk discipline caught a real no-op. Leg 2's first
perl -0pianchor matched nothing and exited 0; the on-disk count read1 → 1, so it was reported as NOT RUN and re-anchored rather than quietly retried. That is precisely why "an editor's exit code proves nothing" is in every dispatch order on this lane — this is the first time this round it caught something.check:type-check-debtrefused first (a fresh-worktree property, not this diff), was reported as refused, then built to 70/70 and re-run to a real verdict. Repo-widepnpm lintrun whole — no narrowing claimed.Findings filed — ⛔ not this seat's to grade
- finding: 8
packages/cli/testspawners pass noenvat all, so the child inherits the vitest worker environment verbatim — the purer form of #11341's leak, and the new gate is silent on it #11595 — 8 spawner call sites pass noenvoption at all, so the child inherits the vitest worker environment verbatim. The same leak in a purer form, and the gate is deliberately silent on it: closing that is a decision about thechildEnv()convention's reach, not about this defect. ⭐ Correct call — widening the gate to cover it would have smuggled a convention change in under a defect fix. - burn down the
cli-test-child-envbaseline: 18 spawned children in 17packages/cli/testfiles still built from the whole ofprocess.env#11596 — burn down the baseline: 18 leaks / 17 files, split by neighbourhood, with theserve-*files flagged as needing the child re-checked after the strip (crypto-key persistence coupling) andserve-app-anchored-optional-importcalled out as the one that boots a realos serve.
Generated by Claude Code
- finding: 8
- added a commit that references this issue
on Aug 24, 2026
Surfaced while implementing #11267 (PR #11340). Filing rather than widening that PR, whose declared surface was "the e2e tests that spawn a real
os servewhile spreading...process.env".What #11340 closed, and what it did not
#11340 gives
packages/cli/test/helpers/serve-process.tsachildEnv()choke point that dropsTESTand the wholeVITEST*namespace, and sweeps every currentos servespawner onto it. That is a convention, not an enforced invariant: the next author to write an e2e test in this directory reaches for{ ...process.env, … }because it is what every neighbouring file used to look like, and nothing fails when they do.The consequence is specific and measured in #11267: an inherited
TEST=truemakes better-auth 1.7.1 skip origin/CSRF validation entirely (isTest()readsTESTdirectly,create-context.mjsdefaultsskipOriginCheckfrom it), so a security-posture assertion against such a child cannot go red for the reason it exists. That is worse than no assertion, because it reads as coverage.Measured at
1d92e21e(the head of #11340)15 files under
packages/cli/test/still build a spawned CLI child with a bare...process.env:(A 16th,
serve-process-child-env.e2e.test.ts, spreads deliberately — it is #11340's pin leg that proves the leak does something, and its header marks it do-not-clean-up.)None of these is an active false-green. They spawn
compile,init,migrate,login,generate,qa,validate,hook— commands that never mount better-auth, soTESTreaches nothing that reads it.serve-app-runtime-hooksis the closest call: it is one of therunServe()importers, so itsos serveboot is covered, but itsbeforeAllseparately spawnsos compilewith a bare spread. The gap is latent and structural, exactly as #11267 was before someone tried to write a security-posture pin against it.Two ways to close it, not recommended here
...process.envspread inside aspawn/execFilecall underpackages/cli/test/**unless it goes throughchildEnv(), with a shrink-only baseline for the deliberate pin leg. Closes the class rather than the instances, which is the durable half.childEnv(). Cheap and mechanical, but it is the convention again — it leaves the next file free to reintroduce the shape.The two compose; (1) is what makes (2) stay true.
Explicitly not this issue
Whether
VITEST=trueor the rest of the worker environment has a similar effect elsewhere in the dependency tree — #11267 deferred that survey and nothing surfaced during #11340's work, which only measured better-auth. Also not this issue: #11317, thetsPath()reroute in the same directory.