/ services / tools / toolHooks.ts
toolHooks.ts
  1  import {
  2    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  3    logEvent,
  4  } from 'src/services/analytics/index.js'
  5  import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
  6  import type z from 'zod/v4'
  7  import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
  8  import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js'
  9  import type { HookProgress } from '../../types/hooks.js'
 10  import type {
 11    AssistantMessage,
 12    AttachmentMessage,
 13    ProgressMessage,
 14  } from '../../types/message.js'
 15  import type { PermissionDecision } from '../../types/permissions.js'
 16  import { createAttachmentMessage } from '../../utils/attachments.js'
 17  import { logForDebugging } from '../../utils/debug.js'
 18  import {
 19    executePostToolHooks,
 20    executePostToolUseFailureHooks,
 21    executePreToolHooks,
 22    getPreToolHookBlockingMessage,
 23  } from '../../utils/hooks.js'
 24  import { logError } from '../../utils/log.js'
 25  import {
 26    getRuleBehaviorDescription,
 27    type PermissionDecisionReason,
 28    type PermissionResult,
 29  } from '../../utils/permissions/PermissionResult.js'
 30  import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js'
 31  import { formatError } from '../../utils/toolErrors.js'
 32  import { isMcpTool } from '../mcp/utils.js'
 33  import type { McpServerType, MessageUpdateLazy } from './toolExecution.js'
 34  
 35  export type PostToolUseHooksResult<Output> =
 36    | MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>>
 37    | { updatedMCPToolOutput: Output }
 38  
 39  export async function* runPostToolUseHooks<Input extends AnyObject, Output>(
 40    toolUseContext: ToolUseContext,
 41    tool: Tool<Input, Output>,
 42    toolUseID: string,
 43    messageId: string,
 44    toolInput: Record<string, unknown>,
 45    toolResponse: Output,
 46    requestId: string | undefined,
 47    mcpServerType: McpServerType,
 48    mcpServerBaseUrl: string | undefined,
 49  ): AsyncGenerator<PostToolUseHooksResult<Output>> {
 50    const postToolStartTime = Date.now()
 51    try {
 52      const appState = toolUseContext.getAppState()
 53      const permissionMode = appState.toolPermissionContext.mode
 54  
 55      let toolOutput = toolResponse
 56      for await (const result of executePostToolHooks(
 57        tool.name,
 58        toolUseID,
 59        toolInput,
 60        toolOutput,
 61        toolUseContext,
 62        permissionMode,
 63        toolUseContext.abortController.signal,
 64      )) {
 65        try {
 66          // Check if we were aborted during hook execution
 67          // IMPORTANT: We emit a cancelled event per hook
 68          if (
 69            result.message?.type === 'attachment' &&
 70            result.message.attachment.type === 'hook_cancelled'
 71          ) {
 72            logEvent('tengu_post_tool_hooks_cancelled', {
 73              toolName: sanitizeToolNameForAnalytics(tool.name),
 74  
 75              queryChainId: toolUseContext.queryTracking
 76                ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 77              queryDepth: toolUseContext.queryTracking?.depth,
 78            })
 79            yield {
 80              message: createAttachmentMessage({
 81                type: 'hook_cancelled',
 82                hookName: `PostToolUse:${tool.name}`,
 83                toolUseID,
 84                hookEvent: 'PostToolUse',
 85              }),
 86            }
 87            continue
 88          }
 89  
 90          // For JSON {decision:"block"} hooks, executeHooks yields two results:
 91          // {blockingError} and {message: hook_blocking_error attachment}. The
 92          // blockingError path below creates that same attachment, so skip it
 93          // here to avoid displaying the block reason twice (#31301). The
 94          // exit-code-2 path only yields {blockingError}, so it's unaffected.
 95          if (
 96            result.message &&
 97            !(
 98              result.message.type === 'attachment' &&
 99              result.message.attachment.type === 'hook_blocking_error'
100            )
101          ) {
102            yield { message: result.message }
103          }
104  
105          if (result.blockingError) {
106            yield {
107              message: createAttachmentMessage({
108                type: 'hook_blocking_error',
109                hookName: `PostToolUse:${tool.name}`,
110                toolUseID: toolUseID,
111                hookEvent: 'PostToolUse',
112                blockingError: result.blockingError,
113              }),
114            }
115          }
116  
117          // If hook indicated to prevent continuation, yield a stop reason message
118          if (result.preventContinuation) {
119            yield {
120              message: createAttachmentMessage({
121                type: 'hook_stopped_continuation',
122                message:
123                  result.stopReason || 'Execution stopped by PostToolUse hook',
124                hookName: `PostToolUse:${tool.name}`,
125                toolUseID: toolUseID,
126                hookEvent: 'PostToolUse',
127              }),
128            }
129            return
130          }
131  
132          // If hooks provided additional context, add it as a message
133          if (result.additionalContexts && result.additionalContexts.length > 0) {
134            yield {
135              message: createAttachmentMessage({
136                type: 'hook_additional_context',
137                content: result.additionalContexts,
138                hookName: `PostToolUse:${tool.name}`,
139                toolUseID: toolUseID,
140                hookEvent: 'PostToolUse',
141              }),
142            }
143          }
144  
145          // If hooks provided updatedMCPToolOutput, yield it if this is an MCP tool
146          if (result.updatedMCPToolOutput && isMcpTool(tool)) {
147            toolOutput = result.updatedMCPToolOutput as Output
148            yield {
149              updatedMCPToolOutput: toolOutput,
150            }
151          }
152        } catch (error) {
153          const postToolDurationMs = Date.now() - postToolStartTime
154          logEvent('tengu_post_tool_hook_error', {
155            messageID:
156              messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
157            toolName: sanitizeToolNameForAnalytics(tool.name),
158            isMcp: tool.isMcp ?? false,
159            duration: postToolDurationMs,
160  
161            queryChainId: toolUseContext.queryTracking
162              ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
163            queryDepth: toolUseContext.queryTracking?.depth,
164            ...(mcpServerType
165              ? {
166                  mcpServerType:
167                    mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
168                }
169              : {}),
170            ...(requestId
171              ? {
172                  requestId:
173                    requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
174                }
175              : {}),
176          })
177          yield {
178            message: createAttachmentMessage({
179              type: 'hook_error_during_execution',
180              content: formatError(error),
181              hookName: `PostToolUse:${tool.name}`,
182              toolUseID: toolUseID,
183              hookEvent: 'PostToolUse',
184            }),
185          }
186        }
187      }
188    } catch (error) {
189      logError(error)
190    }
191  }
192  
193  export async function* runPostToolUseFailureHooks<Input extends AnyObject>(
194    toolUseContext: ToolUseContext,
195    tool: Tool<Input, unknown>,
196    toolUseID: string,
197    messageId: string,
198    processedInput: z.infer<Input>,
199    error: string,
200    isInterrupt: boolean | undefined,
201    requestId: string | undefined,
202    mcpServerType: McpServerType,
203    mcpServerBaseUrl: string | undefined,
204  ): AsyncGenerator<
205    MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>>
206  > {
207    const postToolStartTime = Date.now()
208    try {
209      const appState = toolUseContext.getAppState()
210      const permissionMode = appState.toolPermissionContext.mode
211  
212      for await (const result of executePostToolUseFailureHooks(
213        tool.name,
214        toolUseID,
215        processedInput,
216        error,
217        toolUseContext,
218        isInterrupt,
219        permissionMode,
220        toolUseContext.abortController.signal,
221      )) {
222        try {
223          // Check if we were aborted during hook execution
224          if (
225            result.message?.type === 'attachment' &&
226            result.message.attachment.type === 'hook_cancelled'
227          ) {
228            logEvent('tengu_post_tool_failure_hooks_cancelled', {
229              toolName: sanitizeToolNameForAnalytics(tool.name),
230              queryChainId: toolUseContext.queryTracking
231                ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
232              queryDepth: toolUseContext.queryTracking?.depth,
233            })
234            yield {
235              message: createAttachmentMessage({
236                type: 'hook_cancelled',
237                hookName: `PostToolUseFailure:${tool.name}`,
238                toolUseID,
239                hookEvent: 'PostToolUseFailure',
240              }),
241            }
242            continue
243          }
244  
245          // Skip hook_blocking_error in result.message — blockingError path
246          // below creates the same attachment (see #31301 / PostToolUse above).
247          if (
248            result.message &&
249            !(
250              result.message.type === 'attachment' &&
251              result.message.attachment.type === 'hook_blocking_error'
252            )
253          ) {
254            yield { message: result.message }
255          }
256  
257          if (result.blockingError) {
258            yield {
259              message: createAttachmentMessage({
260                type: 'hook_blocking_error',
261                hookName: `PostToolUseFailure:${tool.name}`,
262                toolUseID: toolUseID,
263                hookEvent: 'PostToolUseFailure',
264                blockingError: result.blockingError,
265              }),
266            }
267          }
268  
269          // If hooks provided additional context, add it as a message
270          if (result.additionalContexts && result.additionalContexts.length > 0) {
271            yield {
272              message: createAttachmentMessage({
273                type: 'hook_additional_context',
274                content: result.additionalContexts,
275                hookName: `PostToolUseFailure:${tool.name}`,
276                toolUseID: toolUseID,
277                hookEvent: 'PostToolUseFailure',
278              }),
279            }
280          }
281        } catch (hookError) {
282          const postToolDurationMs = Date.now() - postToolStartTime
283          logEvent('tengu_post_tool_failure_hook_error', {
284            messageID:
285              messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
286            toolName: sanitizeToolNameForAnalytics(tool.name),
287            isMcp: tool.isMcp ?? false,
288            duration: postToolDurationMs,
289            queryChainId: toolUseContext.queryTracking
290              ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
291            queryDepth: toolUseContext.queryTracking?.depth,
292            ...(mcpServerType
293              ? {
294                  mcpServerType:
295                    mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
296                }
297              : {}),
298            ...(requestId
299              ? {
300                  requestId:
301                    requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
302                }
303              : {}),
304          })
305          yield {
306            message: createAttachmentMessage({
307              type: 'hook_error_during_execution',
308              content: formatError(hookError),
309              hookName: `PostToolUseFailure:${tool.name}`,
310              toolUseID: toolUseID,
311              hookEvent: 'PostToolUseFailure',
312            }),
313          }
314        }
315      }
316    } catch (outerError) {
317      logError(outerError)
318    }
319  }
320  
321  /**
322   * Resolve a PreToolUse hook's permission result into a final PermissionDecision.
323   *
324   * Encapsulates the invariant that hook 'allow' does NOT bypass settings.json
325   * deny/ask rules — checkRuleBasedPermissions still applies (inc-4788 analog).
326   * Also handles the requiresUserInteraction/requireCanUseTool guards and the
327   * 'ask' forceDecision passthrough.
328   *
329   * Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts
330   * (REPL inner calls) so the permission semantics stay in lockstep.
331   */
332  export async function resolveHookPermissionDecision(
333    hookPermissionResult: PermissionResult | undefined,
334    tool: Tool,
335    input: Record<string, unknown>,
336    toolUseContext: ToolUseContext,
337    canUseTool: CanUseToolFn,
338    assistantMessage: AssistantMessage,
339    toolUseID: string,
340  ): Promise<{
341    decision: PermissionDecision
342    input: Record<string, unknown>
343  }> {
344    const requiresInteraction = tool.requiresUserInteraction?.()
345    const requireCanUseTool = toolUseContext.requireCanUseTool
346  
347    if (hookPermissionResult?.behavior === 'allow') {
348      const hookInput = hookPermissionResult.updatedInput ?? input
349  
350      // Hook provided updatedInput for an interactive tool — the hook IS the
351      // user interaction (e.g. headless wrapper that collected AskUserQuestion
352      // answers). Treat as non-interactive for the rule-check path.
353      const interactionSatisfied =
354        requiresInteraction && hookPermissionResult.updatedInput !== undefined
355  
356      if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) {
357        logForDebugging(
358          `Hook approved tool use for ${tool.name}, but canUseTool is required`,
359        )
360        return {
361          decision: await canUseTool(
362            tool,
363            hookInput,
364            toolUseContext,
365            assistantMessage,
366            toolUseID,
367          ),
368          input: hookInput,
369        }
370      }
371  
372      // Hook allow skips the interactive prompt, but deny/ask rules still apply.
373      const ruleCheck = await checkRuleBasedPermissions(
374        tool,
375        hookInput,
376        toolUseContext,
377      )
378      if (ruleCheck === null) {
379        logForDebugging(
380          interactionSatisfied
381            ? `Hook satisfied user interaction for ${tool.name} via updatedInput`
382            : `Hook approved tool use for ${tool.name}, bypassing permission prompt`,
383        )
384        return { decision: hookPermissionResult, input: hookInput }
385      }
386      if (ruleCheck.behavior === 'deny') {
387        logForDebugging(
388          `Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`,
389        )
390        return { decision: ruleCheck, input: hookInput }
391      }
392      // ask rule — dialog required despite hook approval
393      logForDebugging(
394        `Hook approved tool use for ${tool.name}, but ask rule requires prompt`,
395      )
396      return {
397        decision: await canUseTool(
398          tool,
399          hookInput,
400          toolUseContext,
401          assistantMessage,
402          toolUseID,
403        ),
404        input: hookInput,
405      }
406    }
407  
408    if (hookPermissionResult?.behavior === 'deny') {
409      logForDebugging(`Hook denied tool use for ${tool.name}`)
410      return { decision: hookPermissionResult, input }
411    }
412  
413    // No hook decision or 'ask' — normal permission flow, possibly with
414    // forceDecision so the dialog shows the hook's ask message.
415    const forceDecision =
416      hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined
417    const askInput =
418      hookPermissionResult?.behavior === 'ask' &&
419      hookPermissionResult.updatedInput
420        ? hookPermissionResult.updatedInput
421        : input
422    return {
423      decision: await canUseTool(
424        tool,
425        askInput,
426        toolUseContext,
427        assistantMessage,
428        toolUseID,
429        forceDecision,
430      ),
431      input: askInput,
432    }
433  }
434  
435  export async function* runPreToolUseHooks(
436    toolUseContext: ToolUseContext,
437    tool: Tool,
438    processedInput: Record<string, unknown>,
439    toolUseID: string,
440    messageId: string,
441    requestId: string | undefined,
442    mcpServerType: McpServerType,
443    mcpServerBaseUrl: string | undefined,
444  ): AsyncGenerator<
445    | {
446        type: 'message'
447        message: MessageUpdateLazy<
448          AttachmentMessage | ProgressMessage<HookProgress>
449        >
450      }
451    | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult }
452    | { type: 'hookUpdatedInput'; updatedInput: Record<string, unknown> }
453    | { type: 'preventContinuation'; shouldPreventContinuation: boolean }
454    | { type: 'stopReason'; stopReason: string }
455    | {
456        type: 'additionalContext'
457        message: MessageUpdateLazy<AttachmentMessage>
458      }
459    // stop execution
460    | { type: 'stop' }
461  > {
462    const hookStartTime = Date.now()
463    try {
464      const appState = toolUseContext.getAppState()
465  
466      for await (const result of executePreToolHooks(
467        tool.name,
468        toolUseID,
469        processedInput,
470        toolUseContext,
471        appState.toolPermissionContext.mode,
472        toolUseContext.abortController.signal,
473        undefined, // timeoutMs - use default
474        toolUseContext.requestPrompt,
475        tool.getToolUseSummary?.(processedInput),
476      )) {
477        try {
478          if (result.message) {
479            yield { type: 'message', message: { message: result.message } }
480          }
481          if (result.blockingError) {
482            const denialMessage = getPreToolHookBlockingMessage(
483              `PreToolUse:${tool.name}`,
484              result.blockingError,
485            )
486            yield {
487              type: 'hookPermissionResult',
488              hookPermissionResult: {
489                behavior: 'deny',
490                message: denialMessage,
491                decisionReason: {
492                  type: 'hook',
493                  hookName: `PreToolUse:${tool.name}`,
494                  reason: denialMessage,
495                },
496              },
497            }
498          }
499          // Check if hook wants to prevent continuation
500          if (result.preventContinuation) {
501            yield {
502              type: 'preventContinuation',
503              shouldPreventContinuation: true,
504            }
505            if (result.stopReason) {
506              yield { type: 'stopReason', stopReason: result.stopReason }
507            }
508          }
509          // Check for hook-defined permission behavior
510          if (result.permissionBehavior !== undefined) {
511            logForDebugging(
512              `Hook result has permissionBehavior=${result.permissionBehavior}`,
513            )
514            const decisionReason: PermissionDecisionReason = {
515              type: 'hook',
516              hookName: `PreToolUse:${tool.name}`,
517              hookSource: result.hookSource,
518              reason: result.hookPermissionDecisionReason,
519            }
520            if (result.permissionBehavior === 'allow') {
521              yield {
522                type: 'hookPermissionResult',
523                hookPermissionResult: {
524                  behavior: 'allow',
525                  updatedInput: result.updatedInput,
526                  decisionReason,
527                },
528              }
529            } else if (result.permissionBehavior === 'ask') {
530              yield {
531                type: 'hookPermissionResult',
532                hookPermissionResult: {
533                  behavior: 'ask',
534                  updatedInput: result.updatedInput,
535                  message:
536                    result.hookPermissionDecisionReason ||
537                    `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`,
538                  decisionReason,
539                },
540              }
541            } else {
542              // deny - updatedInput is irrelevant since tool won't run
543              yield {
544                type: 'hookPermissionResult',
545                hookPermissionResult: {
546                  behavior: result.permissionBehavior,
547                  message:
548                    result.hookPermissionDecisionReason ||
549                    `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`,
550                  decisionReason,
551                },
552              }
553            }
554          }
555  
556          // Yield updatedInput for passthrough case (no permission decision)
557          // This allows hooks to modify input while letting normal permission flow continue
558          if (result.updatedInput && result.permissionBehavior === undefined) {
559            yield {
560              type: 'hookUpdatedInput',
561              updatedInput: result.updatedInput,
562            }
563          }
564  
565          // If hooks provided additional context, add it as a message
566          if (result.additionalContexts && result.additionalContexts.length > 0) {
567            yield {
568              type: 'additionalContext',
569              message: {
570                message: createAttachmentMessage({
571                  type: 'hook_additional_context',
572                  content: result.additionalContexts,
573                  hookName: `PreToolUse:${tool.name}`,
574                  toolUseID,
575                  hookEvent: 'PreToolUse',
576                }),
577              },
578            }
579          }
580  
581          // Check if we were aborted during hook execution
582          if (toolUseContext.abortController.signal.aborted) {
583            logEvent('tengu_pre_tool_hooks_cancelled', {
584              toolName: sanitizeToolNameForAnalytics(tool.name),
585  
586              queryChainId: toolUseContext.queryTracking
587                ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
588              queryDepth: toolUseContext.queryTracking?.depth,
589            })
590            yield {
591              type: 'message',
592              message: {
593                message: createAttachmentMessage({
594                  type: 'hook_cancelled',
595                  hookName: `PreToolUse:${tool.name}`,
596                  toolUseID,
597                  hookEvent: 'PreToolUse',
598                }),
599              },
600            }
601            yield { type: 'stop' }
602            return
603          }
604        } catch (error) {
605          logError(error)
606          const durationMs = Date.now() - hookStartTime
607          logEvent('tengu_pre_tool_hook_error', {
608            messageID:
609              messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
610            toolName: sanitizeToolNameForAnalytics(tool.name),
611            isMcp: tool.isMcp ?? false,
612            duration: durationMs,
613  
614            queryChainId: toolUseContext.queryTracking
615              ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
616            queryDepth: toolUseContext.queryTracking?.depth,
617            ...(mcpServerType
618              ? {
619                  mcpServerType:
620                    mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
621                }
622              : {}),
623            ...(requestId
624              ? {
625                  requestId:
626                    requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
627                }
628              : {}),
629          })
630          yield {
631            type: 'message',
632            message: {
633              message: createAttachmentMessage({
634                type: 'hook_error_during_execution',
635                content: formatError(error),
636                hookName: `PreToolUse:${tool.name}`,
637                toolUseID: toolUseID,
638                hookEvent: 'PreToolUse',
639              }),
640            },
641          }
642          yield { type: 'stop' }
643        }
644      }
645    } catch (error) {
646      logError(error)
647      yield { type: 'stop' }
648      return
649    }
650  }