source dump of claude code
at main 650 lines 22 kB view raw
1import { 2 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3 logEvent, 4} from 'src/services/analytics/index.js' 5import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' 6import type z from 'zod/v4' 7import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 8import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' 9import type { HookProgress } from '../../types/hooks.js' 10import type { 11 AssistantMessage, 12 AttachmentMessage, 13 ProgressMessage, 14} from '../../types/message.js' 15import type { PermissionDecision } from '../../types/permissions.js' 16import { createAttachmentMessage } from '../../utils/attachments.js' 17import { logForDebugging } from '../../utils/debug.js' 18import { 19 executePostToolHooks, 20 executePostToolUseFailureHooks, 21 executePreToolHooks, 22 getPreToolHookBlockingMessage, 23} from '../../utils/hooks.js' 24import { logError } from '../../utils/log.js' 25import { 26 getRuleBehaviorDescription, 27 type PermissionDecisionReason, 28 type PermissionResult, 29} from '../../utils/permissions/PermissionResult.js' 30import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' 31import { formatError } from '../../utils/toolErrors.js' 32import { isMcpTool } from '../mcp/utils.js' 33import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' 34 35export type PostToolUseHooksResult<Output> = 36 | MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>> 37 | { updatedMCPToolOutput: Output } 38 39export 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 193export 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 */ 332export 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 435export 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}