Merge pull request #2677 from 0xdhx/fix/2665-test-env-base-config-location-vars

fix(#3156): derive the config-location scrub set and close the leaks it cannot reach
This commit is contained in:
Tom Boucher
2026-08-08 11:25:44 -04:00
committed by GitHub
31 changed files with 3072 additions and 599 deletions

View File

@@ -0,0 +1,7 @@
---
type: Added
pr: 2677
---
**`runtime-homes` now exports its non-registry config-home descriptors** — `KIMI_HOOKS_TOML_DESCRIPTOR`, `NON_REGISTRY_CONFIG_HOME_DESCRIPTORS`, `GSD_LOCATION_ENV_KEYS`, and the `ConfigHomeDescriptor` type are public, so consumers that need the *set* of config-location env vars (rather than a single resolved path) can derive it instead of hand-maintaining a copy. `resolveKimiHooksTomlDir()` behaviour is unchanged; its descriptor is simply named rather than inline (#3156).
**The test-instrumentation scripts no longer ship in the npm package** — `scripts/run-tests.cjs`, `scripts/live-config-guard.cjs`, `scripts/affected-tests-lib.cjs`, and `scripts/run-affected-tests.cjs` are now excluded from the tarball (they are one closed require chain of repo-only test tooling). `npm test` in an installed package was already inoperable (`tests/` has never shipped); a deep import of `scripts/run-tests.cjs` from the published package — an unsupported surface — will now be `MODULE_NOT_FOUND` (#3156).

View File

@@ -140,6 +140,11 @@ jobs:
timeout-minutes: 15
env:
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
# #2665: a live-config leak fails the run on Linux/macOS lanes. Windows
# stays report-only: the guard's first run found PRE-EXISTING USERPROFILE
# leaks there (~190 test sites sandbox HOME alone), a documented separate
# class — promote once that sweep lands (see live-config-guard.cjs SEVERITY).
GSD_STRICT_LIVE_CONFIG_GUARD: ${{ matrix.os != 'windows-latest' && '1' || '' }}
# #2854: pin the emitted gate's baseline to the SAME commit the tree was merged
# with. "Rebase check" merges `pull_request.base.sha` (pinned by #2472 so all 12
# matrix jobs agree on one tree), but `resolveBase()` otherwise falls through to
@@ -333,6 +338,8 @@ jobs:
timeout-minutes: 15
env:
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
# #2665: ubuntu-only lane — strict unconditionally (see the `test` job note).
GSD_STRICT_LIVE_CONFIG_GUARD: '1'
# #2854: same pin as the other rebase-merged lanes. This lane runs a targeted
# list that can include the emitted gate, and the invariant is easier to keep
# with no exceptions: if a job merges a pinned base, the gate uses that base.
@@ -410,6 +417,8 @@ jobs:
timeout-minutes: 30
env:
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
# #2665: strict on Linux/macOS, report-only on Windows (see the `test` job note).
GSD_STRICT_LIVE_CONFIG_GUARD: ${{ matrix.os != 'windows-latest' && '1' || '' }}
# #2854: pin the emitted gate's baseline to the SAME commit the tree was merged
# with. "Rebase check" merges `pull_request.base.sha` (pinned by #2472 so all 12
# matrix jobs agree on one tree), but `resolveBase()` otherwise falls through to
@@ -610,6 +619,10 @@ jobs:
timeout-minutes: 15
env:
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
# `npm run test:qa` is `run-tests.cjs --suite qa`, so this lane runs the
# live-config guard like every other suite lane. ubuntu-only, so strict
# unconditionally — the Windows carve-out does not apply here.
GSD_STRICT_LIVE_CONFIG_GUARD: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:

View File

@@ -588,6 +588,16 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation`
`WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream`
`CONFIG.SEAM.loadConfig-context=loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites`
`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal`
`CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2`
`CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second`
`CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST`
`LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite`
`LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled`
`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates <root>/hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot`
`LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing`
`LIVE-CONFIG.GUARD.SEAM.severity=reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1`
`LIVE-CONFIG.GUARD.SEAM.ci-blind=the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes`
---

View File

@@ -1,14 +1,15 @@
{
"schemaVersion": 1,
"count": 416,
"count": 426,
"classes": {
"ARCH": 1,
"CI": 2,
"CONFIG": 1,
"CONFIG": 5,
"DEFECT": 168,
"EXEC": 8,
"GSD-RESEARCH": 6,
"LEARNING": 1,
"LIVE-CONFIG": 6,
"META": 4,
"PLANNING": 3,
"PR": 2,
@@ -39,6 +40,26 @@
"klass": "CI",
"value": "hard-fail if PR body lacks closes/fixes/resolves #<issue>"
},
{
"id": "CONFIG.LOCATION.SEAM.in-process-scrub",
"klass": "CONFIG",
"value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST"
},
{
"id": "CONFIG.LOCATION.SEAM.kimi-two-homes",
"klass": "CONFIG",
"value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second"
},
{
"id": "CONFIG.LOCATION.SEAM.scrub-set",
"klass": "CONFIG",
"value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal"
},
{
"id": "CONFIG.LOCATION.SEAM.two-families",
"klass": "CONFIG",
"value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2"
},
{
"id": "CONFIG.SEAM.loadConfig-context",
"klass": "CONFIG",
@@ -959,6 +980,36 @@
"klass": "LEARNING",
"value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures"
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.ci-blind",
"klass": "LIVE-CONFIG",
"value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes"
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.module",
"klass": "LIVE-CONFIG",
"value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite"
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets",
"klass": "LIVE-CONFIG",
"value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates <root>/hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot"
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.scope",
"klass": "LIVE-CONFIG",
"value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled"
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.severity",
"klass": "LIVE-CONFIG",
"value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1"
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.truncation",
"klass": "LIVE-CONFIG",
"value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing"
},
{
"id": "META.RULE.brief-must-cite-doc",
"klass": "META",

View File

@@ -219,6 +219,47 @@ disagree, trust (and fix) the rule table.
Unknown suites exit non-zero with the list of valid suites. Empty suites (e.g. `--suite security` before any security-tagged file exists) exit `0` with a `no tests in suite "..."` notice on stderr so CI lanes don't go red while a suite is being populated.
## The live-config hermeticity guard
Every `run-tests.cjs` invocation snapshots GSD's own install footprint in each
live runtime config directory before the suite and re-checks it afterwards. It
exists because the failure it catches is silent by construction: a test that
resolves a config directory from the ambient environment instead of a sandbox
writes into *your real* `~/.claude` (or `$GSD_HOME/.gsd`, or a Kimi
`config.toml`), and nothing reports it. CI cannot catch this class at all —
CI never has `CLAUDE_CONFIG_DIR` and friends set.
The guard watches only what GSD unambiguously owns — its top-level install
footprint plus `gsd-`-prefixed children of directories shared with the host
agent — never whole config roots, because a host agent legitimately writing
`history.jsonl` mid-run would make the guard cry wolf, and a guard that cries
wolf gets switched off.
Two environment variables control it:
| Variable | Effect |
|---|---|
| `GSD_STRICT_LIVE_CONFIG_GUARD=1` | A detected write **fails the run**. Set on the Linux/macOS lanes of every CI job that runs the suite. |
| `GSD_SKIP_LIVE_CONFIG_GUARD=1` | Skips the check entirely. |
Unset, the guard **reports and does not fail** — deliberately, not timidly. On
its first CI run it surfaced pre-existing leaks on the Windows lane, where
`os.homedir()` reads `USERPROFILE` and ~190 test sites sandbox `HOME` alone.
Those are real and worth fixing, but they are a different defect class, and a
brand-new gate that instantly reds an unrelated lane gets reverted rather than
obeyed. Windows lanes therefore stay report-only until that sweep lands; this
repo has the pattern already, in the `local/no-source-grep` ESLint rule that
shipped at `warn` and was promoted to `error` after its cleanup (ADR 452).
`GSD_SKIP_LIVE_CONFIG_GUARD` is a bypass on a safety check, so it is documented
here rather than left to be discovered in the source: an undocumented bypass is
one people eventually set without knowing what they turned off. If you need it
routinely, that is a bug report, not a workflow.
Reported paths are labelled `CREATED`, `MODIFIED`, `DELETED`, or `UNVERIFIED`.
`UNVERIFIED` means a scan bound was hit and the path could not be attested
either way — it is never the same as clean.
## CI matrix
The `Tests` workflow runs every PR through a scoped gate generated by

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,10 @@
"scripts",
"!scripts/gen-emitted-baseline.cjs",
"!scripts/qa-smell-ratchet.cjs",
"!scripts/live-config-guard.cjs",
"!scripts/run-tests.cjs",
"!scripts/affected-tests-lib.cjs",
"!scripts/run-affected-tests.cjs",
"pi",
"vscode"
],

View File

@@ -0,0 +1,640 @@
#!/usr/bin/env node
'use strict';
/**
* #2665 — post-suite hermeticity guard.
*
* The test suite must never write into a runtime's LIVE config directory. Two
* mechanisms defend that, and neither one can see this failure:
*
* - `TEST_ENV_BASE` (tests/helpers.cjs) blanks every config-location env var,
* but only for CHILD processes. A test that calls the installer IN-PROCESS
* is untouched by it.
* - CI is blind to the ambient-env half of the class outright, because CI
* never has `CLAUDE_CONFIG_DIR` and friends set.
*
* So the class is silent by construction: it damages the developer's machine and
* reports nothing. #2665 records two prior authors each diagnosing it and fixing
* only the instance in front of them. This module converts it from silent to
* loud by snapshotting GSD's own install footprint before the suite and
* re-checking it after.
*
* LOCATION — `scripts/`, deliberately NOT `scripts/lib/`. The installer copies
* `scripts/lib/` into every user's config dir wholesale (readdirSync), while
* uninstall removes only an explicit allowlist, so a test-only module placed
* there would ship to users AND survive uninstall. This file is also excluded
* from the npm tarball (`package.json` `files[]` `!scripts/live-config-guard.cjs`,
* alongside its whole require chain: run-tests.cjs, affected-tests-lib.cjs,
* run-affected-tests.cjs — excluding one link alone would trip the #2858
* shipped-requires-only-shipped gate on the links that still shipped).
*
* SCOPE — ownership-based, not whole-root. It watches entries GSD unambiguously
* owns: the top-level install footprint (`GSD_OWNED_ENTRIES`) plus `gsd-`-prefixed
* children of the dirs GSD shares with the host agent (`GSD_PREFIXED_PARENTS`).
* It does NOT watch whole config roots. A root such as `~/.claude` is shared with
* the host agent, which may legitimately write `history.jsonl`, `todos/`, or
* `settings.json` while the suite runs; watching the root would turn that into a
* false failure, and a guard that cries wolf gets disabled — after which it
* catches nothing at all.
*
* The ownership test is the prefix, not the location. That distinction is load
* bearing: the first version of this guard watched only the three top-level
* entries and MISSED a real leak into `<live>/skills/gsd-dev-preferences/`.
*
* KNOWN GAP — a leak into a file GSD does not own (e.g. mutating the host's own
* `.claude.json`, `settings.json`, `hooks.json`, `kilo.json`, `opencode.json`) is
* outside this guard by construction. Closing it would require watching shared
* files, which is the false-positive trap above.
*
* NAMED RESIDUALS — stated rather than implied, because two successive rounds
* asserted this list was complete and both were refuted. Still NOT watched:
* - `agents/subagents/**` (kimi stages `subagents/gsd-executor.yaml` under an
* UNPREFIXED intermediate dir, so no prefix scan of `agents/` reaches it);
* - the loose capability generators copied to `<root>/scripts/*.cjs`
* (`fix-slash-commands.cjs` is watched by name; the generators are not);
* - `extensions/package.json` and `plugins/package.json` — CommonJS markers in
* dirs GSD fills but does not own, so they fall under the shared-ground rule
* below rather than being watched;
* - the shared-hooks bundle in a NON-registry root's `<root>/hooks/` (kimi) —
* see resolveExtraWatchTargets; closing it is a layout decision.
* DELIBERATELY not watched, which is a different thing from missed: `hooks/lib`,
* `hooks/package.json`, `scripts/lib` and `scripts/changeset`. The installer
* preserves foreign files in each, so watching them wholesale produces false
* positives — and a guard that cries wolf gets switched off.
* Every item above under-watches, which fails quiet: a missed leak, never a
* false alarm.
*
* SEVERITY — reports by default, fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1.
* Not timidity: on its first CI run this guard found PRE-EXISTING leaks on the
* Windows lane (`C:\Users\runneradmin\.claude\gsd-core` and
* `skills\gsd-dev-preferences`), because os.homedir() reads USERPROFILE there and
* ~190 test sites sandbox HOME alone. Those are real and worth fixing, but they
* are a different defect class from the one #2665 closes, and a brand-new gate
* that immediately reds an unrelated lane gets bypassed or reverted rather than
* obeyed. This repo already has the pattern: the local/no-source-grep ESLint rule
* shipped at `warn` and was promoted to `error` after its cleanup sweep (ADR 452).
* Promote this the same way once the USERPROFILE sweep lands.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
/**
* Top-level entries only a GSD install creates. See SCOPE above before widening.
*
* `.gsd-source` and `.gsd-profile` were added by the round-5 census (see below):
* bin/install.js writes both at the config ROOT for a global install, and an
* exact-name list does not match a dot-prefixed name by the `gsd-` prefix rule.
*/
const GSD_OWNED_ENTRIES = [
'gsd-core',
'gsd-file-manifest.json',
'gsd-pristine',
'.gsd-source',
'.gsd-profile',
];
/**
* Directories GSD SHARES with the host agent. Watching them wholesale would
* false-positive on the host's own writes, so only `gsd-`-prefixed children are
* watched — those are unambiguously ours.
*
* Added after the first version of this guard MISSED a real leak: a raw
* `spawnSync` that sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR
* wrote `<live>/skills/gsd-dev-preferences/SKILL.md`, which sits under none of
* the three top-level entries above.
*
* `hooks` joined them in round 5, found by re-deriving the census rather than by
* a review finding — the SAME shape one parent over. bin/install.js writes
* `hooks/gsd-check-update.js`, `hooks/gsd-context-monitor.js` and
* `hooks/gsd-update-banner.js` into the config root, and with `hooks` absent from
* this list a leak of any of them passed the guard silently. The lesson the first
* miss taught is that this list is the weak point, so it is re-derived from the
* installer's own write sites each round rather than trusted.
*/
const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills', 'hooks'];
const GSD_ARTIFACT_PREFIX = 'gsd-';
/**
* Artifact parents that are NOT registry-declared — the installer writes these
* directly rather than through a capability's artifactLayout.
*/
const NON_REGISTRY_ARTIFACT_PARENTS = ['hooks', 'plugins', 'scripts', 'extensions'];
/**
* Prefixes used for a parent with no registry-declared one. BOTH forms are the
* point: GSD writes `gsd-`-hyphen artifacts (`hooks/gsd-check-update.js`) AND
* bare `gsd.`-dotted ones (pi's `extensions/gsd.js`), and a lone `gsd-` sees
* only the first.
*/
const DEFAULT_ARTIFACT_PREFIXES = ['gsd-', 'gsd.'];
/**
* Files GSD owns by EXACT NAME inside a directory it shares — deliberately NOT
* the directories themselves.
*
* `hooks/lib`, `hooks/package.json`, `scripts/lib` and `scripts/changeset` were
* watched wholesale for exactly one commit, and that was wrong: the installer's
* own uninstall path preserves foreign files in every one of them (it removes the
* CommonJS marker only on an exact content match — "a user-authored package.json
* is never deleted"). Watching them wholesale turns a user editing their own
* helper into a violation, which is the false-positive trap the SCOPE note above
* exists to refuse. Under-watching fails quiet; over-watching disarms the guard.
*/
const GSD_OWNED_NESTED = [
'scripts/fix-slash-commands.cjs',
'hooks/managed-hooks-registry.cjs',
];
/**
* DERIVE the artifact parents from the capability registry rather than naming
* them, for the same reason TEST_ENV_BASE derives its keys: a hand-list is only
* ever as complete as its author's recall, and this one was measurably not.
*
* Round 5's adversarial review found the hand-list missing Kilo's SINGULAR
* `command/`, `workflows/`, and Hermes' `skills/gsd` -- the last being a whole
* directory whose name carries no `gsd-` prefix, so no prefix rule reaches it.
* A capability that declares a new destSubpath now extends this set in the same
* commit that declares it.
*
* @returns {{parents: string[], owned: string[]}} parents = watch gsd-prefixed
* children only; owned = watch the path wholesale (its own name is GSD's).
*/
function deriveArtifactTargets(runtimes) {
const parents = new Map();
const addParent = (dest, prefixes) => {
if (!parents.has(dest)) parents.set(dest, new Set());
for (const pre of prefixes) parents.get(dest).add(pre);
};
for (const dest of [...GSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS]) {
addParent(dest, DEFAULT_ARTIFACT_PREFIXES);
}
const owned = new Set(GSD_OWNED_NESTED);
for (const entry of Object.values(runtimes || {})) {
for (const layout of entry?.runtime?.artifactLayout?.global ?? []) {
const dest = layout?.destSubpath;
if (typeof dest !== 'string' || !dest) continue;
const last = dest.split('/').pop() || '';
// A destination whose own final segment is GSD's (hermes' `skills/gsd`) is
// owned wholesale — that directory is ours, not shared.
if (last.startsWith('gsd')) { owned.add(dest); continue; }
// The layout declares its OWN prefix, and it varies: kimi's `kimi-agents`
// layout declares `gsd` (no hyphen) and writes `agents/gsd.yaml` +
// `agents/gsd.md`, invisible to a fixed `gsd-` scan. The same destSubpath
// also carries different prefixes across runtimes, so a parent maps to a SET.
const declared = typeof layout?.prefix === 'string' && layout.prefix
? [layout.prefix]
: DEFAULT_ARTIFACT_PREFIXES;
addParent(dest, declared);
}
}
const out = {};
for (const [dest, set] of [...parents.entries()].sort()) out[dest] = [...set].sort();
return { parents: out, owned: [...owned].sort() };
}
/** Memoized registry-derived targets; falls back to the static lists unbuilt. */
let _artifactTargets = null;
function artifactTargets(deps = {}) {
if (_artifactTargets && !deps.libDir) return _artifactTargets;
const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
let runtimes;
try {
({ runtimes } = require(path.join(libDir, 'capability-registry.cjs')));
} catch {
// Unbuilt tree: the static lists are a strict subset, never a wrong answer.
return deriveArtifactTargets(null);
}
const derived = deriveArtifactTargets(runtimes);
if (!deps.libDir) _artifactTargets = derived;
return derived;
}
/**
* The file GSD writes into a NON-REGISTRY config home.
*
* Both current descriptors are Kimi's — Kimi CLI's `~/.kimi` (KIMI_SHARE_DIR) and
* Kimi Code's `~/.kimi-code` (KIMI_CODE_HOME) — and GSD writes its native
* `[[hooks]]` block into `config.toml` in each, so the single filename below holds
* for both. NAMED RESIDUAL: this assumes every non-registry descriptor is written
* the same way. That assumption is now load-bearing rather than vacuous — it is
* carrying two descriptors, not one — and a future descriptor whose owned file
* differs needs a per-descriptor mapping here. The consequence of getting it wrong
* is under-watching (a missed leak), not a false positive, so it fails in the quiet
* direction and is called out rather than left to be discovered.
*/
const NON_REGISTRY_OWNED_FILE = 'config.toml';
/**
* Bounds on the recursive walk, so a pathological tree cannot stall the suite.
*
* MAX_ENTRIES is PER WATCH TARGET, not per snapshot. It was a single running
* budget threaded across every target, which made the guard's verdict depend on
* directory ORDER and on unrelated local state: one large early target exhausted
* it, and every target scanned afterwards reported `truncated` -> `unverified`,
* which under GSD_STRICT_LIVE_CONFIG_GUARD=1 is a failed run. Per-target means a
* pathological tree truncates ITSELF and nothing else.
*
* MAX_TOTAL_ENTRIES keeps the aggregate bounded, which is what the single budget
* was really for. It engages only when the per-target bounds together exceed it;
* when it does, the targets it curtails are reported `unverified` -- never
* silently clean.
*
* NAMED RESIDUAL, because the obvious stronger claim is FALSE: order-independence
* holds BELOW the global ceiling, not above it. Once MAX_TOTAL_ENTRIES is
* exhausted, which targets get curtailed still depends on iteration order -- that
* is inherent to any shared aggregate bound, and the fix here removes the ordinary
* case (one large target cascading over everything after it) rather than the
* pathological one. The curtailed targets are reported `unverified`, so the
* residual costs legibility, never a false clean.
*/
const MAX_ENTRIES = 20000;
const MAX_TOTAL_ENTRIES = 200000;
const MAX_DEPTH = 12;
/**
* Resolve every runtime config root the product could write to, using the REAL
* resolver rather than a reimplementation — the guard must watch wherever the
* product actually points, including through an ambient env var.
*
* TWO resolutions, unioned, because the parent and its children do not resolve
* the same way: the AMBIENT one (what this process sees, env-first) and the
* FALLBACK one (what a child that BLANKED the config-location vars resolves to,
* i.e. HOME-derived). Watching only the first leaves the second unwatched, which
* is where a child that scrubs the var but not HOME actually writes.
*
* @returns {string[]} deduped, sorted roots; empty if the built lib is absent.
*/
function resolveLiveConfigRoots(deps = {}) {
const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
const homedir = (deps.os || os).homedir;
let getGlobalConfigDir;
let resolveConfigHomeFromDescriptor;
let runtimes;
try {
({ getGlobalConfigDir, resolveConfigHomeFromDescriptor } =
require(path.join(libDir, 'runtime-homes.cjs')));
({ runtimes } = require(path.join(libDir, 'capability-registry.cjs')));
} catch {
// Unbuilt tree: the guard is advisory infrastructure and must never be the
// reason a test run cannot start. Callers treat [] as "guard unavailable".
return [];
}
const roots = new Set();
// ── The FALLBACK roots, which the ambient resolution above cannot reach ────
//
// #2665 round 5: getGlobalConfigDir is env-first, so the loop below resolves
// whatever THIS process sees. A spawned child does not see that — TEST_ENV_BASE
// blanks the config-location vars precisely so the child cannot follow them —
// and a blanked var is falsy, so the child falls back to its HOME-derived root
// instead. A child that blanks the var and does NOT also sandbox HOME therefore
// writes into the developer's real ~/.claude while the guard is watching the
// ambient path, one process shallower. That is the exact escape route this PR
// exists to close, taken one layer down.
//
// Derived, never re-listed: passing an EMPTY env to the real descriptor resolver
// IS "what a child with no config-location vars resolves to". Deriving it this
// way keeps the guard from carrying a second copy of the scrub set to drift
// against -- the defect this PR spent three rounds closing one layer up.
for (const entry of Object.values(runtimes || {})) {
const descriptor = entry?.runtime?.configHome;
if (!descriptor) continue;
try {
const dir = resolveConfigHomeFromDescriptor(descriptor, { env: {}, home: homedir() });
if (typeof dir === 'string' && dir.length > 0) roots.add(path.resolve(dir));
} catch {
// Same posture as the ambient loop below.
}
}
// grok resolves through a hardcoded branch rather than a descriptor, so its
// fallback is stated here for the same reason it is named in the loop below.
roots.add(path.resolve(path.join(homedir(), '.agents')));
// 'grok' is a hardcoded branch of getGlobalConfigDir with no registry entry.
//
// DELIBERATE NON-ROOT: getGlobalSkillsBase(runtime) is NOT added here. The
// skills base (e.g. codex's ~/.agents/skills) is not a config ROOT, and the
// snapshot applies the config-root layout (GSD_OWNED_ENTRIES x
// GSD_PREFIXED_PARENTS) beneath every root it is given — measured on a
// sandboxed HOME, adding it both false-positives on `<skillsBase>/gsd-core`
// and misses a real `<skillsBase>/gsd-help` write. Watching skills bases
// needs its own layout, like resolveExtraWatchTargets — a separate change.
for (const runtime of [...Object.keys(runtimes || {}), 'grok']) {
try {
const dir = getGlobalConfigDir(runtime);
if (typeof dir === 'string' && dir.length > 0) roots.add(path.resolve(dir));
} catch {
// A descriptor the resolver cannot satisfy is not this module's problem.
}
}
return [...roots].sort();
}
/**
* Watch targets that are NOT runtime config roots, and so cannot be expressed as
* `root x GSD_OWNED_ENTRIES`.
*
* #2665 round 3: resolveLiveConfigRoots enumerates getGlobalConfigDir per registry
* runtime plus grok. A live write surface that is not a config ROOT is invisible to
* that shape, so a leak on one passed through this guard — the PR's own safety net —
* silently. There are THREE today ($GSD_HOME/.gsd, plus one config.toml per entry in
* NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, which #2755 took from one entry to two):
*
* $GSD_HOME/.gsd — GSD's user-owned store (consent.json, defaults.json, capability
* overlays). Watched WHOLESALE: unlike ~/.claude this root is
* exclusively ours, so the shared-root false-positive trap in
* SCOPE above does not apply and an ownership filter would only
* narrow the guard for nothing.
* <non-registry home>/config.toml — the file GSD writes its native [[hooks]] block
* into, one per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry: Kimi
* CLI's ~/.kimi (KIMI_SHARE_DIR) and, since #2755, Kimi Code's
* ~/.kimi-code (KIMI_CODE_HOME). The INVERSE case: those roots
* belong to their products, so the root is never watched
* wholesale. This is the KNOWN GAP above accepted deliberately
* in one direction — GSD demonstrably writes these files
* (bin/install.js resolves the hooks-toml dir at two sites), so
* a concurrent write by those products is the only false
* positive, and neither runs during the suite. NOT watched, and
* it is a real residual rather than a bound: <root>/hooks/, the
* bundle installSharedHooksBundle writes into the same roots —
* see resolveExtraWatchTargets for why closing it is a layout
* decision.
*
* @returns {string[]} absolute paths; empty if the built lib is absent.
*/
function resolveExtraWatchTargets(deps = {}) {
const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
const env = deps.env || process.env;
const homedir = (deps.os || os).homedir;
// BOTH resolutions, exactly as resolveLiveConfigRoots does — the ambient one and
// the one a child that BLANKED the var falls back to. Watching only the ambient
// path leaves the fallback unwatched, which is the same defect B3 closed for the
// registry roots; it lived here too until round 5's adversarial review found it.
const targets = [
path.resolve(path.join(env.GSD_HOME || homedir(), '.gsd')),
path.resolve(path.join(homedir(), '.gsd')),
];
try {
const {
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
resolveConfigHomeFromDescriptor,
} = require(path.join(libDir, 'runtime-homes.cjs'));
// ITERATE the descriptor array rather than naming one resolver. Calling
// resolveKimiHooksTomlDir directly would cover one of today's two entries and
// silently miss tomorrow's — the same partial-enumeration defect that put
// KIMI_SHARE_DIR outside the scrub set in the first place, reintroduced one
// layer over. TEST_ENV_BASE derives its keys from this array; deriving the
// guard's paths from it keeps the two halves from drifting apart.
//
// Thread the SAME injected env/home used above: resolving bare would read
// process.env and os.homedir() regardless of `deps`, leaving the seam
// untestable and the targets resolved against different worlds.
for (const descriptor of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) {
// The fallback leg, per the note above: a child that blanks KIMI_SHARE_DIR /
// KIMI_CODE_HOME writes to the HOME-derived root instead of the ambient one.
const fallbackDir = resolveConfigHomeFromDescriptor(descriptor, { env: {}, home: homedir() });
if (typeof fallbackDir === 'string' && fallbackDir.length > 0) {
targets.push(path.resolve(path.join(fallbackDir, NON_REGISTRY_OWNED_FILE)));
}
const dir = resolveConfigHomeFromDescriptor(descriptor, { env, home: homedir() });
// The root belongs to the runtime, so it is never watched wholesale — only
// the named file below.
//
// NAMED RESIDUAL (#2665, found pre-push while rebasing): config.toml is NOT
// the only thing GSD writes here. bin/install.js also calls
// installSharedHooksBundle(kimiHooksRoot), which populates <root>/hooks/
// with GSD's hook scripts and a CommonJS marker. That subtree is UNWATCHED,
// so a suite-produced leak of a hook bundle into a developer's real ~/.kimi
// or ~/.kimi-code passes this guard silently. Closing it needs a layout
// decision, not one more path: the same reason getGlobalSkillsBase is a
// deliberate non-target above. Under-watching fails quiet, like the
// NON_REGISTRY_OWNED_FILE residual it sits beside.
targets.push(path.resolve(path.join(dir, NON_REGISTRY_OWNED_FILE)));
}
} catch {
// Unbuilt tree — same posture as resolveLiveConfigRoots: advisory, never fatal.
}
// Both legs can coincide when no override is set; the snapshot keys on path,
// but dedupe anyway so the RV-facing target count means what it says.
return [...new Set(targets)];
}
/**
* Newest mtime within a tree, bounded. Returns `truncated: true` when a bound
* was hit — the caller must NOT report such a result as clean, on the same
* principle that an existence probe passing vacuously is worse than no probe.
*/
function newestMtime(target, budget) {
let newest = 0;
let truncated = false;
const walk = (current, depth) => {
if (budget.remaining <= 0) { truncated = true; return; }
if (depth > MAX_DEPTH) { truncated = true; return; }
let st;
try {
st = fs.lstatSync(current);
} catch {
return;
}
budget.remaining -= 1;
if (st.mtimeMs > newest) newest = st.mtimeMs;
if (!st.isDirectory()) return;
let entries;
try {
entries = fs.readdirSync(current);
} catch {
return;
}
for (const entry of entries) {
// RETURN, not continue: without this the loop keeps invoking walk() for
// every remaining sibling after the budget is gone, so the ceiling bounds
// what is RECORDED but not the work done getting there.
if (budget.remaining <= 0) { truncated = true; return; }
walk(path.join(current, entry), depth + 1);
}
};
walk(target, 0);
return { newest, truncated };
}
/**
* Snapshot GSD-owned entries under each root.
*
* @returns {Record<string, {exists: boolean, newest: number, truncated: boolean}>}
* keyed by absolute entry path.
*/
function snapshotLiveConfig(roots, extraTargets = [], limits = {}) {
// Clamp at 0: a negative injected limit would start the ceiling below empty and
// make every target report truncated for a reason that is not a scan bound.
// Number.isFinite, NOT Math.max: `Math.max(0, NaN)` is NaN, and every budget
// comparison against NaN is false — the bound then fails OPEN and the walk is
// unbounded, which is the single thing these constants exist to prevent.
const finite = (v, fallback) => (Number.isFinite(v) && v >= 0 ? v : fallback);
const perTarget = finite(limits.perTarget, MAX_ENTRIES);
const total = { remaining: finite(limits.total, MAX_TOTAL_ENTRIES) };
const snap = {};
const record = (target) => {
if (!fs.existsSync(target)) {
snap[target] = { exists: false, newest: 0, truncated: false };
return;
}
// A FRESH budget per target, drawn against the global ceiling. See the
// MAX_ENTRIES docblock: a shared running budget let one large early target
// cascade `unverified` over every target scanned after it, so the verdict
// depended on iteration order rather than on what the run actually touched.
const budget = { remaining: Math.min(perTarget, total.remaining) };
const allotted = budget.remaining;
const { newest, truncated } = newestMtime(target, budget);
total.remaining -= allotted - budget.remaining;
snap[target] = { exists: true, newest, truncated };
};
// Non-root targets (resolveExtraWatchTargets) are recorded verbatim — they are
// already the exact path to watch, whole-dir or single-file. Passed explicitly
// rather than resolved here so a caller testing a fixture root does not silently
// pull the developer's real ~/.gsd into its snapshot.
for (const target of extraTargets) record(path.resolve(target));
const { parents: watchParents, owned: watchOwned } = artifactTargets();
for (const root of roots) {
for (const entry of GSD_OWNED_ENTRIES) record(path.join(root, entry));
// Paths whose own name is GSD's, nested inside a shared root (hermes'
// `skills/gsd`, `hooks/lib`, `scripts/lib`, …) — no prefix rule sees these.
for (const entry of watchOwned) record(path.join(root, ...entry.split('/')));
// Shared dirs: enumerate only gsd-prefixed children. A child that appears
// between the two snapshots is absent from `before` entirely — diffLiveConfig
// treats after-only paths as created, which is exactly the leak signal.
for (const [parent, prefixes] of Object.entries(watchParents)) {
const parentDir = path.join(root, ...parent.split('/'));
let children;
try {
children = fs.readdirSync(parentDir);
} catch {
continue; // parent absent — nothing of ours can be in it yet
}
for (const child of children) {
if (prefixes.some((pre) => child.startsWith(pre))) record(path.join(parentDir, child));
}
}
}
return snap;
}
/**
* Compare two snapshots. A path is a violation when it was created during the
* run, when its newest mtime advanced, or when it was DELETED by the run.
*
* Iterate the UNION of both key sets, never `after` alone. Deletion reaches this
* function in two shapes and an after-only walk sees neither:
*
* - A FIXED owned entry (GSD_OWNED_ENTRIES x roots, and every extra target) is
* recorded at both ends whether or not it exists, so a deletion reads
* {exists:true} -> {exists:false} and falls through every branch — silently.
* - A gsd-prefixed child is DISCOVERED by readdir, so a deleted one is absent
* from `after` entirely and never enters an after-keyed loop at all.
*
* The second shape is why adding a `pre.exists && !post.exists` branch is not on
* its own sufficient: that branch is unreachable for exactly the discovered
* children the prefix scan exists to catch.
*
* @returns {{path: string, kind: 'created'|'modified'|'deleted'|'unverified'}[]}
*/
function diffLiveConfig(before, after) {
const violations = [];
const targets = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const target of targets) {
const pre = before[target];
const post = after[target];
// Absent from `before` entirely: a gsd-prefixed child that did not exist
// when the run started. Both snapshots cover the same roots, so an
// after-only path was created BY the run — never skip it.
if (!pre) {
if (post && post.exists) violations.push({ path: target, kind: 'created' });
continue;
}
// Absent from `after` entirely: a discovered child that existed when the run
// started and does not now. Deletion is the least recoverable outcome in this
// threat model, so it is never inferred as clean.
if (!post) {
if (pre.exists) violations.push({ path: target, kind: 'deleted' });
continue;
}
if (!pre.exists && post.exists) {
violations.push({ path: target, kind: 'created' });
} else if (pre.exists && !post.exists) {
violations.push({ path: target, kind: 'deleted' });
} else if (pre.exists && post.exists && post.newest > pre.newest) {
violations.push({ path: target, kind: 'modified' });
} else if (pre.truncated || post.truncated) {
// Bound hit: we cannot attest this path either way, and saying nothing
// would let a truncated scan read as a clean one.
violations.push({ path: target, kind: 'unverified' });
}
}
return violations;
}
/** Human-facing report for a non-empty violation set. */
function formatViolations(violations) {
const lines = [
'',
'run-tests: HERMETICITY WARNING — the suite wrote into a LIVE config directory.',
'',
'A test resolved a runtime config dir from the ambient environment instead of a',
'sandbox. The usual cause is an IN-PROCESS install() call: tests/helpers.cjs',
'TEST_ENV_BASE only scrubs CHILD process env, so an in-process caller must also',
'use scrubConfigLocationEnv() (see tests/install.test.cjs) alongside its HOME',
'sandbox. CI cannot catch this class — it never has these env vars set.',
'',
];
for (const v of violations) {
const label = v.kind === 'unverified'
? 'UNVERIFIED (scan bound hit — not attested clean)'
: v.kind.toUpperCase();
lines.push(` ${label}: ${v.path}`);
}
lines.push('');
// The footer must state the mode the run is ACTUALLY in — a strict-mode
// failure captioned "Reporting only" sends the reader away from the very
// violation that just reddened their run.
lines.push(
process.env.GSD_STRICT_LIVE_CONFIG_GUARD === '1'
? 'STRICT MODE (GSD_STRICT_LIVE_CONFIG_GUARD=1): these violations fail the run. ' +
'GSD_SKIP_LIVE_CONFIG_GUARD=1 skips the check entirely.'
: 'Reporting only. Set GSD_STRICT_LIVE_CONFIG_GUARD=1 to make this fail the run, ' +
'or GSD_SKIP_LIVE_CONFIG_GUARD=1 to skip the check entirely.',
);
lines.push('');
return lines.join('\n');
}
module.exports = {
GSD_OWNED_ENTRIES,
GSD_PREFIXED_PARENTS,
GSD_OWNED_NESTED,
NON_REGISTRY_ARTIFACT_PARENTS,
DEFAULT_ARTIFACT_PREFIXES,
deriveArtifactTargets,
artifactTargets,
GSD_ARTIFACT_PREFIX,
MAX_ENTRIES,
MAX_TOTAL_ENTRIES,
MAX_DEPTH,
resolveLiveConfigRoots,
resolveExtraWatchTargets,
snapshotLiveConfig,
diffLiveConfig,
formatViolations,
newestMtime,
os, // exported for test seams only
};

View File

@@ -39,6 +39,13 @@ const { readdirSync, readFileSync } = require('fs');
const { join, basename } = require('path');
const { execFileSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const {
resolveLiveConfigRoots,
resolveExtraWatchTargets,
snapshotLiveConfig,
diffLiveConfig,
formatViolations,
} = require('./live-config-guard.cjs');
const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow', 'qa'];
@@ -965,6 +972,29 @@ function main() {
// job cap. Operator/test override via RUN_TESTS_CHUNK_TIMEOUT_MS.
const chunkTimeoutMs = positiveNumberEnv(process.env.RUN_TESTS_CHUNK_TIMEOUT_MS, 600000);
// #2665: snapshot GSD's install footprint in every LIVE runtime config dir
// before a single test runs. The suite must not write there; the check after
// the chunk loop is what makes a violation loud instead of silent. See
// scripts/live-config-guard.cjs for why the scope is narrow (it is deliberately
// NOT under scripts/lib/, which the installer copies to users wholesale).
const liveConfigGuardEnabled = process.env.GSD_SKIP_LIVE_CONFIG_GUARD !== '1';
let liveConfigRoots = [];
let liveConfigExtras = [];
let liveConfigBefore = null;
if (liveConfigGuardEnabled) {
liveConfigRoots = resolveLiveConfigRoots();
// #2665 round 3: $GSD_HOME/.gsd, and one native config.toml per non-registry
// config-home descriptor (Kimi CLI's and, since #2755, Kimi Code's), are live
// write surfaces that are not runtime config ROOTS, so they are invisible to the
// line above. Watched independently — and note the OR: the extras alone are
// reason enough to snapshot, so an unbuilt tree that yields zero roots no
// longer silently disables the whole guard.
liveConfigExtras = resolveExtraWatchTargets();
if (liveConfigRoots.length > 0 || liveConfigExtras.length > 0) {
liveConfigBefore = snapshotLiveConfig(liveConfigRoots, liveConfigExtras);
}
}
let firstFailureExit = 0;
for (let i = 0; i < chunks.length; i++) {
if (chunks.length > 1) {
@@ -1030,6 +1060,24 @@ function main() {
// and the first non-zero exit is reported at the end.
}
}
// #2665: post-suite hermeticity check. Runs even when tests failed — a leaked
// global install is worth reporting alongside the failure that hid it, and
// suppressing it on red would hide it exactly when the suite is least trusted.
if (liveConfigBefore) {
const violations = diffLiveConfig(
liveConfigBefore,
snapshotLiveConfig(liveConfigRoots, liveConfigExtras),
);
if (violations.length > 0) {
console.error(formatViolations(violations));
// Reports by default; fails only under opt-in strict mode. See the
// SEVERITY note in scripts/live-config-guard.cjs for why.
if (process.env.GSD_STRICT_LIVE_CONFIG_GUARD === '1' && firstFailureExit === 0) {
firstFailureExit = 1;
}
}
}
if (firstFailureExit !== 0) return firstFailureExit;
}

View File

@@ -157,7 +157,7 @@ interface NoneDescriptor {
skillsHome?: ConfigHomeDescriptor;
}
type ConfigHomeDescriptor =
export type ConfigHomeDescriptor =
| DotHomeDescriptor
| DotHomeNestedDescriptor
| XdgDescriptor
@@ -480,6 +480,76 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string {
);
}
/**
* Kimi CLI's own native config.toml home. Hoisted out of resolveKimiHooksTomlDir
* so it is ENUMERABLE, not merely resolvable.
*
* #2665 round 3: a config-location var that lives only inside a function body is
* invisible to every consumer that needs the SET rather than the path — the test
* scrub list and the hermeticity guard both derive from descriptors, and this one
* reached neither. `kimi` is the sharp case precisely because it owns TWO config
* homes: KIMI_CONFIG_DIR (registry-visible, already covered) and KIMI_SHARE_DIR
* (this one), so a derivation keyed only on the registry looks complete and is not.
*/
export const KIMI_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = {
kind: 'dot-home',
name: '.kimi',
env: ['KIMI_SHARE_DIR'],
};
/**
* Kimi Code's native config.toml home — the `kimi-code` counterpart of the
* descriptor above, hoisted for exactly the same reason.
*
* #2755 landed kimi-code hooks support on `next` while this PR was open, and
* declared this descriptor as an inline object literal inside
* resolveKimiHooksTomlDir's body — the same resolvable-but-not-enumerable shape
* round 3 hoisted KIMI_SHARE_DIR out of. Hoisting it puts `KIMI_CODE_HOME` into
* the derived scrub set and the hermeticity guard's watch roots in the SAME
* commit, which is the property NON_REGISTRY_CONFIG_HOME_DESCRIPTORS exists to
* guarantee. Each product's env var stays scoped to that product (#2755).
*/
export const KIMI_CODE_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = {
kind: 'dot-home',
name: '.kimi-code',
env: ['KIMI_CODE_HOME'],
};
/**
* Config-home descriptors resolved OUTSIDE the capability registry.
*
* Anything added here is picked up by every derived consumer in the same commit —
* which is the property that makes the derivation structurally incapable of being
* narrower than the surface it guards. Adding a hardcoded resolver WITHOUT adding
* its descriptor here is the defect this array exists to make hard.
*/
export const NON_REGISTRY_CONFIG_HOME_DESCRIPTORS: ConfigHomeDescriptor[] = [
KIMI_HOOKS_TOML_DESCRIPTOR,
KIMI_CODE_HOOKS_TOML_DESCRIPTOR,
];
/**
* GSD's OWN location vars — a second family, not runtime configHomes.
*
* #2665 round 3: the registry describes where each *third-party runtime* keeps its
* config. It says nothing about where GSD keeps its own user-owned state, and that
* is a separate env-first surface:
*
* GSD_HOME — `process.env['GSD_HOME'] || os.homedir()`, the root of
* `$GSD_HOME/.gsd/` (consent.json, defaults.json, capability
* overlays). Read env-first by capability-loader, capability-consent,
* capability-state, capability-writer, config-loader, install-profiles
* and bin/install.js. A WRITE surface.
* GSD_AGENTS_DIR — `if (process.env['GSD_AGENTS_DIR']) return it`, priority 1 in
* getAgentsDir. Misdirects a READ rather than a write, hence lower
* severity — but it is env-first and unconditional, so it belongs
* to the same class.
*
* Deliberately NOT folded into the descriptor array above: these do not resolve
* through resolveConfigHomeFromDescriptor and have no `kind`/`name` shape.
*/
export const GSD_LOCATION_ENV_KEYS: readonly string[] = ['GSD_HOME', 'GSD_AGENTS_DIR'];
/**
* Resolve the directory holding the Kimi product's OWN native config.toml —
* the file that product itself reads for providers/models/hooks/etc, and the
@@ -517,8 +587,8 @@ export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): st
// value originates from argv, and an index would resolve inherited keys
// (`constructor`, `__proto__`) to something that is not a descriptor.
const descriptor: DotHomeDescriptor = opts.runtime === 'kimi-code'
? { kind: 'dot-home', name: '.kimi-code', env: ['KIMI_CODE_HOME'] }
: { kind: 'dot-home', name: '.kimi', env: ['KIMI_SHARE_DIR'] };
? KIMI_CODE_HOOKS_TOML_DESCRIPTOR
: KIMI_HOOKS_TOML_DESCRIPTOR;
return resolveConfigHomeFromDescriptor(descriptor, { env, home });
}

View File

@@ -16,26 +16,9 @@ const { spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
GSD_WORKSTREAM: '',
TTY: '',
SSH_TTY: '',
};
/**
* Run gsd-tools and capture BOTH stdout and stderr on success.

View File

@@ -20,7 +20,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
// In-process seam for the fail-closed read-injection tests at the bottom of this
// file (#2365 review): readPhaseScope is the pure phase-scope reader behind the
@@ -29,23 +29,6 @@ const { readPhaseScope } = require('../gsd-core/bin/lib/check-command-router.cjs
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runTools(args, cwd) {
const argv = Array.isArray(args)
? args

View File

@@ -21,27 +21,10 @@ const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runTools(args, cwd) {
const argv = Array.isArray(args)
? args

View File

@@ -2108,11 +2108,27 @@ describe('regressions: --runtime override bypasses persisted runtime (#2003)', (
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-'));
try {
writePersistedRuntime(tmpDir, 'codex');
const result = runGsdTools('capability state --runtime claude --raw', tmpDir);
// #2665: sandbox HOME and let the config-location vars stay BLANK (helpers'
// TEST_ENV_BASE zeroes them), so the child resolves through the home-derived
// FALLBACK branch of getGlobalConfigDir. The original test compared the
// child's answer against the PARENT process's getGlobalConfigDir() -- two
// different environments, agreeing only because the child inherited the
// developer's ambient CLAUDE_CONFIG_DIR.
//
// Injecting CLAUDE_CONFIG_DIR here instead would fix that leak but move the
// test onto the env-FIRST branch, silently dropping the only coverage this
// #2003 regression has of the fallback branch. Sandboxing HOME keeps the
// expectation test-controlled AND keeps the branch under test unchanged.
const result = runGsdTools('capability state --runtime claude --raw', tmpDir, {
HOME: tmpDir,
USERPROFILE: tmpDir,
});
assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`);
const parsed = JSON.parse(result.output);
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
assert.strictEqual(parsed.runtimeConfigDir, runtimeHomes.getGlobalConfigDir('claude'),
// Home-derived Claude dir. That this is NOT <tmpDir>/.codex is the #2003
// assertion; a separate notStrictEqual against the codex dir would be dead,
// since it cannot fail whenever this strictEqual passes.
assert.strictEqual(parsed.runtimeConfigDir, path.join(tmpDir, '.claude'),
'`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir');
} finally {
cleanup(tmpDir);

View File

@@ -30,7 +30,7 @@ const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
@@ -136,23 +136,6 @@ function commitFile(git, tmpDir, filename, commitMessage) {
// ─── Helpers for subprocess invocation ────────────────────────────────────────
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runTools(args, cwd) {
const argv = Array.isArray(args)
? args

View File

@@ -740,27 +740,10 @@ const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
/**
* Run gsd-tools and return { stdout, stderr, status }.
* Always captures stderr even when exit code is 0.

View File

@@ -15,27 +15,10 @@ const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runMigrateConfig(cwd, extraArgs = [], env = {}) {
const result = runNode([TOOLS_PATH, 'migrate-config', ...extraArgs], {
cwd,

View File

@@ -1362,7 +1362,10 @@ const EXPECTED_AGENTS = listAgentFiles().length;
const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
function runCopilotInstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--no-sdk'], {
cwd,
@@ -1374,7 +1377,10 @@ function runCopilotInstall(cwd) {
}
function runCopilotUninstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--copilot', '--local', '--uninstall', '--no-sdk'], {
cwd,
@@ -1673,7 +1679,10 @@ describe('E2E: Copilot uninstall verification', () => {
// ─── E2E: Copilot global scope (#786) ──────────────────────────────────────────
function runCopilotInstallGlobal(cwd, configDir) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode(
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'],
@@ -1684,7 +1693,10 @@ function runCopilotInstallGlobal(cwd, configDir) {
}
function runCopilotUninstallGlobal(cwd, configDir) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode(
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'],
@@ -1734,7 +1746,10 @@ describe('E2E: Copilot global install (#786)', () => {
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
function runClaudeInstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,
@@ -1746,7 +1761,10 @@ function runClaudeInstall(cwd) {
}
function runClaudeUninstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--claude', '--local', '--uninstall', '--no-sdk'], {
cwd,

View File

@@ -1,8 +1,45 @@
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 } = require('./helpers.cjs');
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', () => {
@@ -39,3 +76,337 @@ describe('withIsolatedProcessState', () => {
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)');
}
});
// 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);
}
});
});

View File

@@ -10,29 +10,177 @@ const { createFixture } = require('./fixtures/index.cjs');
const processSeam = require('./helpers/process-seam.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// #2665: blank config-LOCATION vars so npm test never writes into the developer's
// live config directory. The resolver consults these before HOME, so an ambient
// value wins unconditionally over a sandboxed HOME. Per-site overrides still win
// because env is spread last in the child-env merge.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
// Session-IDENTITY vars. Blanked so a child cannot inherit the developer's
// terminal/agent session and key shared state off it.
const SESSION_IDENTITY_ENV_KEYS = [
'GSD_SESSION_KEY',
'CODEX_THREAD_ID',
'CLAUDE_SESSION_ID',
'CLAUDE_CODE_SSE_PORT',
'OPENCODE_SESSION_ID',
'GEMINI_SESSION_ID',
'CURSOR_SESSION_ID',
'WINDSURF_SESSION_ID',
'TERM_SESSION_ID',
'WT_SESSION',
'TMUX_PANE',
'ZELLIJ_SESSION_NAME',
'TTY',
'SSH_TTY',
];
// LAZY, and memoized. These live in the BUILT runtime lib, so requiring them at
// module scope made an unbuilt tree throw during `require('./helpers.cjs')` —
// before a single test() had registered — which turns one missing
// `npm run build:lib` into a whole-suite crash with no actionable message, in the
// file ~370 test files import. `npm test` builds via its pretest hook, so the
// shape that hits this is a direct `node --test` invocation.
//
// Deferring the require means only the tests that actually need the derived scrub
// set pay for the build, and they fail with a message that names the remedy.
let _builtLib = null;
function builtLib() {
if (_builtLib) return _builtLib;
try {
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
const {
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
GSD_LOCATION_ENV_KEYS,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
_builtLib = { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS };
} catch (cause) {
throw new Error(
'tests/helpers.cjs derives the config-location scrub set from the built runtime '
+ 'lib (gsd-core/bin/lib), which is not present. Run `npm run build:lib` first — '
+ '`npm test` does this for you via its pretest script.',
{ cause },
);
}
return _builtLib;
}
// Config-location vars that are neither in the registry nor descriptor-shaped,
// each with its reader:
// GROK_AGENTS_HOME — hardcoded `grok` branch in getGlobalConfigDir (src/runtime-homes.cts)
// GSD_RUNTIME — selects WHICH runtime home resolves (src/model-resolver.cts)
// GSD_PROJECT — planningDir() project segment (src/planning-workspace.cts)
// GSD_WORKSTREAM — planningDir() workstream segment (src/planning-workspace.cts)
//
// #2665 round 3: this list shrinks as sources become enumerable, and that direction
// is the point. KIMI_SHARE_DIR was NOT added here — it now derives from
// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, because hand-adding each var a reviewer
// names is precisely what reopened this bug three times.
const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
'GROK_AGENTS_HOME',
'GSD_RUNTIME',
'GSD_PROJECT',
'GSD_WORKSTREAM',
];
// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT
// folded into any of the four rungs below.
//
// #2665 round 5: GSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so it is
// not a config-location var by any honest reading. But install-engine.cts reads it
// env-first (`:214`) and threads it as `allowOptInFollow` into the symlink-escape
// guard at four call sites, each gating a write (`:361/:367`, `:416/:424`,
// `:785/:790`, `:927/:932`). That guard is what stops a write leaving the install
// root, so an ambient `=1` disarms it for the whole suite — the #2665 hazard
// exactly, arriving through a permission rather than a path.
//
// Blanking is fail-safe in the only direction that matters: '' is neither '1' nor
// 'true', so a blanked value makes the guard STRICTER, never looser. That asymmetry
// is why this can be scrubbed wholesale without reasoning about each call site.
const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['GSD_ALLOW_SYMLINKED_DEST'];
// Config-LOCATION vars — distinct in kind from the session-identity vars above:
// these decide WHERE a child writes, so leaving one ambient lets a test that
// sandboxes HOME still escape into the developer's real config dir.
//
// #2665: this list is DERIVED, not hand-maintained. A hand-written list is
// exactly what reopened this bug twice — it can only ever be as complete as the
// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a
// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing
// it from the same registry the resolver reads makes the scrub list structurally
// incapable of being narrower than the surface it guards: adding a capability
// that declares a new configHome env var extends this set in the same commit.
let _configLocationEnvKeys = null;
function configLocationEnvKeys() {
if (_configLocationEnvKeys) return _configLocationEnvKeys;
const { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS } = builtLib();
_configLocationEnvKeys = [
...new Set([
// 1. Every runtime descriptor the capability registry carries — including
// the nested skillsHome descriptor, which resolves independently of
// configHome (resolveSkillsBaseFromDescriptor) and can carry its own
// env array. Inert today (only kilo declares skillsHome, with env: []),
// but walking configHome.env alone is the identical gap-shape this PR
// closed twice already, one field over. (#2665 round 4)
...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
...Object.values(runtimes).flatMap(
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
),
// 2. Descriptor-shaped config homes resolved OUTSIDE the registry (kimi's
// native config.toml home via KIMI_SHARE_DIR). Derived, not hand-listed.
// Same skillsHome walk as rung 1 — a descriptor is a descriptor.
...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => [
...(d?.env ?? []),
...(d?.skillsHome?.env ?? []),
]),
// 3. GSD's OWN location vars — a different family: they decide where GSD keeps
// user-owned state ($GSD_HOME/.gsd/), not where a runtime keeps its config.
...GSD_LOCATION_ENV_KEYS,
// 4. The residue that is neither registry-carried nor descriptor-shaped.
...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS,
// 5. Write-escape permissions — NOT locations. Same mechanism because the
// hazard is identical (ambient env lets a suite write outside the sandbox);
// named separately above so the list does not misdescribe what they are.
...WRITE_ESCAPE_PERMISSION_ENV_KEYS,
]),
].sort();
return _configLocationEnvKeys;
}
let _testEnvBase = null;
function testEnvBase() {
if (_testEnvBase) return _testEnvBase;
_testEnvBase = Object.fromEntries(
[...SESSION_IDENTITY_ENV_KEYS, ...configLocationEnvKeys()].map((k) => [k, '']),
);
return _testEnvBase;
}
/**
* Save + clear every config-LOCATION env var on THIS process; returns a restorer.
*
* #2665: TEST_ENV_BASE only reaches CHILD processes. A test that calls the real
* installer IN-PROCESS — `install(true, 'claude')` — resolves through the same
* env-first `getGlobalConfigDir`, so an ambient CLAUDE_CONFIG_DIR beats a
* sandboxed `process.env.HOME` and a complete global install (agents/, commands/,
* skills/, gsd-core/, manifest, settings) lands in the developer's live config
* dir. No child-env scrub can reach that call; only clearing the parent's env can.
*
* Pair with a HOME sandbox, not instead of one: HOME covers the home-derived
* fallback, this covers the env-first branch that overrides it.
*
* @returns {() => void} restorer — call in afterEach to put the env back exactly
* as it was (deleting keys that were previously unset, rather than setting '').
*/
function scrubConfigLocationEnv() {
const saved = {};
const keys = configLocationEnvKeys();
for (const key of keys) {
saved[key] = process.env[key];
delete process.env[key];
}
return function restoreConfigLocationEnv() {
for (const key of keys) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
};
}
/**
* Run gsd-tools command.
@@ -46,7 +194,7 @@ const TEST_ENV_BASE = {
*/
function runGsdTools(args, cwd = process.cwd(), env = {}) {
// Resolve argv once so both the first attempt and the retry use the same vector.
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env };
const childEnv = { ...process.env, ...testEnvBase(), ...env };
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
@@ -763,4 +911,57 @@ function clearSessionEnv() {
for (const k of SESSION_ENV_KEYS) delete process.env[k];
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH };
/**
* #3156: env for a RAW installer spawn — one that bypasses runGsdTools and so
* never receives TEST_ENV_BASE on its own.
*
* Blanking config-LOCATION vars is necessary but NOT sufficient here.
* bin/install.js writes GSD's own user-owned store through os.homedir()
* DIRECTLY (writeNonClaudeDefaults -> <home>/.gsd/defaults.json, #2834), and
* os.homedir() consults no GSD variable at all — so nothing in
* CONFIG_LOCATION_ENV_KEYS can reach it, and blanking GSD_HOME does not reach
* it either, because a blank GSD_HOME falls back to exactly that homedir().
* Only a sandboxed HOME/USERPROFILE contains it.
*
* HOME stays deliberately OUT of TEST_ENV_BASE — blanking it would break far
* more than it fixed — so it is sandboxed per spawn instead, which is the
* discipline the suite already applies by hand elsewhere. USERPROFILE is set
* with it because os.homedir() reads that one on Windows.
*
* The sandbox home is per-process and removed on exit, so a caller gets
* containment without having to own a lifecycle.
*
* SCOPE, stated because it is a real residual rather than an oversight: this is
* one home per test-FILE process, not one per spawn. Two installer spawns in the
* same file therefore share `.gsd` state, so a prior non-Claude install can be
* observed by a later spawn. That is strictly better than the status quo it
* replaces -- which shared the developer's REAL home, and all of its state --
* and it closes the leak this helper exists for; it does not claim isolation
* BETWEEN spawns. A test needing that passes its own { HOME, USERPROFILE }.
*/
let installSpawnHomeDir = null;
function installSpawnHome() {
if (installSpawnHomeDir === null) {
installSpawnHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-install-home-'));
process.on('exit', () => {
try { fs.rmSync(installSpawnHomeDir, { recursive: true, force: true }); } catch { /* best effort */ }
});
}
return installSpawnHomeDir;
}
function installSpawnEnv(overrides = {}) {
const home = installSpawnHome();
return { ...process.env, ...testEnvBase(), HOME: home, USERPROFILE: home, ...overrides };
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome };
// Lazy, for the reason builtLib() is lazy: reading either of these is what
// forces the built-lib require, so a test file that needs neither can still
// import this helper on an unbuilt tree. Enumerable, so destructuring and
// Object.keys() behave exactly as they did when these were plain properties.
Object.defineProperties(module.exports, {
TEST_ENV_BASE: { enumerable: true, get: testEnvBase },
CONFIG_LOCATION_ENV_KEYS: { enumerable: true, get: configLocationEnvKeys },
});

View File

@@ -520,7 +520,15 @@ function simulateHookCopy(hooksSrc, hooksDest) {
/** Build a clean env for spawned installer processes.
* Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */
function installerEnv(overrides = {}) {
const env = { ...process.env, ...overrides };
// #3156: delegate to the ONE canonical raw-installer-spawn env rather than
// carrying a second shape of it. The installer writes GSD's own user store to
// <home>/.gsd/defaults.json through os.homedir() DIRECTLY
// (bin/install.js writeNonClaudeDefaults, #2834), which reads no GSD variable,
// so no config-location scrub can reach it — only a sandboxed HOME can. Every
// caller that already passes an explicit { HOME, USERPROFILE } still wins:
// overrides spread last.
const { installSpawnEnv } = require('../helpers.cjs');
const env = installSpawnEnv(overrides);
delete env.GSD_TEST_MODE;
return env;
}

View File

@@ -4646,7 +4646,7 @@ const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const PROFILE_OUTPUT = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'profile-output.cjs');
@@ -4672,7 +4672,12 @@ describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-prefe
m.cmdGenerateDevPreferences(${JSON.stringify(tmpHome)}, { analysis: ${JSON.stringify(analysisPath)} }, false);
`);
const result = cp.spawnSync(process.execPath, [driver], {
env: Object.assign({}, process.env, { HOME: tmpHome, USERPROFILE: tmpHome }),
// #2665: TEST_ENV_BASE must be merged in explicitly here. This is a RAW
// spawn, not runGsdTools, so nothing scrubs the config-location vars for
// it -- and the writer under test resolves them env-FIRST. Sandboxing
// HOME alone let an ambient CLAUDE_CONFIG_DIR win, and the SKILL.md
// landed in the developer's live config dir instead of tmpHome.
env: Object.assign({}, process.env, TEST_ENV_BASE, { HOME: tmpHome, USERPROFILE: tmpHome }),
encoding: 'utf-8',
// Bound the subprocess so a regression that hangs the writer
// (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback).

View File

@@ -3827,7 +3827,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
const { install } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
const makeTmpDir = () => createTempDir('gsd-3571-');
function silenceConsole(fn) {
@@ -3853,6 +3853,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
let restoreConfigLocationEnv;
beforeEach(() => {
tmpRoot = makeTmpDir();
@@ -3861,6 +3862,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout
savedUserProfile = process.env.USERPROFILE;
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
// #2665: this block calls the real installer IN-PROCESS with only HOME
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
// sandbox and a complete global install lands in the developer's live config
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
restoreConfigLocationEnv = scrubConfigLocationEnv();
});
afterEach(() => {
@@ -3872,6 +3879,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
} else {
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
}
restoreConfigLocationEnv();
cleanup(tmpRoot);
});
@@ -3984,7 +3992,7 @@ const { install } = require('../bin/install.js');
// ─── helpers ─────────────────────────────────────────────────────────────────
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
const makeTmpDir = createTempDir;
const rmTmpDir = cleanup;
@@ -4026,6 +4034,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
let restoreConfigLocationEnv;
beforeEach(() => {
tmpRoot = makeTmpDir('gsd-3288-');
@@ -4040,6 +4049,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
// and target a different directory than tmpRoot (CR finding, PR #3293).
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
// #2665: this block calls the real installer IN-PROCESS with only HOME
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
// sandbox and a complete global install lands in the developer's live config
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
restoreConfigLocationEnv = scrubConfigLocationEnv();
});
afterEach(() => {
@@ -4051,6 +4066,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
} else {
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
}
restoreConfigLocationEnv();
rmTmpDir(tmpRoot);
});
@@ -5521,7 +5537,10 @@ function ensureHooksDist() {
* GSD_TEST_MODE is cleared so the install() main block executes.
*/
function runInstall(cwd, args) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
// 120s, not 60s. A full install copies and converts the whole shipped
// payload (117 workflows, 100 references, 34 agents, ~71 skills) and
@@ -7873,7 +7892,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
const { install } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
const makeTmpDir = () => createTempDir('gsd-3571-');
function silenceConsole(fn) {
@@ -7899,6 +7918,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
let restoreConfigLocationEnv;
beforeEach(() => {
tmpRoot = makeTmpDir();
@@ -7907,6 +7927,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout
savedUserProfile = process.env.USERPROFILE;
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
// #2665: this block calls the real installer IN-PROCESS with only HOME
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
// sandbox and a complete global install lands in the developer's live config
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
restoreConfigLocationEnv = scrubConfigLocationEnv();
});
afterEach(() => {
@@ -7918,6 +7944,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout
} else {
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
}
restoreConfigLocationEnv();
cleanup(tmpRoot);
});
@@ -8030,7 +8057,7 @@ const { install } = require('../bin/install.js');
// ─── helpers ─────────────────────────────────────────────────────────────────
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
const makeTmpDir = createTempDir;
const rmTmpDir = cleanup;
@@ -8072,6 +8099,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
let restoreConfigLocationEnv;
beforeEach(() => {
tmpRoot = makeTmpDir('gsd-3288-');
@@ -8086,6 +8114,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
// and target a different directory than tmpRoot (CR finding, PR #3293).
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
// #2665: this block calls the real installer IN-PROCESS with only HOME
// sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR
// (or CODEX_HOME, or any other runtime's config-location var) overrides that
// sandbox and a complete global install lands in the developer's live config
// dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env.
restoreConfigLocationEnv = scrubConfigLocationEnv();
});
afterEach(() => {
@@ -8097,6 +8131,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
} else {
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
}
restoreConfigLocationEnv();
rmTmpDir(tmpRoot);
});
@@ -9567,7 +9602,10 @@ function ensureHooksDist() {
* GSD_TEST_MODE is cleared so the install() main block executes.
*/
function runInstall(cwd, args) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
// 120s, not 60s. A full install copies and converts the whole shipped
// payload (117 workflows, 100 references, 34 agents, ~71 skills) and
@@ -9992,7 +10030,10 @@ const {
* GSD_TEST_MODE must be cleared so the install() main block executes.
*/
function runClaudeLocalInstall(cwd) {
const env = { ...process.env };
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
const { installSpawnEnv } = require('./helpers.cjs');
const env = installSpawnEnv();
delete env.GSD_TEST_MODE;
const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
cwd,

View File

@@ -24,7 +24,7 @@ const ROOT = path.resolve(__dirname, '..');
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
const pkg = require(path.join(ROOT, 'package.json'));
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
const { cleanup } = require('./helpers.cjs');
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
@@ -359,9 +359,36 @@ describe('C: plugin.json schema validation', () => {
// ── C2: Opportunistic CLI integration (skipped when claude not on PATH) ──────
// #2665: the `claude` CLI is a THIRD-PARTY binary that bootstraps its own
// config (.claude.json plus a backups/ dir) into whatever CLAUDE_CONFIG_DIR
// names. Two things follow, and only the first is obvious:
//
// 1. Inheriting the developer's ambient CLAUDE_CONFIG_DIR makes even a bare
// `--version` probe write into their live config dir. That alone kept
// `CLAUDE_CONFIG_DIR=<dir> npm run test:unit; find <dir> -type f` from
// returning empty after every GSD-side leak was closed.
// 2. BLANKING it (TEST_ENV_BASE's '' convention) is not enough here. GSD's
// own resolvers treat '' as falsy and fall back to the home dir, but this
// binary is not ours and gives no such guarantee -- blanking it produced a
// stray backups/ directory in the REPO ROOT.
//
// So point it at a real throwaway dir rather than at nothing.
const claudeCliHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2665-claude-cli-'));
const claudeCliEnv = () => ({
...process.env,
...TEST_ENV_BASE,
HOME: claudeCliHome,
USERPROFILE: claudeCliHome,
CLAUDE_CONFIG_DIR: path.join(claudeCliHome, '.claude'),
});
const claudeAvailable = (() => {
try {
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
const result = spawnSync('claude', ['--version'], {
encoding: 'utf-8',
timeout: 5000,
env: claudeCliEnv(),
});
return result.status === 0;
} catch (_) {
return false;
@@ -384,6 +411,7 @@ describe('C: plugin.json schema validation', () => {
cwd: ROOT,
encoding: 'utf-8',
timeout: 15000,
env: claudeCliEnv(),
});
assert.equal(
result.status,

View File

@@ -0,0 +1,861 @@
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const fc = require('fast-check');
const {
GSD_OWNED_ENTRIES,
artifactTargets,
MAX_DEPTH,
resolveLiveConfigRoots,
resolveExtraWatchTargets,
snapshotLiveConfig,
diffLiveConfig,
formatViolations,
newestMtime,
} = require('../scripts/live-config-guard.cjs');
const { cleanup } = require('./helpers.cjs');
function tmpRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'live-config-guard-'));
}
/** Create `n` flat files under a fresh dir; returns [dir, entryCount-including-dir]. */
function treeWithEntries(n) {
const dir = tmpRoot();
for (let i = 0; i < n; i++) fs.writeFileSync(path.join(dir, `f${i}`), 'x');
return [dir, n + 1]; // +1: the directory itself is lstat'd and costs budget
}
describe('#2665: live-config hermeticity guard', () => {
test('resolves real runtime config roots via the product resolver', () => {
const roots = resolveLiveConfigRoots();
// Guards the guard: an empty set would make every downstream assertion
// vacuous, and run-tests.cjs would silently skip the check.
assert.ok(roots.length > 5, `expected many runtime config roots, got ${roots.length}`);
for (const root of roots) {
assert.ok(path.isAbsolute(root), `root must be absolute: ${root}`);
}
});
test('watches the HOME-derived fallback root, not only the ambient one', () => {
// #2665 round 5: the guard resolves env-first, so it sees the AMBIENT root.
// A child that blanks CLAUDE_CONFIG_DIR (which is exactly what TEST_ENV_BASE
// does) falls back to <HOME>/.claude instead. Watching only the ambient path
// leaves that fallback unwatched -- the escape route this PR closes, one
// process deeper.
const ambient = tmpRoot();
const fakeHome = tmpRoot();
const saved = process.env.CLAUDE_CONFIG_DIR;
try {
process.env.CLAUDE_CONFIG_DIR = ambient;
const roots = resolveLiveConfigRoots({ os: { homedir: () => fakeHome } });
assert.ok(
roots.includes(path.resolve(ambient)),
`ambient root missing from ${JSON.stringify(roots)}`,
);
assert.ok(
roots.includes(path.resolve(path.join(fakeHome, '.claude'))),
`HOME-derived fallback root missing from ${JSON.stringify(roots)}`,
);
} finally {
if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = saved;
cleanup(ambient);
cleanup(fakeHome);
}
});
test('a clean run produces no violations', () => {
const root = tmpRoot();
try {
fs.mkdirSync(path.join(root, 'gsd-core', 'bin'), { recursive: true });
fs.writeFileSync(path.join(root, 'gsd-core', 'bin', 'x.cjs'), 'x');
const before = snapshotLiveConfig([root]);
const after = snapshotLiveConfig([root]);
assert.deepStrictEqual(diffLiveConfig(before, after), []);
} finally {
cleanup(root);
}
});
test('detects a global install CREATED during the run', () => {
const root = tmpRoot();
try {
const before = snapshotLiveConfig([root]);
// Exactly the Blocker 1 shape: an in-process install(true, …) landing a
// full global install in a live config dir that was previously empty.
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
fs.writeFileSync(path.join(root, 'gsd-file-manifest.json'), '{}');
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
const kinds = Object.fromEntries(violations.map((v) => [path.basename(v.path), v.kind]));
assert.strictEqual(kinds['gsd-core'], 'created');
assert.strictEqual(kinds['gsd-file-manifest.json'], 'created');
} finally {
cleanup(root);
}
});
test('detects an existing install MODIFIED during the run', () => {
const root = tmpRoot();
try {
const target = path.join(root, 'gsd-core', 'bin');
fs.mkdirSync(target, { recursive: true });
const file = path.join(target, 'gsd-tools.cjs');
fs.writeFileSync(file, 'original');
const before = snapshotLiveConfig([root]);
// mtime resolution is coarse on some filesystems; set it forward explicitly
// rather than racing the clock with a sleep.
const future = new Date(Date.now() + 10000);
fs.writeFileSync(file, 'clobbered');
fs.utimesSync(file, future, future);
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'modified');
assert.strictEqual(path.basename(violations[0].path), 'gsd-core');
} finally {
cleanup(root);
}
});
test('ignores non-GSD writes in a shared config root', () => {
const root = tmpRoot();
try {
const before = snapshotLiveConfig([root]);
// A concurrent host-agent session writing its own state must NOT trip the
// guard — a guard that cries wolf gets disabled, and then catches nothing.
fs.writeFileSync(path.join(root, 'history.jsonl'), '{}');
fs.mkdirSync(path.join(root, 'todos'), { recursive: true });
fs.writeFileSync(path.join(root, 'settings.json'), '{}');
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
} finally {
cleanup(root);
}
});
test('watches exactly the GSD-owned entry set', () => {
const root = tmpRoot();
try {
const snap = snapshotLiveConfig([root]);
const watched = Object.keys(snap).map((p) => path.relative(root, p)).sort();
// Top-level owned entries PLUS the nested paths GSD owns wholesale inside a
// shared root; prefixed children contribute nothing in an empty root.
const expected = [
...GSD_OWNED_ENTRIES,
...artifactTargets().owned.map((o) => path.join(...o.split('/'))),
].sort();
assert.deepStrictEqual(watched, expected);
} finally {
cleanup(root);
}
});
test('detects a gsd-prefixed artifact written into a SHARED dir', () => {
const root = tmpRoot();
try {
fs.mkdirSync(path.join(root, 'skills'), { recursive: true });
const before = snapshotLiveConfig([root]);
// The exact leak the first version of this guard MISSED: a writer that
// sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR landed
// <live>/skills/gsd-dev-preferences/SKILL.md, outside the three
// top-level GSD entries.
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'created');
assert.strictEqual(path.basename(violations[0].path), 'gsd-dev-preferences');
} finally {
cleanup(root);
}
});
test('ignores NON-gsd artifacts in a shared dir', () => {
const root = tmpRoot();
try {
fs.mkdirSync(path.join(root, 'skills'), { recursive: true });
const before = snapshotLiveConfig([root]);
// The host agent's own skills must not trip the guard.
fs.mkdirSync(path.join(root, 'skills', 'my-personal-skill'), { recursive: true });
fs.writeFileSync(path.join(root, 'skills', 'my-personal-skill', 'SKILL.md'), '# mine');
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
} finally {
cleanup(root);
}
});
test('detects a leaked hook script and the install marker files', () => {
// Self-found by re-deriving the census at round 5 rather than by a review
// finding. bin/install.js writes hooks/gsd-*.js, .gsd-source and .gsd-profile
// into the config ROOT; `hooks` was absent from GSD_PREFIXED_PARENTS and the
// two dot-prefixed markers from GSD_OWNED_ENTRIES, so all three leaked past
// the guard silently -- the same shape as the skills/gsd-dev-preferences miss
// that motivated the prefixed-parent scan in the first place.
const root = tmpRoot();
try {
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
const before = snapshotLiveConfig([root]);
fs.writeFileSync(path.join(root, 'hooks', 'gsd-check-update.js'), '// x');
fs.writeFileSync(path.join(root, '.gsd-source'), 'npm');
fs.writeFileSync(path.join(root, '.gsd-profile'), 'default');
const created = diffLiveConfig(before, snapshotLiveConfig([root]))
.filter((v) => v.kind === 'created')
.map((v) => path.basename(v.path))
.sort();
assert.deepStrictEqual(created, ['.gsd-profile', '.gsd-source', 'gsd-check-update.js']);
} finally {
cleanup(root);
}
});
test('a NON-gsd hook belonging to the host agent is still ignored', () => {
// Widening GSD_PREFIXED_PARENTS must not widen ownership: `hooks/` is shared
// with the host agent, and a guard that flags its files gets switched off.
const root = tmpRoot();
try {
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
const before = snapshotLiveConfig([root]);
fs.writeFileSync(path.join(root, 'hooks', 'my-own-hook.js'), '// mine');
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []);
} finally {
cleanup(root);
}
});
test('artifact parents are DERIVED from the registry, not hand-listed', () => {
// Round 5's adversarial review refuted the completeness claim of the
// hand-list: it missed Kilo's SINGULAR `command/`, `workflows/`, and hermes'
// `skills/gsd` -- a whole directory whose name carries no `gsd-` prefix, so
// no prefix rule could ever reach it.
const { parents, owned } = artifactTargets();
// NB: `workflows` is declared only in LOCAL scope (windsurf), so it is
// deliberately NOT a global config-root parent -- the derivation walks
// artifactLayout.global only.
for (const p of ['agents', 'commands', 'command', 'skills', 'hooks', 'plugins', 'scripts', 'extensions']) {
assert.ok(p in parents, `expected derived parent ${p} in ${JSON.stringify(Object.keys(parents))}`);
}
// kimi's kimi-agents layout declares prefix `gsd` (no hyphen); a fixed `gsd-`
// scan cannot see agents/gsd.yaml, which is the defect this derivation closes.
assert.ok(
parents.agents.includes('gsd'),
`agents must carry kimi's bare 'gsd' prefix; got ${JSON.stringify(parents.agents)}`,
);
assert.ok(owned.includes('skills/gsd'), `expected hermes skills/gsd in ${JSON.stringify(owned)}`);
// Exact GSD filenames only — never the shared directories that contain them.
for (const o of ['scripts/fix-slash-commands.cjs', 'hooks/managed-hooks-registry.cjs']) {
assert.ok(owned.includes(o), `expected owned nested ${o} in ${JSON.stringify(owned)}`);
}
for (const shared of ['hooks/lib', 'hooks/package.json', 'scripts/lib', 'scripts/changeset']) {
assert.ok(!owned.includes(shared), `${shared} is shared ground and must NOT be watched wholesale`);
}
});
test('detects leaks a single hardcoded gsd- prefix cannot see', () => {
const root = tmpRoot();
try {
for (const d of ['skills/gsd', 'agents', 'plugins', 'command', 'extensions']) {
fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true });
}
const before = snapshotLiveConfig([root]);
const future = new Date(Date.now() + 10000);
// kimi declares prefix `gsd` (no hyphen) and writes agents/gsd.yaml;
// pi writes extensions/gsd.js. A fixed `gsd-` scan sees neither.
const leaks = [
['skills', 'gsd', 'executor.md'],
['agents', 'gsd.yaml'],
['extensions', 'gsd.js'],
['plugins', 'gsd-core.js'],
['command', 'gsd-plan.md'],
];
for (const seg of leaks) {
const f = path.join(root, ...seg);
fs.writeFileSync(f, '// leaked');
fs.utimesSync(f, future, future);
fs.utimesSync(path.dirname(f), future, future);
}
const hit = diffLiveConfig(before, snapshotLiveConfig([root])).map((v) => v.path);
for (const expected of ['skills/gsd', 'agents/gsd.yaml', 'extensions/gsd.js', 'plugins/gsd-core.js', 'command/gsd-plan.md']) {
const abs = path.join(root, ...expected.split('/'));
assert.ok(hit.includes(abs), `${expected} leaked undetected; got ${JSON.stringify(hit)}`);
}
} finally {
cleanup(root);
}
});
test('a user editing their OWN files in a shared dir is not a violation', () => {
// The false-positive case a prior commit shipped: hooks/lib, hooks/package.json,
// scripts/lib and scripts/changeset were watched WHOLESALE, so touching a
// user-authored helper in any of them tripped the guard. The installer itself
// preserves foreign files in all four, so they are not GSD's to watch.
const root = tmpRoot();
try {
for (const d of ['hooks/lib', 'scripts/lib', 'scripts/changeset']) {
fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true });
}
const foreign = [
['hooks', 'package.json'],
['hooks', 'lib', 'user-helper.js'],
['scripts', 'lib', 'user-helper.cjs'],
['scripts', 'changeset', 'user-tool.cjs'],
];
for (const seg of foreign) fs.writeFileSync(path.join(root, ...seg), 'mine');
const before = snapshotLiveConfig([root]);
const future = new Date(Date.now() + 10000);
for (const seg of foreign) {
const f = path.join(root, ...seg);
fs.writeFileSync(f, 'mine, edited');
fs.utimesSync(f, future, future);
fs.utimesSync(path.dirname(f), future, future);
}
assert.deepStrictEqual(
diffLiveConfig(before, snapshotLiveConfig([root])),
[],
'editing user-owned files in a shared dir must not trip the guard',
);
} finally {
cleanup(root);
}
});
test('extra watch targets cover the fallback root as well as the ambient one', () => {
// The MISSED finding from round 5's review: B3 closed this for the registry
// roots and left the identical hole in resolveExtraWatchTargets.
const targets = resolveExtraWatchTargets({
env: { GSD_HOME: '/ambient-gsd-home', KIMI_SHARE_DIR: '/ambient-kimi' },
os: { homedir: () => '/fallback-home' },
});
assert.ok(targets.includes(path.resolve('/ambient-gsd-home/.gsd')), 'ambient $GSD_HOME/.gsd');
assert.ok(targets.includes(path.resolve('/fallback-home/.gsd')), 'HOME-derived .gsd fallback');
assert.ok(
targets.some((t) => t === path.resolve('/fallback-home/.kimi/config.toml')),
`HOME-derived kimi fallback missing from ${JSON.stringify(targets)}`,
);
});
test('detects a DELETED top-level GSD entry', () => {
const root = tmpRoot();
try {
// A fixed owned entry is recorded at BOTH ends whether or not it exists,
// so a deletion reads {exists:true} -> {exists:false}. Before the union
// walk that pair matched no branch at all and the run passed silently.
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
fs.writeFileSync(path.join(root, 'gsd-core', 'x'), 'x');
const before = snapshotLiveConfig([root]);
cleanup(path.join(root, 'gsd-core'));
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
const deleted = violations.filter((v) => v.kind === 'deleted');
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
assert.strictEqual(path.basename(deleted[0].path), 'gsd-core');
} finally {
cleanup(root);
}
});
test('detects a DELETED gsd-prefixed child of a shared dir', () => {
const root = tmpRoot();
try {
// The shape a `pre.exists && !post.exists` branch cannot reach on its own:
// prefixed children are DISCOVERED by readdir, so a deleted one is absent
// from the `after` snapshot entirely and never enters an after-keyed loop.
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
const before = snapshotLiveConfig([root]);
cleanup(path.join(root, 'skills', 'gsd-dev-preferences'));
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
const deleted = violations.filter((v) => v.kind === 'deleted');
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
assert.strictEqual(path.basename(deleted[0].path), 'gsd-dev-preferences');
} finally {
cleanup(root);
}
});
test('the scan budget is PER TARGET, so one big tree cannot cascade unverified', () => {
// #2665 round 5: with a single running budget, target A exhausting it made
// target B report `truncated` -> `unverified` -- a strict-mode FAILURE caused
// by an unrelated directory. Each target now gets its own allotment.
const [big] = treeWithEntries(8);
const [small] = treeWithEntries(2);
try {
const snap = snapshotLiveConfig([], [big, small], { perTarget: 9, total: 1000 });
assert.strictEqual(snap[path.resolve(big)].truncated, false, 'big target should fit its own budget');
assert.strictEqual(
snap[path.resolve(small)].truncated,
false,
'small target must NOT inherit exhaustion from a target scanned before it',
);
} finally {
cleanup(big);
cleanup(small);
}
});
test('the truncation verdict does not depend on target ORDER (below the global ceiling)', () => {
const [big] = treeWithEntries(8);
const [small] = treeWithEntries(2);
try {
const limits = { perTarget: 9, total: 1000 };
const a = snapshotLiveConfig([], [big, small], limits);
const b = snapshotLiveConfig([], [small, big], limits);
assert.strictEqual(a[path.resolve(small)].truncated, b[path.resolve(small)].truncated);
assert.strictEqual(a[path.resolve(big)].truncated, b[path.resolve(big)].truncated);
} finally {
cleanup(big);
cleanup(small);
}
});
test('a non-finite injected limit falls back to the real bound, never fails open', () => {
// Math.max(0, NaN) is NaN, and every budget comparison against NaN is false,
// so the walk becomes unbounded — the one thing the bound exists to prevent.
// The discriminator has to be a case where the two behaviours DIFFER: pair a
// NaN perTarget with a small finite ceiling. Fixed, perTarget falls back to
// MAX_ENTRIES and the ceiling still bites (truncated). Broken, min(NaN, 3) is
// NaN and nothing truncates at all.
const [dir] = treeWithEntries(6);
try {
const snap = snapshotLiveConfig([], [dir], { perTarget: NaN, total: 3 });
assert.strictEqual(
snap[path.resolve(dir)].truncated,
true,
'a NaN perTarget must fall back to a real bound, not disable budgeting',
);
} finally {
cleanup(dir);
}
});
test('the GLOBAL ceiling still bounds the aggregate, and reports unverified', () => {
// The bound the single budget was really for is kept -- but when it engages,
// the curtailed target is reported rather than silently attested clean.
const [a] = treeWithEntries(5);
const [b] = treeWithEntries(5);
try {
const snap = snapshotLiveConfig([], [a, b], { perTarget: 6, total: 6 });
assert.strictEqual(snap[path.resolve(a)].truncated, false);
assert.strictEqual(snap[path.resolve(b)].truncated, true, 'ceiling-curtailed target must be truncated');
const violations = diffLiveConfig(snap, snap);
assert.ok(
violations.some((v) => v.kind === 'unverified' && v.path === path.resolve(b)),
`expected an unverified violation for the curtailed target: ${JSON.stringify(violations)}`,
);
} finally {
cleanup(a);
cleanup(b);
}
});
test('the report names the path and the remedy', () => {
const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]);
assert.match(out, /HERMETICITY WARNING/);
assert.match(out, /\/live\/\.claude\/gsd-core/);
assert.match(out, /scrubConfigLocationEnv/);
assert.match(out, /GSD_SKIP_LIVE_CONFIG_GUARD/);
assert.match(out, /GSD_STRICT_LIVE_CONFIG_GUARD/);
});
});
// ── Round 3: the write surfaces that are not runtime config ROOTS ───────────
describe('#2665: guard watches non-root write surfaces', () => {
test('resolveExtraWatchTargets covers $GSD_HOME/.gsd and kimi config.toml', () => {
const home = tmpRoot();
const share = tmpRoot();
try {
const targets = resolveExtraWatchTargets({
env: { GSD_HOME: home, KIMI_SHARE_DIR: share },
os: { homedir: () => home },
});
assert.ok(
targets.includes(path.resolve(path.join(home, '.gsd'))),
`expected $GSD_HOME/.gsd in ${JSON.stringify(targets)}`,
);
assert.ok(
targets.some((t) => t === path.resolve(path.join(share, 'config.toml'))),
`expected kimi config.toml in ${JSON.stringify(targets)}`,
);
} finally {
cleanup(home);
cleanup(share);
}
});
test('extra targets are DERIVED from the descriptor array, not a named resolver', () => {
const {
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
resolveConfigHomeFromDescriptor,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
const home = tmpRoot();
try {
const env = { GSD_HOME: home };
const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } });
// Every descriptor in the array must contribute a target. Calling one
// named resolver instead would cover one of today's two entries and silently
// miss tomorrow's — the same partial-enumeration defect that put
// KIMI_SHARE_DIR outside the scrub set, one layer over.
//
// SCOPE BOUNDARY (per round-2 Nit 7, and it bites here): this asserts one
// target PER DESCRIPTOR and nothing about whether one target per descriptor
// is ENOUGH. It is not — <root>/hooks/ is also GSD-written and unwatched
// (named residual in resolveExtraWatchTargets). A test whose expectation is
// derived from the same array it checks cannot see that class.
for (const d of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) {
const dir = resolveConfigHomeFromDescriptor(d, { env, home });
assert.ok(
targets.includes(path.resolve(path.join(dir, 'config.toml'))),
`descriptor ${JSON.stringify(d.env)} contributed no watch target`,
);
}
// The count is what actually catches a regression to a hardcoded call:
// it fails the moment the array grows and the guard does not follow.
assert.strictEqual(
targets.length,
1 + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.length,
'expected the GSD store root plus exactly one target per descriptor',
);
} finally {
cleanup(home);
}
});
test('#2755: kimi-code config.toml is watched — named, not enumeration-relative', () => {
// The test above derives its expectation FROM the descriptor array, so it
// passes for whatever that array happens to contain and cannot see a
// descriptor that was never added — the enumeration-relative scope boundary
// this suite already calls out one layer down. #2755 landed kimi-code's
// `~/.kimi-code` (KIMI_CODE_HOME) on `next` as an inline literal inside
// resolveKimiHooksTomlDir's body; until it was hoisted into
// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS the guard watched Kimi CLI's
// config.toml and not Kimi Code's. Naming the path is what makes dropping
// the descriptor fail loudly instead of quietly shrinking the expectation.
const home = tmpRoot();
const codeHome = tmpRoot();
try {
const env = { GSD_HOME: home, KIMI_CODE_HOME: codeHome };
const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } });
assert.ok(
targets.includes(path.resolve(path.join(codeHome, 'config.toml'))),
`expected kimi-code config.toml in ${JSON.stringify(targets)}`,
);
} finally {
cleanup(home);
cleanup(codeHome);
}
});
test('GSD_HOME falls back to homedir when unset', () => {
const home = tmpRoot();
try {
const targets = resolveExtraWatchTargets({ env: {}, os: { homedir: () => home } });
assert.ok(targets.includes(path.resolve(path.join(home, '.gsd'))));
} finally {
cleanup(home);
}
});
test('detects a consent/defaults write into $GSD_HOME/.gsd', () => {
const home = tmpRoot();
try {
const target = path.join(home, '.gsd');
const before = snapshotLiveConfig([], [target]);
// The Blocker-1 shape one family over: an ambient GSD_HOME sends real
// consent records and defaults.json into the developer's own store.
fs.mkdirSync(target, { recursive: true });
fs.writeFileSync(path.join(target, 'consent.json'), '{}');
const violations = diffLiveConfig(before, snapshotLiveConfig([], [target]));
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'created');
} finally {
cleanup(home);
}
});
test('detects a [[hooks]] write into kimi config.toml', () => {
const share = tmpRoot();
try {
const target = path.join(share, 'config.toml');
const before = snapshotLiveConfig([], [target]);
fs.writeFileSync(target, '[[hooks]]\n');
const violations = diffLiveConfig(before, snapshotLiveConfig([], [target]));
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'created');
} finally {
cleanup(share);
}
});
test('NEGATIVE CONTROL: without the extras both leaks are silent', () => {
const home = tmpRoot();
try {
// This is the pre-round-3 guard shape — roots only. It is what let a leak
// on either variable pass through the PR's own safety net unreported.
const before = snapshotLiveConfig([]);
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
fs.writeFileSync(path.join(home, '.gsd', 'consent.json'), '{}');
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([])), []);
} finally {
cleanup(home);
}
});
test('a whole-dir extra target does not watch unrelated siblings', () => {
const home = tmpRoot();
try {
const target = path.join(home, '.gsd');
fs.mkdirSync(target, { recursive: true });
const before = snapshotLiveConfig([], [target]);
// A sibling of .gsd is outside the watched target entirely.
fs.writeFileSync(path.join(home, 'unrelated.json'), '{}');
assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([], [target])), []);
} finally {
cleanup(home);
}
});
});
// ── Round 3: the truncation budget — the module's own safety-critical case ───
describe('#2665: scan-budget truncation', () => {
test('boundary: limit-1 truncates, limit and limit+1 do not', () => {
const [dir, entries] = treeWithEntries(24);
try {
// RULESET.TESTS.boundary-coverage: N in {limit-1, limit, limit+1}. The
// budget is injected, so the boundary is exercised at a real threshold
// without materialising MAX_ENTRIES files.
assert.strictEqual(
newestMtime(dir, { remaining: entries - 1 }).truncated,
true,
'one entry short of the tree size MUST truncate',
);
assert.strictEqual(
newestMtime(dir, { remaining: entries }).truncated,
false,
'a budget exactly equal to the tree size must NOT truncate',
);
assert.strictEqual(
newestMtime(dir, { remaining: entries + 1 }).truncated,
false,
'a budget above the tree size must NOT truncate',
);
} finally {
cleanup(dir);
}
});
// RULESET.TESTS.boundary-coverage asks for {limit-1, limit, limit+1}. This was
// exercised only at limit+2, which pins neither side of the edge: an off-by-one
// that truncated a legal depth would have passed. `nestedDepth(n)` builds a tree
// whose deepest entry sits at walk-depth n below the scanned root, and
// newestMtime truncates iff that depth EXCEEDS MAX_DEPTH.
const nestedDepth = (n) => {
const dir = tmpRoot();
let deep = dir;
for (let i = 0; i < n; i++) deep = path.join(deep, `d${i}`);
fs.mkdirSync(deep, { recursive: true });
return dir;
};
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH - 1} (limit-1) does NOT truncate`, () => {
const dir = nestedDepth(MAX_DEPTH - 1);
try {
assert.strictEqual(newestMtime(dir, { remaining: 1e6 }).truncated, false);
} finally {
cleanup(dir);
}
});
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH} (limit) does NOT truncate`, () => {
const dir = nestedDepth(MAX_DEPTH);
try {
assert.strictEqual(
newestMtime(dir, { remaining: 1e6 }).truncated,
false,
'a tree exactly at MAX_DEPTH is within bounds and must be attested',
);
} finally {
cleanup(dir);
}
});
test(`MAX_DEPTH boundary: depth ${MAX_DEPTH + 1} (limit+1) truncates`, () => {
const dir = nestedDepth(MAX_DEPTH + 1);
try {
assert.strictEqual(
newestMtime(dir, { remaining: 1e6 }).truncated,
true,
'a tree deeper than MAX_DEPTH must truncate',
);
} finally {
cleanup(dir);
}
});
test('a truncated scan reports UNVERIFIED, never clean', () => {
const [dir, entries] = treeWithEntries(10);
try {
// The safety-critical branch named in this module's own docstring: a scan
// that hit a bound must not read as an attestation of cleanliness.
const snap = { [dir]: { exists: true, newest: 1, truncated: true } };
const violations = diffLiveConfig(snap, {
[dir]: { exists: true, newest: 1, truncated: true },
});
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].kind, 'unverified');
assert.match(formatViolations(violations), /UNVERIFIED \(scan bound hit/);
assert.ok(entries > 0);
} finally {
cleanup(dir);
}
});
test('a modified path outranks unverified (a real leak is never downgraded)', () => {
const p = '/live/.claude/gsd-core';
const violations = diffLiveConfig(
{ [p]: { exists: true, newest: 1, truncated: true } },
{ [p]: { exists: true, newest: 2, truncated: true } },
);
assert.strictEqual(violations[0].kind, 'modified');
});
test('property: truncation is monotone in the budget (boundary containment)', () => {
const [dir, entries] = treeWithEntries(12);
try {
fc.assert(
fc.property(fc.integer({ min: 1, max: entries * 3 }), (budget) => {
const { truncated } = newestMtime(dir, { remaining: budget });
// The invariant: a budget at or above the tree size never truncates,
// and one below it always does. A regression flipping `truncated` to
// false on an exhausted budget — the exact silent-clean failure the
// module warns about — breaks this for every budget < entries.
return budget >= entries ? truncated === false : truncated === true;
}),
{ numRuns: 100 },
);
} finally {
cleanup(dir);
}
});
test('property: newest mtime never exceeds the true maximum', () => {
const [dir, entries] = treeWithEntries(8);
try {
const trueMax = Math.max(
...fs.readdirSync(dir).map((f) => fs.lstatSync(path.join(dir, f)).mtimeMs),
fs.lstatSync(dir).mtimeMs,
);
fc.assert(
fc.property(fc.integer({ min: 1, max: entries * 2 }), (budget) => {
const { newest } = newestMtime(dir, { remaining: budget });
return newest <= trueMax;
}),
{ numRuns: 50 },
);
} finally {
cleanup(dir);
}
});
});
describe('#2665 round 4: CI wires the guard to strict mode', () => {
// The reversion this guards: dropping GSD_STRICT_LIVE_CONFIG_GUARD from
// test.yml silently demotes the guard back to report-only, and a future
// leak of exactly the class #2665 closes prints a warning and CI stays
// green. Windows lanes are deliberately report-only until the documented
// pre-existing USERPROFILE leak class is swept (SEVERITY note in
// scripts/live-config-guard.cjs) — so the assertion is per-OS, not global.
test('both guard env vars are documented for humans, not just in the source', () => {
// A skip-switch on a safety guard has to be discoverable: an undocumented
// bypass is one people eventually set without knowing what they turned off.
// The Docs Required gate gets satisfied by ANY docs/ file in the diff --
// including a generated index -- so it cannot stand in for this.
const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'TESTING-SUITES.md'), 'utf8');
for (const v of ['GSD_STRICT_LIVE_CONFIG_GUARD', 'GSD_SKIP_LIVE_CONFIG_GUARD']) {
assert.ok(doc.includes(v), `${v} must be documented in docs/TESTING-SUITES.md`);
}
});
// DERIVED, not hand-listed. The previous version named three jobs as literals,
// so it could not see a FOURTH lane that runs the suite — and there was one:
// qa-loop-walk reaches run-tests.cjs through `npm run test:qa` and escaped
// strict mode entirely while this test stayed green. A hand-list that certifies
// its own completeness is the exact defect this PR exists to fix, reproduced in
// the test that guards the fix.
test('EVERY job that runs the suite wires GSD_STRICT_LIVE_CONFIG_GUARD', () => {
const yaml = require('js-yaml');
const root = path.join(__dirname, '..');
const wf = yaml.load(fs.readFileSync(path.join(root, '.github', 'workflows', 'test.yml'), 'utf8'));
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
// A step reaches the runner directly OR through an npm script, transitively.
// Grepping the filename alone misses the indirection that hid qa-loop-walk.
const scriptRunsSuite = (name, seen = new Set()) => {
if (seen.has(name)) return false;
seen.add(name);
const body = pkg.scripts?.[name];
if (!body) return false;
if (/run-tests\.cjs/.test(body)) return true;
return [...body.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1], seen));
};
const runsSuite = (run) =>
/run-tests\.cjs/.test(run)
|| [...run.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1]));
const suiteJobs = Object.entries(wf.jobs ?? {})
.filter(([, job]) => (job?.steps ?? []).some((s) => typeof s?.run === 'string' && runsSuite(s.run)))
.map(([name]) => name)
.sort();
// Guards the guard: an empty derivation would make every assertion below
// vacuously true, which is the failure mode of the literal list it replaces.
assert.ok(
suiteJobs.length >= 4,
`expected at least 4 suite-running jobs, derived ${JSON.stringify(suiteJobs)}`,
);
const windowsMatrix = (job) => JSON.stringify(job?.strategy?.matrix ?? {}).includes('windows');
const problems = [];
for (const name of suiteJobs) {
const job = wf.jobs[name];
const v = String(job?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? '');
// Windows lanes stay report-only until the pre-existing USERPROFILE leak
// class is swept (SEVERITY note in scripts/live-config-guard.cjs), so a
// job whose matrix includes Windows carries the conditional; an
// ubuntu/macOS-only lane must be strict outright.
const ok = windowsMatrix(job)
// The WHOLE expression, anchored — a prefix match accepted both
// `&& '1' || '1'` (Windows silently strict) and a malformed tail.
? /^\$\{\{\s*matrix\.os\s*!=\s*'windows-latest'\s*&&\s*'1'\s*\|\|\s*''\s*\}\}$/.test(v)
: v === '1';
if (!ok) problems.push(`jobs.${name}: ${JSON.stringify(v)}`);
}
assert.deepStrictEqual(
problems,
[],
`every job running run-tests.cjs must wire the guard to strict mode `
+ `(Windows matrices carved out). Derived jobs: ${JSON.stringify(suiteJobs)}`,
);
});
});

View File

@@ -389,6 +389,9 @@ test('installer copies plugin as .js, records it in the manifest, and removes it
const run = (args) => {
const result = runNode([installer, '--opencode', '--global', '--config-dir', cfg, ...args], {
timeoutMs: 120000,
// #3156: sandbox HOME — the installer writes <home>/.gsd/defaults.json via
// os.homedir() directly, which no env scrub can reach. See installSpawnEnv.
env: require('./helpers.cjs').installSpawnEnv(),
});
result.status = result.exitCode;
return result;

View File

@@ -135,6 +135,27 @@ describe('#2858 — shipped scripts require only shipped paths', () => {
.sort();
});
test('#2665: the test-instrumentation chain does not ship', () => {
// These four are one closed require chain of test instrumentation
// (run-tests -> live-config-guard, affected-tests-lib -> run-tests,
// run-affected-tests -> affected-tests-lib). Excluding a strict subset
// re-trips the shipped-requires-only-shipped gate above on whichever links
// still ship, so the exclusion set and this assertion cover the chain.
const TEST_INSTRUMENTATION = [
'scripts/live-config-guard.cjs',
'scripts/run-tests.cjs',
'scripts/affected-tests-lib.cjs',
'scripts/run-affected-tests.cjs',
];
for (const f of TEST_INSTRUMENTATION) {
assert.ok(
!shippedFiles.has(f),
`${f} is test instrumentation and must not ship — restore its ` +
"package.json files[] '!'-exclusion (and keep the whole chain excluded)",
);
}
});
test('every shipped scripts/*.{cjs,js} is require-able from a shipped-only tree', () => {
assert.ok(shippedScripts.length > 0, 'expected at least one shipped script');

View File

@@ -12,7 +12,13 @@ const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const {
runGsdTools,
createTempProject,
createTempGitProject,
cleanup,
withIsolatedProcessState,
} = require('./helpers.cjs');
const {
PROFILING_QUESTIONS,
@@ -184,6 +190,50 @@ describe('write-profile command', () => {
assert.strictEqual(out.profile_path, path.join(codexHome, 'gsd-core', 'USER-PROFILE.md'));
});
test('#2665: ambient CLAUDE_CONFIG_DIR cannot escape a HOME-only sandbox', () => {
// The defect: TEST_ENV_BASE blanked session-identity vars but none of the
// config-LOCATION vars, and the dot-home resolver is env-first. So an
// ambient CLAUDE_CONFIG_DIR in the DEVELOPER'S shell beat `{ HOME: tmpDir }`
// and the suite wrote into their real config directory. Setting it on the
// PARENT process is the actual vector — passing it in the per-call env
// argument would test nothing, because that path was never broken.
const analysis = {
profile_version: '1.0',
dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } },
};
const analysisPath = path.join(tmpDir, 'analysis.json');
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
const ambientConfigDir = path.join(tmpDir, 'ambient-live-config');
fs.mkdirSync(ambientConfigDir, { recursive: true });
const out = withIsolatedProcessState(() => {
process.env.CLAUDE_CONFIG_DIR = ambientConfigDir;
const result = runGsdTools(
['write-profile', '--input', analysisPath, '--raw'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `Failed: ${result.error}`);
return JSON.parse(result.output);
});
assert.deepStrictEqual(
fs.readdirSync(ambientConfigDir),
[],
'a call site that sandboxes HOME must not write into an ambient CLAUDE_CONFIG_DIR'
);
// Containment via path.relative, not startsWith: startsWith(ambientConfigDir)
// also matches a SIBLING like `<tmp>/ambient-live-config-2`, so it can report
// a false leak. A path is inside the dir iff the relative path neither escapes
// with '..' nor is absolute.
const rel = path.relative(ambientConfigDir, out.profile_path);
assert.ok(
rel.startsWith('..') || path.isAbsolute(rel),
`profile must not resolve under the ambient config dir, got: ${out.profile_path}`
);
});
test('errors when --input is missing', () => {
const result = runGsdTools('write-profile --raw', tmpDir);
assert.ok(!result.success, 'should fail without --input');

View File

@@ -48,28 +48,11 @@ const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const FIXTURES_ROOT = path.join(__dirname, 'fixtures', 'representative');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runTools(args, cwd) {
try {
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...args], {

View File

@@ -22,7 +22,7 @@ const path = require('path');
const { runNode } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, CONFIG_LOCATION_ENV_KEYS } = require('./helpers.cjs');
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
@@ -1412,10 +1412,17 @@ describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
* We test the identical logic paths using a tiny timeout.
*/
function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) {
// The session-identity subset stays a local literal on purpose: this helper
// mirrors the production one to prove its CONTRACT, so it must not simply
// re-import what it is testing. The config-LOCATION keys are the exception —
// they are a safety scrub rather than part of the contract under test, and a
// hand-copied list of them is the #2665 drift this change exists to end. So
// spread the canonical derived set (tests/helpers.cjs) and keep the rest local.
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
...Object.fromEntries(CONFIG_LOCATION_ENV_KEYS.map((k) => [k, ''])),
};
try {
let result;

View File

@@ -26,7 +26,7 @@ const { resolveRuntimeArtifactLayout, findInstallSourceRoot } = require('../gsd-
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs');
const { install } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
@@ -687,6 +687,7 @@ describe('#1477 .gsd-source marker provisioning', () => {
let savedUserProfile;
let savedExplicitConfigDir;
let savedTestMode;
let restoreConfigLocationEnv;
function silenceConsole(fn) {
const orig = { log: console.log, warn: console.warn, error: console.error };
@@ -732,6 +733,12 @@ describe('#1477 .gsd-source marker provisioning', () => {
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
savedTestMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
// #2665: this block calls install(true, 'claude') IN-PROCESS. Redirecting
// HOME/USERPROFILE is not enough, because getGlobalConfigDir is env-FIRST:
// an ambient CLAUDE_CONFIG_DIR wins over the fixture and a full global
// install lands in the developer's live config dir. Found by the post-suite
// hermeticity guard (scripts/live-config-guard.cjs), not by inspection.
restoreConfigLocationEnv = scrubConfigLocationEnv();
});
afterEach(() => {
@@ -743,6 +750,7 @@ describe('#1477 .gsd-source marker provisioning', () => {
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = savedTestMode;
restoreConfigLocationEnv();
cleanup(tmpRoot);
});
@@ -938,6 +946,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
let savedUserProfile;
let savedExplicitConfigDir;
let savedTestMode;
let restoreConfigLocationEnv;
// Run install() with process.exit and console output mocked via t.mock (auto-restored),
// per CONTRIBUTING.md test rules (no manual monkeypatch / try-finally in test bodies).
@@ -962,6 +971,13 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
savedTestMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
// #2665: same in-process hazard as the block above. This one calls
// install(true, 'claude') via runInstall(), and HOME/USERPROFILE alone do
// not contain it — getGlobalConfigDir is env-FIRST, so an ambient
// CLAUDE_CONFIG_DIR beats the fixture, the install lands in the developer's
// live config dir, and these tests then fail looking for a marker under
// tmpRoot that was never written there.
restoreConfigLocationEnv = scrubConfigLocationEnv();
});
afterEach(() => {
@@ -973,6 +989,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () =>
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = savedTestMode;
restoreConfigLocationEnv();
cleanup(tmpRoot);
});