Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
22 kB · 650 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651import { 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<Output> = | MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>> | { updatedMCPToolOutput: Output }
export async function* runPostToolUseHooks<Input extends AnyObject, Output>( toolUseContext: ToolUseContext, tool: Tool<Input, Output>, toolUseID: string, messageId: string, toolInput: Record<string, unknown>, toolResponse: Output, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: string | undefined,): AsyncGenerator<PostToolUseHooksResult<Output>> { 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<Input extends AnyObject>( toolUseContext: ToolUseContext, tool: Tool<Input, unknown>, toolUseID: string, messageId: string, processedInput: z.infer<Input>, error: string, isInterrupt: boolean | undefined, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: string | undefined,): AsyncGenerator< MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>>> { 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<string, unknown>, toolUseContext: ToolUseContext, canUseTool: CanUseToolFn, assistantMessage: AssistantMessage, toolUseID: string,): Promise<{ decision: PermissionDecision input: Record<string, unknown>}> { 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<string, unknown>, toolUseID: string, messageId: string, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: string | undefined,): AsyncGenerator< | { type: 'message' message: MessageUpdateLazy< AttachmentMessage | ProgressMessage<HookProgress> > } | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } | { type: 'hookUpdatedInput'; updatedInput: Record<string, unknown> } | { type: 'preventContinuation'; shouldPreventContinuation: boolean } | { type: 'stopReason'; stopReason: string } | { type: 'additionalContext' message: MessageUpdateLazy<AttachmentMessage> } // 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 }}