Round 2, both Majors. They are one defect seen twice: the recurrence guard did
not cover the surface it exists to guard.
Blind surfaces. resolveLiveConfigRoots enumerates getGlobalConfigDir per registry
runtime plus a hardcoded grok branch, so it can only ever see runtime config
ROOTS. Two live write surfaces are not roots and passed through silently:
$GSD_HOME/.gsd — GSD's user-owned store. Watched WHOLESALE: unlike ~/.claude
this root is exclusively ours, so the shared-root
false-positive trap the module documents does not apply.
<kimi>/config.toml — the file GSD writes its native [[hooks]] block into. The
INVERSE case: ~/.kimi belongs to Kimi CLI, so only the one
file GSD writes is watched, never the root.
That asymmetry is why this is not a two-line "add two roots" patch — one target
needs the whole tree, the other needs exactly one file, and collapsing them
either under-watches the store or trips the guard's own documented
false-positive trap on a third party's directory.
Extras are passed to snapshotLiveConfig explicitly rather than resolved inside
it, so a caller snapshotting a fixture root cannot silently pull the developer's
real ~/.gsd into its own assertions. run-tests.cjs now snapshots when EITHER the
roots or the extras are non-empty — previously an unbuilt tree yielding zero
roots disabled the entire guard without saying so.
Budget coverage. The MAX_ENTRIES/MAX_DEPTH bound and the truncated -> 'unverified'
branch had zero tests, despite this module's own docstring naming "a truncated
scan reading as clean" as the safety-critical case. Added per
RULESET.TESTS.boundary-coverage (N in {limit-1, limit, limit+1}, exercised
through newestMtime's injected budget so the boundary is real without
materialising 20000 files) and RULESET.TESTS.property-based-testing (fast-check:
truncation is monotone in the budget; reported newest never exceeds the true
maximum). A regression flipping `truncated` to false on an exhausted budget now
breaks the property for every budget below the tree size.
Negative-controlled: neutering the extras wiring fails exactly the two
new-surface tests and nothing else. 21/21 green with it restored.
311 lines
13 KiB
JavaScript
311 lines
13 KiB
JavaScript
#!/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. `scripts/affected-tests-lib.cjs`
|
|
* is the precedent for a non-shipped helper at this level.
|
|
*
|
|
* SCOPE — ownership-based, not whole-root. It watches entries GSD unambiguously
|
|
* owns: the top-level install footprint (`GSD_OWNED_ENTRIES`) plus `gsd-`-prefixed
|
|
* children of the dirs GSD shares with the host agent (`GSD_PREFIXED_PARENTS`).
|
|
* It does NOT watch whole config roots. A root such as `~/.claude` is shared with
|
|
* the host agent, which may legitimately write `history.jsonl`, `todos/`, or
|
|
* `settings.json` while the suite runs; watching the root would turn that into a
|
|
* false failure, and a guard that cries wolf gets disabled — after which it
|
|
* catches nothing at all.
|
|
*
|
|
* The ownership test is the prefix, not the location. That distinction is load
|
|
* bearing: the first version of this guard watched only the three top-level
|
|
* entries and MISSED a real leak into `<live>/skills/gsd-dev-preferences/`.
|
|
*
|
|
* KNOWN GAP — a leak into a file GSD does not own (e.g. mutating the host's own
|
|
* `.claude.json`) is outside this guard by construction. Closing it would require
|
|
* watching shared files, which is the false-positive trap above.
|
|
*
|
|
* 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. */
|
|
const GSD_OWNED_ENTRIES = ['gsd-core', 'gsd-file-manifest.json', 'gsd-pristine'];
|
|
|
|
/**
|
|
* Directories GSD SHARES with the host agent. Watching them wholesale would
|
|
* false-positive on the host's own writes, so only `gsd-`-prefixed children are
|
|
* watched — those are unambiguously ours.
|
|
*
|
|
* Added after the first version of this guard MISSED a real leak: a raw
|
|
* `spawnSync` that sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR
|
|
* wrote `<live>/skills/gsd-dev-preferences/SKILL.md`, which sits under none of
|
|
* the three top-level entries above.
|
|
*/
|
|
const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills'];
|
|
const GSD_ARTIFACT_PREFIX = 'gsd-';
|
|
|
|
/** Bounds on the recursive walk, so a pathological tree cannot stall the suite. */
|
|
const MAX_ENTRIES = 20000;
|
|
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.
|
|
*
|
|
* @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');
|
|
let getGlobalConfigDir;
|
|
let runtimes;
|
|
try {
|
|
({ getGlobalConfigDir } = 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();
|
|
// 'grok' is a hardcoded branch of getGlobalConfigDir with no registry entry.
|
|
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. Two live write surfaces are invisible to that shape, so a leak
|
|
* on either passed through this guard — the PR's own safety net — silently:
|
|
*
|
|
* $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.
|
|
* <kimi>/config.toml — the file GSD writes its native [[hooks]] block into
|
|
* (resolveKimiHooksTomlDir, KIMI_SHARE_DIR). The INVERSE case:
|
|
* ~/.kimi belongs to Kimi CLI, so only the one file GSD writes is
|
|
* watched, never the root. This is the KNOWN GAP above accepted
|
|
* deliberately in one direction — GSD demonstrably writes this
|
|
* file (bin/install.js calls resolveKimiHooksTomlDir at two sites),
|
|
* so a concurrent Kimi CLI write is the only false positive, and
|
|
* Kimi is not running during the suite.
|
|
*
|
|
* @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;
|
|
|
|
const targets = [path.resolve(path.join(env.GSD_HOME || homedir(), '.gsd'))];
|
|
|
|
try {
|
|
const { resolveKimiHooksTomlDir } = require(path.join(libDir, 'runtime-homes.cjs'));
|
|
// Thread the SAME injected env/home the GSD_HOME line above uses. Calling it
|
|
// bare reads process.env and os.homedir() regardless of `deps`, which leaves
|
|
// the seam untestable and the two targets resolved against different worlds.
|
|
const kimiDir = resolveKimiHooksTomlDir({ env, home: homedir() });
|
|
targets.push(path.resolve(path.join(kimiDir, 'config.toml')));
|
|
} catch {
|
|
// Unbuilt tree — same posture as resolveLiveConfigRoots: advisory, never fatal.
|
|
}
|
|
return 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) walk(path.join(current, entry), depth + 1);
|
|
};
|
|
|
|
walk(target, 0);
|
|
return { newest, truncated };
|
|
}
|
|
|
|
/**
|
|
* Snapshot GSD-owned entries under each root.
|
|
*
|
|
* @returns {Record<string, {exists: boolean, newest: number, truncated: boolean}>}
|
|
* keyed by absolute entry path.
|
|
*/
|
|
function snapshotLiveConfig(roots, extraTargets = []) {
|
|
const budget = { remaining: MAX_ENTRIES };
|
|
const snap = {};
|
|
|
|
const record = (target) => {
|
|
if (!fs.existsSync(target)) {
|
|
snap[target] = { exists: false, newest: 0, truncated: false };
|
|
return;
|
|
}
|
|
const { newest, truncated } = newestMtime(target, budget);
|
|
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));
|
|
|
|
for (const root of roots) {
|
|
for (const entry of GSD_OWNED_ENTRIES) record(path.join(root, entry));
|
|
|
|
// 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 of GSD_PREFIXED_PARENTS) {
|
|
const parentDir = path.join(root, parent);
|
|
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 (child.startsWith(GSD_ARTIFACT_PREFIX)) record(path.join(parentDir, child));
|
|
}
|
|
}
|
|
}
|
|
return snap;
|
|
}
|
|
|
|
/**
|
|
* Compare two snapshots. A path is a violation when it was created during the
|
|
* run, or when its newest mtime advanced.
|
|
*
|
|
* @returns {{path: string, kind: 'created'|'modified'|'unverified'}[]}
|
|
*/
|
|
function diffLiveConfig(before, after) {
|
|
const violations = [];
|
|
for (const [target, post] of Object.entries(after)) {
|
|
const pre = before[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.exists) violations.push({ path: target, kind: 'created' });
|
|
continue;
|
|
}
|
|
if (!pre.exists && post.exists) {
|
|
violations.push({ path: target, kind: 'created' });
|
|
} 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('');
|
|
lines.push(
|
|
'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_ARTIFACT_PREFIX,
|
|
MAX_ENTRIES,
|
|
MAX_DEPTH,
|
|
resolveLiveConfigRoots,
|
|
resolveExtraWatchTargets,
|
|
snapshotLiveConfig,
|
|
diffLiveConfig,
|
|
formatViolations,
|
|
newestMtime,
|
|
os, // exported for test seams only
|
|
};
|