feat: add hook engine for sensitive operation confirmation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, PendingConfirmation> = 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<string, unknown>): Promise<HookResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { HookEngine } from './engine.js';
|
||||||
|
export type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js';
|
||||||
@@ -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<string, unknown>;
|
||||||
|
resolve: (result: HookResult) => void;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookConfig {
|
||||||
|
confirm: string[];
|
||||||
|
log: string[];
|
||||||
|
silent: string[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user