mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 07:29:00 +02:00
fix: red-team findings — verify-gate identity, single quoting authority, Windows paths
Red-team pass over the hardened diff (several findings empirically verified by the reviewer before reporting): - KNOWN_HOOKS gains the sixth identity: gstack-verify-gate (README-documented opt-in Stop hook). A tag-stripped verify-gate entry previously survived prune-stale --all and errored at the end of EVERY turn after uninstall deleted the install root — the exact phantom-hook class this branch fixes. Uninstall also sweeps its tagged form. - add-event is now the single quoting authority: every registered command is normalized through the same gsQuoteCmd/gsStripWrap round-trip the healer uses. Pre-fix, only SessionStart got caller-side quoting — a spaced/metachar canonical root registered broken plan-tune/AUQ/timeline hooks that the very next heal rewrote (the codebase disagreed with its own registrations). - Windows: MSYS-form paths (/c/Users/...) are drive-translated for fs checks only (gsWinPath) — native bun resolved them drive-relative, so the heal judged every LIVE Windows hook dead and pruned it. The three AskUserQuestion hooks and the Stop hook now also get the mandatory 'bash ' prefix on Windows (previously only SessionStart did; extensionless bash shims otherwise hit the file-association dialog). - CANONICAL_GSTACK_ROOT falls back to $HOME/.claude/skills/gstack when a CLAUDE_CONFIG_DIR-derived root was never installed (the installer hardcodes the home path — split-brain left such users permanently hookless). - prune-stale preserves foreign entries that STARTED empty (they were silently deleted, uncounted, on every heal). - The timeline Stop registration and its list-sources guard join the zero-silent-mutations contract (stderr attached). Tests: verify-gate tag-stripped heal+sweep, started-empty preservation, add-event quoting-authority round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
19eed1b392
commit
6a3cf611ea
@@ -597,6 +597,56 @@ describe('review-army hardening (specialist findings)', () => {
|
||||
expect(fs.existsSync(latest)).toBe(true);
|
||||
});
|
||||
|
||||
test('tag-stripped verify-gate entry is table-owned: healed by --repoint, swept by --all', () => {
|
||||
// Red-team catch: verify-gate is a README-documented opt-in Stop hook.
|
||||
// Without a KNOWN_HOOKS row, a tag-stripped entry survived uninstall and
|
||||
// errored at the end of EVERY turn after the install root was deleted.
|
||||
const canon = mkCanon(tmpDir);
|
||||
const vg = path.join(canon, 'bin', 'gstack-verify-gate');
|
||||
fs.writeFileSync(vg, '#!/bin/sh\n');
|
||||
fs.chmodSync(vg, 0o755);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: { Stop: [hookEntry('/dead/install/bin/gstack-verify-gate')] }, // tag STRIPPED
|
||||
}, null, 2));
|
||||
runIso(['prune-stale', '--repoint', canon]);
|
||||
let s = settings();
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe(vg);
|
||||
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
|
||||
const r = runIso(['prune-stale', '--all']);
|
||||
expect(r.stdout).toMatch(/removed 1/);
|
||||
expect(settings().hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a foreign entry that STARTED empty survives prune-stale untouched', () => {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [] }] },
|
||||
}, null, 2) + '\n');
|
||||
const before = fs.readFileSync(settingsFile, 'utf-8');
|
||||
const r = runIso(['prune-stale', '--repoint', mkCanon(tmpDir, 'c2')]);
|
||||
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
test('add-event is the quoting authority: spaced canonical path stored escaped-quoted, healer idempotent', () => {
|
||||
// Red-team catch (empirically verified pre-fix): setup registered raw
|
||||
// paths and the very next heal rewrote them — fresh installs shipped a
|
||||
// form the codebase itself considered wrong.
|
||||
const spacedBase = path.join(tmpDir, 'canon root');
|
||||
fs.mkdirSync(spacedBase, { recursive: true });
|
||||
const canon = mkCanon(spacedBase);
|
||||
runIso([
|
||||
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
||||
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
||||
'--source', 'plan-tune-cathedral', '--timeout', '5',
|
||||
]);
|
||||
const stored = settings().hooks.PostToolUse[0].hooks[0].command;
|
||||
expect(stored).toBe(`"${canon}/hosts/claude/hooks/question-log-hook"`);
|
||||
const before = fs.readFileSync(settingsFile, 'utf-8');
|
||||
const r = runIso(['prune-stale', '--repoint', canon]);
|
||||
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
test('rollback refuses a pointer that names a non-backup file', () => {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({ a: 1 }, null, 2));
|
||||
const evil = path.join(tmpDir, 'evil.json');
|
||||
|
||||
Reference in New Issue
Block a user