diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index e6793fff4..9a118a391 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -61,8 +61,20 @@ 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']; +/** + * 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 @@ -73,8 +85,16 @@ const GSD_OWNED_ENTRIES = ['gsd-core', 'gsd-file-manifest.json', 'gsd-pristine'] * `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']; +const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills', 'hooks']; const GSD_ARTIFACT_PREFIX = 'gsd-'; /** diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 6514949ea..bfe07fc0c 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -190,6 +190,46 @@ describe('#2665: live-config hermeticity guard', () => { } }); + 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('detects a DELETED top-level GSD entry', () => { const root = tmpRoot(); try {