import type { ToolApprovalRequest, ToolApprovalDecision } from '../core/agent/agent-hooks.js'; import type { ToolCall, ToolResult } from '../core/tools/tool-types.js'; import type { HooksConfig, HookCommandConfig } from './config.js'; import { safeChildEnv } from '../utils/safe-child-env.js'; import { errorMessage } from '../errors.js'; import { ProcessError, runProcess } from '../utils/run-process.js'; const IS_WIN = process.platform === 'win32'; const DEFAULT_HOOK_TIMEOUT_MS = 30_000; interface HookRunResult { exitCode: number; stdout: string; stderr: string; } interface HookPayload { event: 'PreToolUse' | 'PostToolUse' | 'SessionStart'; toolName?: string; input?: Record; result?: string; isError?: boolean; } function runHookCommand( command: string, payload: HookPayload, cwd: string, timeoutMs: number ): Promise { const shell = IS_WIN ? process.env.COMSPEC || 'cmd.exe' : '/bin/sh'; const args = IS_WIN ? ['/c', command] : ['-c', command]; const env = safeChildEnv({ LANG: process.env.LANG || 'en_US.UTF-8', MOSS_HOOK_EVENT: payload.event, MOSS_TOOL_NAME: payload.toolName ?? '', MOSS_WORKSPACE: cwd, }); return runProcess(shell, { args, cwd, env, timeout: timeoutMs, stdin: JSON.stringify(payload), }).then(({ exitCode, stdout, stderr }) => ({ exitCode, stdout, stderr })).catch((err) => { if (err instanceof ProcessError) { return { exitCode: err.timedOut ? 124 : err.exitCode, stdout: err.stdout, stderr: err.timedOut ? `${err.stderr}\n[hook timed out after ${timeoutMs}ms]` : err.stderr, }; } return { exitCode: 1, stdout: '', stderr: errorMessage(err) }; }); } function toolNameMatches(matcher: string | undefined, toolName: string): boolean { if (!matcher) return true; try { return new RegExp(matcher).test(toolName); } catch { return matcher === toolName; } } function timeoutFor(hook: HookCommandConfig): number { return Math.max(1000, Number(hook.timeoutMs) || DEFAULT_HOOK_TIMEOUT_MS); } export interface ConfiguredHookCallbacks { onBeforeToolExec?: (request: ToolApprovalRequest) => Promise; onToolResult?: (call: ToolCall, result: ToolResult) => void; runSessionStart: () => Promise; hasHooks: boolean; } export function createConfiguredHookCallbacks( hooks: HooksConfig | undefined, opts: { workspaceDir: string } ): ConfiguredHookCallbacks { const pre = hooks?.PreToolUse ?? []; const post = hooks?.PostToolUse ?? []; const sessionStart = hooks?.SessionStart ?? []; const cwd = opts.workspaceDir; const onBeforeToolExec = pre.length === 0 ? undefined : async (request: ToolApprovalRequest): Promise => { for (const hook of pre) { if (!toolNameMatches(hook.matcher, request.tool.name)) continue; const blocking = hook.blocking !== false; const r = await runHookCommand( hook.command, { event: 'PreToolUse', toolName: request.tool.name, input: request.input }, cwd, timeoutFor(hook) ); if (blocking && r.exitCode !== 0) { const reason = (r.stderr || r.stdout || `hook exited ${r.exitCode}`) .trim() .slice(0, 500); return { approved: false, reason: `Blocked by PreToolUse hook: ${reason}` }; } } return { approved: true }; }; const onToolResult = post.length === 0 ? undefined : (call: ToolCall, result: ToolResult): void => { for (const hook of post) { if (!toolNameMatches(hook.matcher, call.name)) continue; void runHookCommand( hook.command, { event: 'PostToolUse', toolName: call.name, input: call.input, result: result.content, isError: Boolean(result.isError), }, cwd, timeoutFor(hook) ).then((r) => { if (r.exitCode !== 0) { process.stderr.write( `[hooks] PostToolUse (${call.name}) exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n` ); } }).catch(() => {}); } }; const runSessionStart = async (): Promise => { for (const hook of sessionStart) { const r = await runHookCommand( hook.command, { event: 'SessionStart' }, cwd, timeoutFor(hook) ); if (r.exitCode !== 0) { process.stderr.write( `[hooks] SessionStart exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n` ); } } }; return { onBeforeToolExec, onToolResult, runSessionStart, hasHooks: pre.length + post.length + sessionStart.length > 0, }; }