source dump of claude code
at main 5022 lines 160 kB view raw
1// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered 2/** 3 * Hooks are user-defined shell commands that can be executed at various points 4 * in Claude Code's lifecycle. 5 */ 6import { basename } from 'path' 7import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' 8import { pathExists } from './file.js' 9import { wrapSpawn } from './ShellCommand.js' 10import { TaskOutput } from './task/TaskOutput.js' 11import { getCwd } from './cwd.js' 12import { randomUUID } from 'crypto' 13import { formatShellPrefixCommand } from './bash/shellPrefix.js' 14import { 15 getHookEnvFilePath, 16 invalidateSessionEnvCache, 17} from './sessionEnvironment.js' 18import { subprocessEnv } from './subprocessEnv.js' 19import { getPlatform } from './platform.js' 20import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js' 21import { getCachedPowerShellPath } from './shell/powershellDetection.js' 22import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js' 23import { buildPowerShellArgs } from './shell/powershellProvider.js' 24import { 25 loadPluginOptions, 26 substituteUserConfigVariables, 27} from './plugins/pluginOptionsStorage.js' 28import { getPluginDataDir } from './plugins/pluginDirectories.js' 29import { 30 getSessionId, 31 getProjectRoot, 32 getIsNonInteractiveSession, 33 getRegisteredHooks, 34 getStatsStore, 35 addToTurnHookDuration, 36 getOriginalCwd, 37 getMainThreadAgentType, 38} from '../bootstrap/state.js' 39import { checkHasTrustDialogAccepted } from './config.js' 40import { 41 getHooksConfigFromSnapshot, 42 shouldAllowManagedHooksOnly, 43 shouldDisableAllHooksIncludingManaged, 44} from './hooks/hooksConfigSnapshot.js' 45import { 46 getTranscriptPathForSession, 47 getAgentTranscriptPath, 48} from './sessionStorage.js' 49import type { AgentId } from '../types/ids.js' 50import { 51 getSettings_DEPRECATED, 52 getSettingsForSource, 53} from './settings/settings.js' 54import { 55 logEvent, 56 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 57} from 'src/services/analytics/index.js' 58import { logOTelEvent } from './telemetry/events.js' 59import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js' 60import { 61 startHookSpan, 62 endHookSpan, 63 isBetaTracingEnabled, 64} from './telemetry/sessionTracing.js' 65import { 66 hookJSONOutputSchema, 67 promptRequestSchema, 68 type HookCallback, 69 type HookCallbackMatcher, 70 type PromptRequest, 71 type PromptResponse, 72 isAsyncHookJSONOutput, 73 isSyncHookJSONOutput, 74 type PermissionRequestResult, 75} from '../types/hooks.js' 76import type { 77 HookEvent, 78 HookInput, 79 HookJSONOutput, 80 NotificationHookInput, 81 PostToolUseHookInput, 82 PostToolUseFailureHookInput, 83 PermissionDeniedHookInput, 84 PreCompactHookInput, 85 PostCompactHookInput, 86 PreToolUseHookInput, 87 SessionStartHookInput, 88 SessionEndHookInput, 89 SetupHookInput, 90 StopHookInput, 91 StopFailureHookInput, 92 SubagentStartHookInput, 93 SubagentStopHookInput, 94 TeammateIdleHookInput, 95 TaskCreatedHookInput, 96 TaskCompletedHookInput, 97 ConfigChangeHookInput, 98 CwdChangedHookInput, 99 FileChangedHookInput, 100 InstructionsLoadedHookInput, 101 UserPromptSubmitHookInput, 102 PermissionRequestHookInput, 103 ElicitationHookInput, 104 ElicitationResultHookInput, 105 PermissionUpdate, 106 ExitReason, 107 SyncHookJSONOutput, 108 AsyncHookJSONOutput, 109} from 'src/entrypoints/agentSdkTypes.js' 110import type { StatusLineCommandInput } from '../types/statusLine.js' 111import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js' 112import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js' 113import type { HookResultMessage } from 'src/types/message.js' 114import chalk from 'chalk' 115import type { 116 HookMatcher, 117 HookCommand, 118 PluginHookMatcher, 119 SkillHookMatcher, 120} from './settings/types.js' 121import { getHookDisplayText } from './hooks/hooksSettings.js' 122import { logForDebugging } from './debug.js' 123import { logForDiagnosticsNoPII } from './diagLogs.js' 124import { firstLineOf } from './stringUtils.js' 125import { 126 normalizeLegacyToolName, 127 getLegacyToolNames, 128 permissionRuleValueFromString, 129} from './permissions/permissionRuleParser.js' 130import { logError } from './log.js' 131import { createCombinedAbortSignal } from './combinedAbortSignal.js' 132import type { PermissionResult } from './permissions/PermissionResult.js' 133import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js' 134import { enqueuePendingNotification } from './messageQueueManager.js' 135import { 136 extractTextContent, 137 getLastAssistantMessage, 138 wrapInSystemReminder, 139} from './messages.js' 140import { 141 emitHookStarted, 142 emitHookResponse, 143 startHookProgressInterval, 144} from './hooks/hookEvents.js' 145import { createAttachmentMessage } from './attachments.js' 146import { all } from './generators.js' 147import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' 148import { execPromptHook } from './hooks/execPromptHook.js' 149import type { Message, AssistantMessage } from '../types/message.js' 150import { execAgentHook } from './hooks/execAgentHook.js' 151import { execHttpHook } from './hooks/execHttpHook.js' 152import type { ShellCommand } from './ShellCommand.js' 153import { 154 getSessionHooks, 155 getSessionFunctionHooks, 156 getSessionHookCallback, 157 clearSessionHooks, 158 type SessionDerivedHookMatcher, 159 type FunctionHook, 160} from './hooks/sessionHooks.js' 161import type { AppState } from '../state/AppState.js' 162import { jsonStringify, jsonParse } from './slowOperations.js' 163import { isEnvTruthy } from './envUtils.js' 164import { errorMessage, getErrnoCode } from './errors.js' 165 166const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 167 168/** 169 * SessionEnd hooks run during shutdown/clear and need a much tighter bound 170 * than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both 171 * the per-hook default timeout AND the overall AbortSignal cap (hooks run in 172 * parallel, so one value suffices). Overridable via env var for users whose 173 * teardown scripts need more time. 174 */ 175const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500 176export function getSessionEndHookTimeoutMs(): number { 177 const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS 178 const parsed = raw ? parseInt(raw, 10) : NaN 179 return Number.isFinite(parsed) && parsed > 0 180 ? parsed 181 : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT 182} 183 184function executeInBackground({ 185 processId, 186 hookId, 187 shellCommand, 188 asyncResponse, 189 hookEvent, 190 hookName, 191 command, 192 asyncRewake, 193 pluginId, 194}: { 195 processId: string 196 hookId: string 197 shellCommand: ShellCommand 198 asyncResponse: AsyncHookJSONOutput 199 hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' 200 hookName: string 201 command: string 202 asyncRewake?: boolean 203 pluginId?: string 204}): boolean { 205 if (asyncRewake) { 206 // asyncRewake hooks bypass the registry entirely. On completion, if exit 207 // code 2 (blocking error), enqueue as a task-notification so it wakes the 208 // model via useQueueProcessor (idle) or gets injected mid-query via 209 // queued_command attachments (busy). 210 // 211 // NOTE: We deliberately do NOT call shellCommand.background() here, because 212 // it calls taskOutput.spillToDisk() which breaks in-memory stdout/stderr 213 // capture (getStderr() returns '' in disk mode). The StreamWrappers stay 214 // attached and pipe data into the in-memory TaskOutput buffers. The abort 215 // handler already no-ops on 'interrupt' reason (user submitted a new 216 // message), so the hook survives new prompts. A hard cancel (Escape) WILL 217 // kill the hook via the abort handler, which is the desired behavior. 218 void shellCommand.result.then(async result => { 219 // result resolves on 'exit', but stdio 'data' events may still be 220 // pending. Yield to I/O so the StreamWrapper data handlers drain into 221 // TaskOutput before we read it. 222 await new Promise(resolve => setImmediate(resolve)) 223 const stdout = await shellCommand.taskOutput.getStdout() 224 const stderr = shellCommand.taskOutput.getStderr() 225 shellCommand.cleanup() 226 emitHookResponse({ 227 hookId, 228 hookName, 229 hookEvent, 230 output: stdout + stderr, 231 stdout, 232 stderr, 233 exitCode: result.code, 234 outcome: result.code === 0 ? 'success' : 'error', 235 }) 236 if (result.code === 2) { 237 enqueuePendingNotification({ 238 value: wrapInSystemReminder( 239 `Stop hook blocking error from command "${hookName}": ${stderr || stdout}`, 240 ), 241 mode: 'task-notification', 242 }) 243 } 244 }) 245 return true 246 } 247 248 // TaskOutput on the ShellCommand accumulates data — no stream listeners needed 249 if (!shellCommand.background(processId)) { 250 return false 251 } 252 253 registerPendingAsyncHook({ 254 processId, 255 hookId, 256 asyncResponse, 257 hookEvent, 258 hookName, 259 command, 260 shellCommand, 261 pluginId, 262 }) 263 264 return true 265} 266 267/** 268 * Checks if a hook should be skipped due to lack of workspace trust. 269 * 270 * ALL hooks require workspace trust because they execute arbitrary commands from 271 * .claude/settings.json. This is a defense-in-depth security measure. 272 * 273 * Context: Hooks are captured via captureHooksConfigSnapshot() before the trust 274 * dialog is shown. While most hooks won't execute until after trust is established 275 * through normal program flow, enforcing trust for ALL hooks prevents: 276 * - Future bugs where a hook might accidentally execute before trust 277 * - Any codepath that might trigger hooks before trust dialog 278 * - Security issues from hook execution in untrusted workspaces 279 * 280 * Historical vulnerabilities that prompted this check: 281 * - SessionEnd hooks executing when user declines trust dialog 282 * - SubagentStop hooks executing when subagent completes before trust 283 * 284 * @returns true if hook should be skipped, false if it should execute 285 */ 286export function shouldSkipHookDueToTrust(): boolean { 287 // In non-interactive mode (SDK), trust is implicit - always execute 288 const isInteractive = !getIsNonInteractiveSession() 289 if (!isInteractive) { 290 return false 291 } 292 293 // In interactive mode, ALL hooks require trust 294 const hasTrust = checkHasTrustDialogAccepted() 295 return !hasTrust 296} 297 298/** 299 * Creates the base hook input that's common to all hook types 300 */ 301export function createBaseHookInput( 302 permissionMode?: string, 303 sessionId?: string, 304 // Typed narrowly (not ToolUseContext) so callers can pass toolUseContext 305 // directly via structural typing without this function depending on Tool.ts. 306 agentInfo?: { agentId?: string; agentType?: string }, 307): { 308 session_id: string 309 transcript_path: string 310 cwd: string 311 permission_mode?: string 312 agent_id?: string 313 agent_type?: string 314} { 315 const resolvedSessionId = sessionId ?? getSessionId() 316 // agent_type: subagent's type (from toolUseContext) takes precedence over 317 // the session's --agent flag. Hooks use agent_id presence to distinguish 318 // subagent calls from main-thread calls in a --agent session. 319 const resolvedAgentType = agentInfo?.agentType ?? getMainThreadAgentType() 320 return { 321 session_id: resolvedSessionId, 322 transcript_path: getTranscriptPathForSession(resolvedSessionId), 323 cwd: getCwd(), 324 permission_mode: permissionMode, 325 agent_id: agentInfo?.agentId, 326 agent_type: resolvedAgentType, 327 } 328} 329 330export interface HookBlockingError { 331 blockingError: string 332 command: string 333} 334 335/** Re-export ElicitResult from MCP SDK as ElicitationResponse for backward compat. */ 336export type ElicitationResponse = ElicitResult 337 338export interface HookResult { 339 message?: HookResultMessage 340 systemMessage?: string 341 blockingError?: HookBlockingError 342 outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled' 343 preventContinuation?: boolean 344 stopReason?: string 345 permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough' 346 hookPermissionDecisionReason?: string 347 additionalContext?: string 348 initialUserMessage?: string 349 updatedInput?: Record<string, unknown> 350 updatedMCPToolOutput?: unknown 351 permissionRequestResult?: PermissionRequestResult 352 elicitationResponse?: ElicitationResponse 353 watchPaths?: string[] 354 elicitationResultResponse?: ElicitationResponse 355 retry?: boolean 356 hook: HookCommand | HookCallback | FunctionHook 357} 358 359export type AggregatedHookResult = { 360 message?: HookResultMessage 361 blockingError?: HookBlockingError 362 preventContinuation?: boolean 363 stopReason?: string 364 hookPermissionDecisionReason?: string 365 hookSource?: string 366 permissionBehavior?: PermissionResult['behavior'] 367 additionalContexts?: string[] 368 initialUserMessage?: string 369 updatedInput?: Record<string, unknown> 370 updatedMCPToolOutput?: unknown 371 permissionRequestResult?: PermissionRequestResult 372 watchPaths?: string[] 373 elicitationResponse?: ElicitationResponse 374 elicitationResultResponse?: ElicitationResponse 375 retry?: boolean 376} 377 378/** 379 * Parse and validate a JSON string against the hook output Zod schema. 380 * Returns the validated output or formatted validation errors. 381 */ 382function validateHookJson( 383 jsonString: string, 384): { json: HookJSONOutput } | { validationError: string } { 385 const parsed = jsonParse(jsonString) 386 const validation = hookJSONOutputSchema().safeParse(parsed) 387 if (validation.success) { 388 logForDebugging('Successfully parsed and validated hook JSON output') 389 return { json: validation.data } 390 } 391 const errors = validation.error.issues 392 .map(err => ` - ${err.path.join('.')}: ${err.message}`) 393 .join('\n') 394 return { 395 validationError: `Hook JSON output validation failed:\n${errors}\n\nThe hook's output was: ${jsonStringify(parsed, null, 2)}`, 396 } 397} 398 399function parseHookOutput(stdout: string): { 400 json?: HookJSONOutput 401 plainText?: string 402 validationError?: string 403} { 404 const trimmed = stdout.trim() 405 if (!trimmed.startsWith('{')) { 406 logForDebugging('Hook output does not start with {, treating as plain text') 407 return { plainText: stdout } 408 } 409 410 try { 411 const result = validateHookJson(trimmed) 412 if ('json' in result) { 413 return result 414 } 415 // For command hooks, include the schema hint in the error message 416 const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify( 417 { 418 continue: 'boolean (optional)', 419 suppressOutput: 'boolean (optional)', 420 stopReason: 'string (optional)', 421 decision: '"approve" | "block" (optional)', 422 reason: 'string (optional)', 423 systemMessage: 'string (optional)', 424 permissionDecision: '"allow" | "deny" | "ask" (optional)', 425 hookSpecificOutput: { 426 'for PreToolUse': { 427 hookEventName: '"PreToolUse"', 428 permissionDecision: '"allow" | "deny" | "ask" (optional)', 429 permissionDecisionReason: 'string (optional)', 430 updatedInput: 'object (optional) - Modified tool input to use', 431 }, 432 'for UserPromptSubmit': { 433 hookEventName: '"UserPromptSubmit"', 434 additionalContext: 'string (required)', 435 }, 436 'for PostToolUse': { 437 hookEventName: '"PostToolUse"', 438 additionalContext: 'string (optional)', 439 }, 440 }, 441 }, 442 null, 443 2, 444 )}` 445 logForDebugging(errorMessage) 446 return { plainText: stdout, validationError: errorMessage } 447 } catch (e) { 448 logForDebugging(`Failed to parse hook output as JSON: ${e}`) 449 return { plainText: stdout } 450 } 451} 452 453function parseHttpHookOutput(body: string): { 454 json?: HookJSONOutput 455 validationError?: string 456} { 457 const trimmed = body.trim() 458 459 if (trimmed === '') { 460 const validation = hookJSONOutputSchema().safeParse({}) 461 if (validation.success) { 462 logForDebugging( 463 'HTTP hook returned empty body, treating as empty JSON object', 464 ) 465 return { json: validation.data } 466 } 467 } 468 469 if (!trimmed.startsWith('{')) { 470 const validationError = `HTTP hook must return JSON, but got non-JSON response body: ${trimmed.length > 200 ? trimmed.slice(0, 200) + '\u2026' : trimmed}` 471 logForDebugging(validationError) 472 return { validationError } 473 } 474 475 try { 476 const result = validateHookJson(trimmed) 477 if ('json' in result) { 478 return result 479 } 480 logForDebugging(result.validationError) 481 return result 482 } catch (e) { 483 const validationError = `HTTP hook must return valid JSON, but parsing failed: ${e}` 484 logForDebugging(validationError) 485 return { validationError } 486 } 487} 488 489function processHookJSONOutput({ 490 json, 491 command, 492 hookName, 493 toolUseID, 494 hookEvent, 495 expectedHookEvent, 496 stdout, 497 stderr, 498 exitCode, 499 durationMs, 500}: { 501 json: SyncHookJSONOutput 502 command: string 503 hookName: string 504 toolUseID: string 505 hookEvent: HookEvent 506 expectedHookEvent?: HookEvent 507 stdout?: string 508 stderr?: string 509 exitCode?: number 510 durationMs?: number 511}): Partial<HookResult> { 512 const result: Partial<HookResult> = {} 513 514 // At this point we know it's a sync response 515 const syncJson = json 516 517 // Handle common elements 518 if (syncJson.continue === false) { 519 result.preventContinuation = true 520 if (syncJson.stopReason) { 521 result.stopReason = syncJson.stopReason 522 } 523 } 524 525 if (json.decision) { 526 switch (json.decision) { 527 case 'approve': 528 result.permissionBehavior = 'allow' 529 break 530 case 'block': 531 result.permissionBehavior = 'deny' 532 result.blockingError = { 533 blockingError: json.reason || 'Blocked by hook', 534 command, 535 } 536 break 537 default: 538 // Handle unknown decision types as errors 539 throw new Error( 540 `Unknown hook decision type: ${json.decision}. Valid types are: approve, block`, 541 ) 542 } 543 } 544 545 // Handle systemMessage field 546 if (json.systemMessage) { 547 result.systemMessage = json.systemMessage 548 } 549 550 // Handle PreToolUse specific 551 if ( 552 json.hookSpecificOutput?.hookEventName === 'PreToolUse' && 553 json.hookSpecificOutput.permissionDecision 554 ) { 555 switch (json.hookSpecificOutput.permissionDecision) { 556 case 'allow': 557 result.permissionBehavior = 'allow' 558 break 559 case 'deny': 560 result.permissionBehavior = 'deny' 561 result.blockingError = { 562 blockingError: json.reason || 'Blocked by hook', 563 command, 564 } 565 break 566 case 'ask': 567 result.permissionBehavior = 'ask' 568 break 569 default: 570 // Handle unknown decision types as errors 571 throw new Error( 572 `Unknown hook permissionDecision type: ${json.hookSpecificOutput.permissionDecision}. Valid types are: allow, deny, ask`, 573 ) 574 } 575 } 576 if (result.permissionBehavior !== undefined && json.reason !== undefined) { 577 result.hookPermissionDecisionReason = json.reason 578 } 579 580 // Handle hookSpecificOutput 581 if (json.hookSpecificOutput) { 582 // Validate hook event name matches expected if provided 583 if ( 584 expectedHookEvent && 585 json.hookSpecificOutput.hookEventName !== expectedHookEvent 586 ) { 587 throw new Error( 588 `Hook returned incorrect event name: expected '${expectedHookEvent}' but got '${json.hookSpecificOutput.hookEventName}'. Full stdout: ${jsonStringify(json, null, 2)}`, 589 ) 590 } 591 592 switch (json.hookSpecificOutput.hookEventName) { 593 case 'PreToolUse': 594 // Override with more specific permission decision if provided 595 if (json.hookSpecificOutput.permissionDecision) { 596 switch (json.hookSpecificOutput.permissionDecision) { 597 case 'allow': 598 result.permissionBehavior = 'allow' 599 break 600 case 'deny': 601 result.permissionBehavior = 'deny' 602 result.blockingError = { 603 blockingError: 604 json.hookSpecificOutput.permissionDecisionReason || 605 json.reason || 606 'Blocked by hook', 607 command, 608 } 609 break 610 case 'ask': 611 result.permissionBehavior = 'ask' 612 break 613 } 614 } 615 result.hookPermissionDecisionReason = 616 json.hookSpecificOutput.permissionDecisionReason 617 // Extract updatedInput if provided 618 if (json.hookSpecificOutput.updatedInput) { 619 result.updatedInput = json.hookSpecificOutput.updatedInput 620 } 621 // Extract additionalContext if provided 622 result.additionalContext = json.hookSpecificOutput.additionalContext 623 break 624 case 'UserPromptSubmit': 625 result.additionalContext = json.hookSpecificOutput.additionalContext 626 break 627 case 'SessionStart': 628 result.additionalContext = json.hookSpecificOutput.additionalContext 629 result.initialUserMessage = json.hookSpecificOutput.initialUserMessage 630 if ( 631 'watchPaths' in json.hookSpecificOutput && 632 json.hookSpecificOutput.watchPaths 633 ) { 634 result.watchPaths = json.hookSpecificOutput.watchPaths 635 } 636 break 637 case 'Setup': 638 result.additionalContext = json.hookSpecificOutput.additionalContext 639 break 640 case 'SubagentStart': 641 result.additionalContext = json.hookSpecificOutput.additionalContext 642 break 643 case 'PostToolUse': 644 result.additionalContext = json.hookSpecificOutput.additionalContext 645 // Extract updatedMCPToolOutput if provided 646 if (json.hookSpecificOutput.updatedMCPToolOutput) { 647 result.updatedMCPToolOutput = 648 json.hookSpecificOutput.updatedMCPToolOutput 649 } 650 break 651 case 'PostToolUseFailure': 652 result.additionalContext = json.hookSpecificOutput.additionalContext 653 break 654 case 'PermissionDenied': 655 result.retry = json.hookSpecificOutput.retry 656 break 657 case 'PermissionRequest': 658 // Extract the permission request decision 659 if (json.hookSpecificOutput.decision) { 660 result.permissionRequestResult = json.hookSpecificOutput.decision 661 // Also update permissionBehavior for consistency 662 result.permissionBehavior = 663 json.hookSpecificOutput.decision.behavior === 'allow' 664 ? 'allow' 665 : 'deny' 666 if ( 667 json.hookSpecificOutput.decision.behavior === 'allow' && 668 json.hookSpecificOutput.decision.updatedInput 669 ) { 670 result.updatedInput = json.hookSpecificOutput.decision.updatedInput 671 } 672 } 673 break 674 case 'Elicitation': 675 if (json.hookSpecificOutput.action) { 676 result.elicitationResponse = { 677 action: json.hookSpecificOutput.action, 678 content: json.hookSpecificOutput.content as 679 | ElicitationResponse['content'] 680 | undefined, 681 } 682 if (json.hookSpecificOutput.action === 'decline') { 683 result.blockingError = { 684 blockingError: json.reason || 'Elicitation denied by hook', 685 command, 686 } 687 } 688 } 689 break 690 case 'ElicitationResult': 691 if (json.hookSpecificOutput.action) { 692 result.elicitationResultResponse = { 693 action: json.hookSpecificOutput.action, 694 content: json.hookSpecificOutput.content as 695 | ElicitationResponse['content'] 696 | undefined, 697 } 698 if (json.hookSpecificOutput.action === 'decline') { 699 result.blockingError = { 700 blockingError: 701 json.reason || 'Elicitation result blocked by hook', 702 command, 703 } 704 } 705 } 706 break 707 } 708 } 709 710 return { 711 ...result, 712 message: result.blockingError 713 ? createAttachmentMessage({ 714 type: 'hook_blocking_error', 715 hookName, 716 toolUseID, 717 hookEvent, 718 blockingError: result.blockingError, 719 }) 720 : createAttachmentMessage({ 721 type: 'hook_success', 722 hookName, 723 toolUseID, 724 hookEvent, 725 // JSON-output hooks inject context via additionalContext → 726 // hook_additional_context, not this field. Empty content suppresses 727 // the trivial "X hook success: Success" system-reminder that 728 // otherwise pollutes every turn (messages.ts:3577 skips on ''). 729 content: '', 730 stdout, 731 stderr, 732 exitCode, 733 command, 734 durationMs, 735 }), 736 } 737} 738 739/** 740 * Execute a command-based hook using bash or PowerShell. 741 * 742 * Shell resolution: hook.shell → 'bash'. PowerShell hooks spawn pwsh 743 * with -NoProfile -NonInteractive -Command and skip bash-specific prep 744 * (POSIX path conversion, .sh auto-prepend, CLAUDE_CODE_SHELL_PREFIX). 745 * See docs/design/ps-shell-selection.md §5.1. 746 */ 747async function execCommandHook( 748 hook: HookCommand & { type: 'command' }, 749 hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion', 750 hookName: string, 751 jsonInput: string, 752 signal: AbortSignal, 753 hookId: string, 754 hookIndex?: number, 755 pluginRoot?: string, 756 pluginId?: string, 757 skillRoot?: string, 758 forceSyncExecution?: boolean, 759 requestPrompt?: (request: PromptRequest) => Promise<PromptResponse>, 760): Promise<{ 761 stdout: string 762 stderr: string 763 output: string 764 status: number 765 aborted?: boolean 766 backgrounded?: boolean 767}> { 768 // Gated to once-per-session events to keep diag_log volume bounded. 769 // started/completed live inside the try/finally so setup-path throws 770 // don't orphan a started marker — that'd be indistinguishable from a hang. 771 const shouldEmitDiag = 772 hookEvent === 'SessionStart' || 773 hookEvent === 'Setup' || 774 hookEvent === 'SessionEnd' 775 const diagStartMs = Date.now() 776 let diagExitCode: number | undefined 777 let diagAborted = false 778 779 const isWindows = getPlatform() === 'windows' 780 781 // -- 782 // Per-hook shell selection (phase 1 of docs/design/ps-shell-selection.md). 783 // Resolution order: hook.shell → DEFAULT_HOOK_SHELL. The defaultShell 784 // fallback (settings.defaultShell) is phase 2 — not wired yet. 785 // 786 // The bash path is the historical default and stays unchanged. The 787 // PowerShell path deliberately skips the Windows-specific bash 788 // accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted 789 // SHELL_PREFIX). 790 const shellType = hook.shell ?? DEFAULT_HOOK_SHELL 791 792 const isPowerShell = shellType === 'powershell' 793 794 // -- 795 // Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe. 796 // 797 // This means every path we put into env vars or substitute into the command 798 // string MUST be a POSIX path (/c/Users/foo), not a Windows path 799 // (C:\Users\foo or C:/Users/foo). Git Bash cannot resolve Windows paths. 800 // 801 // windowsPathToPosixPath() is pure-JS regex conversion (no cygpath shell-out): 802 // C:\Users\foo -> /c/Users/foo, UNC preserved, slashes flipped. Memoized 803 // (LRU-500) so repeated calls are cheap. 804 // 805 // PowerShell path: use native paths — skip the conversion entirely. 806 // PowerShell expects Windows paths on Windows (and native paths on 807 // Unix where pwsh is also available). 808 const toHookPath = 809 isWindows && !isPowerShell 810 ? (p: string) => windowsPathToPosixPath(p) 811 : (p: string) => p 812 813 // Set CLAUDE_PROJECT_DIR to the stable project root (not the worktree path). 814 // getProjectRoot() is never updated when entering a worktree, so hooks that 815 // reference $CLAUDE_PROJECT_DIR always resolve relative to the real repo root. 816 const projectDir = getProjectRoot() 817 818 // Substitute ${CLAUDE_PLUGIN_ROOT} and ${user_config.X} in the command string. 819 // Order matches MCP/LSP (plugin vars FIRST, then user config) so a user- 820 // entered value containing the literal text ${CLAUDE_PLUGIN_ROOT} is treated 821 // as opaque — not re-interpreted as a template. 822 let command = hook.command 823 let pluginOpts: ReturnType<typeof loadPluginOptions> | undefined 824 if (pluginRoot) { 825 // Plugin directory gone (orphan GC race, concurrent session deleted it): 826 // throw so callers yield a non-blocking error. Running would fail — and 827 // `python3 <missing>.py` exits 2, the hook protocol's "block" code, which 828 // bricks UserPromptSubmit/Stop until restart. The pre-check is necessary 829 // because exit-2-from-missing-script is indistinguishable from an 830 // intentional block after spawn. 831 if (!(await pathExists(pluginRoot))) { 832 throw new Error( 833 `Plugin directory does not exist: ${pluginRoot}` + 834 (pluginId ? ` (${pluginId} — run /plugin to reinstall)` : ''), 835 ) 836 } 837 // Inline both ROOT and DATA substitution instead of calling 838 // substitutePluginVariables(). That helper normalizes \ → / on Windows 839 // unconditionally — correct for bash (toHookPath already produced /c/... 840 // so it's a no-op) but wrong for PS where toHookPath is identity and we 841 // want native C:\... backslashes. Inlining also lets us use the function- 842 // form .replace() so paths containing $ aren't mangled by $-pattern 843 // interpretation (rare but possible: \\server\c$\plugin). 844 const rootPath = toHookPath(pluginRoot) 845 command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath) 846 if (pluginId) { 847 const dataPath = toHookPath(getPluginDataDir(pluginId)) 848 command = command.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => dataPath) 849 } 850 if (pluginId) { 851 pluginOpts = loadPluginOptions(pluginId) 852 // Throws if a referenced key is missing — that means the hook uses a key 853 // that's either not declared in manifest.userConfig or not yet configured. 854 // Caught upstream like any other hook exec failure. 855 command = substituteUserConfigVariables(command, pluginOpts) 856 } 857 } 858 859 // On Windows (bash only), auto-prepend `bash` for .sh scripts so they 860 // execute instead of opening in the default file handler. PowerShell 861 // runs .ps1 files natively — no prepend needed. 862 if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) { 863 if (!command.trim().startsWith('bash ')) { 864 command = `bash ${command}` 865 } 866 } 867 868 // CLAUDE_CODE_SHELL_PREFIX wraps the command via POSIX quoting 869 // (formatShellPrefixCommand uses shell-quote). This makes no sense for 870 // PowerShell — see design §8.1. For now PS hooks ignore the prefix; 871 // a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up. 872 const finalCommand = 873 !isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX 874 ? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command) 875 : command 876 877 const hookTimeoutMs = hook.timeout 878 ? hook.timeout * 1000 879 : TOOL_HOOK_EXECUTION_TIMEOUT_MS 880 881 // Build env vars — all paths go through toHookPath for Windows POSIX conversion 882 const envVars: NodeJS.ProcessEnv = { 883 ...subprocessEnv(), 884 CLAUDE_PROJECT_DIR: toHookPath(projectDir), 885 } 886 887 // Plugin and skill hooks both set CLAUDE_PLUGIN_ROOT (skills use the same 888 // name for consistency — skills can migrate to plugins without code changes) 889 if (pluginRoot) { 890 envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot) 891 if (pluginId) { 892 envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId)) 893 } 894 } 895 // Expose plugin options as env vars too, so hooks can read them without 896 // ${user_config.X} in the command string. Sensitive values included — hooks 897 // run the user's own code, same trust boundary as reading keychain directly. 898 if (pluginOpts) { 899 for (const [key, value] of Object.entries(pluginOpts)) { 900 // Sanitize non-identifier chars (bash can't ref $FOO-BAR). The schema 901 // at schemas.ts:611 now constrains keys to /^[A-Za-z_]\w*$/ so this is 902 // belt-and-suspenders, but cheap insurance if someone bypasses the schema. 903 const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase() 904 envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value) 905 } 906 } 907 if (skillRoot) { 908 envVars.CLAUDE_PLUGIN_ROOT = toHookPath(skillRoot) 909 } 910 911 // CLAUDE_ENV_FILE points to a .sh file that the hook writes env var 912 // definitions into; getSessionEnvironmentScript() concatenates them and 913 // bashProvider injects the content into bash commands. A PS hook would 914 // naturally write PS syntax ($env:FOO = 'bar'), which bash can't parse. 915 // Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are 916 // already bash-only above. 917 if ( 918 !isPowerShell && 919 (hookEvent === 'SessionStart' || 920 hookEvent === 'Setup' || 921 hookEvent === 'CwdChanged' || 922 hookEvent === 'FileChanged') && 923 hookIndex !== undefined 924 ) { 925 envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex) 926 } 927 928 // When agent worktrees are removed, getCwd() may return a deleted path via 929 // AsyncLocalStorage. Validate before spawning since spawn() emits async 930 // 'error' events for missing cwd rather than throwing synchronously. 931 const hookCwd = getCwd() 932 const safeCwd = (await pathExists(hookCwd)) ? hookCwd : getOriginalCwd() 933 if (safeCwd !== hookCwd) { 934 logForDebugging( 935 `Hooks: cwd ${hookCwd} not found, falling back to original cwd`, 936 { level: 'warn' }, 937 ) 938 } 939 940 // -- 941 // Spawn. Two completely separate paths: 942 // 943 // Bash: spawn(cmd, [], { shell: <gitBashPath | true> }) — the shell 944 // option makes Node pass the whole string to the shell for parsing. 945 // 946 // PowerShell: spawn(pwshPath, ['-NoProfile', '-NonInteractive', 947 // '-Command', cmd]) — explicit argv, no shell option. -NoProfile 948 // skips user profile scripts (faster, deterministic). 949 // -NonInteractive fails fast instead of prompting. 950 // 951 // The Git Bash hard-exit in findGitBashPath() is still in place for 952 // bash hooks. PowerShell hooks never call it, so a Windows user with 953 // only pwsh and shell: 'powershell' on every hook could in theory run 954 // without Git Bash — but init.ts still calls setShellIfWindows() on 955 // startup, which will exit first. Relaxing that is phase 1 of the 956 // design's implementation order (separate PR). 957 let child: ChildProcessWithoutNullStreams 958 if (shellType === 'powershell') { 959 const pwshPath = await getCachedPowerShellPath() 960 if (!pwshPath) { 961 throw new Error( 962 `Hook "${hook.command}" has shell: 'powershell' but no PowerShell ` + 963 `executable (pwsh or powershell) was found on PATH. Install ` + 964 `PowerShell, or remove "shell": "powershell" to use bash.`, 965 ) 966 } 967 child = spawn(pwshPath, buildPowerShellArgs(finalCommand), { 968 env: envVars, 969 cwd: safeCwd, 970 // Prevent visible console window on Windows (no-op on other platforms) 971 windowsHide: true, 972 }) as ChildProcessWithoutNullStreams 973 } else { 974 // On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax). 975 // On other platforms, shell: true uses /bin/sh. 976 const shell = isWindows ? findGitBashPath() : true 977 child = spawn(finalCommand, [], { 978 env: envVars, 979 cwd: safeCwd, 980 shell, 981 // Prevent visible console window on Windows (no-op on other platforms) 982 windowsHide: true, 983 }) as ChildProcessWithoutNullStreams 984 } 985 986 // Hooks use pipe mode — stdout must be streamed into JS so we can parse 987 // the first response line to detect async hooks ({"async": true}). 988 const hookTaskOutput = new TaskOutput(`hook_${child.pid}`, null) 989 const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput) 990 // Track whether shellCommand ownership was transferred (e.g., to async hook registry) 991 let shellCommandTransferred = false 992 // Track whether stdin has already been written (to avoid "write after end" errors) 993 let stdinWritten = false 994 995 if ((hook.async || hook.asyncRewake) && !forceSyncExecution) { 996 const processId = `async_hook_${child.pid}` 997 logForDebugging( 998 `Hooks: Config-based async hook, backgrounding process ${processId}`, 999 ) 1000 1001 // Write stdin before backgrounding so the hook receives its input. 1002 // The trailing newline matches the sync path (L1000). Without it, 1003 // bash `read -r line` returns exit 1 (EOF before delimiter) — the 1004 // variable IS populated but `if read -r line; then ...` skips the 1005 // branch. See gh-30509 / CC-161. 1006 child.stdin.write(jsonInput + '\n', 'utf8') 1007 child.stdin.end() 1008 stdinWritten = true 1009 1010 const backgrounded = executeInBackground({ 1011 processId, 1012 hookId, 1013 shellCommand, 1014 asyncResponse: { async: true, asyncTimeout: hookTimeoutMs }, 1015 hookEvent, 1016 hookName, 1017 command: hook.command, 1018 asyncRewake: hook.asyncRewake, 1019 pluginId, 1020 }) 1021 if (backgrounded) { 1022 return { 1023 stdout: '', 1024 stderr: '', 1025 output: '', 1026 status: 0, 1027 backgrounded: true, 1028 } 1029 } 1030 } 1031 1032 let stdout = '' 1033 let stderr = '' 1034 let output = '' 1035 1036 // Set up output data collection with explicit UTF-8 encoding 1037 child.stdout.setEncoding('utf8') 1038 child.stderr.setEncoding('utf8') 1039 1040 let initialResponseChecked = false 1041 1042 let asyncResolve: 1043 | ((result: { 1044 stdout: string 1045 stderr: string 1046 output: string 1047 status: number 1048 }) => void) 1049 | null = null 1050 const childIsAsyncPromise = new Promise<{ 1051 stdout: string 1052 stderr: string 1053 output: string 1054 status: number 1055 aborted?: boolean 1056 }>(resolve => { 1057 asyncResolve = resolve 1058 }) 1059 1060 // Track trimmed prompt-request lines we processed so we can strip them 1061 // from final stdout by content match (no index tracking → no index drift) 1062 const processedPromptLines = new Set<string>() 1063 // Serialize async prompt handling so responses are sent in order 1064 let promptChain = Promise.resolve() 1065 // Line buffer for detecting prompt requests in streaming output 1066 let lineBuffer = '' 1067 1068 child.stdout.on('data', data => { 1069 stdout += data 1070 output += data 1071 1072 // When requestPrompt is provided, parse stdout line-by-line for prompt requests 1073 if (requestPrompt) { 1074 lineBuffer += data 1075 const lines = lineBuffer.split('\n') 1076 lineBuffer = lines.pop() ?? '' // last element is an incomplete line 1077 1078 for (const line of lines) { 1079 const trimmed = line.trim() 1080 if (!trimmed) continue 1081 1082 try { 1083 const parsed = jsonParse(trimmed) 1084 const validation = promptRequestSchema().safeParse(parsed) 1085 if (validation.success) { 1086 processedPromptLines.add(trimmed) 1087 logForDebugging( 1088 `Hooks: Detected prompt request from hook: ${trimmed}`, 1089 ) 1090 // Chain the async handling to serialize prompt responses 1091 const promptReq = validation.data 1092 const reqPrompt = requestPrompt 1093 promptChain = promptChain.then(async () => { 1094 try { 1095 const response = await reqPrompt(promptReq) 1096 child.stdin.write(jsonStringify(response) + '\n', 'utf8') 1097 } catch (err) { 1098 logForDebugging(`Hooks: Prompt request handling failed: ${err}`) 1099 // User cancelled or prompt failed — close stdin so the hook 1100 // process doesn't hang waiting for input 1101 child.stdin.destroy() 1102 } 1103 }) 1104 continue 1105 } 1106 } catch { 1107 // Not JSON, just a normal line 1108 } 1109 } 1110 } 1111 1112 // Check for async response on first line of output. The async protocol is: 1113 // hook emits {"async":true,...} as its FIRST line, then its normal output. 1114 // We must parse ONLY the first line — if the process is fast and writes more 1115 // before this 'data' event fires, parsing the full accumulated stdout fails 1116 // and an async hook blocks for its full duration instead of backgrounding. 1117 if (!initialResponseChecked) { 1118 const firstLine = firstLineOf(stdout).trim() 1119 if (!firstLine.includes('}')) return 1120 initialResponseChecked = true 1121 logForDebugging(`Hooks: Checking first line for async: ${firstLine}`) 1122 try { 1123 const parsed = jsonParse(firstLine) 1124 logForDebugging( 1125 `Hooks: Parsed initial response: ${jsonStringify(parsed)}`, 1126 ) 1127 if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) { 1128 const processId = `async_hook_${child.pid}` 1129 logForDebugging( 1130 `Hooks: Detected async hook, backgrounding process ${processId}`, 1131 ) 1132 1133 const backgrounded = executeInBackground({ 1134 processId, 1135 hookId, 1136 shellCommand, 1137 asyncResponse: parsed, 1138 hookEvent, 1139 hookName, 1140 command: hook.command, 1141 pluginId, 1142 }) 1143 if (backgrounded) { 1144 shellCommandTransferred = true 1145 asyncResolve?.({ 1146 stdout, 1147 stderr, 1148 output, 1149 status: 0, 1150 }) 1151 } 1152 } else if (isAsyncHookJSONOutput(parsed) && forceSyncExecution) { 1153 logForDebugging( 1154 `Hooks: Detected async hook but forceSyncExecution is true, waiting for completion`, 1155 ) 1156 } else { 1157 logForDebugging( 1158 `Hooks: Initial response is not async, continuing normal processing`, 1159 ) 1160 } 1161 } catch (e) { 1162 logForDebugging(`Hooks: Failed to parse initial response as JSON: ${e}`) 1163 } 1164 } 1165 }) 1166 1167 child.stderr.on('data', data => { 1168 stderr += data 1169 output += data 1170 }) 1171 1172 const stopProgressInterval = startHookProgressInterval({ 1173 hookId, 1174 hookName, 1175 hookEvent, 1176 getOutput: async () => ({ stdout, stderr, output }), 1177 }) 1178 1179 // Wait for stdout and stderr streams to finish before considering output complete 1180 // This prevents a race condition where 'close' fires before all 'data' events are processed 1181 const stdoutEndPromise = new Promise<void>(resolve => { 1182 child.stdout.on('end', () => resolve()) 1183 }) 1184 1185 const stderrEndPromise = new Promise<void>(resolve => { 1186 child.stderr.on('end', () => resolve()) 1187 }) 1188 1189 // Write to stdin, making sure to handle EPIPE errors that can happen when 1190 // the hook command exits before reading all input. 1191 // Note: EPIPE handling is difficult to set up in testing since Bun and Node 1192 // have different behaviors. 1193 // TODO: Add tests for EPIPE handling. 1194 // Skip if stdin was already written (e.g., by config-based async hook path) 1195 const stdinWritePromise = stdinWritten 1196 ? Promise.resolve() 1197 : new Promise<void>((resolve, reject) => { 1198 child.stdin.on('error', err => { 1199 // When requestPrompt is provided, stdin stays open for prompt responses. 1200 // EPIPE errors from later writes (after process exits) are expected -- suppress them. 1201 if (!requestPrompt) { 1202 reject(err) 1203 } else { 1204 logForDebugging( 1205 `Hooks: stdin error during prompt flow (likely process exited): ${err}`, 1206 ) 1207 } 1208 }) 1209 // Explicitly specify UTF-8 encoding to ensure proper handling of Unicode characters 1210 child.stdin.write(jsonInput + '\n', 'utf8') 1211 // When requestPrompt is provided, keep stdin open for prompt responses 1212 if (!requestPrompt) { 1213 child.stdin.end() 1214 } 1215 resolve() 1216 }) 1217 1218 // Create promise for child process error 1219 const childErrorPromise = new Promise<never>((_, reject) => { 1220 child.on('error', reject) 1221 }) 1222 1223 // Create promise for child process close - but only resolve after streams end 1224 // to ensure all output has been collected 1225 const childClosePromise = new Promise<{ 1226 stdout: string 1227 stderr: string 1228 output: string 1229 status: number 1230 aborted?: boolean 1231 }>(resolve => { 1232 let exitCode: number | null = null 1233 1234 child.on('close', code => { 1235 exitCode = code ?? 1 1236 1237 // Wait for both streams to end before resolving with the final output 1238 void Promise.all([stdoutEndPromise, stderrEndPromise]).then(() => { 1239 // Strip lines we processed as prompt requests so parseHookOutput 1240 // only sees the final hook result. Content-matching against the set 1241 // of actually-processed lines means prompt JSON can never leak 1242 // through (fail-closed), regardless of line positioning. 1243 const finalStdout = 1244 processedPromptLines.size === 0 1245 ? stdout 1246 : stdout 1247 .split('\n') 1248 .filter(line => !processedPromptLines.has(line.trim())) 1249 .join('\n') 1250 1251 resolve({ 1252 stdout: finalStdout, 1253 stderr, 1254 output, 1255 status: exitCode!, 1256 aborted: signal.aborted, 1257 }) 1258 }) 1259 }) 1260 }) 1261 1262 // Race between stdin write, async detection, and process completion 1263 try { 1264 if (shouldEmitDiag) { 1265 logForDiagnosticsNoPII('info', 'hook_spawn_started', { 1266 hook_event_name: hookEvent, 1267 index: hookIndex, 1268 }) 1269 } 1270 await Promise.race([stdinWritePromise, childErrorPromise]) 1271 1272 // Wait for any pending prompt responses before resolving 1273 const result = await Promise.race([ 1274 childIsAsyncPromise, 1275 childClosePromise, 1276 childErrorPromise, 1277 ]) 1278 // Ensure all queued prompt responses have been sent 1279 await promptChain 1280 diagExitCode = result.status 1281 diagAborted = result.aborted ?? false 1282 return result 1283 } catch (error) { 1284 // Handle errors from stdin write or child process 1285 const code = getErrnoCode(error) 1286 diagExitCode = 1 1287 1288 if (code === 'EPIPE') { 1289 logForDebugging( 1290 'EPIPE error while writing to hook stdin (hook command likely closed early)', 1291 ) 1292 const errMsg = 1293 'Hook command closed stdin before hook input was fully written (EPIPE)' 1294 return { 1295 stdout: '', 1296 stderr: errMsg, 1297 output: errMsg, 1298 status: 1, 1299 } 1300 } else if (code === 'ABORT_ERR') { 1301 diagAborted = true 1302 return { 1303 stdout: '', 1304 stderr: 'Hook cancelled', 1305 output: 'Hook cancelled', 1306 status: 1, 1307 aborted: true, 1308 } 1309 } else { 1310 const errorMsg = errorMessage(error) 1311 const errOutput = `Error occurred while executing hook command: ${errorMsg}` 1312 return { 1313 stdout: '', 1314 stderr: errOutput, 1315 output: errOutput, 1316 status: 1, 1317 } 1318 } 1319 } finally { 1320 if (shouldEmitDiag) { 1321 logForDiagnosticsNoPII('info', 'hook_spawn_completed', { 1322 hook_event_name: hookEvent, 1323 index: hookIndex, 1324 duration_ms: Date.now() - diagStartMs, 1325 exit_code: diagExitCode, 1326 aborted: diagAborted, 1327 }) 1328 } 1329 stopProgressInterval() 1330 // Clean up stream resources unless ownership was transferred (e.g., to async hook registry) 1331 if (!shellCommandTransferred) { 1332 shellCommand.cleanup() 1333 } 1334 } 1335} 1336 1337/** 1338 * Check if a match query matches a hook matcher pattern 1339 * @param matchQuery The query to match (e.g., 'Write', 'Edit', 'Bash') 1340 * @param matcher The matcher pattern - can be: 1341 * - Simple string for exact match (e.g., 'Write') 1342 * - Pipe-separated list for multiple exact matches (e.g., 'Write|Edit') 1343 * - Regex pattern (e.g., '^Write.*', '.*', '^(Write|Edit)$') 1344 * @returns true if the query matches the pattern 1345 */ 1346function matchesPattern(matchQuery: string, matcher: string): boolean { 1347 if (!matcher || matcher === '*') { 1348 return true 1349 } 1350 // Check if it's a simple string or pipe-separated list (no regex special chars except |) 1351 if (/^[a-zA-Z0-9_|]+$/.test(matcher)) { 1352 // Handle pipe-separated exact matches 1353 if (matcher.includes('|')) { 1354 const patterns = matcher 1355 .split('|') 1356 .map(p => normalizeLegacyToolName(p.trim())) 1357 return patterns.includes(matchQuery) 1358 } 1359 // Simple exact match 1360 return matchQuery === normalizeLegacyToolName(matcher) 1361 } 1362 1363 // Otherwise treat as regex 1364 try { 1365 const regex = new RegExp(matcher) 1366 if (regex.test(matchQuery)) { 1367 return true 1368 } 1369 // Also test against legacy names so patterns like "^Task$" still match 1370 for (const legacyName of getLegacyToolNames(matchQuery)) { 1371 if (regex.test(legacyName)) { 1372 return true 1373 } 1374 } 1375 return false 1376 } catch { 1377 // If the regex is invalid, log error and return false 1378 logForDebugging(`Invalid regex pattern in hook matcher: ${matcher}`) 1379 return false 1380 } 1381} 1382 1383type IfConditionMatcher = (ifCondition: string) => boolean 1384 1385/** 1386 * Prepare a matcher for hook `if` conditions. Expensive work (tool lookup, 1387 * Zod validation, tree-sitter parsing for Bash) happens once here; the 1388 * returned closure is called per hook. Returns undefined for non-tool events. 1389 */ 1390async function prepareIfConditionMatcher( 1391 hookInput: HookInput, 1392 tools: Tools | undefined, 1393): Promise<IfConditionMatcher | undefined> { 1394 if ( 1395 hookInput.hook_event_name !== 'PreToolUse' && 1396 hookInput.hook_event_name !== 'PostToolUse' && 1397 hookInput.hook_event_name !== 'PostToolUseFailure' && 1398 hookInput.hook_event_name !== 'PermissionRequest' 1399 ) { 1400 return undefined 1401 } 1402 1403 const toolName = normalizeLegacyToolName(hookInput.tool_name) 1404 const tool = tools && findToolByName(tools, hookInput.tool_name) 1405 const input = tool?.inputSchema.safeParse(hookInput.tool_input) 1406 const patternMatcher = 1407 input?.success && tool?.preparePermissionMatcher 1408 ? await tool.preparePermissionMatcher(input.data) 1409 : undefined 1410 1411 return ifCondition => { 1412 const parsed = permissionRuleValueFromString(ifCondition) 1413 if (normalizeLegacyToolName(parsed.toolName) !== toolName) { 1414 return false 1415 } 1416 if (!parsed.ruleContent) { 1417 return true 1418 } 1419 return patternMatcher ? patternMatcher(parsed.ruleContent) : false 1420 } 1421} 1422 1423type FunctionHookMatcher = { 1424 matcher: string 1425 hooks: FunctionHook[] 1426} 1427 1428/** 1429 * A hook paired with optional plugin context. 1430 * Used when returning matched hooks so we can apply plugin env vars at execution time. 1431 */ 1432type MatchedHook = { 1433 hook: HookCommand | HookCallback | FunctionHook 1434 pluginRoot?: string 1435 pluginId?: string 1436 skillRoot?: string 1437 hookSource?: string 1438} 1439 1440function isInternalHook(matched: MatchedHook): boolean { 1441 return matched.hook.type === 'callback' && matched.hook.internal === true 1442} 1443 1444/** 1445 * Build a dedup key for a matched hook, namespaced by source context. 1446 * 1447 * Settings-file hooks (no pluginRoot/skillRoot) share the '' prefix so the 1448 * same command defined in user/project/local still collapses to one — the 1449 * original intent of the dedup. Plugin/skill hooks get their root as the 1450 * prefix, so two plugins sharing an unexpanded `${CLAUDE_PLUGIN_ROOT}/hook.sh` 1451 * template don't collapse: after expansion they point to different files. 1452 */ 1453function hookDedupKey(m: MatchedHook, payload: string): string { 1454 return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}` 1455} 1456 1457/** 1458 * Build a map of {sanitizedPluginName: hookCount} from matched hooks. 1459 * Only logs actual names for official marketplace plugins; others become 'third-party'. 1460 */ 1461function getPluginHookCounts( 1462 hooks: MatchedHook[], 1463): Record<string, number> | undefined { 1464 const pluginHooks = hooks.filter(h => h.pluginId) 1465 if (pluginHooks.length === 0) { 1466 return undefined 1467 } 1468 const counts: Record<string, number> = {} 1469 for (const h of pluginHooks) { 1470 const atIndex = h.pluginId!.lastIndexOf('@') 1471 const isOfficial = 1472 atIndex > 0 && 1473 ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(h.pluginId!.slice(atIndex + 1)) 1474 const key = isOfficial ? h.pluginId! : 'third-party' 1475 counts[key] = (counts[key] || 0) + 1 1476 } 1477 return counts 1478} 1479 1480 1481/** 1482 * Build a map of {hookType: count} from matched hooks. 1483 */ 1484function getHookTypeCounts(hooks: MatchedHook[]): Record<string, number> { 1485 const counts: Record<string, number> = {} 1486 for (const h of hooks) { 1487 counts[h.hook.type] = (counts[h.hook.type] || 0) + 1 1488 } 1489 return counts 1490} 1491 1492function getHooksConfig( 1493 appState: AppState | undefined, 1494 sessionId: string, 1495 hookEvent: HookEvent, 1496): Array< 1497 | HookMatcher 1498 | HookCallbackMatcher 1499 | FunctionHookMatcher 1500 | PluginHookMatcher 1501 | SkillHookMatcher 1502 | SessionDerivedHookMatcher 1503> { 1504 // HookMatcher is a zod-stripped {matcher, hooks} so snapshot matchers can be 1505 // pushed directly without re-wrapping. 1506 const hooks: Array< 1507 | HookMatcher 1508 | HookCallbackMatcher 1509 | FunctionHookMatcher 1510 | PluginHookMatcher 1511 | SkillHookMatcher 1512 | SessionDerivedHookMatcher 1513 > = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])] 1514 1515 // Check if only managed hooks should run (used for both registered and session hooks) 1516 const managedOnly = shouldAllowManagedHooksOnly() 1517 1518 // Process registered hooks (SDK callbacks and plugin native hooks) 1519 const registeredHooks = getRegisteredHooks()?.[hookEvent] 1520 if (registeredHooks) { 1521 for (const matcher of registeredHooks) { 1522 // Skip plugin hooks when restricted to managed hooks only 1523 // Plugin hooks have pluginRoot set, SDK callbacks do not 1524 if (managedOnly && 'pluginRoot' in matcher) { 1525 continue 1526 } 1527 hooks.push(matcher) 1528 } 1529 } 1530 1531 // Merge session hooks for the current session only 1532 // Function hooks (like structured output enforcement) must be scoped to their session 1533 // to prevent hooks from one agent leaking to another (e.g., verification agent to main agent) 1534 // Skip session hooks entirely when allowManagedHooksOnly is set — 1535 // this prevents frontmatter hooks from agents/skills from bypassing the policy. 1536 // strictPluginOnlyCustomization does NOT block here — it gates at the 1537 // REGISTRATION sites (runAgent.ts:526 for agent frontmatter hooks) where 1538 // agentDefinition.source is known. A blanket block here would also kill 1539 // plugin-provided agents' frontmatter hooks, which is too broad. 1540 // Also skip if appState not provided (for backwards compatibility) 1541 if (!managedOnly && appState !== undefined) { 1542 const sessionHooks = getSessionHooks(appState, sessionId, hookEvent).get( 1543 hookEvent, 1544 ) 1545 if (sessionHooks) { 1546 // SessionDerivedHookMatcher already includes optional skillRoot 1547 for (const matcher of sessionHooks) { 1548 hooks.push(matcher) 1549 } 1550 } 1551 1552 // Merge session function hooks separately (can't be persisted to HookMatcher format) 1553 const sessionFunctionHooks = getSessionFunctionHooks( 1554 appState, 1555 sessionId, 1556 hookEvent, 1557 ).get(hookEvent) 1558 if (sessionFunctionHooks) { 1559 for (const matcher of sessionFunctionHooks) { 1560 hooks.push(matcher) 1561 } 1562 } 1563 } 1564 1565 return hooks 1566} 1567 1568/** 1569 * Lightweight existence check for hooks on a given event. Mirrors the sources 1570 * assembled by getHooksConfig() but stops at the first hit without building 1571 * the full merged config. 1572 * 1573 * Intentionally over-approximates: returns true if any matcher exists for the 1574 * event, even if managed-only filtering or pattern matching would later 1575 * discard it. A false positive just means we proceed to the full matching 1576 * path; a false negative would skip a hook, so we err on the side of true. 1577 * 1578 * Used to skip createBaseHookInput (getTranscriptPathForSession path joins) 1579 * and getMatchingHooks on hot paths where hooks are typically unconfigured. 1580 * See hasInstructionsLoadedHook / hasWorktreeCreateHook for the same pattern. 1581 */ 1582function hasHookForEvent( 1583 hookEvent: HookEvent, 1584 appState: AppState | undefined, 1585 sessionId: string, 1586): boolean { 1587 const snap = getHooksConfigFromSnapshot()?.[hookEvent] 1588 if (snap && snap.length > 0) return true 1589 const reg = getRegisteredHooks()?.[hookEvent] 1590 if (reg && reg.length > 0) return true 1591 if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true 1592 return false 1593} 1594 1595/** 1596 * Get hook commands that match the given query 1597 * @param appState The current app state (optional for backwards compatibility) 1598 * @param sessionId The current session ID (main session or agent ID) 1599 * @param hookEvent The hook event 1600 * @param hookInput The hook input for matching 1601 * @returns Array of matched hooks with optional plugin context 1602 */ 1603export async function getMatchingHooks( 1604 appState: AppState | undefined, 1605 sessionId: string, 1606 hookEvent: HookEvent, 1607 hookInput: HookInput, 1608 tools?: Tools, 1609): Promise<MatchedHook[]> { 1610 try { 1611 const hookMatchers = getHooksConfig(appState, sessionId, hookEvent) 1612 1613 // If you change the criteria below, then you must change 1614 // src/utils/hooks/hooksConfigManager.ts as well. 1615 let matchQuery: string | undefined = undefined 1616 switch (hookInput.hook_event_name) { 1617 case 'PreToolUse': 1618 case 'PostToolUse': 1619 case 'PostToolUseFailure': 1620 case 'PermissionRequest': 1621 case 'PermissionDenied': 1622 matchQuery = hookInput.tool_name 1623 break 1624 case 'SessionStart': 1625 matchQuery = hookInput.source 1626 break 1627 case 'Setup': 1628 matchQuery = hookInput.trigger 1629 break 1630 case 'PreCompact': 1631 case 'PostCompact': 1632 matchQuery = hookInput.trigger 1633 break 1634 case 'Notification': 1635 matchQuery = hookInput.notification_type 1636 break 1637 case 'SessionEnd': 1638 matchQuery = hookInput.reason 1639 break 1640 case 'StopFailure': 1641 matchQuery = hookInput.error 1642 break 1643 case 'SubagentStart': 1644 matchQuery = hookInput.agent_type 1645 break 1646 case 'SubagentStop': 1647 matchQuery = hookInput.agent_type 1648 break 1649 case 'TeammateIdle': 1650 case 'TaskCreated': 1651 case 'TaskCompleted': 1652 break 1653 case 'Elicitation': 1654 matchQuery = hookInput.mcp_server_name 1655 break 1656 case 'ElicitationResult': 1657 matchQuery = hookInput.mcp_server_name 1658 break 1659 case 'ConfigChange': 1660 matchQuery = hookInput.source 1661 break 1662 case 'InstructionsLoaded': 1663 matchQuery = hookInput.load_reason 1664 break 1665 case 'FileChanged': 1666 matchQuery = basename(hookInput.file_path) 1667 break 1668 default: 1669 break 1670 } 1671 1672 logForDebugging( 1673 `Getting matching hook commands for ${hookEvent} with query: ${matchQuery}`, 1674 { level: 'verbose' }, 1675 ) 1676 logForDebugging(`Found ${hookMatchers.length} hook matchers in settings`, { 1677 level: 'verbose', 1678 }) 1679 1680 // Extract hooks with their plugin context (if any) 1681 const filteredMatchers = matchQuery 1682 ? hookMatchers.filter( 1683 matcher => 1684 !matcher.matcher || matchesPattern(matchQuery, matcher.matcher), 1685 ) 1686 : hookMatchers 1687 1688 const matchedHooks: MatchedHook[] = filteredMatchers.flatMap(matcher => { 1689 // Check if this is a PluginHookMatcher (has pluginRoot) or SkillHookMatcher (has skillRoot) 1690 const pluginRoot = 1691 'pluginRoot' in matcher ? matcher.pluginRoot : undefined 1692 const pluginId = 'pluginId' in matcher ? matcher.pluginId : undefined 1693 const skillRoot = 'skillRoot' in matcher ? matcher.skillRoot : undefined 1694 const hookSource = pluginRoot 1695 ? 'pluginName' in matcher 1696 ? `plugin:${matcher.pluginName}` 1697 : 'plugin' 1698 : skillRoot 1699 ? 'skillName' in matcher 1700 ? `skill:${matcher.skillName}` 1701 : 'skill' 1702 : 'settings' 1703 return matcher.hooks.map(hook => ({ 1704 hook, 1705 pluginRoot, 1706 pluginId, 1707 skillRoot, 1708 hookSource, 1709 })) 1710 }) 1711 1712 // Deduplicate hooks by command/prompt/url within the same source context. 1713 // Key is namespaced by pluginRoot/skillRoot (see hookDedupKey above) so 1714 // cross-plugin template collisions don't drop hooks (gh-29724). 1715 // 1716 // Note: new Map(entries) keeps the LAST entry on key collision, not first. 1717 // For settings hooks this means the last-merged scope wins; for 1718 // same-plugin duplicates the pluginRoot is identical so it doesn't matter. 1719 // Fast-path: callback/function hooks don't need dedup (each is unique). 1720 // Skip the 6-pass filter + 4×Map + 4×Array.from below when all hooks are 1721 // callback/function — the common case for internal hooks like 1722 // sessionFileAccessHooks/attributionHooks (44x faster in microbench). 1723 if ( 1724 matchedHooks.every( 1725 m => m.hook.type === 'callback' || m.hook.type === 'function', 1726 ) 1727 ) { 1728 return matchedHooks 1729 } 1730 1731 // Helper to extract the `if` condition from a hook for dedup keys. 1732 // Hooks with different `if` conditions are distinct even if otherwise identical. 1733 const getIfCondition = (hook: { if?: string }): string => hook.if ?? '' 1734 1735 const uniqueCommandHooks = Array.from( 1736 new Map( 1737 matchedHooks 1738 .filter( 1739 ( 1740 m, 1741 ): m is MatchedHook & { hook: HookCommand & { type: 'command' } } => 1742 m.hook.type === 'command', 1743 ) 1744 // shell is part of identity: {command:'echo x', shell:'bash'} 1745 // and {command:'echo x', shell:'powershell'} are distinct hooks, 1746 // not duplicates. Default to 'bash' so legacy configs (no shell 1747 // field) still dedup against explicit shell:'bash'. 1748 .map(m => [ 1749 hookDedupKey( 1750 m, 1751 `${m.hook.shell ?? DEFAULT_HOOK_SHELL}\0${m.hook.command}\0${getIfCondition(m.hook)}`, 1752 ), 1753 m, 1754 ]), 1755 ).values(), 1756 ) 1757 const uniquePromptHooks = Array.from( 1758 new Map( 1759 matchedHooks 1760 .filter(m => m.hook.type === 'prompt') 1761 .map(m => [ 1762 hookDedupKey( 1763 m, 1764 `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, 1765 ), 1766 m, 1767 ]), 1768 ).values(), 1769 ) 1770 const uniqueAgentHooks = Array.from( 1771 new Map( 1772 matchedHooks 1773 .filter(m => m.hook.type === 'agent') 1774 .map(m => [ 1775 hookDedupKey( 1776 m, 1777 `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, 1778 ), 1779 m, 1780 ]), 1781 ).values(), 1782 ) 1783 const uniqueHttpHooks = Array.from( 1784 new Map( 1785 matchedHooks 1786 .filter(m => m.hook.type === 'http') 1787 .map(m => [ 1788 hookDedupKey( 1789 m, 1790 `${(m.hook as { url: string }).url}\0${getIfCondition(m.hook as { if?: string })}`, 1791 ), 1792 m, 1793 ]), 1794 ).values(), 1795 ) 1796 const callbackHooks = matchedHooks.filter(m => m.hook.type === 'callback') 1797 // Function hooks don't need deduplication - each callback is unique 1798 const functionHooks = matchedHooks.filter(m => m.hook.type === 'function') 1799 const uniqueHooks = [ 1800 ...uniqueCommandHooks, 1801 ...uniquePromptHooks, 1802 ...uniqueAgentHooks, 1803 ...uniqueHttpHooks, 1804 ...callbackHooks, 1805 ...functionHooks, 1806 ] 1807 1808 // Filter hooks based on their `if` condition. This allows hooks to specify 1809 // conditions like "Bash(git *)" to only run for git commands, avoiding 1810 // process spawning overhead for non-matching commands. 1811 const hasIfCondition = uniqueHooks.some( 1812 h => 1813 (h.hook.type === 'command' || 1814 h.hook.type === 'prompt' || 1815 h.hook.type === 'agent' || 1816 h.hook.type === 'http') && 1817 (h.hook as { if?: string }).if, 1818 ) 1819 const ifMatcher = hasIfCondition 1820 ? await prepareIfConditionMatcher(hookInput, tools) 1821 : undefined 1822 const ifFilteredHooks = uniqueHooks.filter(h => { 1823 if ( 1824 h.hook.type !== 'command' && 1825 h.hook.type !== 'prompt' && 1826 h.hook.type !== 'agent' && 1827 h.hook.type !== 'http' 1828 ) { 1829 return true 1830 } 1831 const ifCondition = (h.hook as { if?: string }).if 1832 if (!ifCondition) { 1833 return true 1834 } 1835 if (!ifMatcher) { 1836 logForDebugging( 1837 `Hook if condition "${ifCondition}" cannot be evaluated for non-tool event ${hookInput.hook_event_name}`, 1838 ) 1839 return false 1840 } 1841 if (ifMatcher(ifCondition)) { 1842 return true 1843 } 1844 logForDebugging( 1845 `Skipping hook due to if condition "${ifCondition}" not matching`, 1846 ) 1847 return false 1848 }) 1849 1850 // HTTP hooks are not supported for SessionStart/Setup events. In headless 1851 // mode the sandbox ask callback deadlocks because the structuredInput 1852 // consumer hasn't started yet when these hooks fire. 1853 const filteredHooks = 1854 hookEvent === 'SessionStart' || hookEvent === 'Setup' 1855 ? ifFilteredHooks.filter(h => { 1856 if (h.hook.type === 'http') { 1857 logForDebugging( 1858 `Skipping HTTP hook ${(h.hook as { url: string }).url} — HTTP hooks are not supported for ${hookEvent}`, 1859 ) 1860 return false 1861 } 1862 return true 1863 }) 1864 : ifFilteredHooks 1865 1866 logForDebugging( 1867 `Matched ${filteredHooks.length} unique hooks for query "${matchQuery || 'no match query'}" (${matchedHooks.length} before deduplication)`, 1868 { level: 'verbose' }, 1869 ) 1870 return filteredHooks 1871 } catch { 1872 return [] 1873 } 1874} 1875 1876/** 1877 * Format a list of blocking errors from a PreTool hook's configured commands. 1878 * @param hookName The name of the hook (e.g., 'PreToolUse:Write', 'PreToolUse:Edit', 'PreToolUse:Bash') 1879 * @param blockingErrors Array of blocking errors from hooks 1880 * @returns Formatted blocking message 1881 */ 1882export function getPreToolHookBlockingMessage( 1883 hookName: string, 1884 blockingError: HookBlockingError, 1885): string { 1886 return `${hookName} hook error: ${blockingError.blockingError}` 1887} 1888 1889/** 1890 * Format a list of blocking errors from a Stop hook's configured commands. 1891 * @param blockingErrors Array of blocking errors from hooks 1892 * @returns Formatted message to give feedback to the model 1893 */ 1894export function getStopHookMessage(blockingError: HookBlockingError): string { 1895 return `Stop hook feedback:\n${blockingError.blockingError}` 1896} 1897 1898/** 1899 * Format a blocking error from a TeammateIdle hook. 1900 * @param blockingError The blocking error from the hook 1901 * @returns Formatted message to give feedback to the model 1902 */ 1903export function getTeammateIdleHookMessage( 1904 blockingError: HookBlockingError, 1905): string { 1906 return `TeammateIdle hook feedback:\n${blockingError.blockingError}` 1907} 1908 1909/** 1910 * Format a blocking error from a TaskCreated hook. 1911 * @param blockingError The blocking error from the hook 1912 * @returns Formatted message to give feedback to the model 1913 */ 1914export function getTaskCreatedHookMessage( 1915 blockingError: HookBlockingError, 1916): string { 1917 return `TaskCreated hook feedback:\n${blockingError.blockingError}` 1918} 1919 1920/** 1921 * Format a blocking error from a TaskCompleted hook. 1922 * @param blockingError The blocking error from the hook 1923 * @returns Formatted message to give feedback to the model 1924 */ 1925export function getTaskCompletedHookMessage( 1926 blockingError: HookBlockingError, 1927): string { 1928 return `TaskCompleted hook feedback:\n${blockingError.blockingError}` 1929} 1930 1931/** 1932 * Format a list of blocking errors from a UserPromptSubmit hook's configured commands. 1933 * @param blockingErrors Array of blocking errors from hooks 1934 * @returns Formatted blocking message 1935 */ 1936export function getUserPromptSubmitHookBlockingMessage( 1937 blockingError: HookBlockingError, 1938): string { 1939 return `UserPromptSubmit operation blocked by hook:\n${blockingError.blockingError}` 1940} 1941/** 1942 * Common logic for executing hooks 1943 * @param hookInput The structured hook input that will be validated and converted to JSON 1944 * @param toolUseID The ID for tracking this hook execution 1945 * @param matchQuery The query to match against hook matchers 1946 * @param signal Optional AbortSignal to cancel hook execution 1947 * @param timeoutMs Optional timeout in milliseconds for hook execution 1948 * @param toolUseContext Optional ToolUseContext for prompt-based hooks (required if using prompt hooks) 1949 * @param messages Optional conversation history for prompt/function hooks 1950 * @returns Async generator that yields progress messages and hook results 1951 */ 1952async function* executeHooks({ 1953 hookInput, 1954 toolUseID, 1955 matchQuery, 1956 signal, 1957 timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 1958 toolUseContext, 1959 messages, 1960 forceSyncExecution, 1961 requestPrompt, 1962 toolInputSummary, 1963}: { 1964 hookInput: HookInput 1965 toolUseID: string 1966 matchQuery?: string 1967 signal?: AbortSignal 1968 timeoutMs?: number 1969 toolUseContext?: ToolUseContext 1970 messages?: Message[] 1971 forceSyncExecution?: boolean 1972 requestPrompt?: ( 1973 sourceName: string, 1974 toolInputSummary?: string | null, 1975 ) => (request: PromptRequest) => Promise<PromptResponse> 1976 toolInputSummary?: string | null 1977}): AsyncGenerator<AggregatedHookResult> { 1978 if (shouldDisableAllHooksIncludingManaged()) { 1979 return 1980 } 1981 1982 if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 1983 return 1984 } 1985 1986 const hookEvent = hookInput.hook_event_name 1987 const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent 1988 1989 // Bind the prompt callback to this hook's name and tool input summary so the UI can display context 1990 const boundRequestPrompt = requestPrompt?.(hookName, toolInputSummary) 1991 1992 // SECURITY: ALL hooks require workspace trust in interactive mode 1993 // This centralized check prevents RCE vulnerabilities for all current and future hooks 1994 if (shouldSkipHookDueToTrust()) { 1995 logForDebugging( 1996 `Skipping ${hookName} hook execution - workspace trust not accepted`, 1997 ) 1998 return 1999 } 2000 2001 const appState = toolUseContext ? toolUseContext.getAppState() : undefined 2002 // Use the agent's session ID if available, otherwise fall back to main session 2003 const sessionId = toolUseContext?.agentId ?? getSessionId() 2004 const matchingHooks = await getMatchingHooks( 2005 appState, 2006 sessionId, 2007 hookEvent, 2008 hookInput, 2009 toolUseContext?.options?.tools, 2010 ) 2011 if (matchingHooks.length === 0) { 2012 return 2013 } 2014 2015 if (signal?.aborted) { 2016 return 2017 } 2018 2019 const userHooks = matchingHooks.filter(h => !isInternalHook(h)) 2020 if (userHooks.length > 0) { 2021 const pluginHookCounts = getPluginHookCounts(userHooks) 2022 const hookTypeCounts = getHookTypeCounts(userHooks) 2023 logEvent(`tengu_run_hook`, { 2024 hookName: 2025 hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2026 numCommands: userHooks.length, 2027 hookTypeCounts: jsonStringify( 2028 hookTypeCounts, 2029 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2030 ...(pluginHookCounts && { 2031 pluginHookCounts: jsonStringify( 2032 pluginHookCounts, 2033 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2034 }), 2035 }) 2036 } else { 2037 // Fast-path: all hooks are internal callbacks (sessionFileAccessHooks, 2038 // attributionHooks). These return {} and don't use the abort signal, so we 2039 // can skip span/progress/abortSignal/processHookJSONOutput/resultLoop. 2040 // Measured: 6.01µs → ~1.8µs per PostToolUse hit (-70%). 2041 const batchStartTime = Date.now() 2042 const context = toolUseContext 2043 ? { 2044 getAppState: toolUseContext.getAppState, 2045 updateAttributionState: toolUseContext.updateAttributionState, 2046 } 2047 : undefined 2048 for (const [i, { hook }] of matchingHooks.entries()) { 2049 if (hook.type === 'callback') { 2050 await hook.callback(hookInput, toolUseID, signal, i, context) 2051 } 2052 } 2053 const totalDurationMs = Date.now() - batchStartTime 2054 getStatsStore()?.observe('hook_duration_ms', totalDurationMs) 2055 addToTurnHookDuration(totalDurationMs) 2056 logEvent(`tengu_repl_hook_finished`, { 2057 hookName: 2058 hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2059 numCommands: matchingHooks.length, 2060 numSuccess: matchingHooks.length, 2061 numBlocking: 0, 2062 numNonBlockingError: 0, 2063 numCancelled: 0, 2064 totalDurationMs, 2065 }) 2066 return 2067 } 2068 2069 // Collect hook definitions for beta tracing telemetry 2070 const hookDefinitionsJson = isBetaTracingEnabled() 2071 ? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks)) 2072 : '[]' 2073 2074 // Log hook execution start to OTEL (only for beta tracing) 2075 if (isBetaTracingEnabled()) { 2076 void logOTelEvent('hook_execution_start', { 2077 hook_event: hookEvent, 2078 hook_name: hookName, 2079 num_hooks: String(matchingHooks.length), 2080 managed_only: String(shouldAllowManagedHooksOnly()), 2081 hook_definitions: hookDefinitionsJson, 2082 hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', 2083 }) 2084 } 2085 2086 // Start hook span for beta tracing 2087 const hookSpan = startHookSpan( 2088 hookEvent, 2089 hookName, 2090 matchingHooks.length, 2091 hookDefinitionsJson, 2092 ) 2093 2094 // Yield progress messages for each hook before execution 2095 for (const { hook } of matchingHooks) { 2096 yield { 2097 message: { 2098 type: 'progress', 2099 data: { 2100 type: 'hook_progress', 2101 hookEvent, 2102 hookName, 2103 command: getHookDisplayText(hook), 2104 ...(hook.type === 'prompt' && { promptText: hook.prompt }), 2105 ...('statusMessage' in hook && 2106 hook.statusMessage != null && { 2107 statusMessage: hook.statusMessage, 2108 }), 2109 }, 2110 parentToolUseID: toolUseID, 2111 toolUseID, 2112 timestamp: new Date().toISOString(), 2113 uuid: randomUUID(), 2114 }, 2115 } 2116 } 2117 2118 // Track wall-clock time for the entire hook batch 2119 const batchStartTime = Date.now() 2120 2121 // Lazy-once stringify of hookInput. Shared across all command/prompt/agent/http 2122 // hooks in this batch (hookInput is never mutated). Callback/function hooks 2123 // return before reaching this, so batches with only those pay no stringify cost. 2124 let jsonInputResult: 2125 | { ok: true; value: string } 2126 | { ok: false; error: unknown } 2127 | undefined 2128 function getJsonInput() { 2129 if (jsonInputResult !== undefined) { 2130 return jsonInputResult 2131 } 2132 try { 2133 return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) }) 2134 } catch (error) { 2135 logError( 2136 Error(`Failed to stringify hook ${hookName} input`, { cause: error }), 2137 ) 2138 return (jsonInputResult = { ok: false, error }) 2139 } 2140 } 2141 2142 // Run all hooks in parallel with individual timeouts 2143 const hookPromises = matchingHooks.map(async function* ( 2144 { hook, pluginRoot, pluginId, skillRoot }, 2145 hookIndex, 2146 ): AsyncGenerator<HookResult> { 2147 if (hook.type === 'callback') { 2148 const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 2149 const { signal: abortSignal, cleanup } = createCombinedAbortSignal( 2150 signal, 2151 { timeoutMs: callbackTimeoutMs }, 2152 ) 2153 yield executeHookCallback({ 2154 toolUseID, 2155 hook, 2156 hookEvent, 2157 hookInput, 2158 signal: abortSignal, 2159 hookIndex, 2160 toolUseContext, 2161 }).finally(cleanup) 2162 return 2163 } 2164 2165 if (hook.type === 'function') { 2166 if (!messages) { 2167 yield { 2168 message: createAttachmentMessage({ 2169 type: 'hook_error_during_execution', 2170 hookName, 2171 toolUseID, 2172 hookEvent, 2173 content: 'Messages not provided for function hook', 2174 }), 2175 outcome: 'non_blocking_error', 2176 hook, 2177 } 2178 return 2179 } 2180 2181 // Function hooks only come from session storage with callback embedded 2182 yield executeFunctionHook({ 2183 hook, 2184 messages, 2185 hookName, 2186 toolUseID, 2187 hookEvent, 2188 timeoutMs, 2189 signal, 2190 }) 2191 return 2192 } 2193 2194 // Command and prompt hooks need jsonInput 2195 const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 2196 const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { 2197 timeoutMs: commandTimeoutMs, 2198 }) 2199 const hookId = randomUUID() 2200 const hookStartMs = Date.now() 2201 const hookCommand = getHookDisplayText(hook) 2202 2203 try { 2204 const jsonInputRes = getJsonInput() 2205 if (!jsonInputRes.ok) { 2206 yield { 2207 message: createAttachmentMessage({ 2208 type: 'hook_error_during_execution', 2209 hookName, 2210 toolUseID, 2211 hookEvent, 2212 content: `Failed to prepare hook input: ${errorMessage(jsonInputRes.error)}`, 2213 command: hookCommand, 2214 durationMs: Date.now() - hookStartMs, 2215 }), 2216 outcome: 'non_blocking_error', 2217 hook, 2218 } 2219 cleanup() 2220 return 2221 } 2222 const jsonInput = jsonInputRes.value 2223 2224 if (hook.type === 'prompt') { 2225 if (!toolUseContext) { 2226 throw new Error( 2227 'ToolUseContext is required for prompt hooks. This is a bug.', 2228 ) 2229 } 2230 const promptResult = await execPromptHook( 2231 hook, 2232 hookName, 2233 hookEvent, 2234 jsonInput, 2235 abortSignal, 2236 toolUseContext, 2237 messages, 2238 toolUseID, 2239 ) 2240 // Inject timing fields for hook visibility 2241 if (promptResult.message?.type === 'attachment') { 2242 const att = promptResult.message.attachment 2243 if ( 2244 att.type === 'hook_success' || 2245 att.type === 'hook_non_blocking_error' 2246 ) { 2247 att.command = hookCommand 2248 att.durationMs = Date.now() - hookStartMs 2249 } 2250 } 2251 yield promptResult 2252 cleanup?.() 2253 return 2254 } 2255 2256 if (hook.type === 'agent') { 2257 if (!toolUseContext) { 2258 throw new Error( 2259 'ToolUseContext is required for agent hooks. This is a bug.', 2260 ) 2261 } 2262 if (!messages) { 2263 throw new Error( 2264 'Messages are required for agent hooks. This is a bug.', 2265 ) 2266 } 2267 const agentResult = await execAgentHook( 2268 hook, 2269 hookName, 2270 hookEvent, 2271 jsonInput, 2272 abortSignal, 2273 toolUseContext, 2274 toolUseID, 2275 messages, 2276 'agent_type' in hookInput 2277 ? (hookInput.agent_type as string) 2278 : undefined, 2279 ) 2280 // Inject timing fields for hook visibility 2281 if (agentResult.message?.type === 'attachment') { 2282 const att = agentResult.message.attachment 2283 if ( 2284 att.type === 'hook_success' || 2285 att.type === 'hook_non_blocking_error' 2286 ) { 2287 att.command = hookCommand 2288 att.durationMs = Date.now() - hookStartMs 2289 } 2290 } 2291 yield agentResult 2292 cleanup?.() 2293 return 2294 } 2295 2296 if (hook.type === 'http') { 2297 emitHookStarted(hookId, hookName, hookEvent) 2298 2299 // execHttpHook manages its own timeout internally via hook.timeout or 2300 // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so pass the parent signal directly 2301 // to avoid double-stacking timeouts with abortSignal. 2302 const httpResult = await execHttpHook( 2303 hook, 2304 hookEvent, 2305 jsonInput, 2306 signal, 2307 ) 2308 cleanup?.() 2309 2310 if (httpResult.aborted) { 2311 emitHookResponse({ 2312 hookId, 2313 hookName, 2314 hookEvent, 2315 output: 'Hook cancelled', 2316 stdout: '', 2317 stderr: '', 2318 exitCode: undefined, 2319 outcome: 'cancelled', 2320 }) 2321 yield { 2322 message: createAttachmentMessage({ 2323 type: 'hook_cancelled', 2324 hookName, 2325 toolUseID, 2326 hookEvent, 2327 }), 2328 outcome: 'cancelled' as const, 2329 hook, 2330 } 2331 return 2332 } 2333 2334 if (httpResult.error || !httpResult.ok) { 2335 const stderr = 2336 httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}` 2337 emitHookResponse({ 2338 hookId, 2339 hookName, 2340 hookEvent, 2341 output: stderr, 2342 stdout: '', 2343 stderr, 2344 exitCode: httpResult.statusCode, 2345 outcome: 'error', 2346 }) 2347 yield { 2348 message: createAttachmentMessage({ 2349 type: 'hook_non_blocking_error', 2350 hookName, 2351 toolUseID, 2352 hookEvent, 2353 stderr, 2354 stdout: '', 2355 exitCode: httpResult.statusCode ?? 0, 2356 }), 2357 outcome: 'non_blocking_error' as const, 2358 hook, 2359 } 2360 return 2361 } 2362 2363 // HTTP hooks must return JSON — parse and validate through Zod 2364 const { json: httpJson, validationError: httpValidationError } = 2365 parseHttpHookOutput(httpResult.body) 2366 2367 if (httpValidationError) { 2368 emitHookResponse({ 2369 hookId, 2370 hookName, 2371 hookEvent, 2372 output: httpResult.body, 2373 stdout: httpResult.body, 2374 stderr: `JSON validation failed: ${httpValidationError}`, 2375 exitCode: httpResult.statusCode, 2376 outcome: 'error', 2377 }) 2378 yield { 2379 message: createAttachmentMessage({ 2380 type: 'hook_non_blocking_error', 2381 hookName, 2382 toolUseID, 2383 hookEvent, 2384 stderr: `JSON validation failed: ${httpValidationError}`, 2385 stdout: httpResult.body, 2386 exitCode: httpResult.statusCode ?? 0, 2387 }), 2388 outcome: 'non_blocking_error' as const, 2389 hook, 2390 } 2391 return 2392 } 2393 2394 if (httpJson && isAsyncHookJSONOutput(httpJson)) { 2395 // Async response: treat as success (no further processing) 2396 emitHookResponse({ 2397 hookId, 2398 hookName, 2399 hookEvent, 2400 output: httpResult.body, 2401 stdout: httpResult.body, 2402 stderr: '', 2403 exitCode: httpResult.statusCode, 2404 outcome: 'success', 2405 }) 2406 yield { 2407 outcome: 'success' as const, 2408 hook, 2409 } 2410 return 2411 } 2412 2413 if (httpJson) { 2414 const processed = processHookJSONOutput({ 2415 json: httpJson, 2416 command: hook.url, 2417 hookName, 2418 toolUseID, 2419 hookEvent, 2420 expectedHookEvent: hookEvent, 2421 stdout: httpResult.body, 2422 stderr: '', 2423 exitCode: httpResult.statusCode, 2424 }) 2425 emitHookResponse({ 2426 hookId, 2427 hookName, 2428 hookEvent, 2429 output: httpResult.body, 2430 stdout: httpResult.body, 2431 stderr: '', 2432 exitCode: httpResult.statusCode, 2433 outcome: 'success', 2434 }) 2435 yield { 2436 ...processed, 2437 outcome: 'success' as const, 2438 hook, 2439 } 2440 return 2441 } 2442 2443 return 2444 } 2445 2446 emitHookStarted(hookId, hookName, hookEvent) 2447 2448 const result = await execCommandHook( 2449 hook, 2450 hookEvent, 2451 hookName, 2452 jsonInput, 2453 abortSignal, 2454 hookId, 2455 hookIndex, 2456 pluginRoot, 2457 pluginId, 2458 skillRoot, 2459 forceSyncExecution, 2460 boundRequestPrompt, 2461 ) 2462 cleanup?.() 2463 const durationMs = Date.now() - hookStartMs 2464 2465 if (result.backgrounded) { 2466 yield { 2467 outcome: 'success' as const, 2468 hook, 2469 } 2470 return 2471 } 2472 2473 if (result.aborted) { 2474 emitHookResponse({ 2475 hookId, 2476 hookName, 2477 hookEvent, 2478 output: result.output, 2479 stdout: result.stdout, 2480 stderr: result.stderr, 2481 exitCode: result.status, 2482 outcome: 'cancelled', 2483 }) 2484 yield { 2485 message: createAttachmentMessage({ 2486 type: 'hook_cancelled', 2487 hookName, 2488 toolUseID, 2489 hookEvent, 2490 command: hookCommand, 2491 durationMs, 2492 }), 2493 outcome: 'cancelled' as const, 2494 hook, 2495 } 2496 return 2497 } 2498 2499 // Try JSON parsing first 2500 const { json, plainText, validationError } = parseHookOutput( 2501 result.stdout, 2502 ) 2503 2504 if (validationError) { 2505 emitHookResponse({ 2506 hookId, 2507 hookName, 2508 hookEvent, 2509 output: result.output, 2510 stdout: result.stdout, 2511 stderr: `JSON validation failed: ${validationError}`, 2512 exitCode: 1, 2513 outcome: 'error', 2514 }) 2515 yield { 2516 message: createAttachmentMessage({ 2517 type: 'hook_non_blocking_error', 2518 hookName, 2519 toolUseID, 2520 hookEvent, 2521 stderr: `JSON validation failed: ${validationError}`, 2522 stdout: result.stdout, 2523 exitCode: 1, 2524 command: hookCommand, 2525 durationMs, 2526 }), 2527 outcome: 'non_blocking_error' as const, 2528 hook, 2529 } 2530 return 2531 } 2532 2533 if (json) { 2534 // Async responses were already backgrounded during execution 2535 if (isAsyncHookJSONOutput(json)) { 2536 yield { 2537 outcome: 'success' as const, 2538 hook, 2539 } 2540 return 2541 } 2542 2543 // Process JSON output 2544 const processed = processHookJSONOutput({ 2545 json, 2546 command: hookCommand, 2547 hookName, 2548 toolUseID, 2549 hookEvent, 2550 expectedHookEvent: hookEvent, 2551 stdout: result.stdout, 2552 stderr: result.stderr, 2553 exitCode: result.status, 2554 durationMs, 2555 }) 2556 2557 // Handle suppressOutput (skip for async responses) 2558 if ( 2559 isSyncHookJSONOutput(json) && 2560 !json.suppressOutput && 2561 plainText && 2562 result.status === 0 2563 ) { 2564 // Still show non-JSON output if not suppressed 2565 const content = `${chalk.bold(hookName)} completed` 2566 emitHookResponse({ 2567 hookId, 2568 hookName, 2569 hookEvent, 2570 output: result.output, 2571 stdout: result.stdout, 2572 stderr: result.stderr, 2573 exitCode: result.status, 2574 outcome: 'success', 2575 }) 2576 yield { 2577 ...processed, 2578 message: 2579 processed.message || 2580 createAttachmentMessage({ 2581 type: 'hook_success', 2582 hookName, 2583 toolUseID, 2584 hookEvent, 2585 content, 2586 stdout: result.stdout, 2587 stderr: result.stderr, 2588 exitCode: result.status, 2589 command: hookCommand, 2590 durationMs, 2591 }), 2592 outcome: 'success' as const, 2593 hook, 2594 } 2595 return 2596 } 2597 2598 emitHookResponse({ 2599 hookId, 2600 hookName, 2601 hookEvent, 2602 output: result.output, 2603 stdout: result.stdout, 2604 stderr: result.stderr, 2605 exitCode: result.status, 2606 outcome: result.status === 0 ? 'success' : 'error', 2607 }) 2608 yield { 2609 ...processed, 2610 outcome: 'success' as const, 2611 hook, 2612 } 2613 return 2614 } 2615 2616 // Fall back to existing logic for non-JSON output 2617 if (result.status === 0) { 2618 emitHookResponse({ 2619 hookId, 2620 hookName, 2621 hookEvent, 2622 output: result.output, 2623 stdout: result.stdout, 2624 stderr: result.stderr, 2625 exitCode: result.status, 2626 outcome: 'success', 2627 }) 2628 yield { 2629 message: createAttachmentMessage({ 2630 type: 'hook_success', 2631 hookName, 2632 toolUseID, 2633 hookEvent, 2634 content: result.stdout.trim(), 2635 stdout: result.stdout, 2636 stderr: result.stderr, 2637 exitCode: result.status, 2638 command: hookCommand, 2639 durationMs, 2640 }), 2641 outcome: 'success' as const, 2642 hook, 2643 } 2644 return 2645 } 2646 2647 // Hooks with exit code 2 provide blocking feedback 2648 if (result.status === 2) { 2649 emitHookResponse({ 2650 hookId, 2651 hookName, 2652 hookEvent, 2653 output: result.output, 2654 stdout: result.stdout, 2655 stderr: result.stderr, 2656 exitCode: result.status, 2657 outcome: 'error', 2658 }) 2659 yield { 2660 blockingError: { 2661 blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`, 2662 command: hook.command, 2663 }, 2664 outcome: 'blocking' as const, 2665 hook, 2666 } 2667 return 2668 } 2669 2670 // Any other non-zero exit code is a non-critical error that should just 2671 // be shown to the user. 2672 emitHookResponse({ 2673 hookId, 2674 hookName, 2675 hookEvent, 2676 output: result.output, 2677 stdout: result.stdout, 2678 stderr: result.stderr, 2679 exitCode: result.status, 2680 outcome: 'error', 2681 }) 2682 yield { 2683 message: createAttachmentMessage({ 2684 type: 'hook_non_blocking_error', 2685 hookName, 2686 toolUseID, 2687 hookEvent, 2688 stderr: `Failed with non-blocking status code: ${result.stderr.trim() || 'No stderr output'}`, 2689 stdout: result.stdout, 2690 exitCode: result.status, 2691 command: hookCommand, 2692 durationMs, 2693 }), 2694 outcome: 'non_blocking_error' as const, 2695 hook, 2696 } 2697 return 2698 } catch (error) { 2699 // Clean up on error 2700 cleanup?.() 2701 2702 const errorMessage = 2703 error instanceof Error ? error.message : String(error) 2704 emitHookResponse({ 2705 hookId, 2706 hookName, 2707 hookEvent, 2708 output: `Failed to run: ${errorMessage}`, 2709 stdout: '', 2710 stderr: `Failed to run: ${errorMessage}`, 2711 exitCode: 1, 2712 outcome: 'error', 2713 }) 2714 yield { 2715 message: createAttachmentMessage({ 2716 type: 'hook_non_blocking_error', 2717 hookName, 2718 toolUseID, 2719 hookEvent, 2720 stderr: `Failed to run: ${errorMessage}`, 2721 stdout: '', 2722 exitCode: 1, 2723 command: hookCommand, 2724 durationMs: Date.now() - hookStartMs, 2725 }), 2726 outcome: 'non_blocking_error' as const, 2727 hook, 2728 } 2729 return 2730 } 2731 }) 2732 2733 // Track outcomes for logging 2734 const outcomes = { 2735 success: 0, 2736 blocking: 0, 2737 non_blocking_error: 0, 2738 cancelled: 0, 2739 } 2740 2741 let permissionBehavior: PermissionResult['behavior'] | undefined 2742 2743 // Run all hooks in parallel and wait for all to complete 2744 for await (const result of all(hookPromises)) { 2745 outcomes[result.outcome]++ 2746 2747 // Check for preventContinuation early 2748 if (result.preventContinuation) { 2749 logForDebugging( 2750 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) requested preventContinuation`, 2751 ) 2752 yield { 2753 preventContinuation: true, 2754 stopReason: result.stopReason, 2755 } 2756 } 2757 2758 // Handle different result types 2759 if (result.blockingError) { 2760 yield { 2761 blockingError: result.blockingError, 2762 } 2763 } 2764 2765 if (result.message) { 2766 yield { message: result.message } 2767 } 2768 2769 // Yield system message separately if present 2770 if (result.systemMessage) { 2771 yield { 2772 message: createAttachmentMessage({ 2773 type: 'hook_system_message', 2774 content: result.systemMessage, 2775 hookName, 2776 toolUseID, 2777 hookEvent, 2778 }), 2779 } 2780 } 2781 2782 // Collect additional context from hooks 2783 if (result.additionalContext) { 2784 logForDebugging( 2785 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided additionalContext (${result.additionalContext.length} chars)`, 2786 ) 2787 yield { 2788 additionalContexts: [result.additionalContext], 2789 } 2790 } 2791 2792 if (result.initialUserMessage) { 2793 logForDebugging( 2794 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided initialUserMessage (${result.initialUserMessage.length} chars)`, 2795 ) 2796 yield { 2797 initialUserMessage: result.initialUserMessage, 2798 } 2799 } 2800 2801 if (result.watchPaths && result.watchPaths.length > 0) { 2802 logForDebugging( 2803 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided ${result.watchPaths.length} watchPaths`, 2804 ) 2805 yield { 2806 watchPaths: result.watchPaths, 2807 } 2808 } 2809 2810 // Yield updatedMCPToolOutput if provided (from PostToolUse hooks) 2811 if (result.updatedMCPToolOutput) { 2812 logForDebugging( 2813 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) replaced MCP tool output`, 2814 ) 2815 yield { 2816 updatedMCPToolOutput: result.updatedMCPToolOutput, 2817 } 2818 } 2819 2820 // Check for permission behavior with precedence: deny > ask > allow 2821 if (result.permissionBehavior) { 2822 logForDebugging( 2823 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) returned permissionDecision: ${result.permissionBehavior}${result.hookPermissionDecisionReason ? ` (reason: ${result.hookPermissionDecisionReason})` : ''}`, 2824 ) 2825 // Apply precedence rules 2826 switch (result.permissionBehavior) { 2827 case 'deny': 2828 // deny always takes precedence 2829 permissionBehavior = 'deny' 2830 break 2831 case 'ask': 2832 // ask takes precedence over allow but not deny 2833 if (permissionBehavior !== 'deny') { 2834 permissionBehavior = 'ask' 2835 } 2836 break 2837 case 'allow': 2838 // allow only if no other behavior set 2839 if (!permissionBehavior) { 2840 permissionBehavior = 'allow' 2841 } 2842 break 2843 case 'passthrough': 2844 // passthrough doesn't set permission behavior 2845 break 2846 } 2847 } 2848 2849 // Yield permission behavior and updatedInput if provided (from allow or ask behavior) 2850 if (permissionBehavior !== undefined) { 2851 const updatedInput = 2852 result.updatedInput && 2853 (result.permissionBehavior === 'allow' || 2854 result.permissionBehavior === 'ask') 2855 ? result.updatedInput 2856 : undefined 2857 if (updatedInput) { 2858 logForDebugging( 2859 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(updatedInput).join(', ')}]`, 2860 ) 2861 } 2862 yield { 2863 permissionBehavior, 2864 hookPermissionDecisionReason: result.hookPermissionDecisionReason, 2865 hookSource: matchingHooks.find(m => m.hook === result.hook)?.hookSource, 2866 updatedInput, 2867 } 2868 } 2869 2870 // Yield updatedInput separately for passthrough case (no permission decision) 2871 // This allows hooks to modify input without making a permission decision 2872 // Note: Check result.permissionBehavior (this hook's behavior), not the aggregated permissionBehavior 2873 if (result.updatedInput && result.permissionBehavior === undefined) { 2874 logForDebugging( 2875 `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(result.updatedInput).join(', ')}]`, 2876 ) 2877 yield { 2878 updatedInput: result.updatedInput, 2879 } 2880 } 2881 // Yield permission request result if provided (from PermissionRequest hooks) 2882 if (result.permissionRequestResult) { 2883 yield { 2884 permissionRequestResult: result.permissionRequestResult, 2885 } 2886 } 2887 // Yield retry flag if provided (from PermissionDenied hooks) 2888 if (result.retry) { 2889 yield { 2890 retry: result.retry, 2891 } 2892 } 2893 // Yield elicitation response if provided (from Elicitation hooks) 2894 if (result.elicitationResponse) { 2895 yield { 2896 elicitationResponse: result.elicitationResponse, 2897 } 2898 } 2899 // Yield elicitation result response if provided (from ElicitationResult hooks) 2900 if (result.elicitationResultResponse) { 2901 yield { 2902 elicitationResultResponse: result.elicitationResultResponse, 2903 } 2904 } 2905 2906 // Invoke session hook callback if this is a command/prompt/function hook (not a callback hook) 2907 if (appState && result.hook.type !== 'callback') { 2908 const sessionId = getSessionId() 2909 // Use empty string as matcher when matchQuery is undefined (e.g., for Stop hooks) 2910 const matcher = matchQuery ?? '' 2911 const hookEntry = getSessionHookCallback( 2912 appState, 2913 sessionId, 2914 hookEvent, 2915 matcher, 2916 result.hook, 2917 ) 2918 // Invoke onHookSuccess only on success outcome 2919 if (hookEntry?.onHookSuccess && result.outcome === 'success') { 2920 try { 2921 hookEntry.onHookSuccess(result.hook, result as AggregatedHookResult) 2922 } catch (error) { 2923 logError( 2924 Error('Session hook success callback failed', { cause: error }), 2925 ) 2926 } 2927 } 2928 } 2929 } 2930 2931 const totalDurationMs = Date.now() - batchStartTime 2932 getStatsStore()?.observe('hook_duration_ms', totalDurationMs) 2933 addToTurnHookDuration(totalDurationMs) 2934 2935 logEvent(`tengu_repl_hook_finished`, { 2936 hookName: 2937 hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2938 numCommands: matchingHooks.length, 2939 numSuccess: outcomes.success, 2940 numBlocking: outcomes.blocking, 2941 numNonBlockingError: outcomes.non_blocking_error, 2942 numCancelled: outcomes.cancelled, 2943 totalDurationMs, 2944 }) 2945 2946 // Log hook execution completion to OTEL (only for beta tracing) 2947 if (isBetaTracingEnabled()) { 2948 const hookDefinitionsComplete = 2949 getHookDefinitionsForTelemetry(matchingHooks) 2950 2951 void logOTelEvent('hook_execution_complete', { 2952 hook_event: hookEvent, 2953 hook_name: hookName, 2954 num_hooks: String(matchingHooks.length), 2955 num_success: String(outcomes.success), 2956 num_blocking: String(outcomes.blocking), 2957 num_non_blocking_error: String(outcomes.non_blocking_error), 2958 num_cancelled: String(outcomes.cancelled), 2959 managed_only: String(shouldAllowManagedHooksOnly()), 2960 hook_definitions: jsonStringify(hookDefinitionsComplete), 2961 hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', 2962 }) 2963 } 2964 2965 // End hook span for beta tracing 2966 endHookSpan(hookSpan, { 2967 numSuccess: outcomes.success, 2968 numBlocking: outcomes.blocking, 2969 numNonBlockingError: outcomes.non_blocking_error, 2970 numCancelled: outcomes.cancelled, 2971 }) 2972} 2973 2974export type HookOutsideReplResult = { 2975 command: string 2976 succeeded: boolean 2977 output: string 2978 blocked: boolean 2979 watchPaths?: string[] 2980 systemMessage?: string 2981} 2982 2983export function hasBlockingResult(results: HookOutsideReplResult[]): boolean { 2984 return results.some(r => r.blocked) 2985} 2986 2987/** 2988 * Execute hooks outside of the REPL (e.g. notifications, session end) 2989 * 2990 * Unlike executeHooks() which yields messages that are exposed to the model as 2991 * system messages, this function only logs errors via logForDebugging (visible 2992 * with --debug). Callers that need to surface errors to users should handle 2993 * the returned results appropriately (e.g. executeSessionEndHooks writes to 2994 * stderr during shutdown). 2995 * 2996 * @param getAppState Optional function to get the current app state (for session hooks) 2997 * @param hookInput The structured hook input that will be validated and converted to JSON 2998 * @param matchQuery The query to match against hook matchers 2999 * @param signal Optional AbortSignal to cancel hook execution 3000 * @param timeoutMs Optional timeout in milliseconds for hook execution 3001 * @returns Array of HookOutsideReplResult objects containing command, succeeded, and output 3002 */ 3003async function executeHooksOutsideREPL({ 3004 getAppState, 3005 hookInput, 3006 matchQuery, 3007 signal, 3008 timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3009}: { 3010 getAppState?: () => AppState 3011 hookInput: HookInput 3012 matchQuery?: string 3013 signal?: AbortSignal 3014 timeoutMs: number 3015}): Promise<HookOutsideReplResult[]> { 3016 if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 3017 return [] 3018 } 3019 3020 const hookEvent = hookInput.hook_event_name 3021 const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent 3022 if (shouldDisableAllHooksIncludingManaged()) { 3023 logForDebugging( 3024 `Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`, 3025 ) 3026 return [] 3027 } 3028 3029 // SECURITY: ALL hooks require workspace trust in interactive mode 3030 // This centralized check prevents RCE vulnerabilities for all current and future hooks 3031 if (shouldSkipHookDueToTrust()) { 3032 logForDebugging( 3033 `Skipping ${hookName} hook execution - workspace trust not accepted`, 3034 ) 3035 return [] 3036 } 3037 3038 const appState = getAppState ? getAppState() : undefined 3039 // Use main session ID for outside-REPL hooks 3040 const sessionId = getSessionId() 3041 const matchingHooks = await getMatchingHooks( 3042 appState, 3043 sessionId, 3044 hookEvent, 3045 hookInput, 3046 ) 3047 if (matchingHooks.length === 0) { 3048 return [] 3049 } 3050 3051 if (signal?.aborted) { 3052 return [] 3053 } 3054 3055 const userHooks = matchingHooks.filter(h => !isInternalHook(h)) 3056 if (userHooks.length > 0) { 3057 const pluginHookCounts = getPluginHookCounts(userHooks) 3058 const hookTypeCounts = getHookTypeCounts(userHooks) 3059 logEvent(`tengu_run_hook`, { 3060 hookName: 3061 hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3062 numCommands: userHooks.length, 3063 hookTypeCounts: jsonStringify( 3064 hookTypeCounts, 3065 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3066 ...(pluginHookCounts && { 3067 pluginHookCounts: jsonStringify( 3068 pluginHookCounts, 3069 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3070 }), 3071 }) 3072 } 3073 3074 // Validate and stringify the hook input 3075 let jsonInput: string 3076 try { 3077 jsonInput = jsonStringify(hookInput) 3078 } catch (error) { 3079 logError(error) 3080 return [] 3081 } 3082 3083 // Run all hooks in parallel with individual timeouts 3084 const hookPromises = matchingHooks.map( 3085 async ({ hook, pluginRoot, pluginId }, hookIndex) => { 3086 // Handle callback hooks 3087 if (hook.type === 'callback') { 3088 const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 3089 const { signal: abortSignal, cleanup } = createCombinedAbortSignal( 3090 signal, 3091 { timeoutMs: callbackTimeoutMs }, 3092 ) 3093 3094 try { 3095 const toolUseID = randomUUID() 3096 const json = await hook.callback( 3097 hookInput, 3098 toolUseID, 3099 abortSignal, 3100 hookIndex, 3101 ) 3102 3103 cleanup?.() 3104 3105 if (isAsyncHookJSONOutput(json)) { 3106 logForDebugging( 3107 `${hookName} [callback] returned async response, returning empty output`, 3108 ) 3109 return { 3110 command: 'callback', 3111 succeeded: true, 3112 output: '', 3113 blocked: false, 3114 } 3115 } 3116 3117 const output = 3118 hookEvent === 'WorktreeCreate' && 3119 isSyncHookJSONOutput(json) && 3120 json.hookSpecificOutput?.hookEventName === 'WorktreeCreate' 3121 ? json.hookSpecificOutput.worktreePath 3122 : json.systemMessage || '' 3123 const blocked = 3124 isSyncHookJSONOutput(json) && json.decision === 'block' 3125 3126 logForDebugging(`${hookName} [callback] completed successfully`) 3127 3128 return { 3129 command: 'callback', 3130 succeeded: true, 3131 output, 3132 blocked, 3133 } 3134 } catch (error) { 3135 cleanup?.() 3136 3137 const errorMessage = 3138 error instanceof Error ? error.message : String(error) 3139 logForDebugging( 3140 `${hookName} [callback] failed to run: ${errorMessage}`, 3141 { level: 'error' }, 3142 ) 3143 return { 3144 command: 'callback', 3145 succeeded: false, 3146 output: errorMessage, 3147 blocked: false, 3148 } 3149 } 3150 } 3151 3152 // TODO: Implement prompt stop hooks outside REPL 3153 if (hook.type === 'prompt') { 3154 return { 3155 command: hook.prompt, 3156 succeeded: false, 3157 output: 'Prompt stop hooks are not yet supported outside REPL', 3158 blocked: false, 3159 } 3160 } 3161 3162 // TODO: Implement agent stop hooks outside REPL 3163 if (hook.type === 'agent') { 3164 return { 3165 command: hook.prompt, 3166 succeeded: false, 3167 output: 'Agent stop hooks are not yet supported outside REPL', 3168 blocked: false, 3169 } 3170 } 3171 3172 // Function hooks require messages array (only available in REPL context) 3173 // For -p mode Stop hooks, use executeStopHooks which supports function hooks 3174 if (hook.type === 'function') { 3175 logError( 3176 new Error( 3177 `Function hook reached executeHooksOutsideREPL for ${hookEvent}. Function hooks should only be used in REPL context (Stop hooks).`, 3178 ), 3179 ) 3180 return { 3181 command: 'function', 3182 succeeded: false, 3183 output: 'Internal error: function hook executed outside REPL context', 3184 blocked: false, 3185 } 3186 } 3187 3188 // Handle HTTP hooks (no toolUseContext needed - just HTTP POST). 3189 // execHttpHook handles its own timeout internally via hook.timeout or 3190 // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so we pass signal directly. 3191 if (hook.type === 'http') { 3192 try { 3193 const httpResult = await execHttpHook( 3194 hook, 3195 hookEvent, 3196 jsonInput, 3197 signal, 3198 ) 3199 3200 if (httpResult.aborted) { 3201 logForDebugging(`${hookName} [${hook.url}] cancelled`) 3202 return { 3203 command: hook.url, 3204 succeeded: false, 3205 output: 'Hook cancelled', 3206 blocked: false, 3207 } 3208 } 3209 3210 if (httpResult.error || !httpResult.ok) { 3211 const errMsg = 3212 httpResult.error || 3213 `HTTP ${httpResult.statusCode} from ${hook.url}` 3214 logForDebugging(`${hookName} [${hook.url}] failed: ${errMsg}`, { 3215 level: 'error', 3216 }) 3217 return { 3218 command: hook.url, 3219 succeeded: false, 3220 output: errMsg, 3221 blocked: false, 3222 } 3223 } 3224 3225 // HTTP hooks must return JSON — parse and validate through Zod 3226 const { json: httpJson, validationError: httpValidationError } = 3227 parseHttpHookOutput(httpResult.body) 3228 if (httpValidationError) { 3229 throw new Error(httpValidationError) 3230 } 3231 if (httpJson && !isAsyncHookJSONOutput(httpJson)) { 3232 logForDebugging( 3233 `Parsed JSON output from HTTP hook: ${jsonStringify(httpJson)}`, 3234 { level: 'verbose' }, 3235 ) 3236 } 3237 const jsonBlocked = 3238 httpJson && 3239 !isAsyncHookJSONOutput(httpJson) && 3240 isSyncHookJSONOutput(httpJson) && 3241 httpJson.decision === 'block' 3242 3243 // WorktreeCreate's consumer reads `output` as the bare filesystem 3244 // path. Command hooks provide it via stdout; http hooks provide it 3245 // via hookSpecificOutput.worktreePath. Without worktreePath, emit '' 3246 // so the consumer's length filter skips it instead of treating the 3247 // raw '{}' body as a path. 3248 const output = 3249 hookEvent === 'WorktreeCreate' 3250 ? httpJson && 3251 isSyncHookJSONOutput(httpJson) && 3252 httpJson.hookSpecificOutput?.hookEventName === 'WorktreeCreate' 3253 ? httpJson.hookSpecificOutput.worktreePath 3254 : '' 3255 : httpResult.body 3256 3257 return { 3258 command: hook.url, 3259 succeeded: true, 3260 output, 3261 blocked: !!jsonBlocked, 3262 } 3263 } catch (error) { 3264 const errorMessage = 3265 error instanceof Error ? error.message : String(error) 3266 logForDebugging( 3267 `${hookName} [${hook.url}] failed to run: ${errorMessage}`, 3268 { level: 'error' }, 3269 ) 3270 return { 3271 command: hook.url, 3272 succeeded: false, 3273 output: errorMessage, 3274 blocked: false, 3275 } 3276 } 3277 } 3278 3279 // Handle command hooks 3280 const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 3281 const { signal: abortSignal, cleanup } = createCombinedAbortSignal( 3282 signal, 3283 { timeoutMs: commandTimeoutMs }, 3284 ) 3285 try { 3286 const result = await execCommandHook( 3287 hook, 3288 hookEvent, 3289 hookName, 3290 jsonInput, 3291 abortSignal, 3292 randomUUID(), 3293 hookIndex, 3294 pluginRoot, 3295 pluginId, 3296 ) 3297 3298 // Clear timeout if hook completes 3299 cleanup?.() 3300 3301 if (result.aborted) { 3302 logForDebugging(`${hookName} [${hook.command}] cancelled`) 3303 return { 3304 command: hook.command, 3305 succeeded: false, 3306 output: 'Hook cancelled', 3307 blocked: false, 3308 } 3309 } 3310 3311 logForDebugging( 3312 `${hookName} [${hook.command}] completed with status ${result.status}`, 3313 ) 3314 3315 // Parse JSON for any messages to print out. 3316 const { json, validationError } = parseHookOutput(result.stdout) 3317 if (validationError) { 3318 // Validation error is logged via logForDebugging and returned in output 3319 throw new Error(validationError) 3320 } 3321 if (json && !isAsyncHookJSONOutput(json)) { 3322 logForDebugging( 3323 `Parsed JSON output from hook: ${jsonStringify(json)}`, 3324 { level: 'verbose' }, 3325 ) 3326 } 3327 3328 // Blocked if exit code 2 or JSON decision: 'block' 3329 const jsonBlocked = 3330 json && 3331 !isAsyncHookJSONOutput(json) && 3332 isSyncHookJSONOutput(json) && 3333 json.decision === 'block' 3334 const blocked = result.status === 2 || !!jsonBlocked 3335 3336 // For successful hooks (exit code 0), use stdout; for failed hooks, use stderr 3337 const output = 3338 result.status === 0 ? result.stdout || '' : result.stderr || '' 3339 3340 const watchPaths = 3341 json && 3342 isSyncHookJSONOutput(json) && 3343 json.hookSpecificOutput && 3344 'watchPaths' in json.hookSpecificOutput 3345 ? json.hookSpecificOutput.watchPaths 3346 : undefined 3347 3348 const systemMessage = 3349 json && isSyncHookJSONOutput(json) ? json.systemMessage : undefined 3350 3351 return { 3352 command: hook.command, 3353 succeeded: result.status === 0, 3354 output, 3355 blocked, 3356 watchPaths, 3357 systemMessage, 3358 } 3359 } catch (error) { 3360 // Clean up on error 3361 cleanup?.() 3362 3363 const errorMessage = 3364 error instanceof Error ? error.message : String(error) 3365 logForDebugging( 3366 `${hookName} [${hook.command}] failed to run: ${errorMessage}`, 3367 { level: 'error' }, 3368 ) 3369 return { 3370 command: hook.command, 3371 succeeded: false, 3372 output: errorMessage, 3373 blocked: false, 3374 } 3375 } 3376 }, 3377 ) 3378 3379 // Wait for all hooks to complete and collect results 3380 return await Promise.all(hookPromises) 3381} 3382 3383/** 3384 * Execute pre-tool hooks if configured 3385 * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') 3386 * @param toolUseID The ID of the tool use 3387 * @param toolInput The input that will be passed to the tool 3388 * @param permissionMode Optional permission mode from toolPermissionContext 3389 * @param signal Optional AbortSignal to cancel hook execution 3390 * @param timeoutMs Optional timeout in milliseconds for hook execution 3391 * @param toolUseContext Optional ToolUseContext for prompt-based hooks 3392 * @returns Async generator that yields progress messages and returns blocking errors 3393 */ 3394export async function* executePreToolHooks<ToolInput>( 3395 toolName: string, 3396 toolUseID: string, 3397 toolInput: ToolInput, 3398 toolUseContext: ToolUseContext, 3399 permissionMode?: string, 3400 signal?: AbortSignal, 3401 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3402 requestPrompt?: ( 3403 sourceName: string, 3404 toolInputSummary?: string | null, 3405 ) => (request: PromptRequest) => Promise<PromptResponse>, 3406 toolInputSummary?: string | null, 3407): AsyncGenerator<AggregatedHookResult> { 3408 const appState = toolUseContext.getAppState() 3409 const sessionId = toolUseContext.agentId ?? getSessionId() 3410 if (!hasHookForEvent('PreToolUse', appState, sessionId)) { 3411 return 3412 } 3413 3414 logForDebugging(`executePreToolHooks called for tool: ${toolName}`, { 3415 level: 'verbose', 3416 }) 3417 3418 const hookInput: PreToolUseHookInput = { 3419 ...createBaseHookInput(permissionMode, undefined, toolUseContext), 3420 hook_event_name: 'PreToolUse', 3421 tool_name: toolName, 3422 tool_input: toolInput, 3423 tool_use_id: toolUseID, 3424 } 3425 3426 yield* executeHooks({ 3427 hookInput, 3428 toolUseID, 3429 matchQuery: toolName, 3430 signal, 3431 timeoutMs, 3432 toolUseContext, 3433 requestPrompt, 3434 toolInputSummary, 3435 }) 3436} 3437 3438/** 3439 * Execute post-tool hooks if configured 3440 * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') 3441 * @param toolUseID The ID of the tool use 3442 * @param toolInput The input that was passed to the tool 3443 * @param toolResponse The response from the tool 3444 * @param toolUseContext ToolUseContext for prompt-based hooks 3445 * @param permissionMode Optional permission mode from toolPermissionContext 3446 * @param signal Optional AbortSignal to cancel hook execution 3447 * @param timeoutMs Optional timeout in milliseconds for hook execution 3448 * @returns Async generator that yields progress messages and blocking errors for automated feedback 3449 */ 3450export async function* executePostToolHooks<ToolInput, ToolResponse>( 3451 toolName: string, 3452 toolUseID: string, 3453 toolInput: ToolInput, 3454 toolResponse: ToolResponse, 3455 toolUseContext: ToolUseContext, 3456 permissionMode?: string, 3457 signal?: AbortSignal, 3458 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3459): AsyncGenerator<AggregatedHookResult> { 3460 const hookInput: PostToolUseHookInput = { 3461 ...createBaseHookInput(permissionMode, undefined, toolUseContext), 3462 hook_event_name: 'PostToolUse', 3463 tool_name: toolName, 3464 tool_input: toolInput, 3465 tool_response: toolResponse, 3466 tool_use_id: toolUseID, 3467 } 3468 3469 yield* executeHooks({ 3470 hookInput, 3471 toolUseID, 3472 matchQuery: toolName, 3473 signal, 3474 timeoutMs, 3475 toolUseContext, 3476 }) 3477} 3478 3479/** 3480 * Execute post-tool-use-failure hooks if configured 3481 * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') 3482 * @param toolUseID The ID of the tool use 3483 * @param toolInput The input that was passed to the tool 3484 * @param error The error message from the failed tool call 3485 * @param toolUseContext ToolUseContext for prompt-based hooks 3486 * @param isInterrupt Whether the tool was interrupted by user 3487 * @param permissionMode Optional permission mode from toolPermissionContext 3488 * @param signal Optional AbortSignal to cancel hook execution 3489 * @param timeoutMs Optional timeout in milliseconds for hook execution 3490 * @returns Async generator that yields progress messages and blocking errors 3491 */ 3492export async function* executePostToolUseFailureHooks<ToolInput>( 3493 toolName: string, 3494 toolUseID: string, 3495 toolInput: ToolInput, 3496 error: string, 3497 toolUseContext: ToolUseContext, 3498 isInterrupt?: boolean, 3499 permissionMode?: string, 3500 signal?: AbortSignal, 3501 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3502): AsyncGenerator<AggregatedHookResult> { 3503 const appState = toolUseContext.getAppState() 3504 const sessionId = toolUseContext.agentId ?? getSessionId() 3505 if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) { 3506 return 3507 } 3508 3509 const hookInput: PostToolUseFailureHookInput = { 3510 ...createBaseHookInput(permissionMode, undefined, toolUseContext), 3511 hook_event_name: 'PostToolUseFailure', 3512 tool_name: toolName, 3513 tool_input: toolInput, 3514 tool_use_id: toolUseID, 3515 error, 3516 is_interrupt: isInterrupt, 3517 } 3518 3519 yield* executeHooks({ 3520 hookInput, 3521 toolUseID, 3522 matchQuery: toolName, 3523 signal, 3524 timeoutMs, 3525 toolUseContext, 3526 }) 3527} 3528 3529export async function* executePermissionDeniedHooks<ToolInput>( 3530 toolName: string, 3531 toolUseID: string, 3532 toolInput: ToolInput, 3533 reason: string, 3534 toolUseContext: ToolUseContext, 3535 permissionMode?: string, 3536 signal?: AbortSignal, 3537 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3538): AsyncGenerator<AggregatedHookResult> { 3539 const appState = toolUseContext.getAppState() 3540 const sessionId = toolUseContext.agentId ?? getSessionId() 3541 if (!hasHookForEvent('PermissionDenied', appState, sessionId)) { 3542 return 3543 } 3544 3545 const hookInput: PermissionDeniedHookInput = { 3546 ...createBaseHookInput(permissionMode, undefined, toolUseContext), 3547 hook_event_name: 'PermissionDenied', 3548 tool_name: toolName, 3549 tool_input: toolInput, 3550 tool_use_id: toolUseID, 3551 reason, 3552 } 3553 3554 yield* executeHooks({ 3555 hookInput, 3556 toolUseID, 3557 matchQuery: toolName, 3558 signal, 3559 timeoutMs, 3560 toolUseContext, 3561 }) 3562} 3563 3564/** 3565 * Execute notification hooks if configured 3566 * @param notificationData The notification data to pass to hooks 3567 * @param timeoutMs Optional timeout in milliseconds for hook execution 3568 * @returns Promise that resolves when all hooks complete 3569 */ 3570export async function executeNotificationHooks( 3571 notificationData: { 3572 message: string 3573 title?: string 3574 notificationType: string 3575 }, 3576 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3577): Promise<void> { 3578 const { message, title, notificationType } = notificationData 3579 const hookInput: NotificationHookInput = { 3580 ...createBaseHookInput(undefined), 3581 hook_event_name: 'Notification', 3582 message, 3583 title, 3584 notification_type: notificationType, 3585 } 3586 3587 await executeHooksOutsideREPL({ 3588 hookInput, 3589 timeoutMs, 3590 matchQuery: notificationType, 3591 }) 3592} 3593 3594export async function executeStopFailureHooks( 3595 lastMessage: AssistantMessage, 3596 toolUseContext?: ToolUseContext, 3597 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3598): Promise<void> { 3599 const appState = toolUseContext?.getAppState() 3600 // executeHooksOutsideREPL hardcodes main sessionId (:2738). Agent frontmatter 3601 // hooks (registerFrontmatterHooks) key by agentId; gating with agentId here 3602 // would pass the gate but fail execution. Align gate with execution. 3603 const sessionId = getSessionId() 3604 if (!hasHookForEvent('StopFailure', appState, sessionId)) return 3605 3606 const lastAssistantText = 3607 extractTextContent(lastMessage.message.content, '\n').trim() || undefined 3608 3609 // Some createAssistantAPIErrorMessage call sites omit `error` (e.g. 3610 // image-size at errors.ts:431). Default to 'unknown' so matcher filtering 3611 // at getMatchingHooks:1525 always applies. 3612 const error = lastMessage.error ?? 'unknown' 3613 const hookInput: StopFailureHookInput = { 3614 ...createBaseHookInput(undefined, undefined, toolUseContext), 3615 hook_event_name: 'StopFailure', 3616 error, 3617 error_details: lastMessage.errorDetails, 3618 last_assistant_message: lastAssistantText, 3619 } 3620 3621 await executeHooksOutsideREPL({ 3622 getAppState: toolUseContext?.getAppState, 3623 hookInput, 3624 timeoutMs, 3625 matchQuery: error, 3626 }) 3627} 3628 3629/** 3630 * Execute stop hooks if configured 3631 * @param toolUseContext ToolUseContext for prompt-based hooks 3632 * @param permissionMode permission mode from toolPermissionContext 3633 * @param signal AbortSignal to cancel hook execution 3634 * @param stopHookActive Whether this call is happening within another stop hook 3635 * @param isSubagent Whether the current execution context is a subagent 3636 * @param messages Optional conversation history for prompt/function hooks 3637 * @returns Async generator that yields progress messages and blocking errors 3638 */ 3639export async function* executeStopHooks( 3640 permissionMode?: string, 3641 signal?: AbortSignal, 3642 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3643 stopHookActive: boolean = false, 3644 subagentId?: AgentId, 3645 toolUseContext?: ToolUseContext, 3646 messages?: Message[], 3647 agentType?: string, 3648 requestPrompt?: ( 3649 sourceName: string, 3650 toolInputSummary?: string | null, 3651 ) => (request: PromptRequest) => Promise<PromptResponse>, 3652): AsyncGenerator<AggregatedHookResult> { 3653 const hookEvent = subagentId ? 'SubagentStop' : 'Stop' 3654 const appState = toolUseContext?.getAppState() 3655 const sessionId = toolUseContext?.agentId ?? getSessionId() 3656 if (!hasHookForEvent(hookEvent, appState, sessionId)) { 3657 return 3658 } 3659 3660 // Extract text content from the last assistant message so hooks can 3661 // inspect the final response without reading the transcript file. 3662 const lastAssistantMessage = messages 3663 ? getLastAssistantMessage(messages) 3664 : undefined 3665 const lastAssistantText = lastAssistantMessage 3666 ? extractTextContent(lastAssistantMessage.message.content, '\n').trim() || 3667 undefined 3668 : undefined 3669 3670 const hookInput: StopHookInput | SubagentStopHookInput = subagentId 3671 ? { 3672 ...createBaseHookInput(permissionMode), 3673 hook_event_name: 'SubagentStop', 3674 stop_hook_active: stopHookActive, 3675 agent_id: subagentId, 3676 agent_transcript_path: getAgentTranscriptPath(subagentId), 3677 agent_type: agentType ?? '', 3678 last_assistant_message: lastAssistantText, 3679 } 3680 : { 3681 ...createBaseHookInput(permissionMode), 3682 hook_event_name: 'Stop', 3683 stop_hook_active: stopHookActive, 3684 last_assistant_message: lastAssistantText, 3685 } 3686 3687 // Trust check is now centralized in executeHooks() 3688 yield* executeHooks({ 3689 hookInput, 3690 toolUseID: randomUUID(), 3691 signal, 3692 timeoutMs, 3693 toolUseContext, 3694 messages, 3695 requestPrompt, 3696 }) 3697} 3698 3699/** 3700 * Execute TeammateIdle hooks when a teammate is about to go idle. 3701 * If a hook blocks (exit code 2), the teammate should continue working instead of going idle. 3702 * @param teammateName The name of the teammate going idle 3703 * @param teamName The team this teammate belongs to 3704 * @param permissionMode Optional permission mode 3705 * @param signal Optional AbortSignal to cancel hook execution 3706 * @param timeoutMs Optional timeout in milliseconds for hook execution 3707 * @returns Async generator that yields progress messages and blocking errors 3708 */ 3709export async function* executeTeammateIdleHooks( 3710 teammateName: string, 3711 teamName: string, 3712 permissionMode?: string, 3713 signal?: AbortSignal, 3714 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3715): AsyncGenerator<AggregatedHookResult> { 3716 const hookInput: TeammateIdleHookInput = { 3717 ...createBaseHookInput(permissionMode), 3718 hook_event_name: 'TeammateIdle', 3719 teammate_name: teammateName, 3720 team_name: teamName, 3721 } 3722 3723 yield* executeHooks({ 3724 hookInput, 3725 toolUseID: randomUUID(), 3726 signal, 3727 timeoutMs, 3728 }) 3729} 3730 3731/** 3732 * Execute TaskCreated hooks when a task is being created. 3733 * If a hook blocks (exit code 2), the task creation should be prevented and feedback returned. 3734 * @param taskId The ID of the task being created 3735 * @param taskSubject The subject/title of the task 3736 * @param taskDescription Optional description of the task 3737 * @param teammateName Optional name of the teammate creating the task 3738 * @param teamName Optional team name 3739 * @param permissionMode Optional permission mode 3740 * @param signal Optional AbortSignal to cancel hook execution 3741 * @param timeoutMs Optional timeout in milliseconds for hook execution 3742 * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId 3743 * @returns Async generator that yields progress messages and blocking errors 3744 */ 3745export async function* executeTaskCreatedHooks( 3746 taskId: string, 3747 taskSubject: string, 3748 taskDescription?: string, 3749 teammateName?: string, 3750 teamName?: string, 3751 permissionMode?: string, 3752 signal?: AbortSignal, 3753 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3754 toolUseContext?: ToolUseContext, 3755): AsyncGenerator<AggregatedHookResult> { 3756 const hookInput: TaskCreatedHookInput = { 3757 ...createBaseHookInput(permissionMode), 3758 hook_event_name: 'TaskCreated', 3759 task_id: taskId, 3760 task_subject: taskSubject, 3761 task_description: taskDescription, 3762 teammate_name: teammateName, 3763 team_name: teamName, 3764 } 3765 3766 yield* executeHooks({ 3767 hookInput, 3768 toolUseID: randomUUID(), 3769 signal, 3770 timeoutMs, 3771 toolUseContext, 3772 }) 3773} 3774 3775/** 3776 * Execute TaskCompleted hooks when a task is being marked as completed. 3777 * If a hook blocks (exit code 2), the task completion should be prevented and feedback returned. 3778 * @param taskId The ID of the task being completed 3779 * @param taskSubject The subject/title of the task 3780 * @param taskDescription Optional description of the task 3781 * @param teammateName Optional name of the teammate completing the task 3782 * @param teamName Optional team name 3783 * @param permissionMode Optional permission mode 3784 * @param signal Optional AbortSignal to cancel hook execution 3785 * @param timeoutMs Optional timeout in milliseconds for hook execution 3786 * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId 3787 * @returns Async generator that yields progress messages and blocking errors 3788 */ 3789export async function* executeTaskCompletedHooks( 3790 taskId: string, 3791 taskSubject: string, 3792 taskDescription?: string, 3793 teammateName?: string, 3794 teamName?: string, 3795 permissionMode?: string, 3796 signal?: AbortSignal, 3797 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3798 toolUseContext?: ToolUseContext, 3799): AsyncGenerator<AggregatedHookResult> { 3800 const hookInput: TaskCompletedHookInput = { 3801 ...createBaseHookInput(permissionMode), 3802 hook_event_name: 'TaskCompleted', 3803 task_id: taskId, 3804 task_subject: taskSubject, 3805 task_description: taskDescription, 3806 teammate_name: teammateName, 3807 team_name: teamName, 3808 } 3809 3810 yield* executeHooks({ 3811 hookInput, 3812 toolUseID: randomUUID(), 3813 signal, 3814 timeoutMs, 3815 toolUseContext, 3816 }) 3817} 3818 3819/** 3820 * Execute start hooks if configured 3821 * @param prompt The user prompt that will be passed to the tool 3822 * @param permissionMode Permission mode from toolPermissionContext 3823 * @param toolUseContext ToolUseContext for prompt-based hooks 3824 * @returns Async generator that yields progress messages and hook results 3825 */ 3826export async function* executeUserPromptSubmitHooks( 3827 prompt: string, 3828 permissionMode: string, 3829 toolUseContext: ToolUseContext, 3830 requestPrompt?: ( 3831 sourceName: string, 3832 toolInputSummary?: string | null, 3833 ) => (request: PromptRequest) => Promise<PromptResponse>, 3834): AsyncGenerator<AggregatedHookResult> { 3835 const appState = toolUseContext.getAppState() 3836 const sessionId = toolUseContext.agentId ?? getSessionId() 3837 if (!hasHookForEvent('UserPromptSubmit', appState, sessionId)) { 3838 return 3839 } 3840 3841 const hookInput: UserPromptSubmitHookInput = { 3842 ...createBaseHookInput(permissionMode), 3843 hook_event_name: 'UserPromptSubmit', 3844 prompt, 3845 } 3846 3847 yield* executeHooks({ 3848 hookInput, 3849 toolUseID: randomUUID(), 3850 signal: toolUseContext.abortController.signal, 3851 timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3852 toolUseContext, 3853 requestPrompt, 3854 }) 3855} 3856 3857/** 3858 * Execute session start hooks if configured 3859 * @param source The source of the session start (startup, resume, clear) 3860 * @param sessionId Optional The session id to use as hook input 3861 * @param agentType Optional The agent type (from --agent flag) running this session 3862 * @param model Optional The model being used for this session 3863 * @param signal Optional AbortSignal to cancel hook execution 3864 * @param timeoutMs Optional timeout in milliseconds for hook execution 3865 * @returns Async generator that yields progress messages and hook results 3866 */ 3867export async function* executeSessionStartHooks( 3868 source: 'startup' | 'resume' | 'clear' | 'compact', 3869 sessionId?: string, 3870 agentType?: string, 3871 model?: string, 3872 signal?: AbortSignal, 3873 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3874 forceSyncExecution?: boolean, 3875): AsyncGenerator<AggregatedHookResult> { 3876 const hookInput: SessionStartHookInput = { 3877 ...createBaseHookInput(undefined, sessionId), 3878 hook_event_name: 'SessionStart', 3879 source, 3880 agent_type: agentType, 3881 model, 3882 } 3883 3884 yield* executeHooks({ 3885 hookInput, 3886 toolUseID: randomUUID(), 3887 matchQuery: source, 3888 signal, 3889 timeoutMs, 3890 forceSyncExecution, 3891 }) 3892} 3893 3894/** 3895 * Execute setup hooks if configured 3896 * @param trigger The trigger type ('init' or 'maintenance') 3897 * @param signal Optional AbortSignal to cancel hook execution 3898 * @param timeoutMs Optional timeout in milliseconds for hook execution 3899 * @param forceSyncExecution If true, async hooks will not be backgrounded 3900 * @returns Async generator that yields progress messages and hook results 3901 */ 3902export async function* executeSetupHooks( 3903 trigger: 'init' | 'maintenance', 3904 signal?: AbortSignal, 3905 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3906 forceSyncExecution?: boolean, 3907): AsyncGenerator<AggregatedHookResult> { 3908 const hookInput: SetupHookInput = { 3909 ...createBaseHookInput(undefined), 3910 hook_event_name: 'Setup', 3911 trigger, 3912 } 3913 3914 yield* executeHooks({ 3915 hookInput, 3916 toolUseID: randomUUID(), 3917 matchQuery: trigger, 3918 signal, 3919 timeoutMs, 3920 forceSyncExecution, 3921 }) 3922} 3923 3924/** 3925 * Execute subagent start hooks if configured 3926 * @param agentId The unique identifier for the subagent 3927 * @param agentType The type/name of the subagent being started 3928 * @param signal Optional AbortSignal to cancel hook execution 3929 * @param timeoutMs Optional timeout in milliseconds for hook execution 3930 * @returns Async generator that yields progress messages and hook results 3931 */ 3932export async function* executeSubagentStartHooks( 3933 agentId: string, 3934 agentType: string, 3935 signal?: AbortSignal, 3936 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3937): AsyncGenerator<AggregatedHookResult> { 3938 const hookInput: SubagentStartHookInput = { 3939 ...createBaseHookInput(undefined), 3940 hook_event_name: 'SubagentStart', 3941 agent_id: agentId, 3942 agent_type: agentType, 3943 } 3944 3945 yield* executeHooks({ 3946 hookInput, 3947 toolUseID: randomUUID(), 3948 matchQuery: agentType, 3949 signal, 3950 timeoutMs, 3951 }) 3952} 3953 3954/** 3955 * Execute pre-compact hooks if configured 3956 * @param compactData The compact data to pass to hooks 3957 * @param signal Optional AbortSignal to cancel hook execution 3958 * @param timeoutMs Optional timeout in milliseconds for hook execution 3959 * @returns Object with optional newCustomInstructions and userDisplayMessage 3960 */ 3961export async function executePreCompactHooks( 3962 compactData: { 3963 trigger: 'manual' | 'auto' 3964 customInstructions: string | null 3965 }, 3966 signal?: AbortSignal, 3967 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3968): Promise<{ 3969 newCustomInstructions?: string 3970 userDisplayMessage?: string 3971}> { 3972 const hookInput: PreCompactHookInput = { 3973 ...createBaseHookInput(undefined), 3974 hook_event_name: 'PreCompact', 3975 trigger: compactData.trigger, 3976 custom_instructions: compactData.customInstructions, 3977 } 3978 3979 const results = await executeHooksOutsideREPL({ 3980 hookInput, 3981 matchQuery: compactData.trigger, 3982 signal, 3983 timeoutMs, 3984 }) 3985 3986 if (results.length === 0) { 3987 return {} 3988 } 3989 3990 // Extract custom instructions from successful hooks with non-empty output 3991 const successfulOutputs = results 3992 .filter(result => result.succeeded && result.output.trim().length > 0) 3993 .map(result => result.output.trim()) 3994 3995 // Build user display messages with command info 3996 const displayMessages: string[] = [] 3997 for (const result of results) { 3998 if (result.succeeded) { 3999 if (result.output.trim()) { 4000 displayMessages.push( 4001 `PreCompact [${result.command}] completed successfully: ${result.output.trim()}`, 4002 ) 4003 } else { 4004 displayMessages.push( 4005 `PreCompact [${result.command}] completed successfully`, 4006 ) 4007 } 4008 } else { 4009 if (result.output.trim()) { 4010 displayMessages.push( 4011 `PreCompact [${result.command}] failed: ${result.output.trim()}`, 4012 ) 4013 } else { 4014 displayMessages.push(`PreCompact [${result.command}] failed`) 4015 } 4016 } 4017 } 4018 4019 return { 4020 newCustomInstructions: 4021 successfulOutputs.length > 0 ? successfulOutputs.join('\n\n') : undefined, 4022 userDisplayMessage: 4023 displayMessages.length > 0 ? displayMessages.join('\n') : undefined, 4024 } 4025} 4026 4027/** 4028 * Execute post-compact hooks if configured 4029 * @param compactData The compact data to pass to hooks, including the summary 4030 * @param signal Optional AbortSignal to cancel hook execution 4031 * @param timeoutMs Optional timeout in milliseconds for hook execution 4032 * @returns Object with optional userDisplayMessage 4033 */ 4034export async function executePostCompactHooks( 4035 compactData: { 4036 trigger: 'manual' | 'auto' 4037 compactSummary: string 4038 }, 4039 signal?: AbortSignal, 4040 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4041): Promise<{ 4042 userDisplayMessage?: string 4043}> { 4044 const hookInput: PostCompactHookInput = { 4045 ...createBaseHookInput(undefined), 4046 hook_event_name: 'PostCompact', 4047 trigger: compactData.trigger, 4048 compact_summary: compactData.compactSummary, 4049 } 4050 4051 const results = await executeHooksOutsideREPL({ 4052 hookInput, 4053 matchQuery: compactData.trigger, 4054 signal, 4055 timeoutMs, 4056 }) 4057 4058 if (results.length === 0) { 4059 return {} 4060 } 4061 4062 const displayMessages: string[] = [] 4063 for (const result of results) { 4064 if (result.succeeded) { 4065 if (result.output.trim()) { 4066 displayMessages.push( 4067 `PostCompact [${result.command}] completed successfully: ${result.output.trim()}`, 4068 ) 4069 } else { 4070 displayMessages.push( 4071 `PostCompact [${result.command}] completed successfully`, 4072 ) 4073 } 4074 } else { 4075 if (result.output.trim()) { 4076 displayMessages.push( 4077 `PostCompact [${result.command}] failed: ${result.output.trim()}`, 4078 ) 4079 } else { 4080 displayMessages.push(`PostCompact [${result.command}] failed`) 4081 } 4082 } 4083 } 4084 4085 return { 4086 userDisplayMessage: 4087 displayMessages.length > 0 ? displayMessages.join('\n') : undefined, 4088 } 4089} 4090 4091/** 4092 * Execute session end hooks if configured 4093 * @param reason The reason for ending the session 4094 * @param options Optional parameters including app state functions and signal 4095 * @returns Promise that resolves when all hooks complete 4096 */ 4097export async function executeSessionEndHooks( 4098 reason: ExitReason, 4099 options?: { 4100 getAppState?: () => AppState 4101 setAppState?: (updater: (prev: AppState) => AppState) => void 4102 signal?: AbortSignal 4103 timeoutMs?: number 4104 }, 4105): Promise<void> { 4106 const { 4107 getAppState, 4108 setAppState, 4109 signal, 4110 timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4111 } = options || {} 4112 4113 const hookInput: SessionEndHookInput = { 4114 ...createBaseHookInput(undefined), 4115 hook_event_name: 'SessionEnd', 4116 reason, 4117 } 4118 4119 const results = await executeHooksOutsideREPL({ 4120 getAppState, 4121 hookInput, 4122 matchQuery: reason, 4123 signal, 4124 timeoutMs, 4125 }) 4126 4127 // During shutdown, Ink is unmounted so we can write directly to stderr 4128 for (const result of results) { 4129 if (!result.succeeded && result.output) { 4130 process.stderr.write( 4131 `SessionEnd hook [${result.command}] failed: ${result.output}\n`, 4132 ) 4133 } 4134 } 4135 4136 // Clear session hooks after execution 4137 if (setAppState) { 4138 const sessionId = getSessionId() 4139 clearSessionHooks(setAppState, sessionId) 4140 } 4141} 4142 4143/** 4144 * Execute permission request hooks if configured 4145 * These hooks are called when a permission dialog would be displayed to the user. 4146 * Hooks can approve or deny the permission request programmatically. 4147 * @param toolName The name of the tool requesting permission 4148 * @param toolUseID The ID of the tool use 4149 * @param toolInput The input that would be passed to the tool 4150 * @param toolUseContext ToolUseContext for the request 4151 * @param permissionMode Optional permission mode from toolPermissionContext 4152 * @param permissionSuggestions Optional permission suggestions (the "always allow" options) 4153 * @param signal Optional AbortSignal to cancel hook execution 4154 * @param timeoutMs Optional timeout in milliseconds for hook execution 4155 * @returns Async generator that yields progress messages and returns aggregated result 4156 */ 4157export async function* executePermissionRequestHooks<ToolInput>( 4158 toolName: string, 4159 toolUseID: string, 4160 toolInput: ToolInput, 4161 toolUseContext: ToolUseContext, 4162 permissionMode?: string, 4163 permissionSuggestions?: PermissionUpdate[], 4164 signal?: AbortSignal, 4165 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4166 requestPrompt?: ( 4167 sourceName: string, 4168 toolInputSummary?: string | null, 4169 ) => (request: PromptRequest) => Promise<PromptResponse>, 4170 toolInputSummary?: string | null, 4171): AsyncGenerator<AggregatedHookResult> { 4172 logForDebugging(`executePermissionRequestHooks called for tool: ${toolName}`) 4173 4174 const hookInput: PermissionRequestHookInput = { 4175 ...createBaseHookInput(permissionMode, undefined, toolUseContext), 4176 hook_event_name: 'PermissionRequest', 4177 tool_name: toolName, 4178 tool_input: toolInput, 4179 permission_suggestions: permissionSuggestions, 4180 } 4181 4182 yield* executeHooks({ 4183 hookInput, 4184 toolUseID, 4185 matchQuery: toolName, 4186 signal, 4187 timeoutMs, 4188 toolUseContext, 4189 requestPrompt, 4190 toolInputSummary, 4191 }) 4192} 4193 4194export type ConfigChangeSource = 4195 | 'user_settings' 4196 | 'project_settings' 4197 | 'local_settings' 4198 | 'policy_settings' 4199 | 'skills' 4200 4201/** 4202 * Execute config change hooks when configuration files change during a session. 4203 * Fired by file watchers when settings, skills, or commands change on disk. 4204 * Enables enterprise admins to audit/log configuration changes for security. 4205 * 4206 * Policy settings are enterprise-managed and must never be blockable by hooks. 4207 * Hooks still fire (for audit logging) but blocking results are ignored — callers 4208 * will always see an empty result for policy sources. 4209 * 4210 * @param source The type of config that changed 4211 * @param filePath Optional path to the changed file 4212 * @param timeoutMs Optional timeout in milliseconds for hook execution 4213 */ 4214export async function executeConfigChangeHooks( 4215 source: ConfigChangeSource, 4216 filePath?: string, 4217 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4218): Promise<HookOutsideReplResult[]> { 4219 const hookInput: ConfigChangeHookInput = { 4220 ...createBaseHookInput(undefined), 4221 hook_event_name: 'ConfigChange', 4222 source, 4223 file_path: filePath, 4224 } 4225 4226 const results = await executeHooksOutsideREPL({ 4227 hookInput, 4228 timeoutMs, 4229 matchQuery: source, 4230 }) 4231 4232 // Policy settings are enterprise-managed — hooks fire for audit logging 4233 // but must never block policy changes from being applied 4234 if (source === 'policy_settings') { 4235 return results.map(r => ({ ...r, blocked: false })) 4236 } 4237 4238 return results 4239} 4240 4241async function executeEnvHooks( 4242 hookInput: HookInput, 4243 timeoutMs: number, 4244): Promise<{ 4245 results: HookOutsideReplResult[] 4246 watchPaths: string[] 4247 systemMessages: string[] 4248}> { 4249 const results = await executeHooksOutsideREPL({ hookInput, timeoutMs }) 4250 if (results.length > 0) { 4251 invalidateSessionEnvCache() 4252 } 4253 const watchPaths = results.flatMap(r => r.watchPaths ?? []) 4254 const systemMessages = results 4255 .map(r => r.systemMessage) 4256 .filter((m): m is string => !!m) 4257 return { results, watchPaths, systemMessages } 4258} 4259 4260export function executeCwdChangedHooks( 4261 oldCwd: string, 4262 newCwd: string, 4263 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4264): Promise<{ 4265 results: HookOutsideReplResult[] 4266 watchPaths: string[] 4267 systemMessages: string[] 4268}> { 4269 const hookInput: CwdChangedHookInput = { 4270 ...createBaseHookInput(undefined), 4271 hook_event_name: 'CwdChanged', 4272 old_cwd: oldCwd, 4273 new_cwd: newCwd, 4274 } 4275 return executeEnvHooks(hookInput, timeoutMs) 4276} 4277 4278export function executeFileChangedHooks( 4279 filePath: string, 4280 event: 'change' | 'add' | 'unlink', 4281 timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4282): Promise<{ 4283 results: HookOutsideReplResult[] 4284 watchPaths: string[] 4285 systemMessages: string[] 4286}> { 4287 const hookInput: FileChangedHookInput = { 4288 ...createBaseHookInput(undefined), 4289 hook_event_name: 'FileChanged', 4290 file_path: filePath, 4291 event, 4292 } 4293 return executeEnvHooks(hookInput, timeoutMs) 4294} 4295 4296export type InstructionsLoadReason = 4297 | 'session_start' 4298 | 'nested_traversal' 4299 | 'path_glob_match' 4300 | 'include' 4301 | 'compact' 4302 4303export type InstructionsMemoryType = 'User' | 'Project' | 'Local' | 'Managed' 4304 4305/** 4306 * Check if InstructionsLoaded hooks are configured (without executing them). 4307 * Callers should check this before invoking executeInstructionsLoadedHooks to avoid 4308 * building hook inputs for every instruction file when no hook is configured. 4309 * 4310 * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered 4311 * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). Session- 4312 * derived hooks (structured output enforcement etc.) are internal and not checked. 4313 */ 4314export function hasInstructionsLoadedHook(): boolean { 4315 const snapshotHooks = getHooksConfigFromSnapshot()?.['InstructionsLoaded'] 4316 if (snapshotHooks && snapshotHooks.length > 0) return true 4317 const registeredHooks = getRegisteredHooks()?.['InstructionsLoaded'] 4318 if (registeredHooks && registeredHooks.length > 0) return true 4319 return false 4320} 4321 4322/** 4323 * Execute InstructionsLoaded hooks when an instruction file (CLAUDE.md or 4324 * .claude/rules/*.md) is loaded into context. Fire-and-forget — this hook is 4325 * for observability/audit only and does not support blocking. 4326 * 4327 * Dispatch sites: 4328 * - Eager load at session start (getMemoryFiles in claudemd.ts) 4329 * - Eager reload after compaction (getMemoryFiles cache cleared by 4330 * runPostCompactCleanup; next call reports load_reason: 'compact') 4331 * - Lazy load when Claude touches a file that triggers nested CLAUDE.md or 4332 * conditional rules with paths: frontmatter (memoryFilesToAttachments in 4333 * attachments.ts) 4334 */ 4335export async function executeInstructionsLoadedHooks( 4336 filePath: string, 4337 memoryType: InstructionsMemoryType, 4338 loadReason: InstructionsLoadReason, 4339 options?: { 4340 globs?: string[] 4341 triggerFilePath?: string 4342 parentFilePath?: string 4343 timeoutMs?: number 4344 }, 4345): Promise<void> { 4346 const { 4347 globs, 4348 triggerFilePath, 4349 parentFilePath, 4350 timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4351 } = options ?? {} 4352 4353 const hookInput: InstructionsLoadedHookInput = { 4354 ...createBaseHookInput(undefined), 4355 hook_event_name: 'InstructionsLoaded', 4356 file_path: filePath, 4357 memory_type: memoryType, 4358 load_reason: loadReason, 4359 globs, 4360 trigger_file_path: triggerFilePath, 4361 parent_file_path: parentFilePath, 4362 } 4363 4364 await executeHooksOutsideREPL({ 4365 hookInput, 4366 timeoutMs, 4367 matchQuery: loadReason, 4368 }) 4369} 4370 4371/** Result of an elicitation hook execution (non-REPL path). */ 4372export type ElicitationHookResult = { 4373 elicitationResponse?: ElicitationResponse 4374 blockingError?: HookBlockingError 4375} 4376 4377/** Result of an elicitation-result hook execution (non-REPL path). */ 4378export type ElicitationResultHookResult = { 4379 elicitationResultResponse?: ElicitationResponse 4380 blockingError?: HookBlockingError 4381} 4382 4383/** 4384 * Parse elicitation-specific fields from a HookOutsideReplResult. 4385 * Mirrors the relevant branches of processHookJSONOutput for Elicitation 4386 * and ElicitationResult hook events. 4387 */ 4388function parseElicitationHookOutput( 4389 result: HookOutsideReplResult, 4390 expectedEventName: 'Elicitation' | 'ElicitationResult', 4391): { 4392 response?: ElicitationResponse 4393 blockingError?: HookBlockingError 4394} { 4395 // Exit code 2 = blocking (same as executeHooks path) 4396 if (result.blocked && !result.succeeded) { 4397 return { 4398 blockingError: { 4399 blockingError: result.output || `Elicitation blocked by hook`, 4400 command: result.command, 4401 }, 4402 } 4403 } 4404 4405 if (!result.output.trim()) { 4406 return {} 4407 } 4408 4409 // Try to parse JSON output for structured elicitation response 4410 const trimmed = result.output.trim() 4411 if (!trimmed.startsWith('{')) { 4412 return {} 4413 } 4414 4415 try { 4416 const parsed = hookJSONOutputSchema().parse(JSON.parse(trimmed)) 4417 if (isAsyncHookJSONOutput(parsed)) { 4418 return {} 4419 } 4420 if (!isSyncHookJSONOutput(parsed)) { 4421 return {} 4422 } 4423 4424 // Check for top-level decision: 'block' (exit code 0 + JSON block) 4425 if (parsed.decision === 'block' || result.blocked) { 4426 return { 4427 blockingError: { 4428 blockingError: parsed.reason || 'Elicitation blocked by hook', 4429 command: result.command, 4430 }, 4431 } 4432 } 4433 4434 const specific = parsed.hookSpecificOutput 4435 if (!specific || specific.hookEventName !== expectedEventName) { 4436 return {} 4437 } 4438 4439 if (!specific.action) { 4440 return {} 4441 } 4442 4443 const response: ElicitationResponse = { 4444 action: specific.action, 4445 content: specific.content as ElicitationResponse['content'] | undefined, 4446 } 4447 4448 const out: { 4449 response?: ElicitationResponse 4450 blockingError?: HookBlockingError 4451 } = { response } 4452 4453 if (specific.action === 'decline') { 4454 out.blockingError = { 4455 blockingError: 4456 parsed.reason || 4457 (expectedEventName === 'Elicitation' 4458 ? 'Elicitation denied by hook' 4459 : 'Elicitation result blocked by hook'), 4460 command: result.command, 4461 } 4462 } 4463 4464 return out 4465 } catch { 4466 return {} 4467 } 4468} 4469 4470export async function executeElicitationHooks({ 4471 serverName, 4472 message, 4473 requestedSchema, 4474 permissionMode, 4475 signal, 4476 timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4477 mode, 4478 url, 4479 elicitationId, 4480}: { 4481 serverName: string 4482 message: string 4483 requestedSchema?: Record<string, unknown> 4484 permissionMode?: string 4485 signal?: AbortSignal 4486 timeoutMs?: number 4487 mode?: 'form' | 'url' 4488 url?: string 4489 elicitationId?: string 4490}): Promise<ElicitationHookResult> { 4491 const hookInput: ElicitationHookInput = { 4492 ...createBaseHookInput(permissionMode), 4493 hook_event_name: 'Elicitation', 4494 mcp_server_name: serverName, 4495 message, 4496 mode, 4497 url, 4498 elicitation_id: elicitationId, 4499 requested_schema: requestedSchema, 4500 } 4501 4502 const results = await executeHooksOutsideREPL({ 4503 hookInput, 4504 matchQuery: serverName, 4505 signal, 4506 timeoutMs, 4507 }) 4508 4509 let elicitationResponse: ElicitationResponse | undefined 4510 let blockingError: HookBlockingError | undefined 4511 4512 for (const result of results) { 4513 const parsed = parseElicitationHookOutput(result, 'Elicitation') 4514 if (parsed.blockingError) { 4515 blockingError = parsed.blockingError 4516 } 4517 if (parsed.response) { 4518 elicitationResponse = parsed.response 4519 } 4520 } 4521 4522 return { elicitationResponse, blockingError } 4523} 4524 4525export async function executeElicitationResultHooks({ 4526 serverName, 4527 action, 4528 content, 4529 permissionMode, 4530 signal, 4531 timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4532 mode, 4533 elicitationId, 4534}: { 4535 serverName: string 4536 action: 'accept' | 'decline' | 'cancel' 4537 content?: Record<string, unknown> 4538 permissionMode?: string 4539 signal?: AbortSignal 4540 timeoutMs?: number 4541 mode?: 'form' | 'url' 4542 elicitationId?: string 4543}): Promise<ElicitationResultHookResult> { 4544 const hookInput: ElicitationResultHookInput = { 4545 ...createBaseHookInput(permissionMode), 4546 hook_event_name: 'ElicitationResult', 4547 mcp_server_name: serverName, 4548 elicitation_id: elicitationId, 4549 mode, 4550 action, 4551 content, 4552 } 4553 4554 const results = await executeHooksOutsideREPL({ 4555 hookInput, 4556 matchQuery: serverName, 4557 signal, 4558 timeoutMs, 4559 }) 4560 4561 let elicitationResultResponse: ElicitationResponse | undefined 4562 let blockingError: HookBlockingError | undefined 4563 4564 for (const result of results) { 4565 const parsed = parseElicitationHookOutput(result, 'ElicitationResult') 4566 if (parsed.blockingError) { 4567 blockingError = parsed.blockingError 4568 } 4569 if (parsed.response) { 4570 elicitationResultResponse = parsed.response 4571 } 4572 } 4573 4574 return { elicitationResultResponse, blockingError } 4575} 4576 4577/** 4578 * Execute status line command if configured 4579 * @param statusLineInput The structured status input that will be converted to JSON 4580 * @param signal Optional AbortSignal to cancel hook execution 4581 * @param timeoutMs Optional timeout in milliseconds for hook execution 4582 * @returns The status line text to display, or undefined if no command configured 4583 */ 4584export async function executeStatusLineCommand( 4585 statusLineInput: StatusLineCommandInput, 4586 signal?: AbortSignal, 4587 timeoutMs: number = 5000, // Short timeout for status line 4588 logResult: boolean = false, 4589): Promise<string | undefined> { 4590 // Check if all hooks (including statusLine) are disabled by managed settings 4591 if (shouldDisableAllHooksIncludingManaged()) { 4592 return undefined 4593 } 4594 4595 // SECURITY: ALL hooks require workspace trust in interactive mode 4596 // This centralized check prevents RCE vulnerabilities for all current and future hooks 4597 if (shouldSkipHookDueToTrust()) { 4598 logForDebugging( 4599 `Skipping StatusLine command execution - workspace trust not accepted`, 4600 ) 4601 return undefined 4602 } 4603 4604 // When disableAllHooks is set in non-managed settings, only managed statusLine runs 4605 // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) 4606 let statusLine 4607 if (shouldAllowManagedHooksOnly()) { 4608 statusLine = getSettingsForSource('policySettings')?.statusLine 4609 } else { 4610 statusLine = getSettings_DEPRECATED()?.statusLine 4611 } 4612 4613 if (!statusLine || statusLine.type !== 'command') { 4614 return undefined 4615 } 4616 4617 // Use provided signal or create a default one 4618 const abortSignal = signal || AbortSignal.timeout(timeoutMs) 4619 4620 try { 4621 // Convert status input to JSON 4622 const jsonInput = jsonStringify(statusLineInput) 4623 4624 const result = await execCommandHook( 4625 statusLine, 4626 'StatusLine', 4627 'statusLine', 4628 jsonInput, 4629 abortSignal, 4630 randomUUID(), 4631 ) 4632 4633 if (result.aborted) { 4634 return undefined 4635 } 4636 4637 // For successful hooks (exit code 0), use stdout 4638 if (result.status === 0) { 4639 // Trim and split output into lines, then join with newlines 4640 const output = result.stdout 4641 .trim() 4642 .split('\n') 4643 .flatMap(line => line.trim() || []) 4644 .join('\n') 4645 4646 if (output) { 4647 if (logResult) { 4648 logForDebugging( 4649 `StatusLine [${statusLine.command}] completed with status ${result.status}`, 4650 ) 4651 } 4652 return output 4653 } 4654 } else if (logResult) { 4655 logForDebugging( 4656 `StatusLine [${statusLine.command}] completed with status ${result.status}`, 4657 { level: 'warn' }, 4658 ) 4659 } 4660 4661 return undefined 4662 } catch (error) { 4663 logForDebugging(`Status hook failed: ${error}`, { level: 'error' }) 4664 return undefined 4665 } 4666} 4667 4668/** 4669 * Execute file suggestion command if configured 4670 * @param fileSuggestionInput The structured input that will be converted to JSON 4671 * @param signal Optional AbortSignal to cancel hook execution 4672 * @param timeoutMs Optional timeout in milliseconds for hook execution 4673 * @returns Array of file paths, or empty array if no command configured 4674 */ 4675export async function executeFileSuggestionCommand( 4676 fileSuggestionInput: FileSuggestionCommandInput, 4677 signal?: AbortSignal, 4678 timeoutMs: number = 5000, // Short timeout for typeahead suggestions 4679): Promise<string[]> { 4680 // Check if all hooks are disabled by managed settings 4681 if (shouldDisableAllHooksIncludingManaged()) { 4682 return [] 4683 } 4684 4685 // SECURITY: ALL hooks require workspace trust in interactive mode 4686 // This centralized check prevents RCE vulnerabilities for all current and future hooks 4687 if (shouldSkipHookDueToTrust()) { 4688 logForDebugging( 4689 `Skipping FileSuggestion command execution - workspace trust not accepted`, 4690 ) 4691 return [] 4692 } 4693 4694 // When disableAllHooks is set in non-managed settings, only managed fileSuggestion runs 4695 // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) 4696 let fileSuggestion 4697 if (shouldAllowManagedHooksOnly()) { 4698 fileSuggestion = getSettingsForSource('policySettings')?.fileSuggestion 4699 } else { 4700 fileSuggestion = getSettings_DEPRECATED()?.fileSuggestion 4701 } 4702 4703 if (!fileSuggestion || fileSuggestion.type !== 'command') { 4704 return [] 4705 } 4706 4707 // Use provided signal or create a default one 4708 const abortSignal = signal || AbortSignal.timeout(timeoutMs) 4709 4710 try { 4711 const jsonInput = jsonStringify(fileSuggestionInput) 4712 4713 const hook = { type: 'command' as const, command: fileSuggestion.command } 4714 4715 const result = await execCommandHook( 4716 hook, 4717 'FileSuggestion', 4718 'FileSuggestion', 4719 jsonInput, 4720 abortSignal, 4721 randomUUID(), 4722 ) 4723 4724 if (result.aborted || result.status !== 0) { 4725 return [] 4726 } 4727 4728 return result.stdout 4729 .split('\n') 4730 .map(line => line.trim()) 4731 .filter(Boolean) 4732 } catch (error) { 4733 logForDebugging(`File suggestion helper failed: ${error}`, { 4734 level: 'error', 4735 }) 4736 return [] 4737 } 4738} 4739 4740async function executeFunctionHook({ 4741 hook, 4742 messages, 4743 hookName, 4744 toolUseID, 4745 hookEvent, 4746 timeoutMs, 4747 signal, 4748}: { 4749 hook: FunctionHook 4750 messages: Message[] 4751 hookName: string 4752 toolUseID: string 4753 hookEvent: HookEvent 4754 timeoutMs: number 4755 signal?: AbortSignal 4756}): Promise<HookResult> { 4757 const callbackTimeoutMs = hook.timeout ?? timeoutMs 4758 const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { 4759 timeoutMs: callbackTimeoutMs, 4760 }) 4761 4762 try { 4763 // Check if already aborted 4764 if (abortSignal.aborted) { 4765 cleanup() 4766 return { 4767 outcome: 'cancelled', 4768 hook, 4769 } 4770 } 4771 4772 // Execute callback with abort signal 4773 const passed = await new Promise<boolean>((resolve, reject) => { 4774 // Handle abort signal 4775 const onAbort = () => reject(new Error('Function hook cancelled')) 4776 abortSignal.addEventListener('abort', onAbort) 4777 4778 // Execute callback 4779 Promise.resolve(hook.callback(messages, abortSignal)) 4780 .then(result => { 4781 abortSignal.removeEventListener('abort', onAbort) 4782 resolve(result) 4783 }) 4784 .catch(error => { 4785 abortSignal.removeEventListener('abort', onAbort) 4786 reject(error) 4787 }) 4788 }) 4789 4790 cleanup() 4791 4792 if (passed) { 4793 return { 4794 outcome: 'success', 4795 hook, 4796 } 4797 } 4798 return { 4799 blockingError: { 4800 blockingError: hook.errorMessage, 4801 command: 'function', 4802 }, 4803 outcome: 'blocking', 4804 hook, 4805 } 4806 } catch (error) { 4807 cleanup() 4808 4809 // Handle cancellation 4810 if ( 4811 error instanceof Error && 4812 (error.message === 'Function hook cancelled' || 4813 error.name === 'AbortError') 4814 ) { 4815 return { 4816 outcome: 'cancelled', 4817 hook, 4818 } 4819 } 4820 4821 // Log for monitoring 4822 logError(error) 4823 return { 4824 message: createAttachmentMessage({ 4825 type: 'hook_error_during_execution', 4826 hookName, 4827 toolUseID, 4828 hookEvent, 4829 content: 4830 error instanceof Error 4831 ? error.message 4832 : 'Function hook execution error', 4833 }), 4834 outcome: 'non_blocking_error', 4835 hook, 4836 } 4837 } 4838} 4839 4840async function executeHookCallback({ 4841 toolUseID, 4842 hook, 4843 hookEvent, 4844 hookInput, 4845 signal, 4846 hookIndex, 4847 toolUseContext, 4848}: { 4849 toolUseID: string 4850 hook: HookCallback 4851 hookEvent: HookEvent 4852 hookInput: HookInput 4853 signal: AbortSignal 4854 hookIndex?: number 4855 toolUseContext?: ToolUseContext 4856}): Promise<HookResult> { 4857 // Create context for callbacks that need state access 4858 const context = toolUseContext 4859 ? { 4860 getAppState: toolUseContext.getAppState, 4861 updateAttributionState: toolUseContext.updateAttributionState, 4862 } 4863 : undefined 4864 const json = await hook.callback( 4865 hookInput, 4866 toolUseID, 4867 signal, 4868 hookIndex, 4869 context, 4870 ) 4871 if (isAsyncHookJSONOutput(json)) { 4872 return { 4873 outcome: 'success', 4874 hook, 4875 } 4876 } 4877 4878 const processed = processHookJSONOutput({ 4879 json, 4880 command: 'callback', 4881 // TODO: If the hook came from a plugin, use the full path to the plugin for easier debugging 4882 hookName: `${hookEvent}:Callback`, 4883 toolUseID, 4884 hookEvent, 4885 expectedHookEvent: hookEvent, 4886 // Callbacks don't have stdout/stderr/exitCode 4887 stdout: undefined, 4888 stderr: undefined, 4889 exitCode: undefined, 4890 }) 4891 return { 4892 ...processed, 4893 outcome: 'success', 4894 hook, 4895 } 4896} 4897 4898/** 4899 * Check if WorktreeCreate hooks are configured (without executing them). 4900 * 4901 * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered 4902 * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). 4903 * 4904 * Must mirror the managedOnly filtering in getHooksConfig() — when 4905 * shouldAllowManagedHooksOnly() is true, plugin hooks (pluginRoot set) are 4906 * skipped at execution, so we must also skip them here. Otherwise this returns 4907 * true but executeWorktreeCreateHook() finds no matching hooks and throws, 4908 * blocking the git-worktree fallback. 4909 */ 4910export function hasWorktreeCreateHook(): boolean { 4911 const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeCreate'] 4912 if (snapshotHooks && snapshotHooks.length > 0) return true 4913 const registeredHooks = getRegisteredHooks()?.['WorktreeCreate'] 4914 if (!registeredHooks || registeredHooks.length === 0) return false 4915 // Mirror getHooksConfig(): skip plugin hooks in managed-only mode 4916 const managedOnly = shouldAllowManagedHooksOnly() 4917 return registeredHooks.some( 4918 matcher => !(managedOnly && 'pluginRoot' in matcher), 4919 ) 4920} 4921 4922/** 4923 * Execute WorktreeCreate hooks. 4924 * Returns the worktree path from hook stdout. 4925 * Throws if hooks fail or produce no output. 4926 * Callers should check hasWorktreeCreateHook() before calling this. 4927 */ 4928export async function executeWorktreeCreateHook( 4929 name: string, 4930): Promise<{ worktreePath: string }> { 4931 const hookInput = { 4932 ...createBaseHookInput(undefined), 4933 hook_event_name: 'WorktreeCreate' as const, 4934 name, 4935 } 4936 4937 const results = await executeHooksOutsideREPL({ 4938 hookInput, 4939 timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4940 }) 4941 4942 // Find the first successful result with non-empty output 4943 const successfulResult = results.find( 4944 r => r.succeeded && r.output.trim().length > 0, 4945 ) 4946 4947 if (!successfulResult) { 4948 const failedOutputs = results 4949 .filter(r => !r.succeeded) 4950 .map(r => `${r.command}: ${r.output.trim() || 'no output'}`) 4951 throw new Error( 4952 `WorktreeCreate hook failed: ${failedOutputs.join('; ') || 'no successful output'}`, 4953 ) 4954 } 4955 4956 const worktreePath = successfulResult.output.trim() 4957 return { worktreePath } 4958} 4959 4960/** 4961 * Execute WorktreeRemove hooks if configured. 4962 * Returns true if hooks were configured and ran, false if no hooks are configured. 4963 * 4964 * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered 4965 * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). 4966 */ 4967export async function executeWorktreeRemoveHook( 4968 worktreePath: string, 4969): Promise<boolean> { 4970 const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeRemove'] 4971 const registeredHooks = getRegisteredHooks()?.['WorktreeRemove'] 4972 const hasSnapshotHooks = snapshotHooks && snapshotHooks.length > 0 4973 const hasRegisteredHooks = registeredHooks && registeredHooks.length > 0 4974 if (!hasSnapshotHooks && !hasRegisteredHooks) { 4975 return false 4976 } 4977 4978 const hookInput = { 4979 ...createBaseHookInput(undefined), 4980 hook_event_name: 'WorktreeRemove' as const, 4981 worktree_path: worktreePath, 4982 } 4983 4984 const results = await executeHooksOutsideREPL({ 4985 hookInput, 4986 timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, 4987 }) 4988 4989 if (results.length === 0) { 4990 return false 4991 } 4992 4993 for (const result of results) { 4994 if (!result.succeeded) { 4995 logForDebugging( 4996 `WorktreeRemove hook failed [${result.command}]: ${result.output.trim()}`, 4997 { level: 'error' }, 4998 ) 4999 } 5000 } 5001 5002 return true 5003} 5004 5005function getHookDefinitionsForTelemetry( 5006 matchedHooks: MatchedHook[], 5007): Array<{ type: string; command?: string; prompt?: string; name?: string }> { 5008 return matchedHooks.map(({ hook }) => { 5009 if (hook.type === 'command') { 5010 return { type: 'command', command: hook.command } 5011 } else if (hook.type === 'prompt') { 5012 return { type: 'prompt', prompt: hook.prompt } 5013 } else if (hook.type === 'http') { 5014 return { type: 'http', command: hook.url } 5015 } else if (hook.type === 'function') { 5016 return { type: 'function', name: 'function' } 5017 } else if (hook.type === 'callback') { 5018 return { type: 'callback', name: 'callback' } 5019 } 5020 return { type: 'unknown' } 5021 }) 5022}