Files
msd-core/tests/helpers-process-isolation.test.cjs
Behruz Nassre Esfahani a44d513566 fix(#3712): confine in-process installs to a sandboxed HOME (#3725)
* fix(#3712): confine in-process installs to a sandboxed HOME

A runtime kind may declare a global `home` override resolved from os.homedir()
rather than from the caller's configDir — codex's skills kind (`home: ".agents"`,
ADR-1239 / #2088) is the only live case. Sandboxing configDir/targetDir does not
contain it, and assertDestWithinConfigHome cannot see the class: that gate
confines a destSubpath to whatever root it is handed, and here the root IS the
escaped home. So an in-process caller that forgot to sandbox HOME wrote to, and
pruned gsd-* entries from, the developer's REAL ~/.agents/skills.

tests/agent-descriptor-parity.install.test.cjs's K1 loop did exactly that: it
iterates every agents-kind runtime (codex included) with a sandboxed targetDir
and an un-sandboxed HOME. Reproduced against a canary home on next @ adb46cdd8 —
71 gsd-* skill dirs deleted, a foreign `cloudflare` skill surviving, suite still
exit 0. It is silent because the runtime's own config home is untouched, so the
manifest keeps reporting a healthy install.

FIVE writers resolve a kind `home` and then destroy under it. Three are reachable
today — installRuntimeArtifacts, uninstallRuntimeArtifacts (install-engine.cts)
and applySurface (surface.cts). Two are descriptor-dependent and guarded against a
future descriptor change rather than a present escape: installOpencodeFamilySkills
(behind the combined-family early return) and installAgentsKindStandalone. Those
two are scoped to the single kind each destroys — passing the whole layout made
codex's unrelated skills override trip a writer that never touches it.

- src/test-home-guard.cts: refuse when a run under a test runner cannot be shown
  to have sandboxed HOME. NODE_TEST_CONTEXT (set by `node --test`) gates it, so
  installs outside a Node test context are untouched; GSD_TEST_MODE is unusable,
  as several candidate files including the offender never set it. Homes are
  compared by FILESYSTEM IDENTITY (st_dev + st_ino), not by pathname:
  path.resolve() resolves neither symlinks nor case, and realpath returns a
  canonical pathname that two routes to one directory can still disagree on (bind
  mounts). Verified on macOS/APFS — HOME=/users/<name> made the strings differ
  while naming the same directory, and the lexical form ALLOWED a write into the
  physical real home. FAILS CLOSED: a pair is "different" only when both identify,
  or one is definitively absent (ENOENT/ENOTDIR) while the other identifies; every
  other errno is "cannot tell" and refuses. Only when neither home identifies is a
  marker consulted, and it carries the sandbox PATH and must equal the home in
  effect — a boolean checked first let an ambient or stale value disarm the guard.
- helpers: promote sandboxHome() out of its two byte-identical private copies,
  which is also what makes them record the sandbox; the three withFakeHome()
  helpers record it too. The marker NAME is duplicated as a bare string rather
  than required from the compiled guard, keeping helpers.cjs's documented
  no-built-lib-at-import-time contract; a test pins the two together.
- agent-descriptor-parity: sandbox HOME across the K1 loop.
- helpers-process-isolation: #3156's canary asserts on <home>/.gsd only, and its
  `--cursor --local` spawn cannot reach `.agents` at all, so an assertion added
  there would pass with all confinement removed. Add a discriminating row — a
  `--codex --global` spawn against a seeded ambient home — which also asserts the
  runtime still declares the override. Its check is a sampled inventory (dir names
  + each SKILL.md), not a tree compare.
- install-write-confinement: predicate rows through the deps seam, covering the
  symlinked HOME, ambient and stale markers, and each sameDirectory branch
  (both-identify, one-absent, neither-identifiable), plus wiring rows that drive
  the REAL entrypoints so deleting a guard call site is red.

Verified: guard fires end-to-end against a real un-sandboxed HOME (exit 1, zero
deletions); the case-variant fail-open reproduced on APFS before the fix and
refuses after; K1 file 29/29 green with skills intact; mutation-tested — each of
the three reachable call sites, lexical-only comparison, and treating an unknown
errno as "absent" each take exactly one row red, with every mutation echoed back;
the process-isolation row negative-controlled by reverting installerEnv to its
pre-#3156 leak (16/0 -> 13/3); a full npm test leaves ~/.agents/skills at 71.

Stated residual: the two descriptor-dependent writers have no wiring test, because
no runtime declares a `home` override on those kinds and neither can be exercised
without inventing a descriptor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3712): add changeset

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3712): let a sandbox nested inside the real home through the guard

All six Windows shards of #3725 failed on legitimately sandboxed
destinations. On Windows os.tmpdir() is %LOCALAPPDATA%\Temp — inside the
user's home — so every sandbox a test creates is a descendant of the real
home, and "does this land inside the real home?" answers yes for the safe
case and the dangerous one alike. POSIX conceals this: /tmp and
/var/folders both sit outside $HOME.

Add the missing conjunct: a destination inside the real home is allowed
only when it also sits beneath a HOME that was sandboxed away from the
passwd home. Both halves are required — dropping the first re-admits a
plain un-sandboxed install, and dropping the second decays into the
"is HOME sandboxed?" check the module rejects, which a layout resolved
before the sandbox walks straight through. Each is mutation-proven by a
row that goes red without it.

Also covers the two fail-closed branches of the new exemption, which
survived mutation to `true` with the suite green, and avoids `<user>` in
a docblock — the prompt-injection scanner reads it as a delimiter tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3712): close three writer/rollback gaps found reviewing the whole PR

Cross-AI review of the full PR (not just the round's delta) surfaced
three ways the guard could still be defeated:

- The nested-sandbox exemption trusted the SPELLING of a destination.
  With HOME sandboxed to a directory inside the real home — legitimate on
  Windows — an aliased `.agents` (symlink, junction, subordinate bind
  mount) beneath it redirected an allowed path into the real home. Decide
  containment on the path the write RESOLVES to: walk up to the nearest
  existing ancestor, canonicalize, re-append the tail.

- `migrateLegacyDevPreferencesToSkill` is a SIXTH writer that resolves a
  skills-kind `home` override. It creates rather than prunes, which is
  why it was missed, and `_runLegacyInstallMigrations` runs it before
  `installRuntimeArtifacts`' own assertion. Guarded, scoped to that kind.

- Worst of the three: `bin/install.js` snapshots the resolved skills root
  before installing, and its outer catch rolls back by deleting and
  recreating every snapshotted `gsd-*` directory there. The guard's own
  throw landed in that catch, so refusing an un-sandboxed codex install
  provoked exactly the mutation the guard exists to prevent. Refusals are
  now marked and rethrown without rollback — nothing was written, so
  there is no partial install to undo. Every other error still rolls back.

Also carries the sandbox marker into `installSpawnEnv`, so spawned
installers are not refused on passwd-less CI images, and corrects three
claims that no longer hold: "every writer" (six, and named), the
unconditional "fails CLOSED" (the passwd-less marker branch is a
deliberate weakening, and TOCTOU is out of scope), and the assertion that
Windows os.tmpdir() is always %LOCALAPPDATA%\Temp (Node honors TEMP/TMP).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#3712): name the guard's two limits instead of overclaiming

Round-2 review found the prose had drifted ahead of the code. Corrected,
with no behavior change:

- The module still said FIVE writers; there are six, and the sixth is
  now named along with why it was missed (it creates rather than prunes)
  and why it carries its own assertion (it runs before the main one).
- The canonicalization docblock listed subordinate bind mounts among the
  aliases it closes. It does not close them: a bind mount is not a link,
  so realpath keeps the mount-point spelling. `sameDirectory` already
  recorded that limit; the new helper now inherits it explicitly rather
  than contradicting it. Closing it needs mount-table introspection.
- "FAILS CLOSED" was unqualified while the passwd-less marker branch is
  a deliberate weakening — with no passwd entry, nothing can contradict a
  marker naming the real home.
- "Refuses BEFORE any write" was too broad: legacy install migrations run
  ahead of the layout-driven ones, which is exactly why the two
  rollbackInstallerMigrations() calls still execute before the rethrow.
  Only the codex skills-root rollback is skipped, and that is the only
  _codexPreConfigRollback() call site — applySurface is never called from
  bin/install.js and uninstall cannot reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3712): sandbox HOME in the opencode-family home-override parity rows

The last two Windows failures, and the same platform asymmetry in a
different disguise. This row drives a skills-kind `home` override on
purpose — precisely what the guard polices — but relied on the override
temp dir happening to sit outside the real home. It does on POSIX
(/tmp, /var/folders); on Windows os.tmpdir() is under %USERPROFILE%, so
the guard correctly refused and only Windows went red.

Declare the sandbox instead of depending on the platform: HOME becomes
the override itself, which is the home the call writes under. This is the
fix the guard's own message prescribes, applied to the test rather than
to the guard.

Both failing Windows shards fail on exactly these two rows and nothing
else; every other shard is green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3712): make sameDirectory answer NO when it cannot tell

Review Major 1. sameDirectory()'s only caller is the passwd-less marker
branch, which reads a `true` as permission to PROCEED:

    if (marker && sameDirectory(marker, osMod.homedir())) return;

The fallthrough returned `true` whenever neither side identified — two
absent paths, or two stats failing EACCES/EPERM/EIO on a locked-down
host — on the reasoning that "cannot tell" should make the caller refuse.
That reasoning was inverted with respect to this caller: it turned the
passwd-less escape hatch into an unconditional bypass for any marker
value at all, on precisely the hosts the fallback exists to serve. Only
two things now answer yes: one resolved pathname, or two readable
identities that match. Restoring the old fallthrough takes the new row
red.

Also from review:

- Major 2 asked whether st_dev/st_ino discriminate directories on
  Windows, where Node derives them from BY_HANDLE_FILE_INFORMATION. The
  whole guard rests on that primitive, so assert it rather than argue it:
  a row comparing two distinct temp directories, and one directory
  reached by two spellings. It runs on every platform in the matrix, so
  Windows answers the question itself.

- Minor 1: the refusal now names the real home it compared against, not
  just the destination it refused. That is the one fact needed to tell a
  true positive from a false one, and its absence is what made the
  Windows case a CI-log dig rather than a glance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3712): refuse a HOME that merely spells the real home more widely

Review round: one Blocker, four Minors, a Nit.

N2 (the one with teeth) — a destination's ancestor chain is linear, so
"inside the real home AND inside the effective HOME" admits two
arrangements, not one. The intended `effectiveHome ⊂ realHome` is the
Windows temp shape; `realHome ⊂ effectiveHome` — HOME at /Users, /home,
C:\Users — is not a sandbox at all, it is the real home reached by a
wider spelling, and it was exempting a stale destination pointing
straight at ~/.agents. Third conjunct added; the docblock no longer
claims two conditions suffice. Removing the conjunct reds the new row
and nothing else.

N4 — the migration guard resolved its OWN layout, and without
capabilityRegistry, so a registry-dependent descriptor could make it
vouch for a path the migration does not write: a guard reporting safe
while the unsafe write proceeds. It now guards the destination already
resolved by _resolveDevPreferencesSkillTarget, keyed on
`installRoot !== targetDir` — which is exactly the condition under which
a `home` override was declared, read off that same result.

N1 — CONTEXT.md gains the Test Home Guard Module glossary entry that
contributor-standards.md requires of a new Module. Not CI-enforced, so
green CI was never evidence it was met.

N3 — the docs/INVENTORY.md row was misfiled between install-fs-adapter
and install-model-override-resolver; the table is alphabetical and the
manifest already had it right. Moved, and its text now names six writers
and the third conjunct.

N5 — applySurface's signature docblock was two parameters stale; this PR
added the second of them.

N6 — the duplicated rollbackInstallerMigrations() adjacent to the new
rethrow: two identical consecutive calls, not two phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3712): guard the sixth writer, and close two false-ALLOW paths

Review round 3 (NEW-1, NEW-2) plus three defects Codex found in the
whole-PR pass, each reproduced before it was fixed.

NEW-1 — migrateLegacyDevPreferencesToSkill called the guard with no
`deps`, so it bound real os/process.env and could not be wiring-tested
the way the other three reachable writers were. It now takes the same
optional `deps: { os?, env? }` tail parameter. The wiring block gains
the missing fourth row, and a fifth pinning the ALLOW half; the test
file's header docblock said "FIVE writers ... the three reachable
today", contradicting the six/four statement this PR already put in
src/test-home-guard.cts, CONTEXT.md, docs/INVENTORY.md and the
changeset. Both directions of the guard's condition now fail a row
when broken — previously neither did.

NEW-2 — derivesFromSandboxedHome's docblock claimed "THREE conditions
are required, and no two of them suffice". False for {2,3}: isInside is
reflexive, so whenever conjunct 1 fires conjunct 2 already returns
false on its own. Reworded as a fast path, which is what it is.

Codex 1 (false ALLOW) — on a host with no readable passwd entry the
marker branch returned as soon as the marker matched the effective
HOME. That attests a caller sandboxed HOME and says nothing about where
an already-resolved destination points, so a layout captured before
sandboxHome() — still naming the real ~/.agents — was waved straight
through: the same stale-layout shape the primary branch refuses by
design. The marker must now identify AND contain every destination.

Codex 2 (false ALLOW) — `installRoot !== targetDir` was the stand-in
for "the skills kind declared a home override". The two are not
equivalent: the inequality is false when the override resolves onto
targetDir itself, which is exactly a configDir of $HOME/.agents. The
guard was skipped and SKILL.md written into the real home under a test
runner. _resolveDevPreferencesSkillTarget now reports hasHomeOverride
off the same resolution instead of inferring it from two paths.

Codex 3 (prose) — the shared refusal message claimed every guarded
writer prunes; the migrate writer only creates. The changeset headline
claimed in-process installer calls can no longer reach the real home,
which is wider than the guard: writeNonClaudeDefaults still writes
~/.gsd/defaults.json through os.homedir(). INVENTORY's and CONTEXT's
fail-closed sentences omitted sameDirectory's pathname-equality
shortcut. All four narrowed to what the code does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3712): let the sandbox marker follow an overridden HOME

Found by Codex in the whole-PR pass. installSpawnEnv spreads
`overrides` last so an explicit HOME wins — deliberate, and its
docblock tells callers needing per-spawn isolation to pass their own
{ HOME, USERPROFILE }. But the #3712 marker was set before that spread,
so such a caller got HOME=<theirs> and marker=<helper default>. On a
host with no readable passwd entry the guard compares the two and
refuses a legitimately sandboxed spawn — tests/install.test.cjs:7143
and install-shared.cjs's own runInstaller both take that path.

The marker is now derived from the final HOME unless the caller
supplied one explicitly. The contract test asserted HOME after an
override but not the marker, which is why it stayed green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#3712): name the shipped guard condition, not the deleted one

Review round 4 of #3725. Two artifacts this PR adds still described
`target.installRoot !== targetDir` in the PRESENT tense as the live guard
condition on `migrateLegacyDevPreferencesToSkill`. The shipped condition is
`runtime && target.hasHomeOverride` (src/install-engine.cts:510).

This is not ordinary doc drift. The named condition is the exact false-ALLOW
the previous round closed: a `home` override resolving onto `targetDir` — a
configDir of `$HOME/.agents`, which is where codex's override points — makes
the inequality FALSE while the override is declared, so the guard was skipped.
A maintainer reading CONTEXT.md:290 as authoritative would believe the guard
still skips that case.

  - tests/install-write-confinement.test.cjs — the ALLOW-half row's comment.
    Its "teeth" rationale is unchanged and still correct as written.
  - CONTEXT.md:290 — the Test Home Guard Module glossary entry, a documented
    PR gate. Now states the condition and names the inequality only as what it
    is NOT, with the reason.

The three surviving mentions of the inequality are all past-tense or negated
(src/install-engine.cts:448, :508 and the sibling test comment at :3698) and
are correct as they stand.

Verified: `npm run lint:ci` exit 0; full `npm test` 31327 tests / 31312 pass /
0 fail / 14 skipped, run with TMPDIR unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3712): canonicalization fails closed, matching identify's errno split

Codex full-PR review of #3725, run against the round-4 head.

`resolveThroughLinks` caught EVERY realpathSync error and fell back to
`path.resolve(dest)` — the lexical spelling. That inverts the function's own
purpose. An aliased `<sandbox>/.agents` that cannot be canonicalized keeps its
sandbox spelling, satisfies the nested-sandbox exemption at :227, and the write
is ALLOWED into the real home — the exact escape this walk exists to close. The
module documents that it fails CLOSED with ONE named exception (the marker
branch); this was a second, unnamed one.

Split by errno, and deliberately by the SAME split `identify` already draws
rather than a second policy in one module — both answer "does this path exist
as named?", so they must not disagree:

  ENOENT / ENOTDIR -> walk up. The ordinary case: a fresh install resolves a
    destination nothing has created yet, so realpath fails on the leaf and on
    every not-yet-created ancestor. Refusing here rejects every install.
  anything else (EACCES, EPERM, ELOOP, EIO) -> refuse. The component exists but
    cannot be resolved, so the guard cannot tell where the write lands.

Three rows in the predicate block, beside the other aliasing rows:
  - a symlink CYCLE in the destination path (ELOOP)   -> REFUSE
  - a destination that does not exist yet (ENOENT)    -> ALLOW
  - a component behind a regular file (ENOTDIR)       -> ALLOW

Teeth checked against the artifact the test loads, not the source: reverting
the condition to the swallow-everything shape in the compiled
test-home-guard.cjs turns row 1 — and only row 1 — red. The ENOTDIR row caught
a stale build during development, which is the point of asserting on the
compiled file.

CONTEXT.md and the resolveThroughLinks docblock both record the new behaviour,
so this does not repeat the prose-vs-code drift the round-4 finding was about.

The changeset's existing scope sentence now bounds "six writers" to the
`installRuntimeArtifacts` call tree and names `cmdGenerateDevPreferences` —
which resolves the same codex `home` override through `getGlobalSkillsBase` and
writes SKILL.md beneath it unguarded. It has no in-process caller today (its
only direct require-and-call is a spawnSync with HOME sandboxed), so it is
latent rather than live, and whether it belongs in this PR is raised with the
maintainer rather than decided here.

Verified: `npm run lint:ci` exit 0; full `npm test` 31330 tests / 31315 pass /
0 fail / 14 skipped, TMPDIR unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:27:02 -04:00

497 lines
24 KiB
JavaScript

const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const {
withIsolatedProcessState,
TEST_ENV_BASE,
CONFIG_LOCATION_ENV_KEYS,
scrubConfigLocationEnv,
} = require('./helpers.cjs');
describe('#2665: the built-lib require is deferred', () => {
// The scrub set derives from gsd-core/bin/lib, which is BUILT. Requiring it at
// module scope made an unbuilt tree throw inside `require('./helpers.cjs')` —
// before any test() registered — so one missing `npm run build:lib` became a
// whole-suite crash in the file ~370 test files import. A cold child is the only
// honest probe: this process has already loaded everything.
const probe = (touch) => {
const src = [
"const path = require('node:path');",
`require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});`,
touch,
"const needle = path.join('gsd-core', 'bin', 'lib', 'capability-registry.cjs');",
'process.stdout.write(String(Object.keys(require.cache).some((m) => m.endsWith(needle))));',
].join('\n');
// Bounded per local/no-unbounded-spawn (#3143): a cold require is sub-second,
// so 30s is ~30x headroom and still fails loudly instead of hanging a lane.
const r = spawnSync(process.execPath, ['-e', src], { encoding: 'utf8', timeout: 30_000 });
assert.strictEqual(r.status, 0, `probe failed: ${r.stderr}`);
return r.stdout === 'true';
};
test('requiring helpers.cjs alone does NOT load the built runtime lib', () => {
assert.strictEqual(probe(''), false, 'the built lib was loaded at import time');
});
test('reading TEST_ENV_BASE is what loads it', () => {
const touch = `require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))}).TEST_ENV_BASE;`;
assert.strictEqual(probe(touch), true, 'reading the scrub set must resolve the built lib');
});
});
describe('withIsolatedProcessState', () => {
test('restores env, cwd, and exitCode after callback', () => {
const originalCwd = process.cwd();
const originalExitCode = process.exitCode;
const originalMarker = process.env.GSD_TEST_ISOLATION_MARKER;
const tempCwd = path.dirname(originalCwd);
withIsolatedProcessState(() => {
process.env.GSD_TEST_ISOLATION_MARKER = 'changed';
process.exitCode = 73;
process.chdir(tempCwd);
});
assert.strictEqual(process.cwd(), originalCwd);
assert.strictEqual(process.exitCode, originalExitCode);
assert.strictEqual(process.env.GSD_TEST_ISOLATION_MARKER, originalMarker);
});
test('restores state even when callback throws', () => {
const originalCwd = process.cwd();
const originalPath = process.env.PATH;
assert.throws(() => {
withIsolatedProcessState(() => {
process.env.PATH = '';
process.chdir(path.dirname(originalCwd));
throw new Error('boom');
});
}, /boom/);
assert.strictEqual(process.cwd(), originalCwd);
assert.strictEqual(process.env.PATH, originalPath);
});
});
// ─── #2665: the config-location scrub is DERIVED, and stays that way ──────────
//
// The recurrence guard. #2665 documents two prior authors independently
// diagnosing this class and each fixing only the instance in front of them;
// this is the third pass. A hand-maintained scrub list cannot be defended by
// review alone, so the invariant is asserted instead of trusted.
//
// SCOPE BOUNDARY — read this before trusting a green run here.
//
// Every test below asserts that TEST_ENV_BASE covers some ENUMERATION (the
// capability registry, the non-registry descriptor set, GSD's own location
// keys). Each therefore proves only that the scrub set is not narrower than the
// enumeration it derives from. NONE of them can prove the enumeration is itself
// complete: a config-location var that no enumeration carries is invisible to
// all of them, and they stay green.
//
// That is not hypothetical — it is how round 2 found GSD_HOME and
// KIMI_SHARE_DIR while this block was fully green. GSD_HOME belonged to no
// enumeration at all (it is GSD's own store root, not a runtime configHome);
// KIMI_SHARE_DIR sat inside a function body where nothing could enumerate it.
// Round 3's fix was to make both enumerable rather than to add two assertions,
// precisely because an assertion added per reviewer-named var is the
// hand-maintained list wearing a test's clothes.
//
// The completeness question — "is every env-first first-party location var in
// SOME enumeration?" — is answered by a source census re-derived each round
// (see the PR discussion), and by scripts/live-config-guard.cjs at runtime,
// which observes actual writes rather than reasoning about names. Neither lives
// here, and this block should not be read as standing in for them.
describe('#2665: TEST_ENV_BASE config-location coverage', () => {
test('every runtime configHome env var in the registry is scrubbed', () => {
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
const declared = [
...new Set(
Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
),
].sort();
// Guards the guard: an empty/renamed registry shape would make the
// assertion below vacuously true and silently retire this test.
assert.ok(
declared.length >= 15,
`expected the registry to declare many configHome env vars, got ${declared.length} — ` +
'if the registry shape changed, this derivation needs updating, not deleting',
);
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
assert.deepStrictEqual(
missing,
[],
`config-location env vars reachable by the resolver but not scrubbed: ${missing.join(', ')}. ` +
'TEST_ENV_BASE derives this set from the capability registry — a gap here means the ' +
'derivation broke, not that the list needs a manual entry.',
);
});
test('every scrubbed config-location var is blanked, not merely present', () => {
for (const key of CONFIG_LOCATION_ENV_KEYS) {
assert.strictEqual(
TEST_ENV_BASE[key],
'',
`${key} must be blanked ('') so the child sees a falsy value on the env-first branch`,
);
}
});
test('the non-registry config-location vars are covered too', () => {
// These have no capability descriptor, so the registry derivation alone
// cannot reach them: GROK_AGENTS_HOME is a hardcoded branch of
// getGlobalConfigDir, GSD_RUNTIME selects which runtime home resolves, and
// GSD_PROJECT / GSD_WORKSTREAM move a child's .planning root
// (src/planning-workspace.cts). Named explicitly so deleting one from the
// helper is a test failure rather than a silent narrowing.
for (const key of ['GROK_AGENTS_HOME', 'GSD_RUNTIME', 'GSD_PROJECT', 'GSD_WORKSTREAM']) {
assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`);
}
});
test('descriptor-shaped config homes OUTSIDE the registry are derived, not listed', () => {
const {
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
// Round 3. kimi owns TWO config homes: KIMI_CONFIG_DIR (registry-visible) and
// KIMI_SHARE_DIR (a hardcoded descriptor inside resolveKimiHooksTomlDir, which
// decides where its native config.toml — carrying GSD's [[hooks]] block — is
// written). The registry-only derivation reached the first and not the second,
// so it looked structurally complete while missing a live write surface.
const declared = [
...new Set(NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => d?.env ?? [])),
];
assert.ok(
declared.length >= 1,
'expected at least one non-registry descriptor — an empty array makes this vacuous',
);
assert.ok(
declared.includes('KIMI_SHARE_DIR'),
`KIMI_SHARE_DIR must come from the descriptor set, got ${declared.join(', ')}`,
);
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
assert.deepStrictEqual(
missing,
[],
`descriptor-declared config-location vars not scrubbed: ${missing.join(', ')}`,
);
});
test('skillsHome env vars are walked on BOTH descriptor rungs', () => {
// Round 4. A configHome descriptor can nest a second, independently-resolved
// descriptor (skillsHome → resolveSkillsBaseFromDescriptor), which carries
// its own env array. Walking configHome.env alone is the identical
// walk-one-field gap-shape rounds 2-3 closed for the registry and the
// non-registry set. Inert today — only kilo declares skillsHome, with
// env: [] — so this asserts the DERIVATION reaches the field, not that any
// var currently flows from it: every skillsHome-declared var (registry and
// non-registry alike) must land in TEST_ENV_BASE the moment one exists.
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
const {
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
const declared = [
...new Set([
...Object.values(runtimes).flatMap(
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
),
...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap(
(d) => d?.skillsHome?.env ?? [],
),
]),
];
// Anti-vacuity: at least one runtime must actually DECLARE skillsHome, or a
// registry reshape could rename the field and retire this test silently.
const declaringRuntimes = Object.values(runtimes).filter(
(r) => r?.runtime?.configHome?.skillsHome !== undefined,
);
assert.ok(
declaringRuntimes.length >= 1,
'expected at least one registry runtime to declare configHome.skillsHome — ' +
'if the field moved, this derivation needs updating, not deleting',
);
const missing = declared.filter((k) => !(k in TEST_ENV_BASE));
assert.deepStrictEqual(
missing,
[],
`skillsHome-declared config-location vars not scrubbed: ${missing.join(', ')}`,
);
});
test("GSD's OWN location vars are scrubbed (a second family, not a registry gap)", () => {
const { GSD_LOCATION_ENV_KEYS } = require('../gsd-core/bin/lib/runtime-homes.cjs');
// GSD_HOME decides where GSD keeps user-owned state ($GSD_HOME/.gsd/ —
// consent.json, defaults.json, capability overlays) and is read env-FIRST,
// ahead of os.homedir(), across capability-loader / capability-consent /
// capability-state / capability-writer / config-loader / install-profiles /
// bin/install.js. GSD_AGENTS_DIR is priority 1 in getAgentsDir. Neither is a
// runtime configHome, so no amount of registry derivation reaches them.
assert.ok(GSD_LOCATION_ENV_KEYS.includes('GSD_HOME'));
for (const key of GSD_LOCATION_ENV_KEYS) {
assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`);
}
});
test('write-escape PERMISSIONS are scrubbed (a fifth family — not a location var)', () => {
// #2665 round 5. GSD_ALLOW_SYMLINKED_DEST names no path, so every rung of the
// derivation above is structurally incapable of reaching it — it is not a
// registry configHome, not descriptor-shaped, not one of GSD's own location
// vars. It is still a #2665 leak vector: install-engine.cts reads it env-first
// and threads it into the symlink-escape guard that stops a write leaving the
// install root, so an ambient `=1` disarms that guard for the whole suite.
//
// Named literally rather than derived from the family constant on purpose: a
// test that reads WRITE_ESCAPE_PERMISSION_ENV_KEYS and asserts over it shrinks
// its own expectation when the family is emptied — the enumeration-relative
// failure this suite already documents, and the one that let the kimi-code
// descriptor go unwatched. Naming it is what makes removal fail loudly.
assert.strictEqual(
TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST,
'',
'GSD_ALLOW_SYMLINKED_DEST must be blanked: ambient =1 disarms the symlink-escape guard',
);
// Blanking must be fail-SAFE — '' is neither '1' nor 'true', so the guard gets
// stricter, never looser. This is what licenses scrubbing it wholesale.
assert.ok(!['1', 'true'].includes(TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST));
});
test('scrubConfigLocationEnv clears and restores the parent process env', () => {
// The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches
// children, so a test calling install() in-process needs the PARENT's env
// cleared. Round-trip both states — set and unset — because restoring an
// originally-unset var as '' rather than deleting it is itself a leak.
withIsolatedProcessState(() => {
process.env.CLAUDE_CONFIG_DIR = '/tmp/ambient-claude';
delete process.env.CODEX_HOME;
const restore = scrubConfigLocationEnv();
assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, undefined,
'a set config-location var must be deleted, not blanked, on the parent');
assert.strictEqual(process.env.CODEX_HOME, undefined);
restore();
assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, '/tmp/ambient-claude',
'restore must put back the original value');
assert.ok(!('CODEX_HOME' in process.env),
'restore must leave an originally-unset var unset, not set it to empty string');
});
});
});
describe('#2665 round 4: the skillsHome walk is reversion-sensitive', () => {
// The coverage tests above are enumeration-relative, and skillsHome.env is
// empty everywhere today — so reverting the skillsHome rungs from the
// derivation leaves every one of them green (measured by this round's
// pre-push adversarial review). This test closes that: it cold-requires
// helpers.cjs in a child process after injecting sentinel skillsHome env
// vars into BOTH enumerations (registry and non-registry), so the walk
// itself is what is under test, not today's empty declarations.
test('sentinel skillsHome vars flow into TEST_ENV_BASE on both rungs', () => {
const { execFileSync } = require('node:child_process');
const regPath = require.resolve('../gsd-core/bin/lib/capability-registry.cjs');
const rhPath = require.resolve('../gsd-core/bin/lib/runtime-homes.cjs');
const helpersPath = require.resolve('./helpers.cjs');
const script = `
'use strict';
const reg = require(${JSON.stringify(regPath)});
const rh = require(${JSON.stringify(rhPath)});
// Rung 1 (registry): give one runtime a skillsHome env var. kilo already
// declares skillsHome (env: []); push a sentinel into whichever runtime
// declares it, or graft one onto the first runtime if none does.
const declaring = Object.values(reg.runtimes).find(
(r) => r?.runtime?.configHome?.skillsHome,
) ?? Object.values(reg.runtimes)[0];
if (!declaring.runtime.configHome.skillsHome) {
declaring.runtime.configHome.skillsHome = { kind: 'dot-home', name: '.x', env: [] };
}
declaring.runtime.configHome.skillsHome.env = ['GSD_TEST_SENTINEL_REGISTRY_SKILLS'];
// Rung 2 (non-registry): graft a skillsHome onto the first descriptor.
rh.NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[0].skillsHome = {
kind: 'dot-home', name: '.x', env: ['GSD_TEST_SENTINEL_NONREG_SKILLS'],
};
const { TEST_ENV_BASE } = require(${JSON.stringify(helpersPath)});
const missing = [
'GSD_TEST_SENTINEL_REGISTRY_SKILLS',
'GSD_TEST_SENTINEL_NONREG_SKILLS',
].filter((k) => TEST_ENV_BASE[k] !== '');
if (missing.length > 0) {
console.error('skillsHome walk missed: ' + missing.join(', '));
process.exit(1);
}
process.exit(0);
`;
const out = execFileSync(process.execPath, ['-e', script], {
cwd: __dirname,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30_000,
});
void out; // exit 0 is the assertion; execFileSync throws on nonzero
});
});
describe('#3156: a raw installer spawn cannot write into the ambient HOME', () => {
const fs = require('node:fs');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { installSpawnEnv, cleanup } = require('./helpers.cjs');
const { installerEnv } = require('./helpers/install-shared.cjs');
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
// Contract half — cheap, and it names the precedence the callers depend on.
test('the sandbox HOME replaces the ambient one, but an explicit override still wins', () => {
for (const build of [installSpawnEnv, installerEnv]) {
const env = build();
assert.notStrictEqual(env.HOME, process.env.HOME,
'a raw installer spawn must not inherit the ambient HOME');
assert.strictEqual(env.USERPROFILE, env.HOME,
'USERPROFILE must track HOME — os.homedir() reads it on Windows');
assert.strictEqual(build({ HOME: '/explicit', USERPROFILE: '/explicit' }).HOME, '/explicit',
'an explicit HOME override must still win (overrides spread last)');
// #3712 / Codex review of #3725: the marker attests to the home ACTUALLY in
// effect. Spreading `overrides` last used to leave it naming this helper's
// default home while HOME was the caller's, and on a passwd-less host the
// test-home guard compares the two and refuses a legitimately sandboxed
// spawn. Asserting HOME alone did not catch it — the marker has to move too.
assert.strictEqual(build().GSD_TEST_HOME_SANDBOX, build().HOME,
'the marker must name the default sandbox home');
const overridden = build({ HOME: '/explicit', USERPROFILE: '/explicit' });
assert.strictEqual(overridden.GSD_TEST_HOME_SANDBOX, '/explicit',
'the marker must follow an overridden HOME, not keep naming the default one');
assert.strictEqual(
build({ HOME: '/explicit', GSD_TEST_HOME_SANDBOX: '/caller-chosen' }).GSD_TEST_HOME_SANDBOX,
'/caller-chosen',
'an explicitly supplied marker still wins over the derived one');
}
});
// Behavioural half — the one that actually fails pre-fix.
//
// bin/install.js writeNonClaudeDefaults() (#2834) writes
// <os.homedir()>/.gsd/defaults.json for every NON-Claude runtime, reading no
// GSD variable at all. So this is deliberately driven through the real
// installer against a real ambient HOME: no assertion about the scrub set can
// stand in for it, because no scrub set can reach os.homedir().
//
// Negative control: revert installerEnv() to `{ ...process.env, ...overrides }`
// and the canary gains .gsd/defaults.json.
test('installing a non-Claude runtime leaves the ambient HOME untouched', () => {
const canaryHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-canary-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-project-'));
const realHome = process.env.HOME;
const realUserProfile = process.env.USERPROFILE;
try {
// Make the AMBIENT home the canary — the vector is the parent process's
// own HOME, exactly as on a developer machine or a CI runner.
process.env.HOME = canaryHome;
process.env.USERPROFILE = canaryHome;
execFileSync(process.execPath, [INSTALL_PATH, '--cursor', '--local', '--no-sdk'], {
cwd: projectDir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: installerEnv(),
timeout: 120_000,
});
assert.ok(!fs.existsSync(path.join(canaryHome, '.gsd')),
`the installer wrote GSD's user store into the ambient HOME: ${
fs.existsSync(path.join(canaryHome, '.gsd'))
? fs.readdirSync(path.join(canaryHome, '.gsd')).join(', ')
: ''
}`);
} finally {
if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome;
if (realUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = realUserProfile;
cleanup(canaryHome);
cleanup(projectDir);
}
});
});
describe('#3712: a raw installer spawn cannot reach the ambient HOME\'s shared skills root', () => {
const fs = require('node:fs');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { installerEnv } = require('./helpers/install-shared.cjs');
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
// #3156's canary asserts on <canaryHome>/.gsd. That is not the only ambient-HOME
// surface: a kind may declare a global `home` override resolved from os.homedir()
// — codex's skills kind (`.agents`) — and the installer PRUNES gsd-* entries under
// it. A 71-skill deletion there passed the .gsd-only canary unnoticed (#3712).
//
// The runtime choice is load-bearing, not incidental. The .gsd row spawns
// `--cursor --local`, and cursor declares no home override, so an `.agents`
// assertion on THAT spawn passes even with all confinement removed. Only a
// runtime that actually carries the override can discriminate — today that is
// codex at global scope, asserted below so a descriptor change fails here loudly.
test('installing codex globally leaves the ambient HOME\'s .agents/skills sampled inventory unchanged', () => {
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
const codexGlobalSkills = (runtimes?.codex?.runtime?.artifactLayout?.global ?? [])
.find((e) => e.kind === 'skills' && e.home);
assert.ok(codexGlobalSkills,
'codex global skills must still declare a `home` override — without it this row '
+ 'is non-discriminating and must be re-pointed at whichever runtime carries one');
const canaryHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3712-canary-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3712-project-'));
const realHome = process.env.HOME;
const realUserProfile = process.env.USERPROFILE;
const skillsRoot = path.join(canaryHome, '.agents', 'skills');
fs.mkdirSync(skillsRoot, { recursive: true });
for (const name of ['gsd-plan-phase', 'gsd-dev-preferences', 'cloudflare']) {
fs.mkdirSync(path.join(skillsRoot, name), { recursive: true });
fs.writeFileSync(path.join(skillsRoot, name, 'SKILL.md'), `# ${name}\n`);
}
// A SAMPLE, not a byte-for-byte tree compare: immediate dir names plus each
// dir's SKILL.md. It catches the demonstrated regression (whole gsd-* dirs
// pruned) and would miss nested-file, mode, or metadata mutations.
const inventory = () => fs.readdirSync(skillsRoot).sort()
.map((d) => `${d}:${fs.readFileSync(path.join(skillsRoot, d, 'SKILL.md'), 'utf-8')}`)
.join('|');
const before = inventory();
try {
// Make the AMBIENT home the canary, exactly as on a developer machine.
process.env.HOME = canaryHome;
process.env.USERPROFILE = canaryHome;
execFileSync(process.execPath, [INSTALL_PATH, '--codex', '--global', '--no-sdk'], {
cwd: projectDir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: installerEnv(),
timeout: 300_000,
});
assert.strictEqual(inventory(), before,
'the installer pruned or wrote GSD skills in the ambient HOME instead of its own sandbox');
} finally {
if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome;
if (realUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = realUserProfile;
cleanup(canaryHome);
cleanup(projectDir);
}
});
});