diff --git a/tests/unit/hooks-emit-adversarial.test.ts b/tests/unit/hooks-emit-adversarial.test.ts new file mode 100644 index 0000000..e5a886d --- /dev/null +++ b/tests/unit/hooks-emit-adversarial.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, vi } from 'vitest'; +import { executeHandlerChain } from '../../src/lib/hooks-emit.js'; +import type { HookContext, HookEntry } from '../../src/lib/hooks-types.js'; + +function makeContext(hookName = 'TestHook'): HookContext { + return { + signal: new AbortController().signal, + hookName, + sessionId: 'test-session', + }; +} + +describe('executeHandlerChain (adversarial)', () => { + describe('empty and degenerate inputs', () => { + it('returns clean result for empty entries array', async () => { + const result = await executeHandlerChain([], { v: 1 }, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + }); + + expect(result.results).toEqual([]); + expect(result.pending).toEqual([]); + expect(result.blocked).toBe(false); + expect(result.finalPayload).toEqual({ v: 1 }); + }); + + it('skips sparse array holes (undefined entries)', async () => { + // eslint-disable-next-line no-sparse-arrays + const entries = [, , { handler: vi.fn() }] as unknown as HookEntry< + unknown, + void + >[]; + + await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + }); + + expect(entries[2]!.handler).toHaveBeenCalledOnce(); + }); + }); + + describe('handler return value edge cases', () => { + it('treats null return as void (no result collected)', async () => { + const entries: HookEntry[] = [ + { handler: () => null as unknown as { v: number } }, + ]; + + const result = await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + }); + + expect(result.results).toEqual([]); + }); + + it('collects non-object primitive results without mutation crash', async () => { + const entries: HookEntry<{ toolInput: Record }, number>[] = + [{ handler: () => 42 }, { handler: () => 99 }]; + + const result = await executeHandlerChain( + entries, + { toolInput: { a: 1 } }, + makeContext(), + { hookName: 'PreToolUse', throwOnHandlerError: false }, + ); + + // applyMutations should be a no-op for primitives + expect(result.results).toEqual([42, 99]); + expect(result.finalPayload.toolInput).toEqual({ a: 1 }); + }); + + it('does not trigger block on non-blocking hooks even if result has block field', async () => { + const entries: HookEntry[] = [ + { handler: () => ({ block: true }) }, + ]; + + const result = await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'PostToolUse', // Not a blocking hook + throwOnHandlerError: false, + }); + + expect(result.blocked).toBe(false); + expect(result.results).toEqual([{ block: true }]); + }); + + it('block: false does not short-circuit PreToolUse', async () => { + const second = vi.fn(() => ({ block: false })); + const entries: HookEntry< + { toolInput: Record }, + { block: boolean } + >[] = [{ handler: () => ({ block: false }) }, { handler: second }]; + + const result = await executeHandlerChain( + entries, + { toolInput: {} }, + makeContext(), + { hookName: 'PreToolUse', throwOnHandlerError: false }, + ); + + expect(result.blocked).toBe(false); + expect(second).toHaveBeenCalled(); + }); + + it('block: 0 (falsy number) does not short-circuit', async () => { + const second = vi.fn(); + const entries: HookEntry< + { toolInput: Record }, + { block: number } + >[] = [ + { handler: () => ({ block: 0 }) }, + { handler: second }, + ]; + + const result = await executeHandlerChain( + entries, + { toolInput: {} }, + makeContext(), + { hookName: 'PreToolUse', throwOnHandlerError: false }, + ); + + // 0 is neither `true` nor a string, so should not block + expect(result.blocked).toBe(false); + expect(second).toHaveBeenCalled(); + }); + + it('block: "" (empty string) triggers short-circuit since typeof is string', async () => { + const second = vi.fn(); + const entries: HookEntry< + { toolInput: Record }, + { block: string } + >[] = [{ handler: () => ({ block: '' }) }, { handler: second }]; + + const result = await executeHandlerChain( + entries, + { toolInput: {} }, + makeContext(), + { hookName: 'PreToolUse', throwOnHandlerError: false }, + ); + + // isBlockTriggered checks typeof value === 'string', so empty string IS a string + expect(result.blocked).toBe(true); + expect(second).not.toHaveBeenCalled(); + }); + }); + + describe('mutation piping edge cases', () => { + it('mutatedInput: undefined does not overwrite existing toolInput', async () => { + const entries: HookEntry< + { toolInput: Record }, + { mutatedInput: undefined } + >[] = [{ handler: () => ({ mutatedInput: undefined }) }]; + + const result = await executeHandlerChain( + entries, + { toolInput: { original: true } }, + makeContext(), + { hookName: 'PreToolUse', throwOnHandlerError: false }, + ); + + expect(result.finalPayload.toolInput).toEqual({ original: true }); + }); + + it('mutatedPrompt pipes correctly through UserPromptSubmit', async () => { + const entries: HookEntry< + { prompt: string }, + { mutatedPrompt: string } + >[] = [ + { handler: () => ({ mutatedPrompt: 'rewritten-1' }) }, + { + handler: (p) => ({ + mutatedPrompt: `${p.prompt}+appended`, + }), + }, + ]; + + const result = await executeHandlerChain( + entries, + { prompt: 'original' }, + makeContext(), + { hookName: 'UserPromptSubmit', throwOnHandlerError: false }, + ); + + // First handler rewrites prompt to 'rewritten-1', second sees 'rewritten-1' as p.prompt + expect(result.finalPayload.prompt).toBe('rewritten-1+appended'); + }); + + it('result with __proto__ key does not cause prototype pollution', async () => { + const entries: HookEntry< + { toolInput: Record }, + Record + >[] = [ + { + handler: () => { + const obj = Object.create(null); + obj['__proto__'] = { polluted: true }; + return obj; + }, + }, + ]; + + const result = await executeHandlerChain( + entries, + { toolInput: {} }, + makeContext(), + { hookName: 'PreToolUse', throwOnHandlerError: false }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((({} as Record)['polluted'] as unknown)).toBeUndefined(); + expect(result.results.length).toBe(1); + }); + }); + + describe('filter edge cases', () => { + it('throwing filter is caught in non-strict mode', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entries: HookEntry[] = [ + { + filter: () => { + throw new Error('filter boom'); + }, + handler: vi.fn(), + }, + ]; + + // The filter throw happens inside the try block since filter is called + // before handler. Let's verify current behavior. + // Looking at the code: filter is called OUTSIDE the try block! + // This means a throwing filter will propagate. + await expect( + executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + }), + ).rejects.toThrow('filter boom'); + + warnSpy.mockRestore(); + }); + + it('throwing filter in strict mode propagates error', async () => { + const entries: HookEntry[] = [ + { + filter: () => { + throw new Error('filter fail'); + }, + handler: vi.fn(), + }, + ]; + + await expect( + executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: true, + }), + ).rejects.toThrow('filter fail'); + }); + }); + + describe('async output edge cases', () => { + it('{ async: true } with block field is treated as async, not block', async () => { + const entries: HookEntry< + { toolInput: Record }, + { async: true; block: true } + >[] = [ + { + handler: () => ({ + async: true as const, + block: true, + }), + }, + ]; + + const result = await executeHandlerChain( + entries, + { toolInput: {} }, + makeContext(), + { hookName: 'PreToolUse', throwOnHandlerError: false }, + ); + + // isAsyncOutput check comes before block check, so this should be async + expect(result.pending.length).toBe(1); + expect(result.blocked).toBe(false); + expect(result.results).toEqual([]); + }); + + it('{ async: "true" } (string) is NOT treated as async', async () => { + const entries: HookEntry[] = [ + { handler: () => ({ async: 'true' }) }, + ]; + + const result = await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + }); + + // isAsyncOutput requires async === true (boolean), not "true" (string) + expect(result.pending.length).toBe(0); + expect(result.results).toEqual([{ async: 'true' }]); + }); + }); + + describe('error handling edge cases', () => { + it('handler returning a rejected promise is caught in non-strict mode', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const secondHandler = vi.fn(); + + const entries: HookEntry[] = [ + { handler: () => Promise.reject(new Error('async boom')) }, + { handler: secondHandler }, + ]; + + await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + }); + + expect(warnSpy).toHaveBeenCalled(); + expect(secondHandler).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('non-Error thrown values are caught in non-strict mode', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const entries: HookEntry[] = [ + { + handler: () => { + throw 'string error'; + }, + }, + ]; + + await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + }); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('non-Error thrown values propagate in strict mode', async () => { + const entries: HookEntry[] = [ + { + handler: () => { + throw 'string error'; + }, + }, + ]; + + await expect( + executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: true, + }), + ).rejects.toBe('string error'); + }); + }); + + describe('matcher + filter interaction', () => { + it('matcher is checked before filter — mismatched matcher skips filter call', async () => { + const filterFn = vi.fn(() => true); + const entries: HookEntry[] = [ + { matcher: 'Bash', filter: filterFn, handler: vi.fn() }, + ]; + + await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + toolName: 'ReadFile', + }); + + expect(filterFn).not.toHaveBeenCalled(); + }); + + it('matcher without toolName in options does NOT skip (matcher ignored)', async () => { + const handler = vi.fn(); + const entries: HookEntry[] = [ + { matcher: 'Bash', handler }, + ]; + + await executeHandlerChain(entries, {}, makeContext(), { + hookName: 'Test', + throwOnHandlerError: false, + // no toolName + }); + + // Code: matcher !== undefined && toolName !== undefined -> skip + // Since toolName is undefined, matcher check is skipped entirely + expect(handler).toHaveBeenCalled(); + }); + }); + + describe('large chain stress', () => { + it('handles 1000 handlers without stack overflow', async () => { + const entries: HookEntry<{ count: number }, void>[] = Array.from( + { length: 1000 }, + () => ({ handler: vi.fn() }), + ); + + const result = await executeHandlerChain( + entries, + { count: 0 }, + makeContext(), + { hookName: 'Test', throwOnHandlerError: false }, + ); + + expect(result.results).toEqual([]); + for (const entry of entries) { + expect(entry.handler).toHaveBeenCalledOnce(); + } + }); + }); +}); diff --git a/tests/unit/hooks-manager-adversarial.test.ts b/tests/unit/hooks-manager-adversarial.test.ts new file mode 100644 index 0000000..4976036 --- /dev/null +++ b/tests/unit/hooks-manager-adversarial.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi } from 'vitest'; +import * as z4 from 'zod/v4'; +import { HooksManager } from '../../src/lib/hooks-manager.js'; + +describe('HooksManager (adversarial)', () => { + describe('unsubscribe edge cases', () => { + it('calling unsubscribe twice does not throw or corrupt state', () => { + const manager = new HooksManager(); + const unsub = manager.on('PostToolUse', { handler: vi.fn() }); + + unsub(); + unsub(); // second call should be harmless + + expect(manager.hasHandlers('PostToolUse')).toBe(false); + }); + + it('unsubscribe from one handler does not affect others', () => { + const manager = new HooksManager(); + const h1 = vi.fn(); + const h2 = vi.fn(); + + const unsub1 = manager.on('PostToolUse', { handler: h1 }); + manager.on('PostToolUse', { handler: h2 }); + + unsub1(); + expect(manager.hasHandlers('PostToolUse')).toBe(true); + }); + }); + + describe('off() edge cases', () => { + it('off with identical-looking but different function reference returns false', () => { + const manager = new HooksManager(); + const h1 = () => {}; + const h2 = () => {}; + + manager.on('PostToolUse', { handler: h1 }); + const removed = manager.off('PostToolUse', h2); + + expect(removed).toBe(false); + expect(manager.hasHandlers('PostToolUse')).toBe(true); + }); + + it('off for a hook name that was never registered returns false', () => { + const manager = new HooksManager(); + expect(manager.off('SessionEnd', vi.fn())).toBe(false); + }); + }); + + describe('re-entrant emit', () => { + it('handler that registers a new handler during emit does not affect current chain', async () => { + const manager = new HooksManager(); + const lateHandler = vi.fn(); + + manager.on('PostToolUse', { + handler: () => { + // Register a new handler mid-emit + manager.on('PostToolUse', { handler: lateHandler }); + }, + }); + + await manager.emit('PostToolUse', { + toolName: 'Bash', + toolInput: {}, + toolOutput: 'ok', + durationMs: 100, + sessionId: 'test', + }); + + // The late handler was added to the array during iteration. + // Since entries is a mutable array and we iterate by index, + // the late handler MAY be called (pushed to end of array, i < entries.length). + // This test documents the actual behavior. + // The handler pushes to the list which the for-loop iterates over, + // so it WILL be called since entries.length grows. + expect(lateHandler).toHaveBeenCalledOnce(); + }); + + it('handler that calls emit recursively does not deadlock', async () => { + const manager = new HooksManager(); + let depth = 0; + + manager.on('SessionStart', { + handler: async () => { + depth++; + if (depth < 3) { + await manager.emit('SessionStart', { + sessionId: `depth-${depth}`, + config: undefined, + }); + } + }, + }); + + await manager.emit('SessionStart', { + sessionId: 'root', + config: undefined, + }); + + expect(depth).toBe(3); + }); + }); + + describe('custom hook validation', () => { + it('throws for each built-in hook name used as custom', () => { + const builtInNames = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'UserPromptSubmit', + 'Stop', + 'PermissionRequest', + 'SessionStart', + 'SessionEnd', + ]; + + for (const name of builtInNames) { + expect( + () => + new HooksManager({ + [name]: { + payload: z4.object({}), + result: z4.void(), + }, + }), + ).toThrow('collides with a built-in hook'); + } + }); + + it('allows custom hook with empty string name', () => { + const manager = new HooksManager({ + '': { + payload: z4.object({}), + result: z4.void(), + }, + }); + expect(manager).toBeInstanceOf(HooksManager); + }); + }); + + describe('emit edge cases', () => { + it('emit for a hook with no registered handlers returns clean result', async () => { + const manager = new HooksManager(); + const result = await manager.emit('PreToolUse', { + toolName: 'Test', + toolInput: {}, + sessionId: 's1', + }); + + expect(result.results).toEqual([]); + expect(result.blocked).toBe(false); + expect(result.pending).toEqual([]); + }); + + it('emit for an unregistered custom hook name returns clean result', async () => { + const manager = new HooksManager(); + // Emitting a never-registered hook name + const result = await manager.emit( + 'NonExistentHook' as 'PostToolUse', + { + toolName: 'X', + toolInput: {}, + toolOutput: null, + durationMs: 0, + sessionId: 's', + }, + ); + + expect(result.results).toEqual([]); + }); + + it('handler that throws in strict mode propagates through emit', async () => { + const manager = new HooksManager(undefined, { + throwOnHandlerError: true, + }); + + manager.on('PostToolUse', { + handler: () => { + throw new Error('handler explosion'); + }, + }); + + await expect( + manager.emit('PostToolUse', { + toolName: 'T', + toolInput: {}, + toolOutput: null, + durationMs: 0, + sessionId: 's', + }), + ).rejects.toThrow('handler explosion'); + }); + + it('handler that throws in default mode does not fail emit', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new HooksManager(); + + manager.on('PostToolUse', { + handler: () => { + throw new Error('soft failure'); + }, + }); + + const result = await manager.emit('PostToolUse', { + toolName: 'T', + toolInput: {}, + toolOutput: null, + durationMs: 0, + sessionId: 's', + }); + + expect(result.results).toEqual([]); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('PreToolUse block through manager', () => { + it('block with string reason short-circuits and reports blocked', async () => { + const manager = new HooksManager(); + const second = vi.fn(); + + manager.on('PreToolUse', { + handler: () => ({ block: 'dangerous tool' }), + }); + manager.on('PreToolUse', { handler: second }); + + const result = await manager.emit( + 'PreToolUse', + { toolName: 'rm', toolInput: { path: '/' }, sessionId: 's' }, + { toolName: 'rm' }, + ); + + expect(result.blocked).toBe(true); + expect(second).not.toHaveBeenCalled(); + expect(result.results).toEqual([{ block: 'dangerous tool' }]); + }); + }); + + describe('drain edge cases', () => { + it('drain can be called multiple times safely', async () => { + const manager = new HooksManager(); + await manager.drain(); + await manager.drain(); + // No error + }); + + it('drain after handlers have been cleared still resolves', async () => { + const manager = new HooksManager(); + manager.on('PostToolUse', { + handler: () => ({ async: true as const }), + }); + + await manager.emit('PostToolUse', { + toolName: 'T', + toolInput: {}, + toolOutput: null, + durationMs: 0, + sessionId: 's', + }); + + manager.removeAll(); + await manager.drain(); // pending async should still drain + }); + }); + + describe('removeAll during usage', () => { + it('removeAll clears all hooks even if called from within a handler', async () => { + const manager = new HooksManager(); + + manager.on('SessionStart', { + handler: () => { + manager.removeAll(); + }, + }); + + await manager.emit('SessionStart', { + sessionId: 's', + config: undefined, + }); + + expect(manager.hasHandlers('SessionStart')).toBe(false); + }); + }); + + describe('setSessionId', () => { + it('changing sessionId mid-session is reflected in subsequent emits', async () => { + const manager = new HooksManager(); + const sessionIds: string[] = []; + + manager.on('PostToolUse', { + handler: (_p, ctx) => { + sessionIds.push(ctx.sessionId); + }, + }); + + manager.setSessionId('session-1'); + await manager.emit('PostToolUse', { + toolName: 'T', + toolInput: {}, + toolOutput: null, + durationMs: 0, + sessionId: 'ignored', + }); + + manager.setSessionId('session-2'); + await manager.emit('PostToolUse', { + toolName: 'T', + toolInput: {}, + toolOutput: null, + durationMs: 0, + sessionId: 'ignored', + }); + + expect(sessionIds).toEqual(['session-1', 'session-2']); + }); + }); +}); diff --git a/tests/unit/hooks-matchers-adversarial.test.ts b/tests/unit/hooks-matchers-adversarial.test.ts new file mode 100644 index 0000000..4e3f064 --- /dev/null +++ b/tests/unit/hooks-matchers-adversarial.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { matchesTool } from '../../src/lib/hooks-matchers.js'; + +describe('matchesTool (adversarial)', () => { + describe('empty string edge cases', () => { + it('empty string matcher matches empty string toolName', () => { + expect(matchesTool('', '')).toBe(true); + }); + + it('empty string matcher does NOT match non-empty toolName', () => { + expect(matchesTool('', 'Bash')).toBe(false); + }); + + it('non-empty matcher does NOT match empty toolName', () => { + expect(matchesTool('Bash', '')).toBe(false); + }); + }); + + describe('RegExp stateful behavior', () => { + it('RegExp with global flag has stateful .test() — may produce inconsistent results', () => { + const globalRegex = /Bash/g; + + // First call — matches + const first = matchesTool(globalRegex, 'Bash'); + // Second call — global regex has lastIndex set, may NOT match + const second = matchesTool(globalRegex, 'Bash'); + + // This documents that global flag is dangerous with matchesTool + // First call returns true, second returns false due to lastIndex + expect(first).toBe(true); + expect(second).toBe(false); + }); + + it('RegExp without global flag is idempotent', () => { + const regex = /Bash/; + expect(matchesTool(regex, 'Bash')).toBe(true); + expect(matchesTool(regex, 'Bash')).toBe(true); + expect(matchesTool(regex, 'Bash')).toBe(true); + }); + + it('RegExp with case-insensitive flag', () => { + expect(matchesTool(/bash/i, 'Bash')).toBe(true); + expect(matchesTool(/bash/i, 'BASH')).toBe(true); + }); + + it('RegExp matching partial tool name (no anchoring)', () => { + // /Read/ matches "ReadFile" — this is regex default behavior, not bug + expect(matchesTool(/Read/, 'ReadFile')).toBe(true); + expect(matchesTool(/Read/, 'OnlyRead')).toBe(true); + }); + }); + + describe('function matcher edge cases', () => { + it('function matcher throwing propagates the error', () => { + const throwingMatcher = () => { + throw new Error('matcher boom'); + }; + expect(() => matchesTool(throwingMatcher, 'Bash')).toThrow('matcher boom'); + }); + + it('function matcher returning truthy non-boolean is NOT coerced to true', () => { + // BUG: matchesTool returns raw value from function, not boolean-coerced. + // Callers that do strict === true checks will behave differently than truthiness checks. + const truthyMatcher = () => 1 as unknown as boolean; + expect(matchesTool(truthyMatcher, 'Bash')).toBe(1); + }); + + it('function matcher returning 0 (falsy) is NOT coerced to false', () => { + // BUG: returns 0 instead of false — truthiness check works but strict equality fails + const falsyMatcher = () => 0 as unknown as boolean; + expect(matchesTool(falsyMatcher, 'Bash')).toBe(0); + }); + + it('function matcher returning null is NOT coerced to false', () => { + // BUG: returns null instead of false — truthiness check works but strict equality fails + const nullMatcher = () => null as unknown as boolean; + expect(matchesTool(nullMatcher, 'Bash')).toBe(null); + }); + }); + + describe('special characters in string matcher', () => { + it('string matcher with regex-special chars does exact match only', () => { + expect(matchesTool('Read.*File', 'Read.*File')).toBe(true); + expect(matchesTool('Read.*File', 'ReadAnyFile')).toBe(false); + }); + }); +}); diff --git a/tests/unit/hooks-resolve-adversarial.test.ts b/tests/unit/hooks-resolve-adversarial.test.ts new file mode 100644 index 0000000..68883d5 --- /dev/null +++ b/tests/unit/hooks-resolve-adversarial.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest'; +import { resolveHooks } from '../../src/lib/hooks-resolve.js'; +import { HooksManager } from '../../src/lib/hooks-manager.js'; +import type { InlineHookConfig } from '../../src/lib/hooks-types.js'; + +describe('resolveHooks (adversarial)', () => { + describe('falsy inputs', () => { + it('returns undefined for null', () => { + expect(resolveHooks(null as unknown as undefined)).toBeUndefined(); + }); + + it('returns undefined for false', () => { + expect(resolveHooks(false as unknown as undefined)).toBeUndefined(); + }); + + it('returns undefined for 0', () => { + expect(resolveHooks(0 as unknown as undefined)).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(resolveHooks('' as unknown as undefined)).toBeUndefined(); + }); + }); + + describe('empty config', () => { + it('empty object returns a HooksManager with no handlers', () => { + const result = resolveHooks({}); + expect(result).toBeInstanceOf(HooksManager); + expect(result!.hasHandlers('PreToolUse')).toBe(false); + }); + + it('config with empty arrays returns HooksManager with no handlers', () => { + const config: InlineHookConfig = { + PreToolUse: [], + PostToolUse: [], + }; + + const result = resolveHooks(config); + expect(result).toBeInstanceOf(HooksManager); + expect(result!.hasHandlers('PreToolUse')).toBe(false); + expect(result!.hasHandlers('PostToolUse')).toBe(false); + }); + }); + + describe('malformed config values', () => { + it('non-array value for a hook key is skipped', () => { + const config = { + PreToolUse: 'not-an-array', + } as unknown as InlineHookConfig; + + const result = resolveHooks(config); + expect(result).toBeInstanceOf(HooksManager); + expect(result!.hasHandlers('PreToolUse')).toBe(false); + }); + + it('null value for a hook key is skipped', () => { + const config = { + PreToolUse: null, + } as unknown as InlineHookConfig; + + const result = resolveHooks(config); + expect(result).toBeInstanceOf(HooksManager); + expect(result!.hasHandlers('PreToolUse')).toBe(false); + }); + + it('number value for a hook key is skipped', () => { + const config = { + PreToolUse: 42, + } as unknown as InlineHookConfig; + + const result = resolveHooks(config); + expect(result).toBeInstanceOf(HooksManager); + expect(result!.hasHandlers('PreToolUse')).toBe(false); + }); + }); + + describe('prototype pollution resistance', () => { + it('__proto__ key in config does not pollute Object prototype', () => { + const config = JSON.parse( + '{"__proto__": [{"handler": null}], "PreToolUse": []}', + ); + + // This should not crash and should not pollute Object.prototype + const result = resolveHooks(config); + expect(result).toBeInstanceOf(HooksManager); + expect(({} as Record)['handler']).toBeUndefined(); + }); + + it('constructor key in config does not crash', () => { + const config = { + constructor: [{ handler: vi.fn() }], + } as unknown as InlineHookConfig; + + const result = resolveHooks(config); + expect(result).toBeInstanceOf(HooksManager); + // 'constructor' is treated as a hook name — it gets registered + expect(result!.hasHandlers('constructor')).toBe(true); + }); + }); + + describe('HooksManager passthrough', () => { + it('returns the exact same instance, not a copy', () => { + const manager = new HooksManager(); + manager.on('PreToolUse', { handler: vi.fn() }); + + const result = resolveHooks(manager); + expect(result).toBe(manager); + }); + }); + + describe('non-standard hook names in inline config', () => { + it('registers handlers for arbitrary hook names (not just built-in)', () => { + const config = { + CustomHook: [{ handler: vi.fn() }], + } as unknown as InlineHookConfig; + + const result = resolveHooks(config); + expect(result).toBeInstanceOf(HooksManager); + expect(result!.hasHandlers('CustomHook')).toBe(true); + }); + }); +}); diff --git a/tests/unit/hooks-types-adversarial.test.ts b/tests/unit/hooks-types-adversarial.test.ts new file mode 100644 index 0000000..daf831c --- /dev/null +++ b/tests/unit/hooks-types-adversarial.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { isAsyncOutput } from '../../src/lib/hooks-types.js'; + +describe('isAsyncOutput (adversarial)', () => { + describe('truthy values that should NOT match', () => { + it('{ async: "true" } (string) is not AsyncOutput', () => { + expect(isAsyncOutput({ async: 'true' })).toBe(false); + }); + + it('{ async: 1 } (number) is not AsyncOutput', () => { + expect(isAsyncOutput({ async: 1 })).toBe(false); + }); + + it('{ async: {} } (object) is not AsyncOutput', () => { + expect(isAsyncOutput({ async: {} })).toBe(false); + }); + + it('{ async: [] } (array) is not AsyncOutput', () => { + expect(isAsyncOutput({ async: [] })).toBe(false); + }); + }); + + describe('non-object inputs', () => { + it('null is not AsyncOutput', () => { + expect(isAsyncOutput(null)).toBe(false); + }); + + it('undefined is not AsyncOutput', () => { + expect(isAsyncOutput(undefined)).toBe(false); + }); + + it('number is not AsyncOutput', () => { + expect(isAsyncOutput(42)).toBe(false); + }); + + it('string is not AsyncOutput', () => { + expect(isAsyncOutput('async')).toBe(false); + }); + + it('boolean true is not AsyncOutput', () => { + expect(isAsyncOutput(true)).toBe(false); + }); + + it('symbol is not AsyncOutput', () => { + expect(isAsyncOutput(Symbol('async'))).toBe(false); + }); + }); + + describe('valid AsyncOutput variations', () => { + it('{ async: true } is AsyncOutput', () => { + expect(isAsyncOutput({ async: true })).toBe(true); + }); + + it('{ async: true, asyncTimeout: 5000 } is AsyncOutput', () => { + expect(isAsyncOutput({ async: true, asyncTimeout: 5000 })).toBe(true); + }); + + it('{ async: true, extraField: "ignored" } is still AsyncOutput', () => { + expect(isAsyncOutput({ async: true, extraField: 'ignored' })).toBe(true); + }); + + it('frozen object { async: true } is AsyncOutput', () => { + expect(isAsyncOutput(Object.freeze({ async: true }))).toBe(true); + }); + }); + + describe('array with async property', () => { + it('array with async property set is treated as AsyncOutput', () => { + const arr: unknown[] = []; + (arr as unknown as Record)['async'] = true; + // Arrays are objects and have 'async' in arr, so this should match + expect(isAsyncOutput(arr)).toBe(true); + }); + }); + + describe('Proxy objects', () => { + it('Proxy that returns true for async property is AsyncOutput', () => { + const proxy = new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'async') return true; + return undefined; + }, + has(_target, prop) { + return prop === 'async'; + }, + }, + ); + expect(isAsyncOutput(proxy)).toBe(true); + }); + + it('Proxy that throws on property access causes isAsyncOutput to throw', () => { + const proxy = new Proxy( + {}, + { + has() { + throw new Error('proxy trap'); + }, + }, + ); + expect(() => isAsyncOutput(proxy)).toThrow('proxy trap'); + }); + }); + + describe('Object.create(null)', () => { + it('bare object with async: true is AsyncOutput', () => { + const obj = Object.create(null); + obj.async = true; + expect(isAsyncOutput(obj)).toBe(true); + }); + }); +});