import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' import type z from 'zod/v4' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' import type { HookProgress } from '../../types/hooks.js' import type { AssistantMessage, AttachmentMessage, ProgressMessage, } from '../../types/message.js' import type { PermissionDecision } from '../../types/permissions.js' import { createAttachmentMessage } from '../../utils/attachments.js' import { logForDebugging } from '../../utils/debug.js' import { executePostToolHooks, executePostToolUseFailureHooks, executePreToolHooks, getPreToolHookBlockingMessage, } from '../../utils/hooks.js' import { logError } from '../../utils/log.js' import { getRuleBehaviorDescription, type PermissionDecisionReason, type PermissionResult, } from '../../utils/permissions/PermissionResult.js' import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' import { formatError } from '../../utils/toolErrors.js' import { isMcpTool } from '../mcp/utils.js' import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' export type PostToolUseHooksResult = | MessageUpdateLazy> | { updatedMCPToolOutput: Output } export async function* runPostToolUseHooks( toolUseContext: ToolUseContext, tool: Tool, toolUseID: string, messageId: string, toolInput: Record, toolResponse: Output, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: string | undefined, ): AsyncGenerator> { const postToolStartTime = Date.now() try { const appState = toolUseContext.getAppState() const permissionMode = appState.toolPermissionContext.mode let toolOutput = toolResponse for await (const result of executePostToolHooks( tool.name, toolUseID, toolInput, toolOutput, toolUseContext, permissionMode, toolUseContext.abortController.signal, )) { try { // Check if we were aborted during hook execution // IMPORTANT: We emit a cancelled event per hook if ( result.message?.type === 'attachment' && result.message.attachment.type === 'hook_cancelled' ) { logEvent('tengu_post_tool_hooks_cancelled', { toolName: sanitizeToolNameForAnalytics(tool.name), queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, }) yield { message: createAttachmentMessage({ type: 'hook_cancelled', hookName: `PostToolUse:${tool.name}`, toolUseID, hookEvent: 'PostToolUse', }), } continue } // For JSON {decision:"block"} hooks, executeHooks yields two results: // {blockingError} and {message: hook_blocking_error attachment}. The // blockingError path below creates that same attachment, so skip it // here to avoid displaying the block reason twice (#31301). The // exit-code-2 path only yields {blockingError}, so it's unaffected. if ( result.message && !( result.message.type === 'attachment' && result.message.attachment.type === 'hook_blocking_error' ) ) { yield { message: result.message } } if (result.blockingError) { yield { message: createAttachmentMessage({ type: 'hook_blocking_error', hookName: `PostToolUse:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PostToolUse', blockingError: result.blockingError, }), } } // If hook indicated to prevent continuation, yield a stop reason message if (result.preventContinuation) { yield { message: createAttachmentMessage({ type: 'hook_stopped_continuation', message: result.stopReason || 'Execution stopped by PostToolUse hook', hookName: `PostToolUse:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PostToolUse', }), } return } // If hooks provided additional context, add it as a message if (result.additionalContexts && result.additionalContexts.length > 0) { yield { message: createAttachmentMessage({ type: 'hook_additional_context', content: result.additionalContexts, hookName: `PostToolUse:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PostToolUse', }), } } // If hooks provided updatedMCPToolOutput, yield it if this is an MCP tool if (result.updatedMCPToolOutput && isMcpTool(tool)) { toolOutput = result.updatedMCPToolOutput as Output yield { updatedMCPToolOutput: toolOutput, } } } catch (error) { const postToolDurationMs = Date.now() - postToolStartTime logEvent('tengu_post_tool_hook_error', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false, duration: postToolDurationMs, queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType ? { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } : {}), ...(requestId ? { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } : {}), }) yield { message: createAttachmentMessage({ type: 'hook_error_during_execution', content: formatError(error), hookName: `PostToolUse:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PostToolUse', }), } } } } catch (error) { logError(error) } } export async function* runPostToolUseFailureHooks( toolUseContext: ToolUseContext, tool: Tool, toolUseID: string, messageId: string, processedInput: z.infer, error: string, isInterrupt: boolean | undefined, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: string | undefined, ): AsyncGenerator< MessageUpdateLazy> > { const postToolStartTime = Date.now() try { const appState = toolUseContext.getAppState() const permissionMode = appState.toolPermissionContext.mode for await (const result of executePostToolUseFailureHooks( tool.name, toolUseID, processedInput, error, toolUseContext, isInterrupt, permissionMode, toolUseContext.abortController.signal, )) { try { // Check if we were aborted during hook execution if ( result.message?.type === 'attachment' && result.message.attachment.type === 'hook_cancelled' ) { logEvent('tengu_post_tool_failure_hooks_cancelled', { toolName: sanitizeToolNameForAnalytics(tool.name), queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, }) yield { message: createAttachmentMessage({ type: 'hook_cancelled', hookName: `PostToolUseFailure:${tool.name}`, toolUseID, hookEvent: 'PostToolUseFailure', }), } continue } // Skip hook_blocking_error in result.message — blockingError path // below creates the same attachment (see #31301 / PostToolUse above). if ( result.message && !( result.message.type === 'attachment' && result.message.attachment.type === 'hook_blocking_error' ) ) { yield { message: result.message } } if (result.blockingError) { yield { message: createAttachmentMessage({ type: 'hook_blocking_error', hookName: `PostToolUseFailure:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PostToolUseFailure', blockingError: result.blockingError, }), } } // If hooks provided additional context, add it as a message if (result.additionalContexts && result.additionalContexts.length > 0) { yield { message: createAttachmentMessage({ type: 'hook_additional_context', content: result.additionalContexts, hookName: `PostToolUseFailure:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PostToolUseFailure', }), } } } catch (hookError) { const postToolDurationMs = Date.now() - postToolStartTime logEvent('tengu_post_tool_failure_hook_error', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false, duration: postToolDurationMs, queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType ? { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } : {}), ...(requestId ? { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } : {}), }) yield { message: createAttachmentMessage({ type: 'hook_error_during_execution', content: formatError(hookError), hookName: `PostToolUseFailure:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PostToolUseFailure', }), } } } } catch (outerError) { logError(outerError) } } /** * Resolve a PreToolUse hook's permission result into a final PermissionDecision. * * Encapsulates the invariant that hook 'allow' does NOT bypass settings.json * deny/ask rules — checkRuleBasedPermissions still applies (inc-4788 analog). * Also handles the requiresUserInteraction/requireCanUseTool guards and the * 'ask' forceDecision passthrough. * * Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts * (REPL inner calls) so the permission semantics stay in lockstep. */ export async function resolveHookPermissionDecision( hookPermissionResult: PermissionResult | undefined, tool: Tool, input: Record, toolUseContext: ToolUseContext, canUseTool: CanUseToolFn, assistantMessage: AssistantMessage, toolUseID: string, ): Promise<{ decision: PermissionDecision input: Record }> { const requiresInteraction = tool.requiresUserInteraction?.() const requireCanUseTool = toolUseContext.requireCanUseTool if (hookPermissionResult?.behavior === 'allow') { const hookInput = hookPermissionResult.updatedInput ?? input // Hook provided updatedInput for an interactive tool — the hook IS the // user interaction (e.g. headless wrapper that collected AskUserQuestion // answers). Treat as non-interactive for the rule-check path. const interactionSatisfied = requiresInteraction && hookPermissionResult.updatedInput !== undefined if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) { logForDebugging( `Hook approved tool use for ${tool.name}, but canUseTool is required`, ) return { decision: await canUseTool( tool, hookInput, toolUseContext, assistantMessage, toolUseID, ), input: hookInput, } } // Hook allow skips the interactive prompt, but deny/ask rules still apply. const ruleCheck = await checkRuleBasedPermissions( tool, hookInput, toolUseContext, ) if (ruleCheck === null) { logForDebugging( interactionSatisfied ? `Hook satisfied user interaction for ${tool.name} via updatedInput` : `Hook approved tool use for ${tool.name}, bypassing permission prompt`, ) return { decision: hookPermissionResult, input: hookInput } } if (ruleCheck.behavior === 'deny') { logForDebugging( `Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`, ) return { decision: ruleCheck, input: hookInput } } // ask rule — dialog required despite hook approval logForDebugging( `Hook approved tool use for ${tool.name}, but ask rule requires prompt`, ) return { decision: await canUseTool( tool, hookInput, toolUseContext, assistantMessage, toolUseID, ), input: hookInput, } } if (hookPermissionResult?.behavior === 'deny') { logForDebugging(`Hook denied tool use for ${tool.name}`) return { decision: hookPermissionResult, input } } // No hook decision or 'ask' — normal permission flow, possibly with // forceDecision so the dialog shows the hook's ask message. const forceDecision = hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined const askInput = hookPermissionResult?.behavior === 'ask' && hookPermissionResult.updatedInput ? hookPermissionResult.updatedInput : input return { decision: await canUseTool( tool, askInput, toolUseContext, assistantMessage, toolUseID, forceDecision, ), input: askInput, } } export async function* runPreToolUseHooks( toolUseContext: ToolUseContext, tool: Tool, processedInput: Record, toolUseID: string, messageId: string, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: string | undefined, ): AsyncGenerator< | { type: 'message' message: MessageUpdateLazy< AttachmentMessage | ProgressMessage > } | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } | { type: 'hookUpdatedInput'; updatedInput: Record } | { type: 'preventContinuation'; shouldPreventContinuation: boolean } | { type: 'stopReason'; stopReason: string } | { type: 'additionalContext' message: MessageUpdateLazy } // stop execution | { type: 'stop' } > { const hookStartTime = Date.now() try { const appState = toolUseContext.getAppState() for await (const result of executePreToolHooks( tool.name, toolUseID, processedInput, toolUseContext, appState.toolPermissionContext.mode, toolUseContext.abortController.signal, undefined, // timeoutMs - use default toolUseContext.requestPrompt, tool.getToolUseSummary?.(processedInput), )) { try { if (result.message) { yield { type: 'message', message: { message: result.message } } } if (result.blockingError) { const denialMessage = getPreToolHookBlockingMessage( `PreToolUse:${tool.name}`, result.blockingError, ) yield { type: 'hookPermissionResult', hookPermissionResult: { behavior: 'deny', message: denialMessage, decisionReason: { type: 'hook', hookName: `PreToolUse:${tool.name}`, reason: denialMessage, }, }, } } // Check if hook wants to prevent continuation if (result.preventContinuation) { yield { type: 'preventContinuation', shouldPreventContinuation: true, } if (result.stopReason) { yield { type: 'stopReason', stopReason: result.stopReason } } } // Check for hook-defined permission behavior if (result.permissionBehavior !== undefined) { logForDebugging( `Hook result has permissionBehavior=${result.permissionBehavior}`, ) const decisionReason: PermissionDecisionReason = { type: 'hook', hookName: `PreToolUse:${tool.name}`, hookSource: result.hookSource, reason: result.hookPermissionDecisionReason, } if (result.permissionBehavior === 'allow') { yield { type: 'hookPermissionResult', hookPermissionResult: { behavior: 'allow', updatedInput: result.updatedInput, decisionReason, }, } } else if (result.permissionBehavior === 'ask') { yield { type: 'hookPermissionResult', hookPermissionResult: { behavior: 'ask', updatedInput: result.updatedInput, message: result.hookPermissionDecisionReason || `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, decisionReason, }, } } else { // deny - updatedInput is irrelevant since tool won't run yield { type: 'hookPermissionResult', hookPermissionResult: { behavior: result.permissionBehavior, message: result.hookPermissionDecisionReason || `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, decisionReason, }, } } } // Yield updatedInput for passthrough case (no permission decision) // This allows hooks to modify input while letting normal permission flow continue if (result.updatedInput && result.permissionBehavior === undefined) { yield { type: 'hookUpdatedInput', updatedInput: result.updatedInput, } } // If hooks provided additional context, add it as a message if (result.additionalContexts && result.additionalContexts.length > 0) { yield { type: 'additionalContext', message: { message: createAttachmentMessage({ type: 'hook_additional_context', content: result.additionalContexts, hookName: `PreToolUse:${tool.name}`, toolUseID, hookEvent: 'PreToolUse', }), }, } } // Check if we were aborted during hook execution if (toolUseContext.abortController.signal.aborted) { logEvent('tengu_pre_tool_hooks_cancelled', { toolName: sanitizeToolNameForAnalytics(tool.name), queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, }) yield { type: 'message', message: { message: createAttachmentMessage({ type: 'hook_cancelled', hookName: `PreToolUse:${tool.name}`, toolUseID, hookEvent: 'PreToolUse', }), }, } yield { type: 'stop' } return } } catch (error) { logError(error) const durationMs = Date.now() - hookStartTime logEvent('tengu_pre_tool_hook_error', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false, duration: durationMs, queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType ? { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } : {}), ...(requestId ? { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } : {}), }) yield { type: 'message', message: { message: createAttachmentMessage({ type: 'hook_error_during_execution', content: formatError(error), hookName: `PreToolUse:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PreToolUse', }), }, } yield { type: 'stop' } } } } catch (error) { logError(error) yield { type: 'stop' } return } }