fix(#2665): let the guard see deletions, in both shapes it can take
diffLiveConfig walked `after` alone, so it had no branch for a path that
existed before the run and does not after. A test run that DELETES a file from
the developer's real config dir passed the guard silently -- the least
recoverable case in the threat model this guard exists to cover.
The review named the missing `pre.exists && !post.exists` branch. That branch is
necessary and not sufficient: deletion arrives in two shapes and it reaches only
one of them.
- A FIXED owned entry (GSD_OWNED_ENTRIES x roots, plus every extra target) is
recorded at both ends whether it exists or not, so a deletion reads
{exists:true} -> {exists:false}. This is the shape the named branch fixes.
- A gsd-prefixed child is DISCOVERED by readdirSync, so a deleted one is
absent from `after` entirely and never enters an after-keyed loop at all.
The named branch is unreachable for it.
So the walk is now over the UNION of both key sets, with the explicit branch for
the first shape and an `!post` branch for the second. Both are covered by a
test, and reverting the fix fails both -- the prefixed-child test is the one
that would still fail with only the prescribed branch in place.
Addresses review finding: Blocker 2.
This commit is contained in:
@@ -297,23 +297,47 @@ function snapshotLiveConfig(roots, extraTargets = []) {
|
||||
|
||||
/**
|
||||
* Compare two snapshots. A path is a violation when it was created during the
|
||||
* run, or when its newest mtime advanced.
|
||||
* run, when its newest mtime advanced, or when it was DELETED by the run.
|
||||
*
|
||||
* @returns {{path: string, kind: 'created'|'modified'|'unverified'}[]}
|
||||
* 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 (GSD_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 gsd-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 = [];
|
||||
for (const [target, post] of Object.entries(after)) {
|
||||
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 gsd-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.exists) violations.push({ path: target, kind: 'created' });
|
||||
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) {
|
||||
|
||||
@@ -162,6 +162,46 @@ describe('#2665: live-config hermeticity guard', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a DELETED top-level GSD entry', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
// A fixed owned entry is recorded at BOTH ends whether or not it exists,
|
||||
// so a deletion reads {exists:true} -> {exists:false}. Before the union
|
||||
// walk that pair matched no branch at all and the run passed silently.
|
||||
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'gsd-core', 'x'), 'x');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
fs.rmSync(path.join(root, 'gsd-core'), { recursive: true, force: true });
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const deleted = violations.filter((v) => v.kind === 'deleted');
|
||||
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
|
||||
assert.strictEqual(path.basename(deleted[0].path), 'gsd-core');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a DELETED gsd-prefixed child of a shared dir', () => {
|
||||
const root = tmpRoot();
|
||||
try {
|
||||
// The shape a `pre.exists && !post.exists` branch cannot reach on its own:
|
||||
// prefixed children are DISCOVERED by readdir, so a deleted one is absent
|
||||
// from the `after` snapshot entirely and never enters an after-keyed loop.
|
||||
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
|
||||
const before = snapshotLiveConfig([root]);
|
||||
fs.rmSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true, force: true });
|
||||
|
||||
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
|
||||
const deleted = violations.filter((v) => v.kind === 'deleted');
|
||||
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
|
||||
assert.strictEqual(path.basename(deleted[0].path), 'gsd-dev-preferences');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('the report names the path and the remedy', () => {
|
||||
const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]);
|
||||
assert.match(out, /HERMETICITY WARNING/);
|
||||
|
||||
Reference in New Issue
Block a user