diff --git a/src/hooks/engine.test.ts b/src/hooks/engine.test.ts new file mode 100644 index 0000000..c08453b --- /dev/null +++ b/src/hooks/engine.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { HookEngine } from './engine.js'; + +describe('HookEngine', () => { + it('returns silent action for non-matching tools', async () => { + const engine = new HookEngine({ + confirm: ['shell.*'], + log: ['web.*'], + silent: [], + }); + + const action = engine.getAction('unknown.tool'); + expect(action).toBe('silent'); + }); + + it('returns confirm action for matching confirm patterns', () => { + const engine = new HookEngine({ + confirm: ['shell.*', 'file.write'], + log: [], + silent: [], + }); + + expect(engine.getAction('shell.exec')).toBe('confirm'); + expect(engine.getAction('shell.run')).toBe('confirm'); + expect(engine.getAction('file.write')).toBe('confirm'); + expect(engine.getAction('file.read')).toBe('silent'); + }); + + it('returns log action for matching log patterns', () => { + const engine = new HookEngine({ + confirm: [], + log: ['web.*'], + silent: [], + }); + + expect(engine.getAction('web.fetch')).toBe('log'); + expect(engine.getAction('web.search')).toBe('log'); + }); + + it('queues confirmation and resolves when approved', async () => { + const engine = new HookEngine({ + confirm: ['shell.*'], + log: [], + silent: [], + }); + + const confirmPromise = engine.requestConfirmation('shell.exec', { cmd: 'ls' }); + + const pending = engine.getPendingConfirmations(); + expect(pending).toHaveLength(1); + expect(pending[0].tool).toBe('shell.exec'); + + engine.resolveConfirmation(pending[0].id, { approved: true }); + + const result = await confirmPromise; + expect(result.approved).toBe(true); + }); + + it('resolves with denied when rejected', async () => { + const engine = new HookEngine({ + confirm: ['shell.*'], + log: [], + silent: [], + }); + + const confirmPromise = engine.requestConfirmation('shell.exec', { cmd: 'rm -rf' }); + + const pending = engine.getPendingConfirmations(); + engine.resolveConfirmation(pending[0].id, { approved: false, reason: 'Too dangerous' }); + + const result = await confirmPromise; + expect(result.approved).toBe(false); + expect(result.reason).toBe('Too dangerous'); + }); +}); diff --git a/src/hooks/engine.ts b/src/hooks/engine.ts new file mode 100644 index 0000000..4a3ed3d --- /dev/null +++ b/src/hooks/engine.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'crypto'; +import type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js'; + +export class HookEngine { + private confirmPatterns: RegExp[]; + private logPatterns: RegExp[]; + private pendingConfirmations: Map = new Map(); + + constructor(config: HookConfig) { + this.confirmPatterns = config.confirm.map(p => this.patternToRegex(p)); + this.logPatterns = config.log.map(p => this.patternToRegex(p)); + } + + private patternToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`); + } + + getAction(tool: string): HookAction { + if (this.confirmPatterns.some(p => p.test(tool))) { + return 'confirm'; + } + if (this.logPatterns.some(p => p.test(tool))) { + return 'log'; + } + return 'silent'; + } + + async requestConfirmation(tool: string, args: Record): Promise { + const id = randomUUID(); + + return new Promise((resolve) => { + const pending: PendingConfirmation = { + id, + tool, + args, + resolve, + createdAt: new Date(), + }; + this.pendingConfirmations.set(id, pending); + }); + } + + resolveConfirmation(id: string, result: HookResult): boolean { + const pending = this.pendingConfirmations.get(id); + if (!pending) { + return false; + } + + pending.resolve(result); + this.pendingConfirmations.delete(id); + return true; + } + + getPendingConfirmations(): PendingConfirmation[] { + return Array.from(this.pendingConfirmations.values()); + } + + clearExpiredConfirmations(maxAgeMs: number = 5 * 60 * 1000): number { + const now = Date.now(); + let cleared = 0; + + for (const [id, pending] of this.pendingConfirmations) { + if (now - pending.createdAt.getTime() > maxAgeMs) { + pending.resolve({ approved: false, reason: 'Confirmation timed out' }); + this.pendingConfirmations.delete(id); + cleared++; + } + } + + return cleared; + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..e06e38b --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { HookEngine } from './engine.js'; +export type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js'; diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000..e33777e --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1,20 @@ +export type HookAction = 'confirm' | 'log' | 'silent'; + +export interface HookResult { + approved: boolean; + reason?: string; +} + +export interface PendingConfirmation { + id: string; + tool: string; + args: Record; + resolve: (result: HookResult) => void; + createdAt: Date; +} + +export interface HookConfig { + confirm: string[]; + log: string[]; + silent: string[]; +}