From 104fc76f70a843e9dddb42e00c2da4294fe3a9ea Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:43:32 -0500 Subject: [PATCH] fix(#2665): watch the hook bundle and the install markers the census found Self-found by re-deriving the guard-shape census against bin/install.js's own write sites, not by a review finding. Three artifacts a global install writes into a live config ROOT were watched by nothing: hooks/gsd-check-update.js, hooks/gsd-context-monitor.js, hooks/gsd-update-banner.js -- `hooks` was absent from GSD_PREFIXED_PARENTS .gsd-source, .gsd-profile -- absent from GSD_OWNED_ENTRIES, and an exact-name list does not match a dot-prefixed name via the `gsd-` prefix rule This is the SAME shape as the leak that motivated the prefixed-parent scan in round 1 -- a gsd-prefixed child under a parent nobody had listed -- one parent over. That it recurred is the argument for re-deriving this list from the installer each round instead of trusting it: the enumeration is the weak point of an enumerate-and-block mechanism, and it does not announce when it falls behind. Ownership is unchanged, only coverage: `hooks/` is shared with the host agent, so only `gsd-`-prefixed children are watched. A test asserts a host-owned hook is still ignored, because widening the parent list must not widen ownership -- a guard that flags the host's own files gets switched off, and then catches nothing at all. Reverting the widening fails the new test. --- scripts/live-config-guard.cjs | 26 ++++++++++++++++++--- tests/live-config-guard.test.cjs | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) 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 {