From e5e4127987e5db47478c528715c699e5dda63b62 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 18 Aug 2026 10:13:50 -0700 Subject: [PATCH] =?UTF-8?q?test:=20adversarial=20regression=20pins=20?= =?UTF-8?q?=E2=80=94=20wrong-shape=20fail-loud,=20prototype=20basename,=20?= =?UTF-8?q?sweep=20exclusion,=20lock=20exit=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pins for the fail-loud hardening: a wrong-shape hooks value (object where an array belongs) exits 4 with "refusing to mutate" and leaves the file byte-identical (pre-gsMain this was a silent exit-0 no-op); a foreign hook whose basename collides with Object.prototype ("toString") survives an --all sweep that still removes gstack rows; GSTACK_SWEEP_EXCLUDE_SOURCES preserves the verify-gate row during --all; the fresh-foreign-lock test now asserts the loud exit 5 instead of a quiet skip. Co-Authored-By: Claude Fable 5 --- .../gstack-settings-hook-schema-aware.test.ts | 108 +++++++++++++++--- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/test/gstack-settings-hook-schema-aware.test.ts b/test/gstack-settings-hook-schema-aware.test.ts index e5f6873e6..9df575aeb 100644 --- a/test/gstack-settings-hook-schema-aware.test.ts +++ b/test/gstack-settings-hook-schema-aware.test.ts @@ -949,27 +949,18 @@ describe('mutation lock', () => { expect(fs.existsSync(lockDir)).toBe(false); // released after the mutation }); - test('fresh foreign lock: mutation skipped with a warning, file untouched', () => { + test('fresh foreign lock: mutation skipped loudly (exit 5), file untouched', () => { fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n'); const before = fs.readFileSync(settingsFile, 'utf-8'); const lockDir = `${settingsFile}.lock`; fs.mkdirSync(lockDir); fs.writeFileSync(path.join(lockDir, 'owner'), 'another-live-process'); - // Capture stderr explicitly: on a zero exit, execSync passes stderr - // through to the parent instead of returning it. - const cmd = [SETTINGS_HOOK, 'add-event', '--event', 'Stop', '--command', '/x', '--source', 's'] - .map((s) => `'${s}'`).join(' '); - const out = execSync(`${cmd} 2>&1`, { - env: { - ...process.env, - GSTACK_SETTINGS_FILE: settingsFile, - GSTACK_STATE_ROOT: tmpDir, - GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300', - }, - encoding: 'utf-8', - timeout: 15000, - }); - expect(out).toMatch(/could not acquire lock/); + const r = runIso( + ['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'], + { GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300' }, + ); + expect(r.exitCode).toBe(5); // loud give-up, not silent skip + expect(r.stderr).toMatch(/could not acquire lock/); expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before); expect(fs.existsSync(lockDir)).toBe(true); // foreign lock NOT stolen }); @@ -989,3 +980,88 @@ describe('mutation lock', () => { expect(s.hooks.PostToolUse).toHaveLength(1); }); }); + +describe('gstack-settings-hook adversarial hardening', () => { + let tmpDir: string; + let settingsFile: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-settings-adv-')); + settingsFile = path.join(tmpDir, 'settings.json'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function runAdv(args: string[], extraEnv: Record = {}) { + try { + const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), { + env: { + ...process.env, + GSTACK_SETTINGS_FILE: settingsFile, + GSTACK_STATE_ROOT: tmpDir, + ...extraEnv, + }, + encoding: 'utf-8', + timeout: 15000, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (e: any) { + return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 }; + } + } + + const advSettings = (): any => JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); + + test('wrong-shape hooks value fails LOUD (exit 4), file untouched', () => { + // bun -e swallows uncaught exceptions after a require() and exits 0 + // (verified on bun 1.3.13) -- without the gsMain umbrella this exact + // input produced a silent exit-0 no-op that reported clean. + const raw = JSON.stringify({ hooks: { Stop: { bogus: 'shape' } } }, null, 2) + '\n'; + fs.writeFileSync(settingsFile, raw); + const r = runAdv(['prune-stale', '--all']); + expect(r.exitCode).toBe(4); + expect(r.stderr).toMatch(/internal error/); + expect(r.stderr).toMatch(/refusing to mutate/); + expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(raw); + }); + + test('foreign hook whose basename collides with Object.prototype survives --all', () => { + // KNOWN_HOOKS["toString"] returns an inherited member without the + // hasOwnProperty guard -- pre-fix, this threw mid-scan and turned the + // sweep into a silent no-op. + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: '/usr/local/bin/toString' }] }, + { _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }, + ], + }, + }, null, 2) + '\n'); + const r = runAdv(['prune-stale', '--all']); + expect(r.exitCode).toBe(0); + const s = advSettings(); + expect(s.hooks.Stop).toHaveLength(1); // gstack entry swept... + expect(s.hooks.Stop[0].hooks[0].command).toBe('/usr/local/bin/toString'); // ...foreign one kept + }); + + test('GSTACK_SWEEP_EXCLUDE_SOURCES preserves verify-gate during an --all sweep', () => { + // `setup --no-team` sweeps team hooks but must not delete the + // user-registered verify-gate opt-in whose binary still exists. + fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { + Stop: [ + { _gstack_source: 'verify-gate', hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] }, + { _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }, + ], + }, + }, null, 2) + '\n'); + const r = runAdv(['prune-stale', '--all'], { GSTACK_SWEEP_EXCLUDE_SOURCES: 'verify-gate' }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/removed 1 /); + const s = advSettings(); + expect(s.hooks.Stop).toHaveLength(1); + expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate'); + }); +});