diff --git a/.changeset/quiet-moons-derive.md b/.changeset/quiet-moons-derive.md new file mode 100644 index 000000000..b0cae781c --- /dev/null +++ b/.changeset/quiet-moons-derive.md @@ -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). diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24d8e8912..19240a797 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/CONTEXT.md b/CONTEXT.md index ef499005c..b76f809c0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 /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` --- diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index b4efe1c43..9956a6ff9 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -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 #" }, + { + "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 /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", diff --git a/docs/TESTING-SUITES.md b/docs/TESTING-SUITES.md index aa126fdf2..fa4e199cb 100644 --- a/docs/TESTING-SUITES.md +++ b/docs/TESTING-SUITES.md @@ -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 diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index dc011439c..b64b5063f 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -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, @@ -28,2497 +29,2557 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 567 + "line": 576 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 551 + "line": 560 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 550 + "line": 559 + }, + { + "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", + "line": 594 + }, + { + "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", + "line": 593 + }, + { + "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", + "line": 591 + }, + { + "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", + "line": 592 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 581 + "line": 590 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect", "klass": "DEFECT", "value": "tests/planner-decomposition.test.cjs (\"planner is under 45K chars (proves mode sections were extracted)\") and tests/reachability-check.test.cjs (\"file stays under 50000 char limit\")", - "line": 783 + "line": 802 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward", "klass": "DEFECT", "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file", - "line": 784 + "line": 803 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state", "klass": "DEFECT", "value": "gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says \"45K\" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over", - "line": 782 + "line": 801 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom", "klass": "DEFECT", "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold", - "line": 781 + "line": 800 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect", "klass": "DEFECT", "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations", - "line": 991 + "line": 1010 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples", "klass": "DEFECT", "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)", - "line": 990 + "line": 1009 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward", "klass": "DEFECT", "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct", - "line": 992 + "line": 1011 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson", "klass": "DEFECT", "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes", - "line": 993 + "line": 1012 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom", "klass": "DEFECT", "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff", - "line": 989 + "line": 1008 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect", "klass": "DEFECT", "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale", - "line": 763 + "line": 782 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples", "klass": "DEFECT", "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)", - "line": 762 + "line": 781 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease", - "line": 764 + "line": 783 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom", "klass": "DEFECT", "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved", - "line": 761 + "line": 780 }, { "id": "DEFECT.CANARY-VERSION-LEAK.detect", "klass": "DEFECT", "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version", - "line": 946 + "line": 965 }, { "id": "DEFECT.CANARY-VERSION-LEAK.examples", "klass": "DEFECT", "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base", - "line": 945 + "line": 964 }, { "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward", "klass": "DEFECT", "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main", - "line": 947 + "line": 966 }, { "id": "DEFECT.CANARY-VERSION-LEAK.symptom", "klass": "DEFECT", "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label", - "line": 944 + "line": 963 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect", "klass": "DEFECT", "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls", - "line": 788 + "line": 807 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples", "klass": "DEFECT", "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle", - "line": 787 + "line": 806 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward", "klass": "DEFECT", "value": "author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess", - "line": 789 + "line": 808 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom", "klass": "DEFECT", "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number", - "line": 786 + "line": 805 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect", "klass": "DEFECT", "value": "any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts", - "line": 823 + "line": 842 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", "klass": "DEFECT", "value": "#3309 v2 default flip from mid-flight to end-of-phase", - "line": 822 + "line": 841 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward", "klass": "DEFECT", "value": "template — \"new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set \"", - "line": 824 + "line": 843 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom", "klass": "DEFECT", "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)", - "line": 821 + "line": 840 }, { "id": "DEFECT.FORMAT", "klass": "DEFECT", "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable", - "line": 738 + "line": 757 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect", "klass": "DEFECT", "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it", - "line": 836 + "line": 855 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples", "klass": "DEFECT", "value": "#586/PR #650 ship.md verification gate — grep \"^status:\" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)", - "line": 835 + "line": 854 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward", "klass": "DEFECT", "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)", - "line": 837 + "line": 856 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom", "klass": "DEFECT", "value": "a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep \"^key:\" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted", - "line": 834 + "line": 853 }, { "id": "DEFECT.GENERATIVE-EXEMPLAR", "klass": "DEFECT", "value": "tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)", - "line": 832 + "line": 851 }, { "id": "DEFECT.GENERATIVE-FIX", "klass": "DEFECT", "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge", - "line": 831 + "line": 850 }, { "id": "DEFECT.GENERATIVE-PRIORITY", "klass": "DEFECT", "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer", - "line": 830 + "line": 849 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect", "klass": "DEFECT", "value": "two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted", - "line": 982 + "line": 1001 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward", "klass": "DEFECT", "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)", - "line": 983 + "line": 1002 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause", "klass": "DEFECT", "value": "gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()", - "line": 981 + "line": 1000 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom", "klass": "DEFECT", "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine", - "line": 980 + "line": 999 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream", "klass": "DEFECT", "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)", - "line": 984 + "line": 1003 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect", "klass": "DEFECT", "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out", - "line": 950 + "line": 969 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples", "klass": "DEFECT", "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes", - "line": 949 + "line": 968 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward", "klass": "DEFECT", "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds", - "line": 951 + "line": 970 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related", "klass": "DEFECT", "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month", - "line": 952 + "line": 971 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom", "klass": "DEFECT", "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all", - "line": 948 + "line": 967 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect", "klass": "DEFECT", "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed", - "line": 974 + "line": 993 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery", "klass": "DEFECT", "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)", - "line": 976 + "line": 995 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause", "klass": "DEFECT", "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts", - "line": 975 + "line": 994 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom", "klass": "DEFECT", "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs", - "line": 973 + "line": 992 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", "klass": "DEFECT", "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe", - "line": 977 + "line": 996 }, { "id": "DEFECT.HALT-COST-PATTERN.detect", "klass": "DEFECT", "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context", - "line": 813 + "line": 832 }, { "id": "DEFECT.HALT-COST-PATTERN.examples", "klass": "DEFECT", "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)", - "line": 812 + "line": 831 }, { "id": "DEFECT.HALT-COST-PATTERN.fix-forward", "klass": "DEFECT", "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer", - "line": 814 + "line": 833 }, { "id": "DEFECT.HALT-COST-PATTERN.symptom", "klass": "DEFECT", "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn", - "line": 811 + "line": 830 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", "klass": "DEFECT", "value": "hook re-fires on each invocation regardless of session-state read receipts", - "line": 818 + "line": 837 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples", "klass": "DEFECT", "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file", - "line": 817 + "line": 836 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward", "klass": "DEFECT", "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match", - "line": 819 + "line": 838 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking", "klass": "DEFECT", "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files", - "line": 979 + "line": 998 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom", "klass": "DEFECT", "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session", - "line": 816 + "line": 835 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass", "klass": "DEFECT", "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks", - "line": 998 + "line": 1017 }, { "id": "DEFECT.HOST-RESERVED-DIR-NAME", "klass": "DEFECT", "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees", - "line": 881 + "line": 900 }, { "id": "DEFECT.INVENTORY-DRIFT.detect", "klass": "DEFECT", "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading", - "line": 778 + "line": 797 }, { "id": "DEFECT.INVENTORY-DRIFT.examples", "klass": "DEFECT", "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)", - "line": 777 + "line": 796 }, { "id": "DEFECT.INVENTORY-DRIFT.fix-forward", "klass": "DEFECT", "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path", - "line": 779 + "line": 798 }, { "id": "DEFECT.INVENTORY-DRIFT.symptom", "klass": "DEFECT", "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json", - "line": 776 + "line": 795 }, { "id": "DEFECT.NAME-COLLISION.detect", "klass": "DEFECT", "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract", - "line": 921 + "line": 940 }, { "id": "DEFECT.NAME-COLLISION.examples", "klass": "DEFECT", "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section
\")", - "line": 920 + "line": 939 }, { "id": "DEFECT.NAME-COLLISION.fix-forward", "klass": "DEFECT", "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract", - "line": 922 + "line": 941 }, { "id": "DEFECT.NAME-COLLISION.symptom", "klass": "DEFECT", "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw", - "line": 919 + "line": 938 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect", "klass": "DEFECT", "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning", - "line": 808 + "line": 827 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", "klass": "DEFECT", "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants", - "line": 807 + "line": 826 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward", "klass": "DEFECT", "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source", - "line": 809 + "line": 828 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom", "klass": "DEFECT", "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed", - "line": 806 + "line": 825 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor", "klass": "DEFECT", "value": "tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)", - "line": 754 + "line": 773 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect", "klass": "DEFECT", "value": "grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting", - "line": 752 + "line": 771 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples", "klass": "DEFECT", "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)", - "line": 751 + "line": 770 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward", "klass": "DEFECT", "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps", - "line": 753 + "line": 772 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom", "klass": "DEFECT", "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces", - "line": 750 + "line": 769 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect", "klass": "DEFECT", "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST", - "line": 871 + "line": 890 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples", "klass": "DEFECT", "value": "PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control", - "line": 870 + "line": 889 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward", "klass": "DEFECT", "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)", - "line": 872 + "line": 891 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention", "klass": "DEFECT", "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)", - "line": 873 + "line": 892 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom", "klass": "DEFECT", "value": "scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation", - "line": 869 + "line": 888 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", "klass": "DEFECT", "value": "any new bare tag in agents/*.md", - "line": 773 + "line": 792 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples", "klass": "DEFECT", "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)", - "line": 772 + "line": 791 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", "klass": "DEFECT", "value": "hyphenate the tag (, ) — scanner regex matches bare names only", - "line": 774 + "line": 793 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom", "klass": "DEFECT", "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate", - "line": 771 + "line": 790 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.detect", "klass": "DEFECT", "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete", - "line": 742 + "line": 761 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.examples", "klass": "DEFECT", "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace", - "line": 741 + "line": 760 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward", "klass": "DEFECT", "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility", - "line": 743 + "line": 762 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.symptom", "klass": "DEFECT", "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)", - "line": 740 + "line": 759 }, { "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", "klass": "DEFECT", "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)", - "line": 355 + "line": 358 }, { "id": "DEFECT.SCOPE.window", "klass": "DEFECT", "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287", - "line": 737 + "line": 756 }, { "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie", "klass": "DEFECT", "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open", - "line": 923 + "line": 942 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect", "klass": "DEFECT", "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)", - "line": 934 + "line": 953 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples", "klass": "DEFECT", "value": "#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002", - "line": 933 + "line": 952 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward", "klass": "DEFECT", "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class", - "line": 935 + "line": 954 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom", "klass": "DEFECT", "value": "a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with \"Cannot find module\" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT", - "line": 932 + "line": 951 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)", - "line": 936 + "line": 955 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect", "klass": "DEFECT", "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation", - "line": 827 + "line": 846 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward", "klass": "DEFECT", "value": "replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification", - "line": 828 + "line": 847 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom", "klass": "DEFECT", "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script", - "line": 826 + "line": 845 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect", "klass": "DEFECT", "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts", - "line": 758 + "line": 777 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples", "klass": "DEFECT", "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged", - "line": 757 + "line": 776 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward", "klass": "DEFECT", "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)", - "line": 759 + "line": 778 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom", "klass": "DEFECT", "value": "PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts", - "line": 756 + "line": 775 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern", "klass": "DEFECT", "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing", - "line": 942 + "line": 961 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect", "klass": "DEFECT", "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\"", - "line": 940 + "line": 959 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples", "klass": "DEFECT", "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all", - "line": 939 + "line": 958 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward", "klass": "DEFECT", "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")", - "line": 941 + "line": 960 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom", "klass": "DEFECT", "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first", - "line": 938 + "line": 957 }, { "id": "DEFECT.STATE-TRAMPLE.detect", "klass": "DEFECT", "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress", - "line": 747 + "line": 766 }, { "id": "DEFECT.STATE-TRAMPLE.examples", "klass": "DEFECT", "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)", - "line": 746 + "line": 765 }, { "id": "DEFECT.STATE-TRAMPLE.fix-forward", "klass": "DEFECT", "value": "route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)", - "line": 748 + "line": 767 }, { "id": "DEFECT.STATE-TRAMPLE.symptom", "klass": "DEFECT", "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter", - "line": 745 + "line": 764 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor", "klass": "DEFECT", "value": "lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)", - "line": 988 + "line": 1007 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect", "klass": "DEFECT", "value": "sub-agent returns prematurely with text like \"I should wait for the notification per CLAUDE.md\" and incomplete work in its worktree (commits absent, push absent, PR absent)", - "line": 986 + "line": 1005 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward", "klass": "DEFECT", "value": "keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task", - "line": 987 + "line": 1006 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom", "klass": "DEFECT", "value": "spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness \"you will be notified\" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator", - "line": 985 + "line": 1004 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect", "klass": "DEFECT", "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded", - "line": 768 + "line": 787 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", "klass": "DEFECT", "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)", - "line": 767 + "line": 786 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward", "klass": "DEFECT", "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history", - "line": 769 + "line": 788 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom", "klass": "DEFECT", "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts", - "line": 766 + "line": 785 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect", "klass": "DEFECT", "value": "test does readFileSync(md).match for a bash fence with literal \\n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703", - "line": 840 + "line": 859 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples", "klass": "DEFECT", "value": "#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \\n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite", - "line": 839 + "line": 858 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward", "klass": "DEFECT", "value": "match the fence with \\r?\\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file", - "line": 841 + "line": 860 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom", "klass": "DEFECT", "value": "a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \\n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\\...) is un-globbable in bash so the pipeline returns empty and assertions fail", - "line": 838 + "line": 857 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect", "klass": "DEFECT", "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view", - "line": 803 + "line": 822 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", "klass": "DEFECT", "value": "a33cbe72 worktree fix bound git subprocesses with timeout", - "line": 802 + "line": 821 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward", "klass": "DEFECT", "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw", - "line": 804 + "line": 823 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom", "klass": "DEFECT", "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network", - "line": 801 + "line": 820 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect", "klass": "DEFECT", "value": "Windows CI job at \"Run unit tests\" exits with code 1 within seconds of starting, no node:test output between \"run-tests: suite=… files=N: …\" line and \"Process completed with exit code 1\"; same job on Linux/macOS runs full duration", - "line": 927 + "line": 946 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples", "klass": "DEFECT", "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed", - "line": 926 + "line": 945 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward", "klass": "DEFECT", "value": "chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths", - "line": 928 + "line": 947 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention", "klass": "DEFECT", "value": "a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)", - "line": 930 + "line": 949 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom", "klass": "DEFECT", "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)", - "line": 925 + "line": 944 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs \"Windows argv-overflow chunking (issue #3597)\" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform", - "line": 929 + "line": 948 }, { "id": "DEFECT.WINDOWS-FS-OPS.detect", "klass": "DEFECT", "value": "ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape", - "line": 798 + "line": 817 }, { "id": "DEFECT.WINDOWS-FS-OPS.examples", "klass": "DEFECT", "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim", - "line": 797 + "line": 816 }, { "id": "DEFECT.WINDOWS-FS-OPS.fix-forward", "klass": "DEFECT", "value": "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop", - "line": 799 + "line": 818 }, { "id": "DEFECT.WINDOWS-FS-OPS.symptom", "klass": "DEFECT", "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target", - "line": 796 + "line": 815 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect", "klass": "DEFECT", "value": "any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\\.md or /…\\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)", - "line": 857 + "line": 876 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples", "klass": "DEFECT", "value": "PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\\...\\gsd-ial-windsurf-XXX\\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only", - "line": 856 + "line": 875 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward", "klass": "DEFECT", "value": "normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always", - "line": 858 + "line": 877 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention", "klass": "DEFECT", "value": "enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\\\/g,'/'))", - "line": 859 + "line": 878 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom", "klass": "DEFECT", "value": "path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected", - "line": 855 + "line": 874 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect", "klass": "DEFECT", "value": "any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)", - "line": 865 + "line": 884 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples", "klass": "DEFECT", "value": "PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side", - "line": 864 + "line": 883 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward", "klass": "DEFECT", "value": "normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.", - "line": 866 + "line": 885 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention", "klass": "DEFECT", "value": "enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT", - "line": 867 + "line": 886 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom", "klass": "DEFECT", "value": "an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual", - "line": 863 + "line": 882 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect", "klass": "DEFECT", "value": "grep tests for \\`.mode & 0o777\\` / \\`.mode) === 0o\\` / \\`writeFileSync(...{ mode: 0o\\` / \\`chmodSync\\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)", - "line": 851 + "line": 870 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples", "klass": "DEFECT", "value": "#1634/PR #1638 tests/capability-lifecycle.test.cjs \"a .cjs hook command is node-prefixed so it runs without the executable bit\" failed windows-latest,24 on \"precondition: file staged without +x\" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact", - "line": 850 + "line": 869 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward", "klass": "DEFECT", "value": "gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX", - "line": 852 + "line": 871 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention", "klass": "DEFECT", "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", - "line": 853 + "line": 872 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom", "klass": "DEFECT", "value": "a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755", - "line": 849 + "line": 868 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", "klass": "DEFECT", "value": "npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done", - "line": 845 + "line": 864 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples", "klass": "DEFECT", "value": "PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes", - "line": 844 + "line": 863 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward", "klass": "DEFECT", "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)", - "line": 846 + "line": 865 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention", "klass": "DEFECT", "value": "run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it", - "line": 847 + "line": 866 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom", "klass": "DEFECT", "value": "local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \\ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally", - "line": 843 + "line": 862 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect", "klass": "DEFECT", "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present", - "line": 877 + "line": 896 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples", "klass": "DEFECT", "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge", - "line": 876 + "line": 895 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward", "klass": "DEFECT", "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves", - "line": 878 + "line": 897 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention", "klass": "DEFECT", "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime", - "line": 879 + "line": 898 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom", "klass": "DEFECT", "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail", - "line": 875 + "line": 894 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect", "klass": "DEFECT", "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook", - "line": 793 + "line": 812 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", "klass": "DEFECT", "value": "this session, branch fix/3309-... and pr-3316", - "line": 792 + "line": 811 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:", - "line": 794 + "line": 813 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom", "klass": "DEFECT", "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch", - "line": 791 + "line": 810 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 966 + "line": 985 }, { "id": "EXEC.CLASSIFY.cross-runtime", "klass": "EXEC", "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests", - "line": 968 + "line": 987 }, { "id": "EXEC.CLASSIFY.handler", "klass": "EXEC", "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)", - "line": 964 + "line": 983 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 969 + "line": 988 }, { "id": "EXEC.CLASSIFY.proactive-signal-not-usable", "klass": "EXEC", "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)", - "line": 971 + "line": 990 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 970 + "line": 989 }, { "id": "EXEC.CLASSIFY.sentinel-order", "klass": "EXEC", "value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form", - "line": 967 + "line": 986 }, { "id": "EXEC.CLASSIFY.workflow", "klass": "EXEC", "value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)", - "line": 965 + "line": 984 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", "klass": "GSD-RESEARCH", "value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob", - "line": 354 + "line": 357 }, { "id": "GSD-RESEARCH.INTEGRATION.L2-hybrid", "klass": "GSD-RESEARCH", "value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches", - "line": 352 + "line": 355 }, { "id": "GSD-RESEARCH.MODULE.package-legitimacy", "klass": "GSD-RESEARCH", "value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate", - "line": 351 + "line": 354 }, { "id": "GSD-RESEARCH.MODULE.research-provider", "klass": "GSD-RESEARCH", "value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)", - "line": 350 + "line": 353 }, { "id": "GSD-RESEARCH.MODULE.research-store", "klass": "GSD-RESEARCH", "value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache", - "line": 349 + "line": 352 }, { "id": "GSD-RESEARCH.PROVIDER.availability", "klass": "GSD-RESEARCH", "value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal", - "line": 353 + "line": 356 }, { "id": "LEARNING.prompt-budget.boundary-gap", "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", - "line": 498 + "line": 507 + }, + { + "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", + "line": 600 + }, + { + "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", + "line": 595 + }, + { + "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 /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", + "line": 597 + }, + { + "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", + "line": 596 + }, + { + "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", + "line": 599 + }, + { + "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", + "line": 598 }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations", - "line": 632 + "line": 651 }, { "id": "META.RULE.brief-no-paraphrase", "klass": "META", "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110", - "line": 633 + "line": 652 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 630 + "line": 649 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 631 + "line": 650 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 576 + "line": 585 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 577 + "line": 586 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 578 + "line": 587 }, { "id": "PR.3267.POSTMORTEM.recovery", "klass": "PR", "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]", - "line": 555 + "line": 564 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 554 + "line": 563 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 636 + "line": 655 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 642 + "line": 661 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 643 + "line": 662 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 638 + "line": 657 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 645 + "line": 664 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 641 + "line": 660 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 644 + "line": 663 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 637 + "line": 656 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 635 + "line": 654 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 639 + "line": 658 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 640 + "line": 659 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 651 + "line": 670 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 649 + "line": 668 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 650 + "line": 669 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 648 + "line": 667 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 647 + "line": 666 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 656 + "line": 675 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 657 + "line": 676 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 654 + "line": 673 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 659 + "line": 678 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 658 + "line": 677 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 655 + "line": 674 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 653 + "line": 672 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 664 + "line": 683 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 663 + "line": 682 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 666 + "line": 685 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 665 + "line": 684 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 662 + "line": 681 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 661 + "line": 680 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 670 + "line": 689 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 672 + "line": 691 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 669 + "line": 688 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 671 + "line": 690 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 668 + "line": 687 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 677 + "line": 696 }, { "id": "PRED.k325.cure", "klass": "PRED", "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/", - "line": 676 + "line": 695 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 678 + "line": 697 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 675 + "line": 694 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 674 + "line": 693 }, { "id": "PRED.k326.cure", "klass": "PRED", "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"", - "line": 682 + "line": 701 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 683 + "line": 702 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 681 + "line": 700 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 680 + "line": 699 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 686 + "line": 705 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 689 + "line": 708 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 690 + "line": 709 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 688 + "line": 707 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 687 + "line": 706 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 685 + "line": 704 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 695 + "line": 714 }, { "id": "PRED.k328.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)", - "line": 693 + "line": 712 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 694 + "line": 713 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 692 + "line": 711 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 701 + "line": 720 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 698 + "line": 717 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 699 + "line": 718 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 700 + "line": 719 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 702 + "line": 721 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 697 + "line": 716 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 706 + "line": 725 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 705 + "line": 724 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 704 + "line": 723 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 711 + "line": 730 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 713 + "line": 732 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 710 + "line": 729 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 712 + "line": 731 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 709 + "line": 728 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 708 + "line": 727 }, { "id": "PROBE.ci.surface", "klass": "PROBE", "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)", - "line": 469 + "line": 478 }, { "id": "PROBE.core.seam", "klass": "PROBE", "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)", - "line": 462 + "line": 471 }, { "id": "PROBE.edge.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 464 + "line": 473 }, { "id": "PROBE.family", "klass": "PROBE", "value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)", - "line": 460 + "line": 469 }, { "id": "PROBE.item.axes", "klass": "PROBE", "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)", - "line": 463 + "line": 472 }, { "id": "PROBE.principle", "klass": "PROBE", "value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md", - "line": 459 + "line": 468 }, { "id": "PROBE.prohib.verification", "klass": "PROBE", "value": "test|judgment", - "line": 465 + "line": 474 }, { "id": "PROBE.protocol", "klass": "PROBE", "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason", - "line": 461 + "line": 470 }, { "id": "PROBE.ui.axis", "klass": "PROBE", "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)", - "line": 467 + "line": 476 }, { "id": "PROBE.ui.seam", "klass": "PROBE", "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)", - "line": 468 + "line": 477 }, { "id": "PROBE.ui.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 466 + "line": 475 }, { "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 717 + "line": 736 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 716 + "line": 735 }, { "id": "PROC.AGENT-DISPATCH.preflight", "klass": "PROC", "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]", - "line": 715 + "line": 734 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 721 + "line": 740 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 722 + "line": 741 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 723 + "line": 742 }, { "id": "PROC.MERGE-WAVE.ordering", "klass": "PROC", "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]", - "line": 719 + "line": 738 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 720 + "line": 739 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.observed", "klass": "PROC", "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened", - "line": 996 + "line": 1015 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", "klass": "PROC", "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill", - "line": 994 + "line": 1013 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", "klass": "PROC", "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION", - "line": 995 + "line": 1014 }, { "id": "PROC.TRIAGE.comment-shape", "klass": "PROC", "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close", - "line": 1001 + "line": 1020 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 1002 + "line": 1021 }, { "id": "PROC.TRIAGE.routing-incoming", "klass": "PROC", "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest", - "line": 1000 + "line": 1019 }, { "id": "PROHIB.canon-referral", "klass": "PROHIB", "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)", - "line": 471 + "line": 480 }, { "id": "PROHIB.descriptor.shape", "klass": "PROHIB", "value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)", - "line": 476 + "line": 485 }, { "id": "PROHIB.enforce.adr", "klass": "PROHIB", "value": "docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)", - "line": 479 + "line": 488 }, { "id": "PROHIB.enforce.causation", "klass": "PROHIB", "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)", - "line": 475 + "line": 484 }, { "id": "PROHIB.enforce.failfirst", "klass": "PROHIB", "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)", - "line": 474 + "line": 483 }, { "id": "PROHIB.enforce.green-rule", "klass": "PROHIB", "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default", - "line": 472 + "line": 481 }, { "id": "PROHIB.enforce.kinds", "klass": "PROHIB", "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)", - "line": 473 + "line": 482 }, { "id": "PROHIB.judgment-tier", "klass": "PROHIB", "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)", - "line": 478 + "line": 487 }, { "id": "PROHIB.rail", "klass": "PROHIB", "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability", - "line": 477 + "line": 486 }, { "id": "PROHIB.recall", "klass": "PROHIB", "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)", - "line": 470 + "line": 479 }, { "id": "RELEASE-NOTES.ANTI-PATTERN", "klass": "RELEASE-NOTES", "value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes", - "line": 612 + "line": 631 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", "klass": "RELEASE-NOTES", "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior", - "line": 613 + "line": 632 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", "klass": "RELEASE-NOTES", "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong", - "line": 614 + "line": 633 }, { "id": "RELEASE-NOTES.DEFAULT-STATE", "klass": "RELEASE-NOTES", "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final", - "line": 588 + "line": 607 }, { "id": "RELEASE-NOTES.EXAMPLE.hotfix", "klass": "RELEASE-NOTES", "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups", - "line": 616 + "line": 635 }, { "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", "klass": "RELEASE-NOTES", "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles", - "line": 618 + "line": 637 }, { "id": "RELEASE-NOTES.EXAMPLE.rc", "klass": "RELEASE-NOTES", "value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy", - "line": 617 + "line": 636 }, { "id": "RELEASE-NOTES.GATE.hotfix", "klass": "RELEASE-NOTES", "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body", - "line": 589 + "line": 608 }, { "id": "RELEASE-NOTES.GATE.minor", "klass": "RELEASE-NOTES", "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix", - "line": 591 + "line": 610 }, { "id": "RELEASE-NOTES.GATE.rc", "klass": "RELEASE-NOTES", "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard", - "line": 590 + "line": 609 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 623 + "line": 642 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 624 + "line": 643 }, { "id": "RELEASE-NOTES.SCOPE", "klass": "RELEASE-NOTES", "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)", - "line": 587 + "line": 606 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 603 + "line": 622 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 602 + "line": 621 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 604 + "line": 623 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 605 + "line": 624 }, { "id": "RELEASE-NOTES.STANDARD.bullet-shape", "klass": "RELEASE-NOTES", "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.", - "line": 595 + "line": 614 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 599 + "line": 618 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 597 + "line": 616 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 598 + "line": 617 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 594 + "line": 613 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 600 + "line": 619 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 596 + "line": 615 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 593 + "line": 612 }, { "id": "RELEASE-NOTES.TEMPLATE.hotfix", "klass": "RELEASE-NOTES", "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ", - "line": 620 + "line": 639 }, { "id": "RELEASE-NOTES.TEMPLATE.rc", "klass": "RELEASE-NOTES", "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 621 + "line": 640 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 607 + "line": 626 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 610 + "line": 629 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 609 + "line": 628 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 608 + "line": 627 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 522 + "line": 531 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 511 + "line": 520 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 518 + "line": 527 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 519 + "line": 528 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 507 + "line": 516 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", "klass": "RULESET", "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.", - "line": 304 + "line": 307 }, { "id": "RULESET.CAPABILITY.off-means-off", "klass": "RULESET", "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.", - "line": 302 + "line": 305 }, { "id": "RULESET.CAPABILITY.precedence-engine-single-owner", "klass": "RULESET", "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.", - "line": 308 + "line": 311 }, { "id": "RULESET.CAPABILITY.step-additive-gate-blocks", "klass": "RULESET", "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.", - "line": 306 + "line": 309 }, { "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 544 + "line": 553 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 545 + "line": 554 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 543 + "line": 552 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 546 + "line": 555 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 547 + "line": 556 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 548 + "line": 557 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)", - "line": 861 + "line": 880 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 537 + "line": 546 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 538 + "line": 547 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 536 + "line": 545 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 535 + "line": 544 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 529 + "line": 538 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other, and a fragment left on `next` after merge is inert rather than a shared cell; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins. `tests/emitted-drift-ack.json` (the LEGACY file specifically, NOT the fragment directory) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; a persisting FRAGMENT is harmless by construction and is deliberately not what this guard checks. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next` (`assertAbsentOnNext`), which fails on the LEGACY file's PRESENCE alone, valid or not — a PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", - "line": 512 + "line": 521 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 542 + "line": 551 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM", - "line": 954 + "line": 973 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib", - "line": 523 + "line": 532 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — \"we don't set a timer we actively watch and record results in real time as possible\". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:\"passed\" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.", - "line": 956 + "line": 975 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 958 + "line": 977 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 525 + "line": 534 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 520 + "line": 529 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 549 + "line": 558 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 494 + "line": 503 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 497 + "line": 506 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 496 + "line": 505 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 501 + "line": 510 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 492 + "line": 501 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 504 + "line": 513 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 493 + "line": 502 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 489 + "line": 498 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)", - "line": 505 + "line": 514 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 495 + "line": 504 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 491 + "line": 500 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 503 + "line": 512 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 490 + "line": 499 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 485 + "line": 494 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 486 + "line": 495 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 487 + "line": 496 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 500 + "line": 509 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 502 + "line": 511 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 527 + "line": 536 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 516 + "line": 525 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 515 + "line": 524 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 514 + "line": 523 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 513 + "line": 522 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 509 + "line": 518 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 510 + "line": 519 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 909 + "line": 928 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 910 + "line": 929 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 911 + "line": 930 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 912 + "line": 931 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 913 + "line": 932 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 914 + "line": 933 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 915 + "line": 934 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]", - "line": 916 + "line": 935 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]", - "line": 917 + "line": 936 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 730 + "line": 749 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 727 + "line": 746 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 728 + "line": 747 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 731 + "line": 750 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 729 + "line": 748 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 563 + "line": 572 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 564 + "line": 573 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 579 + "line": 588 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 580 + "line": 589 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 565 + "line": 574 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 573 + "line": 582 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 557 + "line": 566 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 561 + "line": 570 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 560 + "line": 569 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 558 + "line": 567 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 559 + "line": 568 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 571 + "line": 580 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 572 + "line": 581 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 575 + "line": 584 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 574 + "line": 583 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 570 + "line": 579 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 569 + "line": 578 } ], "duplicates": [] diff --git a/package.json b/package.json index fe7e212c8..6048786b7 100644 --- a/package.json +++ b/package.json @@ -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" ], diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs new file mode 100644 index 000000000..e611fb53f --- /dev/null +++ b/scripts/live-config-guard.cjs @@ -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 `/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 `/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 `/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 `/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 `/gsd-core` + // and misses a real `/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. + * /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: /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 /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} + * 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 +}; diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 4d9912127..24d12767f 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -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; } diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index e8f37818e..0605d9055 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -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 }); } diff --git a/tests/agent-skills.test.cjs b/tests/agent-skills.test.cjs index 40063bc1f..9da0ad0f1 100644 --- a/tests/agent-skills.test.cjs +++ b/tests/agent-skills.test.cjs @@ -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. diff --git a/tests/api-coverage-gate-e2e.test.cjs b/tests/api-coverage-gate-e2e.test.cjs index c9f49f70f..6dee682f0 100644 --- a/tests/api-coverage-gate-e2e.test.cjs +++ b/tests/api-coverage-gate-e2e.test.cjs @@ -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 diff --git a/tests/assumption-delta-checkpoint-e2e.test.cjs b/tests/assumption-delta-checkpoint-e2e.test.cjs index 7ae030fb4..b19895d7d 100644 --- a/tests/assumption-delta-checkpoint-e2e.test.cjs +++ b/tests/assumption-delta-checkpoint-e2e.test.cjs @@ -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 diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 4ab47fecc..24a997bd6 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -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 /.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); diff --git a/tests/check-tdd-review-checkpoint-e2e.test.cjs b/tests/check-tdd-review-checkpoint-e2e.test.cjs index 1a01e8aa6..8ea2149da 100644 --- a/tests/check-tdd-review-checkpoint-e2e.test.cjs +++ b/tests/check-tdd-review-checkpoint-e2e.test.cjs @@ -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 diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs index 1e3e89cdf..8bf697425 100644 --- a/tests/config-loader.test.cjs +++ b/tests/config-loader.test.cjs @@ -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. diff --git a/tests/configuration-migrate-config.test.cjs b/tests/configuration-migrate-config.test.cjs index d98baea6e..964d92234 100644 --- a/tests/configuration-migrate-config.test.cjs +++ b/tests/configuration-migrate-config.test.cjs @@ -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, diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index c66d1e56b..c60dc671d 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -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 /.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 /.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 /.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 /.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 /.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 /.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, diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index 92c054f63..2f1db0ea7 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -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 + // /.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); + } + }); +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 812c257f1..bcd850161 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -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 -> /.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 }, +}); diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index b4c5ce580..153a449cb 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -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 + // /.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; } diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 310e55e8e..a41ba1d11 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -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). diff --git a/tests/install.test.cjs b/tests/install.test.cjs index f4da7e67b..ff1e8d9a1 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -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 /.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 /.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 /.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, diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/issue-766-plugin-manifest.test.cjs index 329e29a9f..1a78e243c 100644 --- a/tests/issue-766-plugin-manifest.test.cjs +++ b/tests/issue-766-plugin-manifest.test.cjs @@ -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= npm run test:unit; find -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, diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs new file mode 100644 index 000000000..4bea10d07 --- /dev/null +++ b/tests/live-config-guard.test.cjs @@ -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 /.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 + // /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 — /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)}`, + ); + }); +}); diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs index 6d4c11ecc..49b3ff23a 100644 --- a/tests/opencode-plugin-adapter.test.cjs +++ b/tests/opencode-plugin-adapter.test.cjs @@ -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 /.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; diff --git a/tests/packaging-shipped-scripts-require-only-shipped.test.cjs b/tests/packaging-shipped-scripts-require-only-shipped.test.cjs index 8d3142c31..774a3d4df 100644 --- a/tests/packaging-shipped-scripts-require-only-shipped.test.cjs +++ b/tests/packaging-shipped-scripts-require-only-shipped.test.cjs @@ -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'); diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index cc40ba566..62ad61681 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -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 `/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'); diff --git a/tests/representative-corpus.test.cjs b/tests/representative-corpus.test.cjs index 9f4f2971f..573bb2b17 100644 --- a/tests/representative-corpus.test.cjs +++ b/tests/representative-corpus.test.cjs @@ -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], { diff --git a/tests/run-tests-harness.test.cjs b/tests/run-tests-harness.test.cjs index 07a822f64..4cb8a6bb7 100644 --- a/tests/run-tests-harness.test.cjs +++ b/tests/run-tests-harness.test.cjs @@ -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; diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 4aa19de92..20c79c1ee 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -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); });