test(#2665): widen the hermeticity guard to its two blind surfaces, and cover its budget
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.
This commit is contained in:
@@ -111,6 +111,50 @@ function resolveLiveConfigRoots(deps = {}) {
|
||||
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
|
||||
@@ -151,7 +195,7 @@ function newestMtime(target, budget) {
|
||||
* @returns {Record<string, {exists: boolean, newest: number, truncated: boolean}>}
|
||||
* keyed by absolute entry path.
|
||||
*/
|
||||
function snapshotLiveConfig(roots) {
|
||||
function snapshotLiveConfig(roots, extraTargets = []) {
|
||||
const budget = { remaining: MAX_ENTRIES };
|
||||
const snap = {};
|
||||
|
||||
@@ -164,6 +208,12 @@ function snapshotLiveConfig(roots) {
|
||||
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));
|
||||
|
||||
@@ -251,6 +301,7 @@ module.exports = {
|
||||
MAX_ENTRIES,
|
||||
MAX_DEPTH,
|
||||
resolveLiveConfigRoots,
|
||||
resolveExtraWatchTargets,
|
||||
snapshotLiveConfig,
|
||||
diffLiveConfig,
|
||||
formatViolations,
|
||||
|
||||
@@ -41,6 +41,7 @@ const { execFileSync } = require('child_process');
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const {
|
||||
resolveLiveConfigRoots,
|
||||
resolveExtraWatchTargets,
|
||||
snapshotLiveConfig,
|
||||
diffLiveConfig,
|
||||
formatViolations,
|
||||
@@ -977,10 +978,19 @@ function main() {
|
||||
// scripts/lib/live-config-guard.cjs for why the scope is narrow.
|
||||
const liveConfigGuardEnabled = process.env.GSD_SKIP_LIVE_CONFIG_GUARD !== '1';
|
||||
let liveConfigRoots = [];
|
||||
let liveConfigExtras = [];
|
||||
let liveConfigBefore = null;
|
||||
if (liveConfigGuardEnabled) {
|
||||
liveConfigRoots = resolveLiveConfigRoots();
|
||||
if (liveConfigRoots.length > 0) liveConfigBefore = snapshotLiveConfig(liveConfigRoots);
|
||||
// #2665 round 3: $GSD_HOME/.gsd and kimi's native config.toml 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;
|
||||
@@ -1052,7 +1062,10 @@ function main() {
|
||||
// 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));
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user