#!/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 MSD'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 MSD unambiguously * owns: the top-level install footprint (`MSD_OWNED_ENTRIES`) plus `msd-`-prefixed * children of the dirs MSD shares with the host agent (`MSD_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/msd-dev-preferences/`. * * KNOWN GAP — a leak into a file MSD does not own (e.g. mutating the host's own * `.claude.json`, `settings.json`, `hooks.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: * - 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 MSD 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/` — * 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 MSD_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\msd-core` and * `skills\msd-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 MSD install creates. See SCOPE above before widening. * * `.msd-source` and `.msd-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 `msd-` prefix rule. */ const MSD_OWNED_ENTRIES = [ 'msd-core', 'msd-file-manifest.json', 'msd-pristine', '.msd-source', '.msd-profile', ]; /** * Directories MSD SHARES with the host agent. Watching them wholesale would * false-positive on the host's own writes, so only `msd-`-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/msd-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/msd-check-update.js`, `hooks/msd-context-monitor.js` and * `hooks/msd-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 MSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills', 'hooks']; const MSD_ARTIFACT_PREFIX = 'msd-'; /** * 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: MSD writes `msd-`-hyphen artifacts (`hooks/msd-check-update.js`) AND * bare `msd.`-dotted ones (`plugins/msd.js`), and a lone `msd-` sees * only the first. */ const DEFAULT_ARTIFACT_PREFIXES = ['msd-', 'msd.']; /** * Files MSD 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 MSD_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 a SINGULAR * `command/` dir, `workflows/`, and a `skills/msd` dir -- the last being a whole * directory whose name carries no `msd-` 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 msd-prefixed * children only; owned = watch the path wholesale (its own name is MSD'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 [...MSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS]) { addParent(dest, DEFAULT_ARTIFACT_PREFIXES); } const owned = new Set(MSD_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 MSD's (`skills/msd`) is // owned wholesale — that directory is ours, not shared. if (last.startsWith('msd')) { owned.add(dest); continue; } // The layout declares its OWN prefix, and it may vary: a layout declaring // `msd` (no hyphen) would be invisible to a fixed `msd-` 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, '..', 'msd-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; } /** * 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 MSD_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, '..', 'msd-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 (MSD_OWNED_ENTRIES x // MSD_PREFIXED_PARENTS) beneath every root it is given — measured on a // sandboxed HOME, adding it both false-positives on `/msd-core` // and misses a real `/msd-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 MSD_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. Today: $MSD_HOME/.msd: * * $MSD_HOME/.msd — MSD'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. * * @returns {string[]} absolute paths; empty if the built lib is absent. */ function resolveExtraWatchTargets(deps = {}) { 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.MSD_HOME || homedir(), '.msd')), path.resolve(path.join(homedir(), '.msd')), ]; // 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 MSD-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 ~/.msd 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 MSD_OWNED_ENTRIES) record(path.join(root, entry)); // Paths whose own name is MSD's, nested inside a shared root // (`skills/msd`, `hooks/lib`, `scripts/lib`, …) — no prefix rule sees these. for (const entry of watchOwned) record(path.join(root, ...entry.split('/'))); // Shared dirs: enumerate only msd-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 (MSD_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 msd-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 msd-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.MSD_STRICT_LIVE_CONFIG_GUARD === '1' ? 'STRICT MODE (MSD_STRICT_LIVE_CONFIG_GUARD=1): these violations fail the run. ' + 'MSD_SKIP_LIVE_CONFIG_GUARD=1 skips the check entirely.' : 'Reporting only. Set MSD_STRICT_LIVE_CONFIG_GUARD=1 to make this fail the run, ' + 'or MSD_SKIP_LIVE_CONFIG_GUARD=1 to skip the check entirely.', ); lines.push(''); return lines.join('\n'); } module.exports = { MSD_OWNED_ENTRIES, MSD_PREFIXED_PARENTS, MSD_OWNED_NESTED, NON_REGISTRY_ARTIFACT_PARENTS, DEFAULT_ARTIFACT_PREFIXES, deriveArtifactTargets, artifactTargets, MSD_ARTIFACT_PREFIX, MAX_ENTRIES, MAX_TOTAL_ENTRIES, MAX_DEPTH, resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime, os, // exported for test seams only };