diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f3116141f0..c9f10c5e9f 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -95,6 +95,7 @@ export interface ExtensionMessage { | "customToolsResult" | "modes" | "taskWithAggregatedCosts" + | "hookExecutionStatus" text?: string payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any checkpointWarning?: { @@ -190,6 +191,96 @@ export interface ExtensionMessage { childrenCost: number } historyItem?: HistoryItem + hookExecutionStatus?: HookExecutionStatusPayload +} + +/** + * HookExecutionStatusPayload + * Sent when hook execution starts, completes, or fails. + */ +export interface HookExecutionStatusPayload { + /** Status of the hook execution */ + status: "running" | "completed" | "failed" | "blocked" + /** Event type that triggered the hook */ + event: string + /** Tool name if this is a tool-related event */ + toolName?: string + /** Hook ID being executed */ + hookId?: string + /** Duration in milliseconds (only for completed/failed) */ + duration?: number + /** Error message if failed */ + error?: string + /** Block message if hook blocked the operation */ + blockMessage?: string + /** Whether tool input was modified */ + modified?: boolean +} + +/** + * Serializable hook information for webview display. + * This is a subset of ResolvedHook that can be safely serialized to JSON. + */ +export interface HookInfo { + /** Unique identifier for this hook */ + id: string + /** The event type this hook is registered for */ + event: string + /** Tool name filter (regex/glob pattern) */ + matcher?: string + /** Preview of the command (truncated for display) */ + commandPreview: string + /** Whether this hook is enabled */ + enabled: boolean + /** Source of this hook configuration */ + source: "project" | "mode" | "global" + /** Timeout in seconds */ + timeout: number + /** Override shell if specified */ + shell?: string + /** Human-readable description */ + description?: string +} + +/** + * Serializable hook execution record for webview display. + */ +export interface HookExecutionRecord { + /** When the hook was executed (ISO string) */ + timestamp: string + /** The hook ID that was executed */ + hookId: string + /** The event that triggered execution */ + event: string + /** Tool name if this was a tool-related event */ + toolName?: string + /** Exit code from the process */ + exitCode: number | null + /** Execution duration in milliseconds */ + duration: number + /** Whether the hook timed out */ + timedOut: boolean + /** Whether the hook blocked execution */ + blocked: boolean + /** Error message if the hook failed */ + error?: string + /** Block message if the hook blocked */ + blockMessage?: string +} + +/** + * Hooks state for webview display. + * Contains all information needed to render the Hooks settings tab. + */ +export interface HooksState { + /** Array of resolved hooks with display information */ + enabledHooks: HookInfo[] + /** Recent execution history (last N records) */ + executionHistory: HookExecutionRecord[] + /** Whether project-level hooks are present (for security warnings) */ + hasProjectHooks: boolean + /** When the config snapshot was last loaded (ISO string) */ + snapshotTimestamp?: string } export type ExtensionState = Pick< @@ -335,6 +426,9 @@ export type ExtensionState = Pick< claudeCodeIsAuthenticated?: boolean openAiCodexIsAuthenticated?: boolean debug?: boolean + + /** Hooks configuration and execution state for the Hooks settings tab */ + hooks?: HooksState } export interface Command { @@ -521,6 +615,9 @@ export interface WebviewMessage { | "requestModes" | "switchMode" | "debugSetting" + | "hooksReloadConfig" + | "hooksSetEnabled" + | "hooksOpenConfigFolder" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -576,6 +673,9 @@ export interface WebviewMessage { list?: string[] // For dismissedUpsells response organizationId?: string | null // For organization switching useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow + hookId?: string // For hooksSetEnabled + hookEnabled?: boolean // For hooksSetEnabled + hooksSource?: "global" | "project" // For hooksOpenConfigFolder codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-hooks.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-hooks.spec.ts new file mode 100644 index 0000000000..dad5259c79 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-hooks.spec.ts @@ -0,0 +1,257 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-hooks.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" + +// Mock dependencies that are noisy / unrelated to these tests +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureException: vi.fn(), + }, + }, +})) + +// Mock a tool that uses askApproval so we can exercise the PermissionRequest integration. +vi.mock("../../tools/ListFilesTool", () => ({ + listFilesTool: { + handle: vi.fn(async (_task: any, block: any, callbacks: any) => { + // Allow tests to trigger a failure path without real side effects. + if (block?.params?.path === "FAIL") { + await callbacks.handleError("listing files", new Error("boom")) + return + } + + const didApprove = await callbacks.askApproval("tool", `list_files:${String(block?.params?.path ?? "")}`) + if (!didApprove) { + return + } + callbacks.pushToolResult("ok") + }), + }, +})) + +let presentAssistantMessage: (task: any) => Promise + +describe("presentAssistantMessage - hooks integration", () => { + let mockTask: any + + beforeEach(async () => { + if (!presentAssistantMessage) { + ;({ presentAssistantMessage } = await import("../presentAssistantMessage")) + } + + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + cwd: "/project", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + diffEnabled: false, + consecutiveMistakeCount: 0, + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + browserSession: { + closeBrowser: vi.fn().mockResolvedValue(undefined), + }, + recordToolUsage: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + toolExecutionHooks: { + executePermissionRequest: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }), + executePreToolUse: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }), + executePostToolUse: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }), + executePostToolUseFailure: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + // ask() is called by presentAssistantMessage via askApproval + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + } + + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) + }) + + it("PreToolUse can block execution", async () => { + const toolCallId = "tool_call_block" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "list_files", + params: { path: "." }, + }, + ] + + mockTask.toolExecutionHooks.executePreToolUse.mockResolvedValue({ + proceed: false, + blockReason: "nope", + hookResult: { results: [], blocked: true, totalDuration: 1 }, + }) + + await presentAssistantMessage(mockTask) + + expect(mockTask.toolExecutionHooks.executePreToolUse).toHaveBeenCalledTimes(1) + // Should not even attempt to show approval prompt + expect(mockTask.toolExecutionHooks.executePermissionRequest).not.toHaveBeenCalled() + // Should emit a tool_result (native protocol) with the denial message + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + expect(toolResult).toBeDefined() + expect(toolResult.content).toContain("nope") + }) + + it("PreToolUse can modify tool input", async () => { + const toolCallId = "tool_call_modify" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "list_files", + params: { path: "original" }, + }, + ] + + mockTask.toolExecutionHooks.executePreToolUse.mockResolvedValue({ + proceed: true, + modifiedInput: { path: "modified" }, + hookResult: { results: [], blocked: false, totalDuration: 1 }, + }) + + let askedPartialMessage: string | undefined + mockTask.ask = vi.fn().mockImplementation(async (_type: string, partialMessage?: string) => { + askedPartialMessage = partialMessage + return { response: "yesButtonClicked" } + }) + + await presentAssistantMessage(mockTask) + + expect(mockTask.toolExecutionHooks.executePreToolUse).toHaveBeenCalledTimes(1) + // list_files should invoke askApproval via our mock; its message should contain the modified path. + expect(askedPartialMessage).toContain("modified") + }) + + it("PostToolUse is invoked on success", async () => { + const toolCallId = "tool_call_post_success" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "list_files", + params: { path: "." }, + }, + ] + + mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + + await presentAssistantMessage(mockTask) + + expect(mockTask.toolExecutionHooks.executePostToolUse).toHaveBeenCalledTimes(1) + expect(mockTask.toolExecutionHooks.executePostToolUseFailure).not.toHaveBeenCalled() + }) + + it("PostToolUseFailure is invoked on tool failure", async () => { + const toolCallId = "tool_call_post_failure" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "list_files", + params: { path: "FAIL" }, + }, + ] + + await presentAssistantMessage(mockTask) + + expect(mockTask.toolExecutionHooks.executePostToolUseFailure).toHaveBeenCalledTimes(1) + }) + + it("PermissionRequest hook runs before approval prompt", async () => { + const toolCallId = "tool_call_permission_request" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "list_files", + params: { path: "." }, + }, + ] + + const calls: string[] = [] + + mockTask.toolExecutionHooks.executePermissionRequest = vi.fn().mockImplementation(async () => { + calls.push("permission") + return { proceed: true, hookResult: {} } + }) + + mockTask.ask = vi.fn().mockImplementation(async () => { + calls.push("ask") + return { response: "yesButtonClicked" } + }) + + await presentAssistantMessage(mockTask) + + expect(calls[0]).toBe("permission") + expect(calls[1]).toBe("ask") + }) + + it("PermissionRequest hook can block showing approval prompt", async () => { + const toolCallId = "tool_call_permission_block" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "list_files", + params: { path: "." }, + }, + ] + + mockTask.toolExecutionHooks.executePermissionRequest = vi.fn().mockResolvedValue({ + proceed: false, + blockReason: "blocked by policy", + hookResult: { results: [], blocked: true, totalDuration: 1 }, + }) + + await presentAssistantMessage(mockTask) + + // ask() should never be called if hook blocks the permission prompt + expect(mockTask.ask).not.toHaveBeenCalled() + + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + expect(toolResult).toBeDefined() + expect(toolResult.content).toContain("blocked by policy") + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index 72ee430609..6c41dc801a 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -28,6 +28,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () => mockTask = { taskId: "test-task-id", instanceId: "test-instance", + cwd: "/project", abort: false, presentAssistantMessageLocked: false, presentAssistantMessageHasPendingUpdates: false, @@ -49,6 +50,12 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () => toolRepetitionDetector: { check: vi.fn().mockReturnValue({ allowExecution: true }), }, + toolExecutionHooks: { + executePermissionRequest: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }), + executePreToolUse: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }), + executePostToolUse: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }), + executePostToolUseFailure: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }), + }, providerRef: { deref: () => ({ getState: vi.fn().mockResolvedValue({ diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 693327a022..ca22dddfb3 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -376,6 +376,25 @@ export async function presentAssistantMessage(cline: Task) { const state = await cline.providerRef.deref()?.getState() const { mode, customModes, experiments: stateExperiments } = state ?? {} + const buildToolExecutionHookContext = (toolName: string, toolInput: Record) => { + const projectDirectory = cline.cwd + const projectName = projectDirectory.split(/[/\\]/).filter(Boolean).pop() ?? projectDirectory + + return { + toolName, + toolInput, + session: { + taskId: cline.taskId, + sessionId: cline.instanceId, + mode: mode ?? defaultModeSlug, + }, + project: { + directory: projectDirectory, + name: projectName, + }, + } + } + const toolDescription = (): string => { switch (block.name) { case "execute_command": @@ -509,6 +528,7 @@ export async function presentAssistantMessage(cline: Task) { // Track if we've already pushed a tool result for this tool call (native protocol only) let hasToolResult = false + let toolOutputForHooks: ToolResponse | undefined // Determine protocol by checking if this tool call has an ID. // Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks). @@ -524,6 +544,13 @@ export async function presentAssistantMessage(cline: Task) { let approvalFeedback: { text: string; images?: string[] } | undefined const pushToolResult = (content: ToolResponse) => { + // Capture the final tool output for PostToolUse hooks. + // Note: This captures the first tool_result emitted by the tool. + // Tools generally emit a single tool result; if a tool emits multiple, we only retain the first. + if (toolOutputForHooks === undefined) { + toolOutputForHooks = content + } + if (toolProtocol === TOOL_PROTOCOL.NATIVE) { // For native protocol, only allow ONE tool_result per tool call if (hasToolResult) { @@ -638,12 +665,41 @@ export async function presentAssistantMessage(cline: Task) { // allow multiple tool calls in sequence (don't set didAlreadyUseTool) } + let toolDeniedByHook = false + let toolDeniedByUser = false + let toolExecutionHadFailure = false + let toolFailureAction: string | undefined + let toolFailureMessage: string | undefined + let toolInputForHooks = (block.nativeArgs ?? block.params ?? {}) as Record + let blockToExecute: any = block + const askApproval = async ( type: ClineAsk, partialMessage?: string, progressStatus?: ToolProgressStatus, isProtected?: boolean, ) => { + // Hooks: PermissionRequest (blocking) + // This hook is invoked immediately before the user approval prompt is shown. + // It must NOT bypass existing approval rules (it can only deny). + if (!block.partial && !toolDeniedByHook) { + const permissionRequest = await cline.toolExecutionHooks.executePermissionRequest( + buildToolExecutionHookContext(blockToExecute.name, toolInputForHooks), + ) + + if (!permissionRequest.proceed) { + toolDeniedByHook = true + pushToolResult( + formatResponse.toolDeniedWithFeedback( + permissionRequest.blockReason ?? "Blocked by PermissionRequest hook", + toolProtocol, + ), + ) + cline.didRejectTool = true + return false + } + } + const { response, text, images } = await cline.ask( type, partialMessage, @@ -654,6 +710,7 @@ export async function presentAssistantMessage(cline: Task) { if (response !== "yesButtonClicked") { // Handle both messageResponse and noButtonClicked with text. + toolDeniedByUser = true if (text) { await cline.say("user_feedback", text, images) pushToolResult( @@ -696,6 +753,9 @@ export async function presentAssistantMessage(cline: Task) { return } const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}` + toolExecutionHadFailure = true + toolFailureAction = action + toolFailureMessage = error.message ?? errorString await cline.say( "error", @@ -869,10 +929,39 @@ export async function presentAssistantMessage(cline: Task) { } } - switch (block.name) { + // Hooks: PreToolUse (blocking / may modify tool input) + if (!block.partial) { + const pre = await cline.toolExecutionHooks.executePreToolUse( + buildToolExecutionHookContext(blockToExecute.name, toolInputForHooks), + ) + + if (!pre.proceed) { + pushToolResult( + formatResponse.toolDeniedWithFeedback( + pre.blockReason ?? "Blocked by PreToolUse hook", + toolProtocol, + ), + ) + cline.didRejectTool = true + break + } + + if (pre.modifiedInput) { + toolInputForHooks = pre.modifiedInput + blockToExecute = { + ...block, + params: pre.modifiedInput, + nativeArgs: block.nativeArgs ? pre.modifiedInput : undefined, + } + } + } + + const toolExecutionStart = Date.now() + + switch (blockToExecute.name) { case "write_to_file": await checkpointSaveAndMark(cline) - await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + await writeToFileTool.handle(cline, blockToExecute as ToolUse<"write_to_file">, { askApproval, handleError, pushToolResult, @@ -881,7 +970,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "update_todo_list": - await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { + await updateTodoListTool.handle(cline, blockToExecute as ToolUse<"update_todo_list">, { askApproval, handleError, pushToolResult, @@ -895,7 +984,7 @@ export async function presentAssistantMessage(cline: Task) { // Check if this tool call came from native protocol by checking for ID // Native calls always have IDs, XML calls never do if (toolProtocol === TOOL_PROTOCOL.NATIVE) { - await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + await applyDiffToolClass.handle(cline, blockToExecute as ToolUse<"apply_diff">, { askApproval, handleError, pushToolResult, @@ -918,9 +1007,16 @@ export async function presentAssistantMessage(cline: Task) { } if (isMultiFileApplyDiffEnabled) { - await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await applyDiffTool( + cline, + blockToExecute, + askApproval, + handleError, + pushToolResult, + removeClosingTag, + ) } else { - await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + await applyDiffToolClass.handle(cline, blockToExecute as ToolUse<"apply_diff">, { askApproval, handleError, pushToolResult, @@ -932,7 +1028,7 @@ export async function presentAssistantMessage(cline: Task) { } case "search_and_replace": await checkpointSaveAndMark(cline) - await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, { + await searchAndReplaceTool.handle(cline, blockToExecute as ToolUse<"search_and_replace">, { askApproval, handleError, pushToolResult, @@ -942,7 +1038,7 @@ export async function presentAssistantMessage(cline: Task) { break case "search_replace": await checkpointSaveAndMark(cline) - await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { + await searchReplaceTool.handle(cline, blockToExecute as ToolUse<"search_replace">, { askApproval, handleError, pushToolResult, @@ -952,7 +1048,7 @@ export async function presentAssistantMessage(cline: Task) { break case "edit_file": await checkpointSaveAndMark(cline) - await editFileTool.handle(cline, block as ToolUse<"edit_file">, { + await editFileTool.handle(cline, blockToExecute as ToolUse<"edit_file">, { askApproval, handleError, pushToolResult, @@ -962,7 +1058,7 @@ export async function presentAssistantMessage(cline: Task) { break case "apply_patch": await checkpointSaveAndMark(cline) - await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { + await applyPatchTool.handle(cline, blockToExecute as ToolUse<"apply_patch">, { askApproval, handleError, pushToolResult, @@ -972,7 +1068,7 @@ export async function presentAssistantMessage(cline: Task) { break case "read_file": // Type assertion is safe here because we're in the "read_file" case - await readFileTool.handle(cline, block as ToolUse<"read_file">, { + await readFileTool.handle(cline, blockToExecute as ToolUse<"read_file">, { askApproval, handleError, pushToolResult, @@ -981,7 +1077,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "fetch_instructions": - await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, { + await fetchInstructionsTool.handle(cline, blockToExecute as ToolUse<"fetch_instructions">, { askApproval, handleError, pushToolResult, @@ -990,7 +1086,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "list_files": - await listFilesTool.handle(cline, block as ToolUse<"list_files">, { + await listFilesTool.handle(cline, blockToExecute as ToolUse<"list_files">, { askApproval, handleError, pushToolResult, @@ -999,7 +1095,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "codebase_search": - await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { + await codebaseSearchTool.handle(cline, blockToExecute as ToolUse<"codebase_search">, { askApproval, handleError, pushToolResult, @@ -1008,7 +1104,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "search_files": - await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { + await searchFilesTool.handle(cline, blockToExecute as ToolUse<"search_files">, { askApproval, handleError, pushToolResult, @@ -1019,7 +1115,7 @@ export async function presentAssistantMessage(cline: Task) { case "browser_action": await browserActionTool( cline, - block as ToolUse<"browser_action">, + blockToExecute as ToolUse<"browser_action">, askApproval, handleError, pushToolResult, @@ -1027,7 +1123,7 @@ export async function presentAssistantMessage(cline: Task) { ) break case "execute_command": - await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { + await executeCommandTool.handle(cline, blockToExecute as ToolUse<"execute_command">, { askApproval, handleError, pushToolResult, @@ -1036,7 +1132,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "use_mcp_tool": - await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { + await useMcpToolTool.handle(cline, blockToExecute as ToolUse<"use_mcp_tool">, { askApproval, handleError, pushToolResult, @@ -1045,7 +1141,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "access_mcp_resource": - await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { + await accessMcpResourceTool.handle(cline, blockToExecute as ToolUse<"access_mcp_resource">, { askApproval, handleError, pushToolResult, @@ -1054,7 +1150,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "ask_followup_question": - await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { + await askFollowupQuestionTool.handle(cline, blockToExecute as ToolUse<"ask_followup_question">, { askApproval, handleError, pushToolResult, @@ -1063,7 +1159,7 @@ export async function presentAssistantMessage(cline: Task) { }) break case "switch_mode": - await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { + await switchModeTool.handle(cline, blockToExecute as ToolUse<"switch_mode">, { askApproval, handleError, pushToolResult, @@ -1072,13 +1168,13 @@ export async function presentAssistantMessage(cline: Task) { }) break case "new_task": - await newTaskTool.handle(cline, block as ToolUse<"new_task">, { + await newTaskTool.handle(cline, blockToExecute as ToolUse<"new_task">, { askApproval, handleError, pushToolResult, removeClosingTag, toolProtocol, - toolCallId: block.id, + toolCallId: blockToExecute.id, }) break case "attempt_completion": { @@ -1093,13 +1189,13 @@ export async function presentAssistantMessage(cline: Task) { } await attemptCompletionTool.handle( cline, - block as ToolUse<"attempt_completion">, + blockToExecute as ToolUse<"attempt_completion">, completionCallbacks, ) break } case "run_slash_command": - await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { + await runSlashCommandTool.handle(cline, blockToExecute as ToolUse<"run_slash_command">, { askApproval, handleError, pushToolResult, @@ -1109,7 +1205,7 @@ export async function presentAssistantMessage(cline: Task) { break case "generate_image": await checkpointSaveAndMark(cline) - await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { + await generateImageTool.handle(cline, blockToExecute as ToolUse<"generate_image">, { askApproval, handleError, pushToolResult, @@ -1128,7 +1224,9 @@ export async function presentAssistantMessage(cline: Task) { break } - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined + const customTool = stateExperiments?.customTools + ? customToolRegistry.get(blockToExecute.name) + : undefined if (customTool) { try { @@ -1138,7 +1236,7 @@ export async function presentAssistantMessage(cline: Task) { try { customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {}) } catch (parseParamsError) { - const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` + const message = `Custom tool "${blockToExecute.name}" argument validation failed: ${parseParamsError.message}` console.error(message) cline.consecutiveMistakeCount++ await cline.say("error", message) @@ -1162,17 +1260,17 @@ export async function presentAssistantMessage(cline: Task) { cline.consecutiveMistakeCount++ // Record custom tool error with static name cline.recordToolError("custom_tool", executionError.message) - await handleError(`executing custom tool "${block.name}"`, executionError) + await handleError(`executing custom tool "${blockToExecute.name}"`, executionError) } break } // Not a custom tool - handle as unknown tool error - const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` + const errorMessage = `Unknown tool "${blockToExecute.name}". This tool does not exist. Please use one of the available tools.` cline.consecutiveMistakeCount++ - cline.recordToolError(block.name as ToolName, errorMessage) - await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) + cline.recordToolError(blockToExecute.name as ToolName, errorMessage) + await cline.say("error", t("tools:unknownToolError", { toolName: blockToExecute.name })) // Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) { @@ -1189,6 +1287,30 @@ export async function presentAssistantMessage(cline: Task) { } } + // Hooks: PostToolUse / PostToolUseFailure (non-blocking) + if (!block.partial && !toolDeniedByHook && !toolDeniedByUser) { + const duration = Date.now() - toolExecutionStart + const finalToolInput = (blockToExecute.nativeArgs ?? blockToExecute.params ?? {}) as Record< + string, + unknown + > + const hookContext = buildToolExecutionHookContext(blockToExecute.name, finalToolInput) + + if (toolExecutionHadFailure) { + void cline.toolExecutionHooks + .executePostToolUseFailure( + hookContext, + toolFailureAction ?? "tool_error", + toolFailureMessage ?? "Tool execution failed", + ) + .catch(() => {}) + } else { + void cline.toolExecutionHooks + .executePostToolUse(hookContext, toolOutputForHooks, duration) + .catch(() => {}) + } + } + break } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index af7aed86d5..07f8a6d808 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -81,6 +81,7 @@ import { BrowserSession } from "../../services/browser/BrowserSession" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { RepoPerTaskCheckpointService } from "../../services/checkpoints" +import { ToolExecutionHooks, createToolExecutionHooks } from "../../services/hooks" // integrations import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider" @@ -334,6 +335,9 @@ export class Task extends EventEmitter implements TaskLike { // Computer User browserSession: BrowserSession + // Hooks + toolExecutionHooks: ToolExecutionHooks + // Editing diffViewProvider: DiffViewProvider diffStrategy?: DiffStrategy @@ -535,6 +539,12 @@ export class Task extends EventEmitter implements TaskLike { } } }) + + // Initialize tool execution hooks + this.toolExecutionHooks = createToolExecutionHooks(provider.getHookManager() ?? null, (status) => + provider.postHookStatusToWebview(status), + ) + this.diffEnabled = enableDiff this.fuzzyMatchThreshold = fuzzyMatchThreshold this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78..8dfcf9f4f0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -75,6 +75,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" +import { HookManager, createHookManager, type IHookManager } from "../../services/hooks" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -142,6 +143,7 @@ export class ClineProvider private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager + protected hookManager?: IHookManager private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -208,6 +210,11 @@ export class ClineProvider this.log(`Failed to initialize Skills Manager: ${error}`) }) + // Initialize Hook Manager for lifecycle hooks + this.initializeHookManager().catch((error) => { + this.log(`Failed to initialize Hook Manager: ${error}`) + }) + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -2217,6 +2224,58 @@ export class ClineProvider } })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), + + // Hooks state for settings tab + hooks: this.getHooksStateForWebview(), + } + } + + /** + * Build hooks state for webview from HookManager. + * Converts internal types to serializable HooksState. + */ + private getHooksStateForWebview(): ExtensionState["hooks"] { + if (!this.hookManager) { + return undefined + } + + const snapshot = this.hookManager.getConfigSnapshot() + const enabledHooks = this.hookManager.getEnabledHooks() + const executionHistory = this.hookManager.getHookExecutionHistory() + + // Convert ResolvedHook[] to HookInfo[] + const hookInfos = enabledHooks.map((hook) => ({ + id: hook.id, + event: hook.event, + matcher: hook.matcher, + commandPreview: hook.command.length > 100 ? hook.command.substring(0, 97) + "..." : hook.command, + enabled: hook.enabled ?? true, + source: hook.source, + timeout: hook.timeout ?? 60, + shell: hook.shell, + description: hook.description, + })) + + // Convert HookExecution[] to HookExecutionRecord[] + // Limit to last 50 records for UI + const executionRecords = executionHistory.slice(-50).map((exec) => ({ + timestamp: exec.timestamp.toISOString(), + hookId: exec.hook.id, + event: exec.event, + toolName: exec.result.hook.matcher ? undefined : undefined, // Tool name is in context, not easily accessible here + exitCode: exec.result.exitCode, + duration: exec.result.duration, + timedOut: exec.result.timedOut, + blocked: exec.result.exitCode === 2, + error: exec.result.error?.message, + blockMessage: exec.result.exitCode === 2 ? exec.result.stderr : undefined, + })) + + return { + enabledHooks: hookInfos, + executionHistory: executionRecords, + hasProjectHooks: snapshot?.hasProjectHooks ?? false, + snapshotTimestamp: snapshot?.loadedAt?.toISOString(), } } @@ -2582,6 +2641,85 @@ export class ClineProvider return this.mcpHub } + /** + * Initialize the Hook Manager for lifecycle hooks. + * This loads hooks configuration from project/.roo/hooks/ files. + */ + private async initializeHookManager(): Promise { + const cwd = this.currentWorkspacePath || getWorkspacePath() + if (!cwd) { + this.log("[HookManager] No workspace path available, hooks disabled") + return + } + + try { + const state = await this.getState() + this.hookManager = createHookManager({ + cwd, + mode: state?.mode, + logger: { + debug: (msg: string) => this.log(`[Hooks/debug] ${msg}`), + info: (msg: string) => this.log(`[Hooks/info] ${msg}`), + warn: (msg: string) => this.log(`[Hooks/warn] ${msg}`), + error: (msg: string) => this.log(`[Hooks/error] ${msg}`), + }, + }) + + // Load hooks configuration + await this.hookManager.loadHooksConfig() + this.log("[HookManager] Hooks loaded successfully") + } catch (error) { + this.log( + `[HookManager] Failed to initialize hooks: ${error instanceof Error ? error.message : String(error)}`, + ) + // Don't throw - hooks are optional + this.hookManager = undefined + } + } + + /** + * Get the Hook Manager instance. + */ + public getHookManager(): IHookManager | undefined { + return this.hookManager + } + + /** + * Reload the Hook Manager configuration. + * Call this when hooks configuration files may have changed. + */ + public async reloadHooksConfig(): Promise { + if (this.hookManager) { + try { + await this.hookManager.reloadHooksConfig() + this.log("[HookManager] Hooks reloaded successfully") + } catch (error) { + this.log( + `[HookManager] Failed to reload hooks: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + + /** + * Post hook execution status to webview. + */ + public postHookStatusToWebview(status: { + status: "running" | "completed" | "failed" | "blocked" + event: string + toolName?: string + hookId?: string + duration?: number + error?: string + blockMessage?: string + modified?: boolean + }): void { + this.postMessageToWebview({ + type: "hookExecutionStatus", + hookExecutionStatus: status, + }) + } + public getSkillsManager(): SkillsManager | undefined { return this.skillsManager } diff --git a/src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts new file mode 100644 index 0000000000..cd1a228ede --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts @@ -0,0 +1,350 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.hooks.spec.ts + +import type { IHookManager, ResolvedHook, HookExecution, HooksConfigSnapshot } from "../../../services/hooks/types" + +// Mock vscode before importing webviewMessageHandler +vi.mock("vscode", () => { + const executeCommand = vi.fn().mockResolvedValue(undefined) + const showInformationMessage = vi.fn() + const showErrorMessage = vi.fn() + + return { + window: { + showInformationMessage, + showErrorMessage, + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + }, + commands: { + executeCommand, + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path })), + }, + } +}) + +vi.mock("fs/promises", () => { + const mockMkdir = vi.fn().mockResolvedValue(undefined) + + return { + default: { + mkdir: mockMkdir, + }, + mkdir: mockMkdir, + } +}) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../../api/providers/fetchers/modelCache") + +import * as vscode from "vscode" +import * as fs from "fs/promises" +import * as fsUtils from "../../../utils/fs" +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +// Create mock HookManager +const createMockHookManager = (): IHookManager => ({ + loadHooksConfig: vi.fn().mockResolvedValue({ + hooksByEvent: new Map(), + hooksById: new Map(), + loadedAt: new Date(), + disabledHookIds: new Set(), + hasProjectHooks: false, + }), + reloadHooksConfig: vi.fn().mockResolvedValue(undefined), + getEnabledHooks: vi.fn().mockReturnValue([]), + executeHooks: vi.fn().mockResolvedValue({ + results: [], + blocked: false, + totalDuration: 0, + }), + setHookEnabled: vi.fn().mockResolvedValue(undefined), + getHookExecutionHistory: vi.fn().mockReturnValue([]), + getConfigSnapshot: vi.fn().mockReturnValue({ + hooksByEvent: new Map(), + hooksById: new Map(), + loadedAt: new Date(), + disabledHookIds: new Set(), + hasProjectHooks: false, + }), +}) + +// Create mock ClineProvider +const createMockClineProvider = (hookManager?: IHookManager) => { + const mockProvider = { + getState: vi.fn(), + postMessageToWebview: vi.fn(), + postStateToWebview: vi.fn(), + getHookManager: vi.fn().mockReturnValue(hookManager), + log: vi.fn(), + getCurrentTask: vi.fn(), + getTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + cwd: "/mock/workspace", + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + contextProxy: { + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + setValue: vi.fn(), + getValue: vi.fn(), + }, + customModesManager: { + getCustomModes: vi.fn(), + deleteCustomMode: vi.fn(), + }, + } as unknown as ClineProvider + + return mockProvider +} + +describe("webviewMessageHandler - hooks commands", () => { + let mockHookManager: IHookManager + let mockClineProvider: ClineProvider + + beforeEach(() => { + vi.clearAllMocks() + mockHookManager = createMockHookManager() + mockClineProvider = createMockClineProvider(mockHookManager) + }) + + describe("hooksReloadConfig", () => { + it("should call reloadHooksConfig and postStateToWebview when hookManager exists", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksReloadConfig", + }) + + expect(mockHookManager.reloadHooksConfig).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) + + it("should not throw when hookManager is undefined", async () => { + const providerWithoutHookManager = createMockClineProvider(undefined) + + await expect( + webviewMessageHandler(providerWithoutHookManager, { + type: "hooksReloadConfig", + }), + ).resolves.not.toThrow() + + expect(providerWithoutHookManager.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should show error message when reloadHooksConfig fails", async () => { + const error = new Error("Failed to load hooks config") + vi.mocked(mockHookManager.reloadHooksConfig).mockRejectedValueOnce(error) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksReloadConfig", + }) + + expect(mockClineProvider.log).toHaveBeenCalledWith( + "Failed to reload hooks config: Failed to load hooks config", + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to reload hooks configuration") + }) + }) + + describe("hooksSetEnabled", () => { + it("should call setHookEnabled with correct parameters and postStateToWebview", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksSetEnabled", + hookId: "test-hook-id", + hookEnabled: false, + }) + + expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("test-hook-id", false) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) + + it("should enable a previously disabled hook", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksSetEnabled", + hookId: "test-hook-id", + hookEnabled: true, + }) + + expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("test-hook-id", true) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) + + it("should not call setHookEnabled when hookId is missing", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksSetEnabled", + hookEnabled: true, + } as any) + + expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should not call setHookEnabled when hookEnabled is not a boolean", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksSetEnabled", + hookId: "test-hook-id", + hookEnabled: "true", // string, not boolean + } as any) + + expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should show error message when setHookEnabled fails", async () => { + const error = new Error("Hook not found") + vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(error) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksSetEnabled", + hookId: "nonexistent-hook", + hookEnabled: true, + }) + + expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to set hook enabled: Hook not found") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to enable hook") + }) + + it("should show correct error message when disabling fails", async () => { + const error = new Error("Hook not found") + vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(error) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksSetEnabled", + hookId: "nonexistent-hook", + hookEnabled: false, + }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to disable hook") + }) + }) + + describe("hooksOpenConfigFolder", () => { + it("should open project hooks folder by default", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenConfigFolder", + }) + + expect(vscode.Uri.file).toHaveBeenCalledWith("/mock/workspace/.roo/hooks") + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object)) + }) + + it("should open global hooks folder when source is global", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenConfigFolder", + hooksSource: "global", + }) + + expect(vscode.Uri.file).toHaveBeenCalledWith(expect.stringContaining(".roo/hooks")) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object)) + }) + + it("should open project hooks folder when source is project", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenConfigFolder", + hooksSource: "project", + }) + + expect(vscode.Uri.file).toHaveBeenCalledWith("/mock/workspace/.roo/hooks") + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object)) + }) + + it("should create hooks folder if it does not exist", async () => { + vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValueOnce(false) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenConfigFolder", + }) + + expect(fs.mkdir).toHaveBeenCalledWith("/mock/workspace/.roo/hooks", { recursive: true }) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object)) + }) + + it("should not create hooks folder if it already exists", async () => { + vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValueOnce(true) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenConfigFolder", + }) + + expect(fs.mkdir).not.toHaveBeenCalled() + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object)) + }) + + it("should show error message when open fails", async () => { + vi.mocked(vscode.commands.executeCommand).mockRejectedValueOnce(new Error("Failed to open folder")) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenConfigFolder", + }) + + expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to open hooks folder: Failed to open folder") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to open hooks configuration folder") + }) + }) +}) + +describe("webviewMessageHandler - hooks state integration", () => { + it("should return hooks state when hookManager has data", () => { + const mockResolvedHook: ResolvedHook = { + id: "hook-1", + event: "PreToolUse", + matcher: ".*", + command: "echo 'hello'", + enabled: true, + source: "project", + timeout: 30, + shell: "/bin/bash", + description: "Test hook", + filePath: "/mock/workspace/.roo/hooks/pre-tool-use.json", + includeConversationHistory: false, + } + + const mockExecutionHistory: HookExecution[] = [ + { + timestamp: new Date(1642000000000), + hook: mockResolvedHook, + event: "PreToolUse", + result: { + hook: mockResolvedHook, + exitCode: 0, + stdout: "hello", + stderr: "", + duration: 100, + timedOut: false, + }, + }, + ] + + const mockHookManager = createMockHookManager() + vi.mocked(mockHookManager.getEnabledHooks).mockReturnValue([mockResolvedHook]) + vi.mocked(mockHookManager.getHookExecutionHistory).mockReturnValue(mockExecutionHistory) + + const hooksByEvent = new Map() + hooksByEvent.set("PreToolUse", [mockResolvedHook]) + + const hooksById = new Map() + hooksById.set("hook-1", mockResolvedHook) + + vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({ + hooksByEvent, + hooksById, + loadedAt: new Date(1642000000000), + disabledHookIds: new Set(), + hasProjectHooks: true, + }) + + // Verify mock data is correctly formatted + expect(mockHookManager.getEnabledHooks()).toHaveLength(1) + expect(mockHookManager.getHookExecutionHistory()).toHaveLength(1) + expect(mockHookManager.getConfigSnapshot()?.hasProjectHooks).toBe(true) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e93be3278d..e4ffd2858b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3336,6 +3336,74 @@ export const webviewMessageHandler = async ( break } + // ===================================================================== + // Hooks Management Commands + // ===================================================================== + + case "hooksReloadConfig": { + // Reload hooks configuration from all sources + const hookManager = provider.getHookManager() + if (hookManager) { + try { + await hookManager.reloadHooksConfig() + await provider.postStateToWebview() + } catch (error) { + provider.log( + `Failed to reload hooks config: ${error instanceof Error ? error.message : String(error)}`, + ) + vscode.window.showErrorMessage("Failed to reload hooks configuration") + } + } + break + } + + case "hooksSetEnabled": { + // Enable or disable a specific hook + const hookManager = provider.getHookManager() + if (hookManager && message.hookId && typeof message.hookEnabled === "boolean") { + try { + await hookManager.setHookEnabled(message.hookId, message.hookEnabled) + await provider.postStateToWebview() + } catch (error) { + provider.log( + `Failed to set hook enabled: ${error instanceof Error ? error.message : String(error)}`, + ) + vscode.window.showErrorMessage(`Failed to ${message.hookEnabled ? "enable" : "disable"} hook`) + } + } + break + } + + case "hooksOpenConfigFolder": { + // Open the hooks configuration folder in VS Code + const source = message.hooksSource ?? "project" + try { + let hooksPath: string + if (source === "global") { + // Global hooks: ~/.roo/hooks + hooksPath = path.join(os.homedir(), ".roo", "hooks") + } else { + // Project hooks: .roo/hooks in workspace + const cwd = provider.cwd + hooksPath = path.join(cwd, ".roo", "hooks") + } + + // Check if directory exists, create if not + const exists = await fileExistsAtPath(hooksPath) + if (!exists) { + await fs.mkdir(hooksPath, { recursive: true }) + } + + // Open the folder in VS Code + const uri = vscode.Uri.file(hooksPath) + await vscode.commands.executeCommand("revealFileInOS", uri) + } catch (error) { + provider.log(`Failed to open hooks folder: ${error instanceof Error ? error.message : String(error)}`) + vscode.window.showErrorMessage("Failed to open hooks configuration folder") + } + break + } + default: { // console.log(`Unhandled message type: ${message.type}`) // diff --git a/src/services/hooks/HookConfigLoader.ts b/src/services/hooks/HookConfigLoader.ts new file mode 100644 index 0000000000..77f11f5f72 --- /dev/null +++ b/src/services/hooks/HookConfigLoader.ts @@ -0,0 +1,362 @@ +/** + * Hook Configuration Loader + * + * Loads and merges hook configurations from: + * 1. Project directory: .roo/hooks/*.yaml or *.json (highest priority) + * 2. Mode-specific: .roo/hooks-{mode}/*.yaml or *.json (middle priority) + * 3. Global directory: ~/.roo/hooks/*.yaml or *.json (lowest priority) + * + * Files within each directory are processed in alphabetical order. + * Same hook ID at higher precedence level overrides lower levels. + */ + +import * as path from "path" +import fs from "fs/promises" +import YAML from "yaml" +import { z } from "zod" +import { + HooksConfigFileSchema, + HooksConfigSnapshot, + HookDefinition, + ResolvedHook, + HookSource, + HookEventType, +} from "./types" +import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config" + +/** + * Result of loading a single config file. + */ +interface LoadedConfigFile { + filePath: string + source: HookSource + hooks: Map + errors: string[] +} + +/** + * Options for loading hooks configuration. + */ +export interface LoadHooksConfigOptions { + /** Project directory (cwd) */ + cwd: string + + /** Current mode slug (for mode-specific hooks) */ + mode?: string +} + +/** + * Result of loading all hooks configuration. + */ +export interface LoadHooksConfigResult { + snapshot: HooksConfigSnapshot + errors: string[] + warnings: string[] +} + +/** + * Check if a file has a supported extension (.yaml, .yml, .json). + */ +function isSupportedConfigFile(filename: string): boolean { + const lower = filename.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") || lower.endsWith(".json") +} + +/** + * Parse a config file content (YAML or JSON). + */ +function parseConfigContent(content: string, filePath: string): unknown { + const lower = filePath.toLowerCase() + + if (lower.endsWith(".json")) { + return JSON.parse(content) + } + + // Parse as YAML (which also handles plain JSON) + return YAML.parse(content) +} + +/** + * Validate parsed config against the schema. + */ +function validateConfig( + parsed: unknown, + filePath: string, +): { success: true; data: z.infer } | { success: false; errors: string[] } { + const result = HooksConfigFileSchema.safeParse(parsed) + + if (result.success) { + return { success: true, data: result.data } + } + + // Format Zod errors nicely + const errors = result.error.errors.map((err) => { + const pathStr = err.path.length > 0 ? err.path.join(".") : "(root)" + return `${filePath}: ${pathStr}: ${err.message}` + }) + + return { success: false, errors } +} + +/** + * Load a single config file. + */ +async function loadConfigFile(filePath: string, source: HookSource): Promise { + const result: LoadedConfigFile = { + filePath, + source, + hooks: new Map(), + errors: [], + } + + try { + const content = await fs.readFile(filePath, "utf-8") + const parsed = parseConfigContent(content, filePath) + const validated = validateConfig(parsed, filePath) + + if (!validated.success) { + result.errors = validated.errors + return result + } + + // Convert hooks record to Map + const hooksRecord = validated.data.hooks || {} + for (const [eventStr, definitions] of Object.entries(hooksRecord)) { + const event = eventStr as HookEventType + if (definitions && definitions.length > 0) { + result.hooks.set(event, definitions) + } + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + // File doesn't exist - not an error, just skip + return result + } + + const message = err instanceof Error ? err.message : String(err) + result.errors.push(`${filePath}: Failed to load: ${message}`) + } + + return result +} + +/** + * List config files in a directory (sorted alphabetically). + */ +async function listConfigFiles(dirPath: string): Promise { + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + const files = entries + .filter((entry) => entry.isFile() && isSupportedConfigFile(entry.name)) + .map((entry) => path.join(dirPath, entry.name)) + .sort() // Alphabetical order + + return files + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + // Directory doesn't exist - not an error + return [] + } + throw err + } +} + +/** + * Load all config files from a directory. + */ +async function loadConfigDirectory(dirPath: string, source: HookSource): Promise { + const files = await listConfigFiles(dirPath) + const results: LoadedConfigFile[] = [] + + for (const filePath of files) { + const loaded = await loadConfigFile(filePath, source) + results.push(loaded) + } + + return results +} + +/** + * Merge loaded configs into a snapshot, respecting precedence rules. + * + * Precedence (highest to lowest): + * 1. Project hooks (.roo/hooks/) + * 2. Mode-specific hooks (.roo/hooks-{mode}/) + * 3. Global hooks (~/.roo/hooks/) + * + * Within same level: alphabetical file order. + * Same hook ID: higher precedence wins (can also disable with enabled: false). + */ +function mergeConfigs(loadedConfigs: LoadedConfigFile[]): { + hooksByEvent: Map + hooksById: Map + hasProjectHooks: boolean +} { + // Track hooks by ID to detect overrides + const hooksById = new Map() + + // Track hooks by event for efficient lookup + const hooksByEvent = new Map() + + // Track if we have any project hooks (for security warnings) + let hasProjectHooks = false + + // Process configs in reverse precedence order (global -> mode -> project) + // so that later (higher precedence) configs override earlier ones + const orderedConfigs = [...loadedConfigs].sort((a, b) => { + const precedence: Record = { + global: 0, + mode: 1, + project: 2, + } + return precedence[a.source] - precedence[b.source] + }) + + for (const config of orderedConfigs) { + if (config.source === "project" && config.hooks.size > 0) { + hasProjectHooks = true + } + + for (const [event, definitions] of config.hooks) { + for (const def of definitions) { + const resolved: ResolvedHook = { + ...def, + source: config.source, + event, + filePath: config.filePath, + } + + // Check for existing hook with same ID + const existing = hooksById.get(def.id) + if (existing) { + // Remove from its event list + const eventList = hooksByEvent.get(existing.event) + if (eventList) { + const idx = eventList.findIndex((h) => h.id === def.id) + if (idx !== -1) { + eventList.splice(idx, 1) + } + } + } + + // Add/replace in lookup maps + hooksById.set(def.id, resolved) + + // Add to event list + if (!hooksByEvent.has(event)) { + hooksByEvent.set(event, []) + } + hooksByEvent.get(event)!.push(resolved) + } + } + } + + return { hooksByEvent, hooksById, hasProjectHooks } +} + +/** + * Load hooks configuration from all sources. + * + * @param options - Loading options (cwd, mode) + * @returns Loaded configuration snapshot and any errors/warnings + */ +export async function loadHooksConfig(options: LoadHooksConfigOptions): Promise { + const { cwd, mode } = options + const errors: string[] = [] + const warnings: string[] = [] + const loadedConfigs: LoadedConfigFile[] = [] + + // 1. Load global hooks (~/.roo/hooks/) + const globalDir = path.join(getGlobalRooDirectory(), "hooks") + try { + const globalConfigs = await loadConfigDirectory(globalDir, "global") + loadedConfigs.push(...globalConfigs) + for (const config of globalConfigs) { + errors.push(...config.errors) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warnings.push(`Failed to load global hooks from ${globalDir}: ${message}`) + } + + // 2. Load mode-specific hooks (.roo/hooks-{mode}/) + if (mode) { + const modeDir = path.join(getProjectRooDirectoryForCwd(cwd), `hooks-${mode}`) + try { + const modeConfigs = await loadConfigDirectory(modeDir, "mode") + loadedConfigs.push(...modeConfigs) + for (const config of modeConfigs) { + errors.push(...config.errors) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warnings.push(`Failed to load mode-specific hooks from ${modeDir}: ${message}`) + } + } + + // 3. Load project hooks (.roo/hooks/) + const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "hooks") + try { + const projectConfigs = await loadConfigDirectory(projectDir, "project") + loadedConfigs.push(...projectConfigs) + for (const config of projectConfigs) { + errors.push(...config.errors) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warnings.push(`Failed to load project hooks from ${projectDir}: ${message}`) + } + + // Merge configs with precedence rules + const { hooksByEvent, hooksById, hasProjectHooks } = mergeConfigs(loadedConfigs) + + // Create snapshot + const snapshot: HooksConfigSnapshot = { + hooksByEvent, + hooksById, + loadedAt: new Date(), + disabledHookIds: new Set(), + hasProjectHooks, + } + + // Security warning for project hooks + if (hasProjectHooks) { + warnings.push( + "⚠️ Project hooks are active: This workspace has hooks defined in .roo/hooks/. " + + "These hooks run shell commands when Roo Code performs actions. " + + "Only enable project hooks for repositories you trust.", + ) + } + + return { snapshot, errors, warnings } +} + +/** + * Get hooks for a specific event from a snapshot. + * + * @param snapshot - The config snapshot + * @param event - The event type + * @returns Array of hooks for the event (excluding disabled ones) + */ +export function getHooksForEvent(snapshot: HooksConfigSnapshot, event: HookEventType): ResolvedHook[] { + const hooks = snapshot.hooksByEvent.get(event) || [] + return hooks.filter((hook) => { + // Check if explicitly disabled via setHookEnabled + if (snapshot.disabledHookIds.has(hook.id)) { + return false + } + // Check if disabled in config + return hook.enabled !== false + }) +} + +/** + * Get a specific hook by ID from a snapshot. + * + * @param snapshot - The config snapshot + * @param hookId - The hook ID + * @returns The hook, or undefined if not found + */ +export function getHookById(snapshot: HooksConfigSnapshot, hookId: string): ResolvedHook | undefined { + return snapshot.hooksById.get(hookId) +} diff --git a/src/services/hooks/HookExecutor.ts b/src/services/hooks/HookExecutor.ts new file mode 100644 index 0000000000..1673062561 --- /dev/null +++ b/src/services/hooks/HookExecutor.ts @@ -0,0 +1,351 @@ +/** + * Hook Executor + * + * Executes shell commands for hooks with: + * - JSON context passed via stdin + * - Environment variables set per PRD + * - Timeout handling + * - Exit code interpretation (0=success, 2=block, other=error) + * - stdout/stderr capture + */ + +import { spawn, ChildProcess } from "child_process" +import * as os from "os" +import * as path from "path" +import { + ResolvedHook, + HookContext, + HookExecutionResult, + HookExitCode, + HookModificationSchema, + HookModification, + isBlockingEvent, + ConversationHistoryEntry, +} from "./types" + +/** + * Default timeout in seconds. + */ +const DEFAULT_TIMEOUT = 60 + +/** + * Get the default shell based on platform. + * Windows: PowerShell + * Unix: User's shell from SHELL env var, or /bin/sh + */ +function getDefaultShell(): { shell: string; shellArgs: string[] } { + if (os.platform() === "win32") { + return { + shell: "powershell.exe", + shellArgs: ["-NoProfile", "-NonInteractive", "-Command"], + } + } + + // Unix: try user's shell, fall back to /bin/sh + const userShell = process.env.SHELL || "/bin/sh" + return { + shell: userShell, + shellArgs: ["-c"], + } +} + +/** + * Parse a custom shell specification. + * Supports "bash", "/bin/bash", "powershell.exe", etc. + */ +function parseShellSpec(shellSpec: string): { shell: string; shellArgs: string[] } { + const lower = shellSpec.toLowerCase() + + // Handle PowerShell variants + if (lower.includes("powershell") || lower.includes("pwsh")) { + return { + shell: shellSpec, + shellArgs: ["-NoProfile", "-NonInteractive", "-Command"], + } + } + + // All other shells use -c + return { + shell: shellSpec, + shellArgs: ["-c"], + } +} + +/** + * Build environment variables for hook execution. + */ +function buildEnvVars(hook: ResolvedHook, context: HookContext): NodeJS.ProcessEnv { + const baseEnv = { ...process.env } + + return { + ...baseEnv, + ROO_PROJECT_DIR: context.project.directory, + ROO_TASK_ID: context.session.taskId, + ROO_SESSION_ID: context.session.sessionId, + ROO_MODE: context.session.mode, + ROO_TOOL_NAME: context.tool?.name || "", + ROO_EVENT: context.event, + ROO_HOOK_ID: hook.id, + } +} + +/** + * Build the stdin JSON payload for a hook. + */ +function buildStdinPayload( + hook: ResolvedHook, + context: HookContext, + conversationHistory?: ConversationHistoryEntry[], +): string { + // Start with the base context + const payload: HookContext = { ...context } + + // Only include conversation history if the hook opts in + if (hook.includeConversationHistory && conversationHistory) { + payload.conversationHistory = conversationHistory + } + + return JSON.stringify(payload) +} + +/** + * Try to parse stdout as a modification response. + * Returns undefined if stdout is empty or not valid modification JSON. + */ +function parseModificationResponse(stdout: string, hook: ResolvedHook): HookModification | undefined { + if (!stdout.trim()) { + return undefined + } + + try { + const parsed = JSON.parse(stdout) + const result = HookModificationSchema.safeParse(parsed) + + if (result.success) { + // Only PreToolUse hooks can modify input + if (hook.event !== "PreToolUse") { + console.warn(`Hook "${hook.id}" returned modification but is not a PreToolUse hook - ignoring`) + return undefined + } + return result.data + } + + // Not a valid modification response - that's fine, hooks don't have to return JSON + return undefined + } catch { + // Not valid JSON - that's fine + return undefined + } +} + +/** + * Execute a single hook command. + * + * @param hook - The hook to execute + * @param context - The hook context + * @param conversationHistory - Optional conversation history (only included if hook opts in) + * @returns Execution result + */ +export async function executeHook( + hook: ResolvedHook, + context: HookContext, + conversationHistory?: ConversationHistoryEntry[], +): Promise { + const startTime = Date.now() + const timeout = (hook.timeout || DEFAULT_TIMEOUT) * 1000 // Convert to ms + + // Determine shell + const shellConfig = hook.shell ? parseShellSpec(hook.shell) : getDefaultShell() + + // Build environment and stdin + const env = buildEnvVars(hook, context) + const stdin = buildStdinPayload(hook, context, conversationHistory) + + return new Promise((resolve) => { + let child: ChildProcess | null = null + let stdout = "" + let stderr = "" + let timedOut = false + let resolved = false + + const finalize = (exitCode: number | null, error?: Error) => { + if (resolved) return + resolved = true + + const duration = Date.now() - startTime + + // Try to parse modification from stdout + const modification = parseModificationResponse(stdout, hook) + + resolve({ + hook, + exitCode, + stdout, + stderr, + duration, + timedOut, + error, + modification, + }) + } + + // Set up timeout + const timeoutHandle = setTimeout(() => { + timedOut = true + if (child) { + // Try graceful kill first (SIGTERM), then force (SIGKILL) + child.kill("SIGTERM") + setTimeout(() => { + if (child && !child.killed) { + child.kill("SIGKILL") + } + }, 1000) + } + }, timeout) + + try { + // Spawn the shell process + child = spawn(shellConfig.shell, [...shellConfig.shellArgs, hook.command], { + cwd: context.project.directory, + env, + stdio: ["pipe", "pipe", "pipe"], + // Don't throw on Windows if shell not found + windowsHide: true, + }) + + // Write stdin + if (child.stdin) { + child.stdin.write(stdin) + child.stdin.end() + } + + // Capture stdout + if (child.stdout) { + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString() + }) + } + + // Capture stderr + if (child.stderr) { + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString() + }) + } + + // Handle process exit + child.on("close", (code) => { + clearTimeout(timeoutHandle) + finalize(code) + }) + + // Handle spawn errors + child.on("error", (err) => { + clearTimeout(timeoutHandle) + finalize(null, err) + }) + } catch (err) { + clearTimeout(timeoutHandle) + finalize(null, err instanceof Error ? err : new Error(String(err))) + } + }) +} + +/** + * Interpret the result of a hook execution. + * + * @param result - The execution result + * @returns Object with interpretation flags + */ +export function interpretResult(result: HookExecutionResult): { + success: boolean + blocked: boolean + blockMessage: string | undefined + shouldContinue: boolean +} { + // Check for execution errors first + if (result.error || result.exitCode === null) { + return { + success: false, + blocked: false, + blockMessage: undefined, + shouldContinue: true, // Execution errors don't block, per PRD + } + } + + // Check for timeout + if (result.timedOut) { + return { + success: false, + blocked: false, + blockMessage: undefined, + shouldContinue: true, // Timeouts don't block, per PRD + } + } + + // Exit code 0 = success + if (result.exitCode === HookExitCode.Success) { + return { + success: true, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + } + } + + // Exit code 2 = block (only for blocking events) + if (result.exitCode === HookExitCode.Block) { + if (isBlockingEvent(result.hook.event)) { + return { + success: false, + blocked: true, + blockMessage: result.stderr.trim() || `Hook "${result.hook.id}" blocked execution`, + shouldContinue: false, + } + } else { + // Non-blocking event with exit code 2 is treated as regular failure + console.warn( + `Hook "${result.hook.id}" returned exit code 2 (block) but ${result.hook.event} is not a blocking event - treating as error`, + ) + return { + success: false, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + } + } + } + + // Other non-zero exit codes = error, but don't block + return { + success: false, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + } +} + +/** + * Get a human-readable description of a hook result for logging. + */ +export function describeResult(result: HookExecutionResult): string { + const hook = result.hook + + if (result.error) { + return `Hook "${hook.id}" failed to execute: ${result.error.message}` + } + + if (result.timedOut) { + return `Hook "${hook.id}" timed out after ${hook.timeout || DEFAULT_TIMEOUT}s` + } + + if (result.exitCode === HookExitCode.Success) { + return `Hook "${hook.id}" completed successfully in ${result.duration}ms` + } + + if (result.exitCode === HookExitCode.Block) { + return `Hook "${hook.id}" blocked with: ${result.stderr.trim() || "(no message)"}` + } + + return `Hook "${hook.id}" returned exit code ${result.exitCode} in ${result.duration}ms` +} diff --git a/src/services/hooks/HookManager.ts b/src/services/hooks/HookManager.ts new file mode 100644 index 0000000000..7ff9338859 --- /dev/null +++ b/src/services/hooks/HookManager.ts @@ -0,0 +1,320 @@ +/** + * Hook Manager + * + * Central service for orchestrating hooks in Roo Code. + * Implements the IHookManager interface from the PRD. + * + * Key responsibilities: + * - Load and maintain a snapshot of hook configuration + * - Execute matching hooks sequentially for events + * - Manage execution history for debugging + * - Provide enable/disable API for individual hooks + */ + +import { + IHookManager, + HookEventType, + HooksConfigSnapshot, + ResolvedHook, + HookExecution, + HooksExecutionResult, + HookExecutionResult, + ExecuteHooksOptions, + HookContext, + ConversationHistoryEntry, +} from "./types" +import { loadHooksConfig, getHooksForEvent, LoadHooksConfigOptions } from "./HookConfigLoader" +import { filterMatchingHooks } from "./HookMatcher" +import { executeHook, interpretResult, describeResult } from "./HookExecutor" + +/** + * Default options for the HookManager. + */ +export interface HookManagerOptions { + /** Project directory (cwd) */ + cwd: string + + /** Current mode slug */ + mode?: string + + /** Maximum execution history entries to keep (default: 100) */ + maxHistoryEntries?: number + + /** Optional logger for hook activity */ + logger?: { + debug: (message: string) => void + info: (message: string) => void + warn: (message: string) => void + error: (message: string) => void + } +} + +/** + * Default maximum history entries. + */ +const DEFAULT_MAX_HISTORY = 100 + +/** + * HookManager implementation. + * + * This class maintains an immutable snapshot of hook configuration that is + * loaded once and only changes on explicit reload (for security). + */ +export class HookManager implements IHookManager { + private options: HookManagerOptions + private snapshot: HooksConfigSnapshot | null = null + private executionHistory: HookExecution[] = [] + private maxHistoryEntries: number + + constructor(options: HookManagerOptions) { + this.options = options + this.maxHistoryEntries = options.maxHistoryEntries ?? DEFAULT_MAX_HISTORY + } + + /** + * Load hooks configuration from all sources. + * Creates an immutable snapshot that won't change until explicit reload. + */ + async loadHooksConfig(): Promise { + const loadOptions: LoadHooksConfigOptions = { + cwd: this.options.cwd, + mode: this.options.mode, + } + + const { snapshot, errors, warnings } = await loadHooksConfig(loadOptions) + + // Log errors and warnings + if (this.options.logger) { + for (const error of errors) { + this.options.logger.error(`Hook config error: ${error}`) + } + for (const warning of warnings) { + this.options.logger.warn(warning) + } + } + + // Store the snapshot + this.snapshot = snapshot + + // Log summary + const hookCount = Array.from(snapshot.hooksByEvent.values()).reduce((sum, hooks) => sum + hooks.length, 0) + this.log("info", `Loaded ${hookCount} hooks from configuration`) + + return snapshot + } + + /** + * Explicitly reload hooks configuration. + * Required for security - config changes don't auto-apply. + */ + async reloadHooksConfig(): Promise { + this.log("info", "Reloading hooks configuration...") + + // Preserve disabled hook IDs across reload + const previousDisabled = this.snapshot?.disabledHookIds ?? new Set() + + await this.loadHooksConfig() + + // Restore disabled state for hooks that still exist + if (this.snapshot) { + for (const hookId of previousDisabled) { + if (this.snapshot.hooksById.has(hookId)) { + this.snapshot.disabledHookIds.add(hookId) + } + } + } + + this.log("info", "Hooks configuration reloaded") + } + + /** + * Execute all matching hooks for an event. + * Hooks are executed sequentially in their defined order. + * If a blocking event returns exit code 2, subsequent hooks are skipped. + */ + async executeHooks(event: HookEventType, options: ExecuteHooksOptions): Promise { + const startTime = Date.now() + const results: HookExecutionResult[] = [] + + // Ensure config is loaded + if (!this.snapshot) { + await this.loadHooksConfig() + } + + // Get enabled hooks for this event + const eventHooks = getHooksForEvent(this.snapshot!, event) + + // Filter by tool name if this is a tool-related event + let matchingHooks: ResolvedHook[] + if (options.context.tool?.name) { + matchingHooks = filterMatchingHooks(eventHooks, options.context.tool.name) + } else { + matchingHooks = eventHooks + } + + this.log("debug", `Executing ${matchingHooks.length} hooks for ${event}`) + + let blocked = false + let blockMessage: string | undefined + let blockingHook: ResolvedHook | undefined + let modification: HooksExecutionResult["modification"] + + // Execute hooks sequentially + for (const hook of matchingHooks) { + this.log("debug", `Executing hook "${hook.id}" for ${event}`) + + // Execute the hook + const result = await executeHook(hook, options.context, options.conversationHistory) + results.push(result) + + // Record in history + this.recordExecution(hook, event, result) + + // Log the result + this.log("info", describeResult(result)) + + // Interpret the result + const interpretation = interpretResult(result) + + // Check for blocking + if (interpretation.blocked) { + blocked = true + blockMessage = interpretation.blockMessage + blockingHook = hook + this.log("warn", `Hook "${hook.id}" blocked ${event}: ${blockMessage}`) + break // Stop executing subsequent hooks + } + + // Check for modification (only first modification wins) + if (!modification && result.modification) { + modification = result.modification + this.log("info", `Hook "${hook.id}" modified tool input`) + } + + // If hook returned an error but we should continue, log it + if (!interpretation.success && interpretation.shouldContinue) { + this.log("warn", `Hook "${hook.id}" failed but continuing: ${result.error?.message || result.stderr}`) + } + } + + const totalDuration = Date.now() - startTime + this.log("debug", `Executed ${results.length} hooks in ${totalDuration}ms`) + + return { + results, + blocked, + blockMessage, + blockingHook, + modification, + totalDuration, + } + } + + /** + * Get all currently enabled hooks. + */ + getEnabledHooks(): ResolvedHook[] { + if (!this.snapshot) { + return [] + } + + const allHooks: ResolvedHook[] = [] + for (const hooks of this.snapshot.hooksByEvent.values()) { + for (const hook of hooks) { + // Check if enabled in config AND not disabled at runtime + if (hook.enabled !== false && !this.snapshot.disabledHookIds.has(hook.id)) { + allHooks.push(hook) + } + } + } + + return allHooks + } + + /** + * Enable or disable a specific hook by ID. + * This persists until config reload. + */ + async setHookEnabled(hookId: string, enabled: boolean): Promise { + if (!this.snapshot) { + await this.loadHooksConfig() + } + + const hook = this.snapshot!.hooksById.get(hookId) + if (!hook) { + throw new Error(`Hook not found: ${hookId}`) + } + + if (enabled) { + this.snapshot!.disabledHookIds.delete(hookId) + this.log("info", `Enabled hook "${hookId}"`) + } else { + this.snapshot!.disabledHookIds.add(hookId) + this.log("info", `Disabled hook "${hookId}"`) + } + } + + /** + * Get execution history for debugging. + */ + getHookExecutionHistory(): HookExecution[] { + return [...this.executionHistory] + } + + /** + * Get the current config snapshot (or null if not loaded). + */ + getConfigSnapshot(): HooksConfigSnapshot | null { + return this.snapshot + } + + /** + * Update the current mode (useful when mode changes during session). + * Requires reload to take effect. + */ + setMode(mode: string): void { + this.options.mode = mode + } + + /** + * Clear execution history. + */ + clearHistory(): void { + this.executionHistory = [] + } + + /** + * Record a hook execution in history. + */ + private recordExecution(hook: ResolvedHook, event: HookEventType, result: HookExecutionResult): void { + const entry: HookExecution = { + timestamp: new Date(), + hook, + event, + result, + } + + this.executionHistory.push(entry) + + // Trim history if needed + if (this.executionHistory.length > this.maxHistoryEntries) { + this.executionHistory = this.executionHistory.slice(-this.maxHistoryEntries) + } + } + + /** + * Log a message using the configured logger. + */ + private log(level: "debug" | "info" | "warn" | "error", message: string): void { + if (this.options.logger) { + this.options.logger[level](`[Hooks] ${message}`) + } + } +} + +/** + * Create a new HookManager instance. + */ +export function createHookManager(options: HookManagerOptions): IHookManager { + return new HookManager(options) +} diff --git a/src/services/hooks/HookMatcher.ts b/src/services/hooks/HookMatcher.ts new file mode 100644 index 0000000000..bf10b4f54d --- /dev/null +++ b/src/services/hooks/HookMatcher.ts @@ -0,0 +1,167 @@ +/** + * Hook Matcher + * + * Provides pattern matching for hooks against tool names. + * Supports exact match, regex patterns, glob patterns, and match-all. + */ + +import { ResolvedHook } from "./types" + +/** + * Result of compiling a matcher pattern. + */ +interface CompiledMatcher { + /** The original pattern string */ + pattern: string + + /** Type of matching to use */ + type: "all" | "exact" | "regex" | "glob" + + /** Compiled regex for regex/glob matching */ + regex?: RegExp + + /** Test if a tool name matches this pattern */ + matches: (toolName: string) => boolean +} + +/** + * Compile a matcher pattern into an efficient matcher function. + * + * Supports: + * - Exact tool name: "Write" + * - Regex pattern: "Edit|Write" (contains | or regex metacharacters) + * - Glob pattern: "mcp__*" (contains * or ?) + * - Match all: "*" or undefined/empty + * + * @param pattern - The matcher pattern string (or undefined for match-all) + * @returns Compiled matcher object + */ +export function compileMatcher(pattern: string | undefined): CompiledMatcher { + // Handle match-all cases + if (!pattern || pattern === "*") { + return { + pattern: pattern || "*", + type: "all", + matches: () => true, + } + } + + // Check if pattern looks like a regex (contains regex metacharacters except * and ?) + const regexMetaChars = /[|^$+.()[\]{}\\]/ + const isRegexPattern = regexMetaChars.test(pattern) + + // Check if pattern looks like a glob (contains * or ?) + const isGlobPattern = /[*?]/.test(pattern) && !isRegexPattern + + if (isRegexPattern) { + // Treat as regex pattern + try { + const regex = new RegExp(`^(?:${pattern})$`, "i") + return { + pattern, + type: "regex", + regex, + matches: (toolName: string) => regex.test(toolName), + } + } catch (e) { + // If regex compilation fails, fall back to exact match + console.warn(`Invalid regex pattern "${pattern}", falling back to exact match:`, e) + return { + pattern, + type: "exact", + matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(), + } + } + } + + if (isGlobPattern) { + // Convert glob to regex + // * matches any characters, ? matches single character + const regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * and ? + .replace(/\*/g, ".*") // * -> .* + .replace(/\?/g, ".") // ? -> . + + try { + const regex = new RegExp(`^${regexPattern}$`, "i") + return { + pattern, + type: "glob", + regex, + matches: (toolName: string) => regex.test(toolName), + } + } catch (e) { + // If regex compilation fails, fall back to exact match + console.warn(`Invalid glob pattern "${pattern}", falling back to exact match:`, e) + return { + pattern, + type: "exact", + matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(), + } + } + } + + // Exact match (case-insensitive) + return { + pattern, + type: "exact", + matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(), + } +} + +/** + * Cache of compiled matchers for performance. + * Key is the pattern string, value is the compiled matcher. + */ +const matcherCache = new Map() + +/** + * Get a compiled matcher, using cache when possible. + * + * @param pattern - The matcher pattern string (or undefined for match-all) + * @returns Compiled matcher object + */ +export function getMatcher(pattern: string | undefined): CompiledMatcher { + const cacheKey = pattern || "*" + + let matcher = matcherCache.get(cacheKey) + if (!matcher) { + matcher = compileMatcher(pattern) + matcherCache.set(cacheKey, matcher) + } + + return matcher +} + +/** + * Clear the matcher cache (useful for testing). + */ +export function clearMatcherCache(): void { + matcherCache.clear() +} + +/** + * Filter hooks that match a given tool name. + * + * @param hooks - Array of hooks to filter + * @param toolName - Tool name to match against + * @returns Hooks that match the tool name + */ +export function filterMatchingHooks(hooks: ResolvedHook[], toolName: string): ResolvedHook[] { + return hooks.filter((hook) => { + const matcher = getMatcher(hook.matcher) + return matcher.matches(toolName) + }) +} + +/** + * Check if a single hook matches a tool name. + * + * @param hook - The hook to check + * @param toolName - Tool name to match against + * @returns Whether the hook matches + */ +export function hookMatchesTool(hook: ResolvedHook, toolName: string): boolean { + const matcher = getMatcher(hook.matcher) + return matcher.matches(toolName) +} diff --git a/src/services/hooks/ToolExecutionHooks.ts b/src/services/hooks/ToolExecutionHooks.ts new file mode 100644 index 0000000000..2db99abfce --- /dev/null +++ b/src/services/hooks/ToolExecutionHooks.ts @@ -0,0 +1,457 @@ +/** + * Tool Execution Hooks Service + * + * Provides integration between the tool execution pipeline and the hooks system. + * Handles PreToolUse, PostToolUse, PostToolUseFailure, and PermissionRequest events. + */ + +import type { + IHookManager, + HookEventType, + HookContext, + HookSessionContext, + HookProjectContext, + HookToolContext, + HooksExecutionResult, + ExecuteHooksOptions, +} from "./types" + +/** + * Tool execution context for hooks. + */ +export interface ToolExecutionContext { + /** Tool name being executed */ + toolName: string + /** Tool input parameters */ + toolInput: Record + /** Session context */ + session: HookSessionContext + /** Project context */ + project: HookProjectContext +} + +/** + * Result from PreToolUse hook execution. + */ +export interface PreToolUseResult { + /** Whether the tool execution should proceed */ + proceed: boolean + /** If blocked, the reason */ + blockReason?: string + /** Modified tool input (if hooks modified it) */ + modifiedInput?: Record + /** Hook execution result for debugging */ + hookResult: HooksExecutionResult +} + +/** + * Result from PermissionRequest hook execution. + */ +export interface PermissionRequestResult { + /** Whether the permission request should proceed to user */ + proceed: boolean + /** If blocked, the reason */ + blockReason?: string + /** Hook execution result for debugging */ + hookResult: HooksExecutionResult +} + +/** + * Callback for emitting hook execution status to webview. + */ +export type HookStatusCallback = (status: { + status: "running" | "completed" | "failed" | "blocked" + event: HookEventType + toolName?: string + hookId?: string + duration?: number + error?: string + blockMessage?: string + modified?: boolean +}) => void + +/** + * Tool Execution Hooks Service + * + * Orchestrates hook execution for tool lifecycle events. + */ +export class ToolExecutionHooks { + private hookManager: IHookManager | null + private statusCallback?: HookStatusCallback + + constructor(hookManager: IHookManager | null, statusCallback?: HookStatusCallback) { + this.hookManager = hookManager + this.statusCallback = statusCallback + } + + /** + * Update the hook manager instance. + */ + setHookManager(hookManager: IHookManager | null): void { + this.hookManager = hookManager + } + + /** + * Update the status callback. + */ + setStatusCallback(callback: HookStatusCallback | undefined): void { + this.statusCallback = callback + } + + /** + * Execute PreToolUse hooks before a tool is executed. + * + * @returns Result indicating whether to proceed, and optionally modified input + */ + async executePreToolUse(context: ToolExecutionContext): Promise { + if (!this.hookManager) { + // No hooks configured - proceed normally + return { + proceed: true, + hookResult: { + results: [], + blocked: false, + totalDuration: 0, + }, + } + } + + const hookContext = this.buildToolHookContext("PreToolUse", context) + + // Emit running status + this.emitStatus({ + status: "running", + event: "PreToolUse", + toolName: context.toolName, + }) + + try { + const result = await this.hookManager.executeHooks("PreToolUse", { context: hookContext }) + + if (result.blocked) { + // Hook blocked the execution + this.emitStatus({ + status: "blocked", + event: "PreToolUse", + toolName: context.toolName, + blockMessage: result.blockMessage, + duration: result.totalDuration, + }) + + return { + proceed: false, + blockReason: result.blockMessage || "Blocked by PreToolUse hook", + hookResult: result, + } + } + + // Check for modifications + const modified = !!result.modification + const modifiedInput = result.modification?.toolInput + + this.emitStatus({ + status: "completed", + event: "PreToolUse", + toolName: context.toolName, + duration: result.totalDuration, + modified, + }) + + return { + proceed: true, + modifiedInput, + hookResult: result, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + this.emitStatus({ + status: "failed", + event: "PreToolUse", + toolName: context.toolName, + error: errorMessage, + }) + + // On hook execution error, proceed with original execution + // (fail-open for safety) + return { + proceed: true, + hookResult: { + results: [], + blocked: false, + totalDuration: 0, + }, + } + } + } + + /** + * Execute PostToolUse hooks after successful tool execution. + * This is non-blocking and fire-and-forget. + */ + async executePostToolUse( + context: ToolExecutionContext, + output: unknown, + duration: number, + ): Promise { + if (!this.hookManager) { + return { + results: [], + blocked: false, + totalDuration: 0, + } + } + + const hookContext = this.buildToolHookContext("PostToolUse", context, { + output, + duration, + }) + + this.emitStatus({ + status: "running", + event: "PostToolUse", + toolName: context.toolName, + }) + + try { + const result = await this.hookManager.executeHooks("PostToolUse", { context: hookContext }) + + this.emitStatus({ + status: "completed", + event: "PostToolUse", + toolName: context.toolName, + duration: result.totalDuration, + }) + + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + this.emitStatus({ + status: "failed", + event: "PostToolUse", + toolName: context.toolName, + error: errorMessage, + }) + + return { + results: [], + blocked: false, + totalDuration: 0, + } + } + } + + /** + * Execute PostToolUseFailure hooks after failed tool execution. + * This is non-blocking and fire-and-forget. + */ + async executePostToolUseFailure( + context: ToolExecutionContext, + error: string, + errorMessage: string, + ): Promise { + if (!this.hookManager) { + return { + results: [], + blocked: false, + totalDuration: 0, + } + } + + const hookContext = this.buildToolHookContext("PostToolUseFailure", context, { + error, + errorMessage, + }) + + this.emitStatus({ + status: "running", + event: "PostToolUseFailure", + toolName: context.toolName, + }) + + try { + const result = await this.hookManager.executeHooks("PostToolUseFailure", { context: hookContext }) + + this.emitStatus({ + status: "completed", + event: "PostToolUseFailure", + toolName: context.toolName, + duration: result.totalDuration, + }) + + return result + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + + this.emitStatus({ + status: "failed", + event: "PostToolUseFailure", + toolName: context.toolName, + error: errMsg, + }) + + return { + results: [], + blocked: false, + totalDuration: 0, + } + } + } + + /** + * Execute PermissionRequest hooks before showing approval prompt. + * + * Note: Even if hooks indicate "block", this does NOT auto-approve restricted tools. + * The hook can only prevent the approval dialog from appearing (denying the tool), + * not bypass the existing approval/auto-approval rules. + * + * @returns Result indicating whether to proceed with showing the prompt + */ + async executePermissionRequest(context: ToolExecutionContext): Promise { + if (!this.hookManager) { + return { + proceed: true, + hookResult: { + results: [], + blocked: false, + totalDuration: 0, + }, + } + } + + const hookContext = this.buildToolHookContext("PermissionRequest", context) + + this.emitStatus({ + status: "running", + event: "PermissionRequest", + toolName: context.toolName, + }) + + try { + const result = await this.hookManager.executeHooks("PermissionRequest", { context: hookContext }) + + if (result.blocked) { + // Hook blocked - do not show approval dialog, deny the tool + this.emitStatus({ + status: "blocked", + event: "PermissionRequest", + toolName: context.toolName, + blockMessage: result.blockMessage, + duration: result.totalDuration, + }) + + return { + proceed: false, + blockReason: result.blockMessage || "Blocked by PermissionRequest hook", + hookResult: result, + } + } + + this.emitStatus({ + status: "completed", + event: "PermissionRequest", + toolName: context.toolName, + duration: result.totalDuration, + }) + + return { + proceed: true, + hookResult: result, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + this.emitStatus({ + status: "failed", + event: "PermissionRequest", + toolName: context.toolName, + error: errorMessage, + }) + + // On hook execution error, proceed with showing approval prompt + // (fail-open for safety) + return { + proceed: true, + hookResult: { + results: [], + blocked: false, + totalDuration: 0, + }, + } + } + } + + /** + * Check if hooks are configured and available. + */ + hasHooks(): boolean { + return this.hookManager !== null && this.hookManager.getConfigSnapshot() !== null + } + + /** + * Build hook context for tool-related events. + */ + private buildToolHookContext( + event: HookEventType, + context: ToolExecutionContext, + extra?: { + output?: unknown + duration?: number + error?: string + errorMessage?: string + }, + ): HookContext { + const toolContext: HookToolContext = { + name: context.toolName, + input: context.toolInput, + } + + if (extra?.output !== undefined) { + toolContext.output = extra.output + } + + if (extra?.duration !== undefined) { + toolContext.duration = extra.duration + } + + if (extra?.error !== undefined) { + toolContext.error = extra.error + } + + if (extra?.errorMessage !== undefined) { + toolContext.errorMessage = extra.errorMessage + } + + return { + event, + timestamp: new Date().toISOString(), + session: context.session, + project: context.project, + tool: toolContext, + } + } + + /** + * Emit status to webview if callback is set. + */ + private emitStatus(status: Parameters[0]): void { + if (this.statusCallback) { + try { + this.statusCallback(status) + } catch { + // Ignore callback errors + } + } + } +} + +/** + * Create a ToolExecutionHooks instance. + */ +export function createToolExecutionHooks( + hookManager: IHookManager | null, + statusCallback?: HookStatusCallback, +): ToolExecutionHooks { + return new ToolExecutionHooks(hookManager, statusCallback) +} diff --git a/src/services/hooks/__tests__/HookConfigLoader.spec.ts b/src/services/hooks/__tests__/HookConfigLoader.spec.ts new file mode 100644 index 0000000000..252cb5ae00 --- /dev/null +++ b/src/services/hooks/__tests__/HookConfigLoader.spec.ts @@ -0,0 +1,337 @@ +/** + * Tests for HookConfigLoader + * + * Covers: + * - Config parsing (YAML and JSON) + * - Zod validation + * - Precedence merging (project > mode > global) + * - Error handling for invalid configs + */ + +import { loadHooksConfig, getHooksForEvent, getHookById } from "../HookConfigLoader" +import type { HooksConfigSnapshot, HookEventType } from "../types" + +// Create hoisted mocks +const mockFsPromises = vi.hoisted(() => ({ + readdir: vi.fn(), + readFile: vi.fn(), + access: vi.fn(), +})) + +vi.mock("fs/promises", () => ({ + default: mockFsPromises, + readdir: mockFsPromises.readdir, + readFile: mockFsPromises.readFile, + access: mockFsPromises.access, +})) + +vi.mock("../../roo-config", () => ({ + getGlobalRooDirectory: () => "/home/user/.roo", + getProjectRooDirectoryForCwd: (cwd: string) => `${cwd}/.roo`, +})) + +describe("HookConfigLoader", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("loadHooksConfig", () => { + it("should return empty snapshot when no config files exist", async () => { + // Mock: no directories exist + mockFsPromises.readdir.mockRejectedValue({ code: "ENOENT" }) + + const result = await loadHooksConfig({ cwd: "/project" }) + + expect(result.snapshot.hooksByEvent.size).toBe(0) + expect(result.snapshot.hooksById.size).toBe(0) + expect(result.errors).toHaveLength(0) + }) + + it("should parse YAML config files", async () => { + const yamlContent = ` +version: "1" +hooks: + PreToolUse: + - id: lint-check + matcher: "Edit|Write" + command: "./lint.sh" + timeout: 30 +` + mockFsPromises.readdir.mockImplementation(async (dirPath) => { + const dir = dirPath.toString() + if (dir.includes("/.roo/hooks")) { + return [{ name: "pre-tool.yaml", isFile: () => true, isDirectory: () => false }] + } + throw { code: "ENOENT" } + }) + mockFsPromises.readFile.mockImplementation(async (filePath) => { + if (filePath.toString().endsWith("pre-tool.yaml")) { + return yamlContent + } + throw { code: "ENOENT" } + }) + + const result = await loadHooksConfig({ cwd: "/project" }) + + expect(result.errors).toHaveLength(0) + expect(result.snapshot.hooksByEvent.has("PreToolUse")).toBe(true) + const hooks = result.snapshot.hooksByEvent.get("PreToolUse")! + expect(hooks).toHaveLength(1) + expect(hooks[0].id).toBe("lint-check") + expect(hooks[0].command).toBe("./lint.sh") + expect(hooks[0].timeout).toBe(30) + }) + + it("should parse JSON config files", async () => { + const jsonContent = JSON.stringify({ + version: "1", + hooks: { + PostToolUse: [{ id: "notify-slack", command: "./notify.sh" }], + }, + }) + + mockFsPromises.readdir.mockImplementation(async (dirPath) => { + const dir = dirPath.toString() + if (dir.includes("/.roo/hooks")) { + return [{ name: "post-tool.json", isFile: () => true, isDirectory: () => false }] + } + throw { code: "ENOENT" } + }) + mockFsPromises.readFile.mockImplementation(async (filePath) => { + if (filePath.toString().endsWith("post-tool.json")) { + return jsonContent + } + throw { code: "ENOENT" } + }) + + const result = await loadHooksConfig({ cwd: "/project" }) + + expect(result.errors).toHaveLength(0) + expect(result.snapshot.hooksByEvent.has("PostToolUse")).toBe(true) + }) + + it("should report validation errors for invalid config", async () => { + // Missing required 'command' field + const invalidYaml = ` +version: "1" +hooks: + PreToolUse: + - id: bad-hook + command: "" +` + mockFsPromises.readdir.mockImplementation(async (dirPath) => { + const dir = dirPath.toString() + if (dir.includes("/.roo/hooks")) { + return [{ name: "invalid.yaml", isFile: () => true, isDirectory: () => false }] + } + throw { code: "ENOENT" } + }) + mockFsPromises.readFile.mockImplementation(async (filePath) => { + if (filePath.toString().endsWith("invalid.yaml")) { + return invalidYaml + } + throw { code: "ENOENT" } + }) + + const result = await loadHooksConfig({ cwd: "/project" }) + + // Should have validation error (command cannot be empty) + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors[0]).toContain("command") + }) + + it("should merge configs with project taking precedence over global", async () => { + const globalYaml = ` +version: "1" +hooks: + PreToolUse: + - id: shared-hook + command: "global-command" +` + const projectYaml = ` +version: "1" +hooks: + PreToolUse: + - id: shared-hook + command: "project-command" +` + mockFsPromises.readdir.mockImplementation(async (dirPath) => { + const dir = dirPath.toString() + if (dir.includes(".roo/hooks")) { + return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }] + } + throw { code: "ENOENT" } + }) + mockFsPromises.readFile.mockImplementation(async (filePath) => { + const path = filePath.toString() + if (path.includes("/home/user/.roo") && path.endsWith("hooks.yaml")) { + return globalYaml + } + if (path.includes("/project/.roo") && path.endsWith("hooks.yaml")) { + return projectYaml + } + throw { code: "ENOENT" } + }) + + const result = await loadHooksConfig({ cwd: "/project" }) + + expect(result.errors).toHaveLength(0) + const hooks = result.snapshot.hooksByEvent.get("PreToolUse")! + expect(hooks).toHaveLength(1) + // Project should take precedence + expect(hooks[0].command).toBe("project-command") + }) + + it("should include mode-specific hooks when mode is provided", async () => { + const modeYaml = ` +version: "1" +hooks: + PreToolUse: + - id: mode-hook + command: "./mode-specific.sh" +` + mockFsPromises.readdir.mockImplementation(async (dirPath) => { + const dir = dirPath.toString() + if (dir.includes("hooks-code")) { + return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }] + } + throw { code: "ENOENT" } + }) + mockFsPromises.readFile.mockImplementation(async (filePath) => { + if (filePath.toString().includes("hooks-code")) { + return modeYaml + } + throw { code: "ENOENT" } + }) + + const result = await loadHooksConfig({ cwd: "/project", mode: "code" }) + + expect(result.errors).toHaveLength(0) + expect(result.snapshot.hooksByEvent.has("PreToolUse")).toBe(true) + }) + + it("should set hasProjectHooks flag when project hooks exist", async () => { + const projectYaml = ` +version: "1" +hooks: + PreToolUse: + - id: project-hook + command: "./project.sh" +` + mockFsPromises.readdir.mockImplementation(async (dirPath) => { + const dir = dirPath.toString() + if (dir.endsWith("/.roo/hooks")) { + return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }] + } + throw { code: "ENOENT" } + }) + mockFsPromises.readFile.mockImplementation(async (filePath) => { + if (filePath.toString().includes("/project/.roo")) { + return projectYaml + } + throw { code: "ENOENT" } + }) + + const result = await loadHooksConfig({ cwd: "/project" }) + + expect(result.snapshot.hasProjectHooks).toBe(true) + }) + }) + + describe("getHooksForEvent", () => { + const createSnapshot = ( + hooks: Array<{ id: string; event: HookEventType; command: string }>, + ): HooksConfigSnapshot => { + const hooksByEvent = new Map() + const hooksById = new Map() + + for (const h of hooks) { + const hook = { ...h, enabled: true, _runtimeDisabled: false } + if (!hooksByEvent.has(h.event)) { + hooksByEvent.set(h.event, []) + } + hooksByEvent.get(h.event)!.push(hook) + hooksById.set(h.id, hook) + } + + return { + hooksByEvent, + hooksById, + hasProjectHooks: false, + loadedAt: new Date(), + disabledHookIds: new Set(), + } + } + + it("should return hooks for specific event", () => { + const snapshot = createSnapshot([ + { id: "hook1", event: "PreToolUse", command: "./a.sh" }, + { id: "hook2", event: "PostToolUse", command: "./b.sh" }, + ]) + + const result = getHooksForEvent(snapshot, "PreToolUse") + + expect(result).toHaveLength(1) + expect(result[0].id).toBe("hook1") + }) + + it("should exclude disabled hooks", () => { + const snapshot = createSnapshot([{ id: "hook1", event: "PreToolUse", command: "./a.sh" }]) + // Manually disable the hook + snapshot.hooksByEvent.get("PreToolUse")![0].enabled = false + + const result = getHooksForEvent(snapshot, "PreToolUse") + + expect(result).toHaveLength(0) + }) + + it("should exclude runtime-disabled hooks", () => { + const snapshot = createSnapshot([{ id: "hook1", event: "PreToolUse", command: "./a.sh" }]) + // Add hook ID to disabled set + snapshot.disabledHookIds.add("hook1") + + const result = getHooksForEvent(snapshot, "PreToolUse") + + expect(result).toHaveLength(0) + }) + + it("should return empty array for events with no hooks", () => { + const snapshot = createSnapshot([]) + + const result = getHooksForEvent(snapshot, "Notification") + + expect(result).toHaveLength(0) + }) + }) + + describe("getHookById", () => { + it("should return hook by ID", () => { + const snapshot: HooksConfigSnapshot = { + hooksByEvent: new Map(), + hooksById: new Map([["my-hook", { id: "my-hook", command: "./test.sh" } as any]]), + hasProjectHooks: false, + loadedAt: new Date(), + disabledHookIds: new Set(), + } + + const result = getHookById(snapshot, "my-hook") + + expect(result).toBeDefined() + expect(result!.id).toBe("my-hook") + }) + + it("should return undefined for non-existent hook", () => { + const snapshot: HooksConfigSnapshot = { + hooksByEvent: new Map(), + hooksById: new Map(), + hasProjectHooks: false, + loadedAt: new Date(), + disabledHookIds: new Set(), + } + + const result = getHookById(snapshot, "non-existent") + + expect(result).toBeUndefined() + }) + }) +}) diff --git a/src/services/hooks/__tests__/HookExecutor.spec.ts b/src/services/hooks/__tests__/HookExecutor.spec.ts new file mode 100644 index 0000000000..71482d1773 --- /dev/null +++ b/src/services/hooks/__tests__/HookExecutor.spec.ts @@ -0,0 +1,542 @@ +/** + * Tests for HookExecutor + * + * Covers: + * - Exit code handling (0=success, 2=block, other=error) + * - Timeout behavior + * - Environment variable setup + * - stdin JSON payload + * - Blocking vs non-blocking events + */ + +import { spawn } from "child_process" +import { executeHook, interpretResult, describeResult } from "../HookExecutor" +import type { ResolvedHook, HookContext, HookExecutionResult, HookEventType } from "../types" + +// Mock child_process +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +const mockSpawn = vi.mocked(spawn) + +describe("HookExecutor", () => { + const createMockHook = (overrides: Partial = {}): ResolvedHook => + ({ + id: "test-hook", + matcher: "*", + enabled: true, + command: "echo test", + timeout: 5, + source: "project", + event: "PreToolUse" as HookEventType, + filePath: "/test/hooks.yaml", + includeConversationHistory: false, + ...overrides, + }) as ResolvedHook + + const createMockContext = (overrides: Partial = {}): HookContext => ({ + event: "PreToolUse", + timestamp: "2026-01-16T12:00:00Z", + session: { + taskId: "task_123", + sessionId: "session_456", + mode: "code", + }, + project: { + directory: "/project", + name: "test-project", + }, + tool: { + name: "Write", + input: { filePath: "/src/index.ts", content: "// test" }, + }, + ...overrides, + }) + + describe("interpretResult", () => { + it("should interpret exit code 0 as success", () => { + const result: HookExecutionResult = { + hook: createMockHook(), + exitCode: 0, + stdout: "", + stderr: "", + duration: 100, + timedOut: false, + } + + const interpretation = interpretResult(result) + + expect(interpretation.success).toBe(true) + expect(interpretation.blocked).toBe(false) + expect(interpretation.shouldContinue).toBe(true) + }) + + it("should interpret exit code 2 as block for blocking events", () => { + const result: HookExecutionResult = { + hook: createMockHook({ event: "PreToolUse" }), + exitCode: 2, + stdout: "", + stderr: "Lint errors found", + duration: 100, + timedOut: false, + } + + const interpretation = interpretResult(result) + + expect(interpretation.success).toBe(false) + expect(interpretation.blocked).toBe(true) + expect(interpretation.blockMessage).toBe("Lint errors found") + expect(interpretation.shouldContinue).toBe(false) + }) + + it("should NOT block for non-blocking events even with exit code 2", () => { + const result: HookExecutionResult = { + hook: createMockHook({ event: "PostToolUse" }), + exitCode: 2, + stdout: "", + stderr: "Some error", + duration: 100, + timedOut: false, + } + + const interpretation = interpretResult(result) + + expect(interpretation.blocked).toBe(false) + expect(interpretation.shouldContinue).toBe(true) + }) + + it("should interpret other non-zero codes as error (continue)", () => { + const result: HookExecutionResult = { + hook: createMockHook(), + exitCode: 1, + stdout: "", + stderr: "Command failed", + duration: 100, + timedOut: false, + } + + const interpretation = interpretResult(result) + + expect(interpretation.success).toBe(false) + expect(interpretation.blocked).toBe(false) + expect(interpretation.shouldContinue).toBe(true) + }) + + it("should handle timeout (continue)", () => { + const result: HookExecutionResult = { + hook: createMockHook(), + exitCode: null, + stdout: "", + stderr: "", + duration: 5000, + timedOut: true, + } + + const interpretation = interpretResult(result) + + expect(interpretation.success).toBe(false) + expect(interpretation.blocked).toBe(false) + expect(interpretation.shouldContinue).toBe(true) + }) + + it("should handle execution error (continue)", () => { + const result: HookExecutionResult = { + hook: createMockHook(), + exitCode: null, + stdout: "", + stderr: "", + duration: 10, + timedOut: false, + error: new Error("Command not found"), + } + + const interpretation = interpretResult(result) + + expect(interpretation.success).toBe(false) + expect(interpretation.blocked).toBe(false) + expect(interpretation.shouldContinue).toBe(true) + }) + + describe("blocking events", () => { + const blockingEvents: HookEventType[] = [ + "PreToolUse", + "PermissionRequest", + "UserPromptSubmit", + "Stop", + "SubagentStop", + ] + + for (const event of blockingEvents) { + it(`should allow blocking for ${event}`, () => { + const result: HookExecutionResult = { + hook: createMockHook({ event }), + exitCode: 2, + stdout: "", + stderr: "Blocked", + duration: 100, + timedOut: false, + } + + const interpretation = interpretResult(result) + expect(interpretation.blocked).toBe(true) + }) + } + }) + + describe("non-blocking events", () => { + const nonBlockingEvents: HookEventType[] = [ + "PostToolUse", + "PostToolUseFailure", + "SubagentStart", + "SessionStart", + "SessionEnd", + "Notification", + "PreCompact", + ] + + for (const event of nonBlockingEvents) { + it(`should NOT block for ${event}`, () => { + const result: HookExecutionResult = { + hook: createMockHook({ event }), + exitCode: 2, + stdout: "", + stderr: "Attempted block", + duration: 100, + timedOut: false, + } + + const interpretation = interpretResult(result) + expect(interpretation.blocked).toBe(false) + }) + } + }) + }) + + describe("describeResult", () => { + it("should describe successful execution", () => { + const result: HookExecutionResult = { + hook: createMockHook({ id: "my-hook" }), + exitCode: 0, + stdout: "", + stderr: "", + duration: 150, + timedOut: false, + } + + const description = describeResult(result) + + expect(description).toContain("my-hook") + expect(description).toContain("successfully") + expect(description).toContain("150ms") + }) + + it("should describe blocked execution", () => { + const result: HookExecutionResult = { + hook: createMockHook({ id: "blocker" }), + exitCode: 2, + stdout: "", + stderr: "Policy violation", + duration: 100, + timedOut: false, + } + + const description = describeResult(result) + + expect(description).toContain("blocker") + expect(description).toContain("blocked") + expect(description).toContain("Policy violation") + }) + + it("should describe timeout", () => { + const result: HookExecutionResult = { + hook: createMockHook({ id: "slow-hook", timeout: 30 }), + exitCode: null, + stdout: "", + stderr: "", + duration: 30000, + timedOut: true, + } + + const description = describeResult(result) + + expect(description).toContain("slow-hook") + expect(description).toContain("timed out") + expect(description).toContain("30") + }) + + it("should describe execution error", () => { + const result: HookExecutionResult = { + hook: createMockHook({ id: "broken" }), + exitCode: null, + stdout: "", + stderr: "", + duration: 10, + timedOut: false, + error: new Error("Command not found: badcmd"), + } + + const description = describeResult(result) + + expect(description).toContain("broken") + expect(description).toContain("failed") + expect(description).toContain("Command not found") + }) + }) + + describe("executeHook", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should spawn process with correct arguments", async () => { + const mockProcess = { + stdin: { write: vi.fn(), end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event, cb) => { + if (event === "close") { + setTimeout(() => cb(0), 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const hook = createMockHook({ command: "./test-script.sh" }) + const context = createMockContext() + + const resultPromise = executeHook(hook, context) + + // Wait a tick for the spawn to be called + await new Promise((r) => setTimeout(r, 0)) + + expect(mockSpawn).toHaveBeenCalled() + const [shell, args, options] = mockSpawn.mock.calls[0] + + // Should use shell with -c flag (or PowerShell equivalent) + expect(args[args.length - 1]).toBe("./test-script.sh") + expect(options.cwd).toBe("/project") + + const result = await resultPromise + expect(result.exitCode).toBe(0) + }) + + it("should write JSON context to stdin", async () => { + const stdinWrite = vi.fn() + const mockProcess = { + stdin: { write: stdinWrite, end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event, cb) => { + if (event === "close") { + setTimeout(() => cb(0), 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const hook = createMockHook() + const context = createMockContext() + + await executeHook(hook, context) + + expect(stdinWrite).toHaveBeenCalled() + const jsonPayload = stdinWrite.mock.calls[0][0] + const parsed = JSON.parse(jsonPayload) + + expect(parsed.event).toBe("PreToolUse") + expect(parsed.session.taskId).toBe("task_123") + expect(parsed.project.directory).toBe("/project") + expect(parsed.tool.name).toBe("Write") + }) + + it("should set environment variables", async () => { + const mockProcess = { + stdin: { write: vi.fn(), end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event, cb) => { + if (event === "close") { + setTimeout(() => cb(0), 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const hook = createMockHook({ id: "env-test" }) + const context = createMockContext() + + await executeHook(hook, context) + + const options = mockSpawn.mock.calls[0][2] + const env = options.env + + expect(env).toBeDefined() + if (!env) throw new Error("Expected env to be captured") + + expect(env.ROO_PROJECT_DIR).toBe("/project") + expect(env.ROO_TASK_ID).toBe("task_123") + expect(env.ROO_SESSION_ID).toBe("session_456") + expect(env.ROO_MODE).toBe("code") + expect(env.ROO_TOOL_NAME).toBe("Write") + expect(env.ROO_EVENT).toBe("PreToolUse") + expect(env.ROO_HOOK_ID).toBe("env-test") + }) + + it("should NOT include conversation history by default", async () => { + const stdinWrite = vi.fn() + const mockProcess = { + stdin: { write: stdinWrite, end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event, cb) => { + if (event === "close") { + setTimeout(() => cb(0), 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const hook = createMockHook({ includeConversationHistory: false }) + const context = createMockContext() + const history = [{ role: "user" as const, content: "Hello" }] + + await executeHook(hook, context, history) + + const jsonPayload = stdinWrite.mock.calls[0][0] + const parsed = JSON.parse(jsonPayload) + + expect(parsed.conversationHistory).toBeUndefined() + }) + + it("should include conversation history when opted in", async () => { + const stdinWrite = vi.fn() + const mockProcess = { + stdin: { write: stdinWrite, end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event, cb) => { + if (event === "close") { + setTimeout(() => cb(0), 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const hook = createMockHook({ includeConversationHistory: true }) + const context = createMockContext() + const history = [{ role: "user" as const, content: "Hello" }] + + await executeHook(hook, context, history) + + const jsonPayload = stdinWrite.mock.calls[0][0] + const parsed = JSON.parse(jsonPayload) + + expect(parsed.conversationHistory).toBeDefined() + expect(parsed.conversationHistory).toHaveLength(1) + }) + + it("should capture stdout and stderr", async () => { + let stdoutCallback: ((data: Buffer) => void) | undefined + let stderrCallback: ((data: Buffer) => void) | undefined + + const mockProcess = { + stdin: { write: vi.fn(), end: vi.fn() }, + stdout: { + on: vi.fn((event, cb) => { + if (event === "data") stdoutCallback = cb + }), + }, + stderr: { + on: vi.fn((event, cb) => { + if (event === "data") stderrCallback = cb + }), + }, + on: vi.fn((event, cb) => { + if (event === "close") { + setTimeout(() => { + stdoutCallback?.(Buffer.from("stdout content")) + stderrCallback?.(Buffer.from("stderr content")) + cb(0) + }, 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const result = await executeHook(createMockHook(), createMockContext()) + + expect(result.stdout).toContain("stdout content") + expect(result.stderr).toContain("stderr content") + }) + + it("should handle process spawn errors", async () => { + const mockProcess = { + stdin: null, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event, cb) => { + if (event === "error") { + setTimeout(() => cb(new Error("spawn ENOENT")), 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const result = await executeHook(createMockHook(), createMockContext()) + + expect(result.error).toBeDefined() + expect(result.error?.message).toContain("ENOENT") + }) + + it("should parse modification JSON from stdout for PreToolUse", async () => { + let stdoutCallback: ((data: Buffer) => void) | undefined + + const modificationJson = JSON.stringify({ + action: "modify", + toolInput: { filePath: "/modified/path.ts", content: "// modified" }, + }) + + const mockProcess = { + stdin: { write: vi.fn(), end: vi.fn() }, + stdout: { + on: vi.fn((event, cb) => { + if (event === "data") stdoutCallback = cb + }), + }, + stderr: { on: vi.fn() }, + on: vi.fn((event, cb) => { + if (event === "close") { + setTimeout(() => { + stdoutCallback?.(Buffer.from(modificationJson)) + cb(0) + }, 10) + } + }), + kill: vi.fn(), + killed: false, + } + mockSpawn.mockReturnValue(mockProcess as any) + + const result = await executeHook(createMockHook({ event: "PreToolUse" }), createMockContext()) + + expect(result.modification).toBeDefined() + expect(result.modification?.action).toBe("modify") + expect(result.modification?.toolInput.filePath).toBe("/modified/path.ts") + }) + }) +}) diff --git a/src/services/hooks/__tests__/HookManager.spec.ts b/src/services/hooks/__tests__/HookManager.spec.ts new file mode 100644 index 0000000000..be49e5a264 --- /dev/null +++ b/src/services/hooks/__tests__/HookManager.spec.ts @@ -0,0 +1,548 @@ +/** + * Tests for HookManager + * + * Covers: + * - Config loading and snapshot management + * - Sequential hook execution + * - Enable/disable functionality + * - Execution history tracking + */ + +import { HookManager, createHookManager } from "../HookManager" +import * as HookConfigLoader from "../HookConfigLoader" +import * as HookExecutor from "../HookExecutor" +import type { HooksConfigSnapshot, ResolvedHook, HookEventType, HookContext } from "../types" + +// Mock dependencies +vi.mock("../HookConfigLoader") +vi.mock("../HookExecutor") + +const mockLoadHooksConfig = vi.mocked(HookConfigLoader.loadHooksConfig) +const mockGetHooksForEvent = vi.mocked(HookConfigLoader.getHooksForEvent) +const mockExecuteHook = vi.mocked(HookExecutor.executeHook) +const mockInterpretResult = vi.mocked(HookExecutor.interpretResult) +const mockDescribeResult = vi.mocked(HookExecutor.describeResult) + +describe("HookManager", () => { + const createMockHook = (id: string, event: HookEventType = "PreToolUse"): ResolvedHook => + ({ + id, + matcher: "*", + enabled: true, + command: `echo ${id}`, + timeout: 60, + source: "project", + event, + filePath: "/test/hooks.yaml", + }) as ResolvedHook + + const createMockSnapshot = (hooks: ResolvedHook[] = []): HooksConfigSnapshot => { + const hooksByEvent = new Map() + const hooksById = new Map() + + for (const hook of hooks) { + if (!hooksByEvent.has(hook.event)) { + hooksByEvent.set(hook.event, []) + } + hooksByEvent.get(hook.event)!.push(hook) + hooksById.set(hook.id, hook) + } + + return { + hooksByEvent, + hooksById, + loadedAt: new Date(), + disabledHookIds: new Set(), + hasProjectHooks: hooks.some((h) => h.source === "project"), + } + } + + beforeEach(() => { + vi.clearAllMocks() + mockDescribeResult.mockReturnValue("Hook executed") + }) + + describe("loadHooksConfig", () => { + it("should load config and return snapshot", async () => { + const mockSnapshot = createMockSnapshot([createMockHook("hook1")]) + mockLoadHooksConfig.mockResolvedValue({ + snapshot: mockSnapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project" }) + const snapshot = await manager.loadHooksConfig() + + expect(mockLoadHooksConfig).toHaveBeenCalledWith({ cwd: "/project", mode: undefined }) + expect(snapshot).toBe(mockSnapshot) + }) + + it("should pass mode to config loader", async () => { + const mockSnapshot = createMockSnapshot([]) + mockLoadHooksConfig.mockResolvedValue({ + snapshot: mockSnapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project", mode: "code" }) + await manager.loadHooksConfig() + + expect(mockLoadHooksConfig).toHaveBeenCalledWith({ cwd: "/project", mode: "code" }) + }) + + it("should log errors and warnings", async () => { + const mockSnapshot = createMockSnapshot([]) + const errorLog: string[] = [] + const warnLog: string[] = [] + + mockLoadHooksConfig.mockResolvedValue({ + snapshot: mockSnapshot, + errors: ["Config error 1"], + warnings: ["Warning 1"], + }) + + const manager = createHookManager({ + cwd: "/project", + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: (msg) => warnLog.push(msg), + error: (msg) => errorLog.push(msg), + }, + }) + + await manager.loadHooksConfig() + + expect(errorLog.some((e) => e.includes("Config error 1"))).toBe(true) + expect(warnLog.some((w) => w.includes("Warning 1"))).toBe(true) + }) + }) + + describe("reloadHooksConfig", () => { + it("should reload config while preserving disabled hooks", async () => { + const hook1 = createMockHook("hook1") + const hook2 = createMockHook("hook2") + + // Initial load + const initialSnapshot = createMockSnapshot([hook1, hook2]) + mockLoadHooksConfig.mockResolvedValueOnce({ + snapshot: initialSnapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + // Disable hook2 + await manager.setHookEnabled("hook2", false) + + // Reload with same hooks + const reloadedSnapshot = createMockSnapshot([hook1, hook2]) + mockLoadHooksConfig.mockResolvedValueOnce({ + snapshot: reloadedSnapshot, + errors: [], + warnings: [], + }) + + await manager.reloadHooksConfig() + + const snapshot = manager.getConfigSnapshot() + expect(snapshot?.disabledHookIds.has("hook2")).toBe(true) + }) + }) + + describe("executeHooks", () => { + const createMockContext = (): HookContext => ({ + event: "PreToolUse", + timestamp: "2026-01-16T12:00:00Z", + session: { taskId: "task_1", sessionId: "session_1", mode: "code" }, + project: { directory: "/project", name: "test" }, + tool: { name: "Write", input: { filePath: "/test.ts", content: "test" } }, + }) + + it("should execute hooks sequentially", async () => { + const hook1 = createMockHook("hook1") + const hook2 = createMockHook("hook2") + const snapshot = createMockSnapshot([hook1, hook2]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + mockGetHooksForEvent.mockReturnValue([hook1, hook2]) + mockExecuteHook.mockResolvedValue({ + hook: hook1, + exitCode: 0, + stdout: "", + stderr: "", + duration: 100, + timedOut: false, + }) + mockInterpretResult.mockReturnValue({ + success: true, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + const result = await manager.executeHooks("PreToolUse", { context: createMockContext() }) + + // Should execute both hooks + expect(mockExecuteHook).toHaveBeenCalledTimes(2) + expect(result.results).toHaveLength(2) + expect(result.blocked).toBe(false) + }) + + it("should stop execution when a hook blocks", async () => { + const hook1 = createMockHook("blocker") + const hook2 = createMockHook("after-block") + const snapshot = createMockSnapshot([hook1, hook2]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + mockGetHooksForEvent.mockReturnValue([hook1, hook2]) + mockExecuteHook.mockResolvedValue({ + hook: hook1, + exitCode: 2, + stdout: "", + stderr: "Blocked!", + duration: 100, + timedOut: false, + }) + mockInterpretResult.mockReturnValue({ + success: false, + blocked: true, + blockMessage: "Blocked!", + shouldContinue: false, + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + const result = await manager.executeHooks("PreToolUse", { context: createMockContext() }) + + // Should only execute first hook (the blocker) + expect(mockExecuteHook).toHaveBeenCalledTimes(1) + expect(result.results).toHaveLength(1) + expect(result.blocked).toBe(true) + expect(result.blockMessage).toBe("Blocked!") + }) + + it("should continue on non-blocking failures", async () => { + const hook1 = createMockHook("failing") + const hook2 = createMockHook("after-fail") + const snapshot = createMockSnapshot([hook1, hook2]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + mockGetHooksForEvent.mockReturnValue([hook1, hook2]) + + let callCount = 0 + mockExecuteHook.mockImplementation(async (hook) => ({ + hook, + exitCode: callCount++ === 0 ? 1 : 0, + stdout: "", + stderr: callCount === 1 ? "Error" : "", + duration: 100, + timedOut: false, + })) + mockInterpretResult.mockImplementation((result) => ({ + success: result.exitCode === 0, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + })) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + const result = await manager.executeHooks("PreToolUse", { context: createMockContext() }) + + // Should execute both hooks despite first failure + expect(mockExecuteHook).toHaveBeenCalledTimes(2) + expect(result.results).toHaveLength(2) + expect(result.blocked).toBe(false) + }) + + it("should return first modification only", async () => { + const hook1 = createMockHook("modifier1") + const hook2 = createMockHook("modifier2") + const snapshot = createMockSnapshot([hook1, hook2]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + mockGetHooksForEvent.mockReturnValue([hook1, hook2]) + + let callCount = 0 + mockExecuteHook.mockImplementation(async (hook) => ({ + hook, + exitCode: 0, + stdout: "", + stderr: "", + duration: 100, + timedOut: false, + modification: + callCount++ === 0 + ? { action: "modify" as const, toolInput: { from: "first" } } + : { action: "modify" as const, toolInput: { from: "second" } }, + })) + mockInterpretResult.mockReturnValue({ + success: true, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + const result = await manager.executeHooks("PreToolUse", { context: createMockContext() }) + + expect(result.modification?.toolInput).toEqual({ from: "first" }) + }) + + it("should auto-load config if not loaded", async () => { + const hook1 = createMockHook("hook1") + const snapshot = createMockSnapshot([hook1]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + mockGetHooksForEvent.mockReturnValue([hook1]) + mockExecuteHook.mockResolvedValue({ + hook: hook1, + exitCode: 0, + stdout: "", + stderr: "", + duration: 100, + timedOut: false, + }) + mockInterpretResult.mockReturnValue({ + success: true, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + }) + + const manager = createHookManager({ cwd: "/project" }) + // Don't call loadHooksConfig explicitly + + await manager.executeHooks("PreToolUse", { context: createMockContext() }) + + // Should have auto-loaded + expect(mockLoadHooksConfig).toHaveBeenCalled() + }) + }) + + describe("getEnabledHooks", () => { + it("should return all enabled hooks", async () => { + const hook1 = createMockHook("hook1") + const hook2 = createMockHook("hook2") + const disabledHook = { ...createMockHook("disabled"), enabled: false } as ResolvedHook + const snapshot = createMockSnapshot([hook1, hook2, disabledHook]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + const enabled = manager.getEnabledHooks() + + expect(enabled).toHaveLength(2) + expect(enabled.map((h) => h.id).sort()).toEqual(["hook1", "hook2"]) + }) + + it("should exclude runtime-disabled hooks", async () => { + const hook1 = createMockHook("hook1") + const hook2 = createMockHook("hook2") + const snapshot = createMockSnapshot([hook1, hook2]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + await manager.setHookEnabled("hook2", false) + + const enabled = manager.getEnabledHooks() + + expect(enabled).toHaveLength(1) + expect(enabled[0].id).toBe("hook1") + }) + }) + + describe("setHookEnabled", () => { + it("should disable a hook", async () => { + const hook1 = createMockHook("hook1") + const snapshot = createMockSnapshot([hook1]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + await manager.setHookEnabled("hook1", false) + + expect(manager.getConfigSnapshot()?.disabledHookIds.has("hook1")).toBe(true) + }) + + it("should re-enable a disabled hook", async () => { + const hook1 = createMockHook("hook1") + const snapshot = createMockSnapshot([hook1]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + await manager.setHookEnabled("hook1", false) + await manager.setHookEnabled("hook1", true) + + expect(manager.getConfigSnapshot()?.disabledHookIds.has("hook1")).toBe(false) + }) + + it("should throw for non-existent hook", async () => { + const snapshot = createMockSnapshot([]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + await expect(manager.setHookEnabled("nonexistent", false)).rejects.toThrow("Hook not found") + }) + }) + + describe("getHookExecutionHistory", () => { + it("should return execution history", async () => { + const hook1 = createMockHook("hook1") + const snapshot = createMockSnapshot([hook1]) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + mockGetHooksForEvent.mockReturnValue([hook1]) + mockExecuteHook.mockResolvedValue({ + hook: hook1, + exitCode: 0, + stdout: "", + stderr: "", + duration: 100, + timedOut: false, + }) + mockInterpretResult.mockReturnValue({ + success: true, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + }) + + const manager = createHookManager({ cwd: "/project" }) + await manager.loadHooksConfig() + + await manager.executeHooks("PreToolUse", { + context: { + event: "PreToolUse", + timestamp: "2026-01-16T12:00:00Z", + session: { taskId: "task_1", sessionId: "session_1", mode: "code" }, + project: { directory: "/project", name: "test" }, + }, + }) + + const history = manager.getHookExecutionHistory() + + expect(history).toHaveLength(1) + expect(history[0].hook.id).toBe("hook1") + expect(history[0].event).toBe("PreToolUse") + }) + + it("should limit history size", async () => { + const hooks = Array.from({ length: 150 }, (_, i) => createMockHook(`hook${i}`)) + const snapshot = createMockSnapshot(hooks) + + mockLoadHooksConfig.mockResolvedValue({ + snapshot, + errors: [], + warnings: [], + }) + mockGetHooksForEvent.mockReturnValue(hooks) + mockExecuteHook.mockImplementation(async (hook) => ({ + hook, + exitCode: 0, + stdout: "", + stderr: "", + duration: 10, + timedOut: false, + })) + mockInterpretResult.mockReturnValue({ + success: true, + blocked: false, + blockMessage: undefined, + shouldContinue: true, + }) + + const manager = createHookManager({ cwd: "/project", maxHistoryEntries: 100 }) + await manager.loadHooksConfig() + + await manager.executeHooks("PreToolUse", { + context: { + event: "PreToolUse", + timestamp: "2026-01-16T12:00:00Z", + session: { taskId: "task_1", sessionId: "session_1", mode: "code" }, + project: { directory: "/project", name: "test" }, + }, + }) + + const history = manager.getHookExecutionHistory() + + expect(history.length).toBeLessThanOrEqual(100) + }) + }) + + describe("createHookManager", () => { + it("should create a HookManager instance", () => { + const manager = createHookManager({ cwd: "/project" }) + expect(manager).toBeInstanceOf(HookManager) + }) + }) +}) diff --git a/src/services/hooks/__tests__/HookMatcher.spec.ts b/src/services/hooks/__tests__/HookMatcher.spec.ts new file mode 100644 index 0000000000..004857366a --- /dev/null +++ b/src/services/hooks/__tests__/HookMatcher.spec.ts @@ -0,0 +1,219 @@ +/** + * Tests for HookMatcher + * + * Covers: + * - Exact matching + * - Regex pattern matching + * - Glob pattern matching + * - Match-all behavior + * - Cache behavior + */ + +import { compileMatcher, getMatcher, clearMatcherCache, filterMatchingHooks, hookMatchesTool } from "../HookMatcher" +import type { ResolvedHook } from "../types" + +describe("HookMatcher", () => { + beforeEach(() => { + clearMatcherCache() + }) + + describe("compileMatcher", () => { + describe("match-all patterns", () => { + it('should match all tools with "*" pattern', () => { + const matcher = compileMatcher("*") + expect(matcher.type).toBe("all") + expect(matcher.matches("Write")).toBe(true) + expect(matcher.matches("Read")).toBe(true) + expect(matcher.matches("Bash")).toBe(true) + expect(matcher.matches("anything")).toBe(true) + }) + + it("should match all tools with undefined pattern", () => { + const matcher = compileMatcher(undefined) + expect(matcher.type).toBe("all") + expect(matcher.matches("Write")).toBe(true) + expect(matcher.matches("Read")).toBe(true) + }) + + it("should match all tools with empty string pattern", () => { + const matcher = compileMatcher("") + expect(matcher.type).toBe("all") + expect(matcher.matches("Write")).toBe(true) + }) + }) + + describe("exact matching", () => { + it("should match exact tool name (case-insensitive)", () => { + const matcher = compileMatcher("Write") + expect(matcher.type).toBe("exact") + expect(matcher.matches("Write")).toBe(true) + expect(matcher.matches("write")).toBe(true) + expect(matcher.matches("WRITE")).toBe(true) + expect(matcher.matches("Read")).toBe(false) + }) + + it("should not match partial names", () => { + const matcher = compileMatcher("Write") + expect(matcher.matches("WriteFile")).toBe(false) + expect(matcher.matches("FileWrite")).toBe(false) + }) + }) + + describe("regex pattern matching", () => { + it("should match regex with pipe (|) alternation", () => { + const matcher = compileMatcher("Edit|Write") + expect(matcher.type).toBe("regex") + expect(matcher.matches("Edit")).toBe(true) + expect(matcher.matches("Write")).toBe(true) + expect(matcher.matches("Read")).toBe(false) + }) + + it("should match regex with character classes", () => { + const matcher = compileMatcher("File[RW].*") + expect(matcher.type).toBe("regex") + expect(matcher.matches("FileRead")).toBe(true) + expect(matcher.matches("FileWrite")).toBe(true) + expect(matcher.matches("FileDelete")).toBe(false) + }) + + it("should be case-insensitive", () => { + const matcher = compileMatcher("edit|write") + expect(matcher.matches("Edit")).toBe(true) + expect(matcher.matches("WRITE")).toBe(true) + }) + + it("should fall back to exact match on invalid regex", () => { + // Unclosed bracket is invalid regex + const matcher = compileMatcher("Edit[") + expect(matcher.type).toBe("exact") + expect(matcher.matches("Edit[")).toBe(true) + }) + }) + + describe("glob pattern matching", () => { + it("should match glob with * wildcard", () => { + const matcher = compileMatcher("mcp__*") + expect(matcher.type).toBe("glob") + expect(matcher.matches("mcp__tool1")).toBe(true) + expect(matcher.matches("mcp__server__action")).toBe(true) + expect(matcher.matches("mcp_")).toBe(false) + expect(matcher.matches("other_tool")).toBe(false) + }) + + it("should match glob with ? single-char wildcard", () => { + const matcher = compileMatcher("Tool?") + expect(matcher.type).toBe("glob") + expect(matcher.matches("Tool1")).toBe(true) + expect(matcher.matches("ToolA")).toBe(true) + expect(matcher.matches("Tool")).toBe(false) + expect(matcher.matches("Tool12")).toBe(false) + }) + + it("should match glob with * in middle", () => { + const matcher = compileMatcher("*File*") + expect(matcher.type).toBe("glob") + expect(matcher.matches("FileRead")).toBe(true) + expect(matcher.matches("ReadFile")).toBe(true) + expect(matcher.matches("ReadFileNow")).toBe(true) + expect(matcher.matches("Tool")).toBe(false) + }) + + it("should be case-insensitive", () => { + const matcher = compileMatcher("MCP__*") + expect(matcher.matches("mcp__tool")).toBe(true) + expect(matcher.matches("MCP__TOOL")).toBe(true) + }) + }) + }) + + describe("getMatcher (caching)", () => { + it("should return same matcher for same pattern", () => { + const matcher1 = getMatcher("Write") + const matcher2 = getMatcher("Write") + expect(matcher1).toBe(matcher2) + }) + + it("should cache undefined as '*'", () => { + const matcher1 = getMatcher(undefined) + const matcher2 = getMatcher("*") + expect(matcher1).toBe(matcher2) + }) + + it("should return different matchers for different patterns", () => { + const matcher1 = getMatcher("Write") + const matcher2 = getMatcher("Read") + expect(matcher1).not.toBe(matcher2) + }) + }) + + describe("filterMatchingHooks", () => { + const createMockHook = (id: string, matcher?: string): ResolvedHook => + ({ + id, + matcher, + enabled: true, + command: "echo test", + timeout: 60, + source: "project", + event: "PreToolUse", + filePath: "/test/hooks.yaml", + }) as ResolvedHook + + it("should filter hooks by tool name", () => { + const hooks = [ + createMockHook("hook1", "Write"), + createMockHook("hook2", "Read"), + createMockHook("hook3", "Edit|Write"), + ] + + const matching = filterMatchingHooks(hooks, "Write") + expect(matching).toHaveLength(2) + expect(matching.map((h) => h.id)).toEqual(["hook1", "hook3"]) + }) + + it("should include match-all hooks", () => { + const hooks = [ + createMockHook("hook1", "*"), + createMockHook("hook2", undefined), + createMockHook("hook3", "Read"), + ] + + const matching = filterMatchingHooks(hooks, "Write") + expect(matching).toHaveLength(2) + expect(matching.map((h) => h.id)).toEqual(["hook1", "hook2"]) + }) + + it("should return empty array when no hooks match", () => { + const hooks = [createMockHook("hook1", "Read"), createMockHook("hook2", "Edit")] + + const matching = filterMatchingHooks(hooks, "Write") + expect(matching).toHaveLength(0) + }) + }) + + describe("hookMatchesTool", () => { + const createMockHook = (matcher?: string): ResolvedHook => + ({ + id: "test", + matcher, + enabled: true, + command: "echo test", + timeout: 60, + source: "project", + event: "PreToolUse", + filePath: "/test/hooks.yaml", + }) as ResolvedHook + + it("should return true for matching hook", () => { + expect(hookMatchesTool(createMockHook("Write"), "Write")).toBe(true) + }) + + it("should return false for non-matching hook", () => { + expect(hookMatchesTool(createMockHook("Read"), "Write")).toBe(false) + }) + + it("should return true for match-all hook", () => { + expect(hookMatchesTool(createMockHook("*"), "AnyTool")).toBe(true) + }) + }) +}) diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts new file mode 100644 index 0000000000..b65d568aa4 --- /dev/null +++ b/src/services/hooks/index.ts @@ -0,0 +1,102 @@ +/** + * Hooks Service + * + * Provides Claude Code-style hooks for Roo Code. + * Hooks allow users to run custom shell commands at key lifecycle events. + * + * @example + * ```typescript + * import { createHookManager, HookEventType } from './services/hooks' + * + * const hookManager = createHookManager({ + * cwd: '/path/to/project', + * mode: 'code' + * }) + * + * // Load configuration + * await hookManager.loadHooksConfig() + * + * // Execute hooks for an event + * const result = await hookManager.executeHooks('PreToolUse', { + * context: { + * event: 'PreToolUse', + * timestamp: new Date().toISOString(), + * session: { taskId: 'task_1', sessionId: 'session_1', mode: 'code' }, + * project: { directory: '/path/to/project', name: 'my-project' }, + * tool: { name: 'Write', input: { filePath: '/src/index.ts', content: '...' } } + * } + * }) + * + * if (result.blocked) { + * console.log('Hook blocked:', result.blockMessage) + * } + * ``` + */ + +// Types +export { + // Event types + HookEventType, + BLOCKING_EVENTS, + isBlockingEvent, + + // Schema types + HookDefinitionSchema, + HooksConfigFileSchema, + HookModificationSchema, + + // Type definitions + type HookDefinition, + type HooksConfigFile, + type HookSource, + type ResolvedHook, + type HooksConfigSnapshot, + + // Context types + type HookContext, + type HookSessionContext, + type HookProjectContext, + type HookToolContext, + type HookPromptContext, + type HookNotificationContext, + type ConversationHistoryEntry, + + // Execution types + HookExitCode, + type HookExecutionResult, + type HooksExecutionResult, + type HookModification, + type HookExecution, + type ExecuteHooksOptions, + + // Manager interface + type IHookManager, +} from "./types" + +// Config loader +export { + loadHooksConfig, + getHooksForEvent, + getHookById, + type LoadHooksConfigOptions, + type LoadHooksConfigResult, +} from "./HookConfigLoader" + +// Matcher +export { compileMatcher, getMatcher, clearMatcherCache, filterMatchingHooks, hookMatchesTool } from "./HookMatcher" + +// Executor +export { executeHook, interpretResult, describeResult } from "./HookExecutor" + +// Manager +export { HookManager, createHookManager, type HookManagerOptions } from "./HookManager" + +// Tool Execution Integration +export { + ToolExecutionHooks, + createToolExecutionHooks, + type ToolExecutionContext, + type PreToolUseResult, + type PermissionRequestResult, + type HookStatusCallback, +} from "./ToolExecutionHooks" diff --git a/src/services/hooks/types.ts b/src/services/hooks/types.ts new file mode 100644 index 0000000000..1583bbebc4 --- /dev/null +++ b/src/services/hooks/types.ts @@ -0,0 +1,379 @@ +/** + * Hook System Types and Zod Schemas + * + * This module defines the configuration schema and types for the Roo Code hooks system. + * Compatible with Claude Code hook semantics. + */ + +import { z } from "zod" + +// ============================================================================ +// Hook Events +// ============================================================================ + +/** + * All supported hook event types. + * Blocking events can halt/modify execution via exit code 2. + * Non-blocking events are informational only. + */ +export const HookEventType = z.enum([ + "PreToolUse", // Before tool execution (blocking) + "PostToolUse", // After successful tool completion + "PostToolUseFailure", // After tool execution fails + "PermissionRequest", // When tool approval dialog shown (blocking) + "UserPromptSubmit", // When user sends message (blocking) + "Stop", // When task completes or stops (blocking) + "SubagentStop", // When subtask completes (blocking) + "SubagentStart", // When subtask begins + "SessionStart", // When new task created + "SessionEnd", // When task fully ends + "Notification", // When status messages sent + "PreCompact", // Before context compaction +]) + +export type HookEventType = z.infer + +/** + * Events that can block execution by returning exit code 2. + */ +export const BLOCKING_EVENTS: Set = new Set([ + "PreToolUse", + "PermissionRequest", + "UserPromptSubmit", + "Stop", + "SubagentStop", +]) + +/** + * Check if an event type supports blocking behavior. + */ +export function isBlockingEvent(event: HookEventType): boolean { + return BLOCKING_EVENTS.has(event) +} + +// ============================================================================ +// Hook Definition Schema +// ============================================================================ + +/** + * Schema for a single hook definition within a config file. + */ +export const HookDefinitionSchema = z.object({ + /** Unique identifier for this hook */ + id: z.string().min(1, "Hook ID cannot be empty"), + + /** Tool name filter (regex/glob pattern). If omitted, matches all tools. */ + matcher: z.string().optional(), + + /** Whether this hook is enabled. Defaults to true. */ + enabled: z.boolean().optional().default(true), + + /** Shell command to execute */ + command: z.string().min(1, "Command cannot be empty"), + + /** Timeout in seconds. Defaults to 60. */ + timeout: z.number().positive().optional().default(60), + + /** Human-readable description of what this hook does */ + description: z.string().optional(), + + /** Override shell (default: user's shell on Unix, PowerShell on Windows) */ + shell: z.string().optional(), + + /** Opt-in to receive conversation history in stdin. Defaults to false. */ + includeConversationHistory: z.boolean().optional().default(false), +}) + +export type HookDefinition = z.infer + +// ============================================================================ +// Hook Config File Schema +// ============================================================================ + +/** + * Schema for a hooks configuration file (.roo/hooks/*.yaml or *.json). + */ +export const HooksConfigFileSchema = z.object({ + /** Config format version */ + version: z.literal("1"), + + /** Hooks organized by event type */ + hooks: z.record(HookEventType, z.array(HookDefinitionSchema)).optional().default({}), +}) + +export type HooksConfigFile = z.infer + +// ============================================================================ +// Internal Types (for runtime use) +// ============================================================================ + +/** + * Source of a hook configuration. + */ +export type HookSource = "project" | "mode" | "global" + +/** + * Extended hook definition with source information. + * Used internally after merging configs from multiple sources. + */ +export interface ResolvedHook extends HookDefinition { + /** Which config source this hook came from */ + source: HookSource + + /** The event type this hook is registered for */ + event: HookEventType + + /** File path where this hook was defined */ + filePath: string +} + +/** + * In-memory snapshot of all loaded hooks configuration. + * This is immutable once created - changes require explicit reload. + */ +export interface HooksConfigSnapshot { + /** All resolved hooks, organized by event type */ + hooksByEvent: Map + + /** Lookup by hook ID for quick access */ + hooksById: Map + + /** When this snapshot was created */ + loadedAt: Date + + /** IDs of hooks that have been disabled at runtime */ + disabledHookIds: Set + + /** Whether project hooks are present (for security warnings) */ + hasProjectHooks: boolean +} + +// ============================================================================ +// Hook Context (passed to hooks via stdin) +// ============================================================================ + +/** + * Session information included in hook context. + */ +export interface HookSessionContext { + taskId: string + sessionId: string + mode: string +} + +/** + * Project information included in hook context. + */ +export interface HookProjectContext { + directory: string + name: string +} + +/** + * Tool information for tool-related events. + */ +export interface HookToolContext { + name: string + input: Record + /** Only present for PostToolUse */ + output?: unknown + /** Only present for PostToolUse */ + duration?: number + /** Only present for PostToolUseFailure */ + error?: string + /** Only present for PostToolUseFailure */ + errorMessage?: string +} + +/** + * User prompt information for UserPromptSubmit event. + */ +export interface HookPromptContext { + text: string + images?: string[] +} + +/** + * Notification information for Notification event. + */ +export interface HookNotificationContext { + message: string + type: string +} + +/** + * Conversation history entry (opt-in via includeConversationHistory). + */ +export interface ConversationHistoryEntry { + role: "user" | "assistant" + content: string +} + +/** + * Full context passed to hooks via stdin as JSON. + */ +export interface HookContext { + event: HookEventType + timestamp: string + session: HookSessionContext + project: HookProjectContext + + /** Tool context - present for tool-related events */ + tool?: HookToolContext + + /** Prompt context - present for UserPromptSubmit */ + prompt?: HookPromptContext + + /** Notification context - present for Notification event */ + notification?: HookNotificationContext + + /** Stop reason - present for Stop event */ + reason?: string + + /** Summary - present for Stop event */ + summary?: string + + /** Conversation history - only if hook opts in via includeConversationHistory */ + conversationHistory?: ConversationHistoryEntry[] +} + +// ============================================================================ +// Hook Execution Types +// ============================================================================ + +/** + * Exit code semantics for hooks. + */ +export enum HookExitCode { + /** Success - continue execution */ + Success = 0, + + /** Block/deny - halt execution (only valid for blocking events) */ + Block = 2, +} + +/** + * Result of executing a single hook. + */ +export interface HookExecutionResult { + /** The hook that was executed */ + hook: ResolvedHook + + /** Exit code from the process */ + exitCode: number | null + + /** stdout output (may contain JSON for modification) */ + stdout: string + + /** stderr output (shown to user on block) */ + stderr: string + + /** Execution duration in milliseconds */ + duration: number + + /** Whether the hook timed out */ + timedOut: boolean + + /** Error if hook failed to execute */ + error?: Error + + /** Parsed modification request from stdout (PreToolUse only) */ + modification?: HookModification +} + +/** + * Modification request from hook stdout (PreToolUse only). + */ +export interface HookModification { + action: "modify" + toolInput: Record +} + +/** + * Schema for hook stdout modification response. + */ +export const HookModificationSchema = z.object({ + action: z.literal("modify"), + toolInput: z.record(z.unknown()), +}) + +/** + * Aggregated result of executing all hooks for an event. + */ +export interface HooksExecutionResult { + /** Results from each hook */ + results: HookExecutionResult[] + + /** Whether any hook blocked execution (exit code 2) */ + blocked: boolean + + /** Block message from stderr if blocked */ + blockMessage?: string + + /** The hook that blocked, if any */ + blockingHook?: ResolvedHook + + /** Tool input modification, if any hook modified it */ + modification?: HookModification + + /** Total execution time for all hooks */ + totalDuration: number +} + +// ============================================================================ +// Hook Manager Interface +// ============================================================================ + +/** + * Execution history entry for debugging/UI. + */ +export interface HookExecution { + /** When the hook was executed */ + timestamp: Date + + /** The hook that was executed */ + hook: ResolvedHook + + /** The event that triggered execution */ + event: HookEventType + + /** Execution result */ + result: HookExecutionResult +} + +/** + * Options for hook execution. + */ +export interface ExecuteHooksOptions { + /** Context to pass to hooks */ + context: HookContext + + /** Conversation history (will be included only for hooks with includeConversationHistory: true) */ + conversationHistory?: ConversationHistoryEntry[] +} + +/** + * Hook manager service interface (per PRD FR5). + */ +export interface IHookManager { + /** Load hooks configuration from all sources */ + loadHooksConfig(): Promise + + /** Explicitly reload hooks configuration */ + reloadHooksConfig(): Promise + + /** Execute all matching hooks for an event */ + executeHooks(event: HookEventType, options: ExecuteHooksOptions): Promise + + /** Get all currently enabled hooks */ + getEnabledHooks(): ResolvedHook[] + + /** Enable or disable a specific hook by ID */ + setHookEnabled(hookId: string, enabled: boolean): Promise + + /** Get execution history for debugging */ + getHookExecutionHistory(): HookExecution[] + + /** Get the current config snapshot (or null if not loaded) */ + getConfigSnapshot(): HooksConfigSnapshot | null +} diff --git a/webview-ui/src/components/settings/HooksSettings.tsx b/webview-ui/src/components/settings/HooksSettings.tsx new file mode 100644 index 0000000000..fee595d2af --- /dev/null +++ b/webview-ui/src/components/settings/HooksSettings.tsx @@ -0,0 +1,334 @@ +import React, { useCallback, useEffect, useState } from "react" +import { RefreshCw, FolderOpen, AlertTriangle, Clock, Zap, X } from "lucide-react" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" +import { Button, StandardTooltip } from "@src/components/ui" +import type { HookInfo, HookExecutionRecord, HookExecutionStatusPayload } from "@roo-code/types" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" + +export const HooksSettings: React.FC = () => { + const { t } = useAppTranslation() + const { hooks } = useExtensionState() + const [executionHistory, setExecutionHistory] = useState(hooks?.executionHistory || []) + + // Listen for realtime hookExecutionStatus messages + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "hookExecutionStatus") { + const payload: HookExecutionStatusPayload = message.hookExecutionStatus + + // Convert realtime status to execution record format when completed/failed + if (payload.status === "completed" || payload.status === "failed" || payload.status === "blocked") { + const record: HookExecutionRecord = { + timestamp: new Date().toISOString(), + hookId: payload.hookId || "unknown", + event: payload.event, + toolName: payload.toolName, + exitCode: payload.status === "completed" ? 0 : 1, + duration: payload.duration || 0, + timedOut: false, + blocked: payload.status === "blocked", + error: payload.error, + blockMessage: payload.blockMessage, + } + + setExecutionHistory((prev) => [record, ...prev].slice(0, 50)) // Keep last 50 + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + // Update local state when extension state changes + useEffect(() => { + if (hooks?.executionHistory) { + setExecutionHistory(hooks.executionHistory) + } + }, [hooks?.executionHistory]) + + const handleReloadConfig = useCallback(() => { + vscode.postMessage({ type: "hooksReloadConfig" }) + }, []) + + const handleOpenConfigFolder = useCallback((source: "global" | "project") => { + vscode.postMessage({ type: "hooksOpenConfigFolder", hooksSource: source }) + }, []) + + const handleToggleHook = useCallback((hookId: string, enabled: boolean) => { + vscode.postMessage({ type: "hooksSetEnabled", hookId, hookEnabled: enabled }) + }, []) + + const enabledHooks = hooks?.enabledHooks || [] + const hasProjectHooks = hooks?.hasProjectHooks || false + const snapshotTimestamp = hooks?.snapshotTimestamp + + return ( +
+ {t("settings:sections.hooks")} + +
+ {/* Header with actions */} +
+
+

{t("settings:hooks.configuredHooks")}

+ {snapshotTimestamp && ( + + + + )} +
+
+ + + + + + +
+
+ + {/* Security warning for project hooks */} + {hasProjectHooks && ( +
+ +
+
{t("settings:hooks.projectHooksWarningTitle")}
+
+ {t("settings:hooks.projectHooksWarningMessage")} +
+
+
+ )} + + {/* Note about edits requiring reload */} +
{t("settings:hooks.reloadNote")}
+ + {/* Hooks list */} + {enabledHooks.length === 0 ? ( +
+ +

{t("settings:hooks.noHooksConfigured")}

+

{t("settings:hooks.noHooksHint")}

+
+ ) : ( +
+ {enabledHooks.map((hook) => ( + + ))} +
+ )} + + {/* Hook Activity Log */} + +
+
+ ) +} + +interface HookItemProps { + hook: HookInfo + onToggle: (hookId: string, enabled: boolean) => void +} + +const HookItem: React.FC = ({ hook, onToggle }) => { + const { t } = useAppTranslation() + + return ( +
+
+
+
+ + {hook.event} + {hook.matcher && ( + <> + + + {hook.matcher} + + + )} + + {hook.source} + +
+ + {hook.description &&

{hook.description}

} + +
+ + {hook.commandPreview} + + {hook.shell && ( + + {t("settings:hooks.shell")}: {hook.shell} + + )} + + {t("settings:hooks.timeout")}: {hook.timeout}s + +
+
+ + +
+
+ ) +} + +interface HookActivityLogProps { + executionHistory: HookExecutionRecord[] +} + +const HookActivityLog: React.FC = ({ executionHistory }) => { + const { t } = useAppTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + if (executionHistory.length === 0) { + return null + } + + return ( +
+ + + {isExpanded && ( +
+ {executionHistory.map((record, index) => ( + + ))} +
+ )} +
+ ) +} + +interface ActivityLogItemProps { + record: HookExecutionRecord +} + +const ActivityLogItem: React.FC = ({ record }) => { + const { t } = useAppTranslation() + + const getStatusDisplay = () => { + if (record.blocked) { + return { + label: t("settings:hooks.status.blocked"), + className: "bg-red-500/20 text-red-500", + icon: , + } + } + if (record.error || record.exitCode !== 0) { + return { + label: t("settings:hooks.status.failed"), + className: "bg-red-500/20 text-red-500", + icon: , + } + } + if (record.timedOut) { + return { + label: t("settings:hooks.status.timeout"), + className: "bg-yellow-500/20 text-yellow-500", + icon: , + } + } + return { + label: t("settings:hooks.status.completed"), + className: "bg-green-500/20 text-green-500", + icon: , + } + } + + const status = getStatusDisplay() + const timestamp = new Date(record.timestamp) + const timeAgo = getTimeAgo(timestamp) + + return ( +
+
+
+ + {status.icon} + {status.label} + + + {record.event} + {record.toolName && ` (${record.toolName})`} + +
+
+ {record.duration}ms + + {timeAgo} + +
+
+ + {(record.error || record.blockMessage) && ( +
+ {record.blockMessage || record.error} +
+ )} +
+ ) +} + +function getTimeAgo(date: Date): string { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000) + + if (seconds < 60) return `${seconds}s ago` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` + return `${Math.floor(seconds / 86400)}d ago` +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 4f7499a1b1..243556f693 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -28,6 +28,7 @@ import { Server, Users2, ArrowLeft, + Zap, } from "lucide-react" import { @@ -79,6 +80,7 @@ import { SlashCommandsSettings } from "./SlashCommandsSettings" import { UISettings } from "./UISettings" import ModesView from "../modes/ModesView" import McpView from "../mcp/McpView" +import { HooksSettings } from "./HooksSettings" import { SettingsSearch } from "./SettingsSearch" import { useSearchIndexRegistry, SearchIndexProvider } from "./useSettingsSearch" @@ -104,6 +106,7 @@ export const sectionNames = [ "terminal", "modes", "mcp", + "hooks", "prompts", "ui", "experimental", @@ -530,6 +533,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "notifications", icon: Bell }, { id: "contextManagement", icon: Database }, { id: "terminal", icon: SquareTerminal }, + { id: "hooks", icon: Zap }, { id: "prompts", icon: MessageSquare }, { id: "ui", icon: Glasses }, { id: "experimental", icon: FlaskConical }, @@ -880,6 +884,9 @@ const SettingsView = forwardRef(({ onDone, t {/* MCP Section */} {renderTab === "mcp" && } + {/* Hooks Section */} + {renderTab === "hooks" && } + {/* Prompts Section */} {renderTab === "prompts" && ( ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock the translation hook +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, params?: any) => { + // Simple mock that returns translation keys + if (params) { + return key.replace(/\{\{(\w+)\}\}/g, (_, k) => params[k] || "") + } + return key + }, + }), +})) + +// Mock the ExtensionStateContext +const mockHooksState: HooksState = { + enabledHooks: [], + executionHistory: [], + hasProjectHooks: false, + snapshotTimestamp: undefined, +} + +let currentHooksState = mockHooksState + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + hooks: currentHooksState, + }), +})) + +// Mock UI components +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), + StandardTooltip: ({ children, content }: any) =>
{children}
, +})) + +// Mock Section components +vi.mock("../SectionHeader", () => ({ + SectionHeader: ({ children }: any) =>

{children}

, +})) + +vi.mock("../Section", () => ({ + Section: ({ children }: any) =>
{children}
, +})) + +describe("HooksSettings", () => { + beforeEach(async () => { + vi.clearAllMocks() + // Reset to default state + currentHooksState = { + enabledHooks: [], + executionHistory: [], + hasProjectHooks: false, + snapshotTimestamp: undefined, + } + // Get fresh reference to mocked vscode + const { vscode } = await import("@src/utils/vscode") + vi.mocked(vscode.postMessage).mockClear() + }) + + it("renders with no hooks configured", () => { + render() + + expect(screen.getByText("settings:sections.hooks")).toBeInTheDocument() + expect(screen.getByText("settings:hooks.noHooksConfigured")).toBeInTheDocument() + expect(screen.getByText("settings:hooks.noHooksHint")).toBeInTheDocument() + }) + + it("renders hooks list when hooks are configured", () => { + const mockHook: HookInfo = { + id: "hook-1", + event: "before_execute_command", + matcher: "git*", + commandPreview: "echo 'Before git command'", + enabled: true, + source: "project", + timeout: 30, + description: "Test hook", + } + + currentHooksState = { + enabledHooks: [mockHook], + executionHistory: [], + hasProjectHooks: true, + snapshotTimestamp: new Date().toISOString(), + } + + render() + + expect(screen.getByText(mockHook.event)).toBeInTheDocument() + expect(screen.getByText(mockHook.matcher!)).toBeInTheDocument() + expect(screen.getByText(mockHook.commandPreview)).toBeInTheDocument() + }) + + it("shows project hooks warning when hasProjectHooks is true", () => { + currentHooksState = { + ...mockHooksState, + hasProjectHooks: true, + } + + render() + + expect(screen.getByText("settings:hooks.projectHooksWarningTitle")).toBeInTheDocument() + expect(screen.getByText("settings:hooks.projectHooksWarningMessage")).toBeInTheDocument() + }) + + it("sends hooksReloadConfig message when Reload button is clicked", async () => { + const { vscode } = await import("@src/utils/vscode") + render() + + const reloadButton = screen.getByText("settings:hooks.reload") + fireEvent.click(reloadButton) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "hooksReloadConfig" }) + }) + + it("sends hooksOpenConfigFolder message when Open Folder button is clicked", async () => { + const { vscode } = await import("@src/utils/vscode") + currentHooksState = { + ...mockHooksState, + hasProjectHooks: true, + } + + render() + + const openFolderButton = screen.getByText("settings:hooks.openProjectFolder") + fireEvent.click(openFolderButton) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "hooksOpenConfigFolder", + hooksSource: "project", + }) + }) + + it("sends hooksSetEnabled message when hook toggle is changed", async () => { + const { vscode } = await import("@src/utils/vscode") + const mockHook: HookInfo = { + id: "hook-1", + event: "before_execute_command", + commandPreview: "echo test", + enabled: true, + source: "global", + timeout: 30, + } + + currentHooksState = { + enabledHooks: [mockHook], + executionHistory: [], + hasProjectHooks: false, + } + + render() + + const checkbox = screen.getByRole("checkbox") + fireEvent.click(checkbox) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "hooksSetEnabled", + hookId: "hook-1", + hookEnabled: false, + }) + }) + + it("renders execution history when available", () => { + const mockRecord: HookExecutionRecord = { + timestamp: new Date().toISOString(), + hookId: "hook-1", + event: "before_execute_command", + exitCode: 0, + duration: 150, + timedOut: false, + blocked: false, + } + + currentHooksState = { + enabledHooks: [], + executionHistory: [mockRecord], + hasProjectHooks: false, + } + + render() + + // Activity log should be present + expect(screen.getByText(/settings:hooks.activityLog/)).toBeInTheDocument() + }) + + it("updates execution history on realtime hookExecutionStatus message", async () => { + render() + + // Simulate receiving a hookExecutionStatus message + const event = new MessageEvent("message", { + data: { + type: "hookExecutionStatus", + hookExecutionStatus: { + status: "completed", + event: "before_execute_command", + hookId: "hook-1", + duration: 200, + }, + }, + }) + + window.dispatchEvent(event) + + // Wait for state update + await waitFor(() => { + // The activity log should now show the new execution + const activityLog = screen.getByText(/settings:hooks.activityLog/) + expect(activityLog).toBeInTheDocument() + }) + }) + + it("expands and collapses activity log on click", () => { + const mockRecord: HookExecutionRecord = { + timestamp: new Date().toISOString(), + hookId: "hook-1", + event: "before_execute_command", + exitCode: 0, + duration: 150, + timedOut: false, + blocked: false, + } + + currentHooksState = { + enabledHooks: [], + executionHistory: [mockRecord], + hasProjectHooks: false, + } + + render() + + const activityLogButton = screen.getByText(/settings:hooks.activityLog/) + + // Should start collapsed + expect(screen.queryByText(mockRecord.event)).not.toBeInTheDocument() + + // Click to expand + fireEvent.click(activityLogButton) + + // Should now show the record + expect(screen.getByText(mockRecord.event)).toBeInTheDocument() + }) + + it("displays different hook sources with appropriate styling", () => { + const hooks: HookInfo[] = [ + { + id: "hook-1", + event: "event1", + commandPreview: "cmd1", + enabled: true, + source: "project", + timeout: 30, + }, + { + id: "hook-2", + event: "event2", + commandPreview: "cmd2", + enabled: true, + source: "mode", + timeout: 30, + }, + { + id: "hook-3", + event: "event3", + commandPreview: "cmd3", + enabled: true, + source: "global", + timeout: 30, + }, + ] + + currentHooksState = { + enabledHooks: hooks, + executionHistory: [], + hasProjectHooks: false, + } + + render() + + // All three source types should be present + expect(screen.getByText("project")).toBeInTheDocument() + expect(screen.getByText("mode")).toBeInTheDocument() + expect(screen.getByText("global")).toBeInTheDocument() + }) + + it("displays activity log status pills correctly", () => { + const records: HookExecutionRecord[] = [ + { + timestamp: new Date().toISOString(), + hookId: "hook-1", + event: "event1", + exitCode: 0, + duration: 100, + timedOut: false, + blocked: false, + }, + { + timestamp: new Date().toISOString(), + hookId: "hook-2", + event: "event2", + exitCode: 1, + duration: 200, + timedOut: false, + blocked: false, + error: "Command failed", + }, + { + timestamp: new Date().toISOString(), + hookId: "hook-3", + event: "event3", + exitCode: null, + duration: 300, + timedOut: false, + blocked: true, + blockMessage: "Operation blocked", + }, + ] + + currentHooksState = { + enabledHooks: [], + executionHistory: records, + hasProjectHooks: false, + } + + render() + + // Expand activity log + const activityLogButton = screen.getByText(/settings:hooks.activityLog/) + fireEvent.click(activityLogButton) + + // Check for status labels + expect(screen.getByText("settings:hooks.status.completed")).toBeInTheDocument() + expect(screen.getByText("settings:hooks.status.failed")).toBeInTheDocument() + expect(screen.getByText("settings:hooks.status.blocked")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index fc64ad1851..a175bbefc1 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -36,12 +36,39 @@ "contextManagement": "Context", "terminal": "Terminal", "slashCommands": "Slash Commands", + "hooks": "Hooks", "prompts": "Prompts", "ui": "UI", "experimental": "Experimental", "language": "Language", "about": "About Roo Code" }, + "hooks": { + "configuredHooks": "Configured Hooks", + "lastLoadedTooltip": "Last loaded at {{time}}", + "reloadTooltip": "Reload hooks configuration from disk", + "reload": "Reload", + "openProjectFolderTooltip": "Open project hooks configuration folder", + "openGlobalFolderTooltip": "Open global hooks configuration folder", + "openProjectFolder": "Open Project Folder", + "openGlobalFolder": "Open Global Folder", + "projectHooksWarningTitle": "Project-level hooks detected", + "projectHooksWarningMessage": "This project includes hook configurations that will execute shell commands. Only enable hooks from sources you trust.", + "reloadNote": "Changes to hook configuration files require clicking Reload to take effect.", + "noHooksConfigured": "No hooks configured", + "noHooksHint": "Create hook configuration files to automate actions on tool execution events.", + "enabled": "Enabled", + "shell": "Shell", + "timeout": "Timeout", + "activityLog": "Hook Activity", + "status": { + "running": "Running", + "completed": "Completed", + "failed": "Failed", + "blocked": "Blocked", + "timeout": "Timeout" + } + }, "about": { "bugReport": { "label": "Found a bug?",