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.
This commit is contained in:
@@ -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 `<live>/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-';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user