// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered /** * Hooks are user-defined shell commands that can be executed at various points * in Claude Code's lifecycle. */ import { basename } from 'path' import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' import { pathExists } from './file.js' import { wrapSpawn } from './ShellCommand.js' import { TaskOutput } from './task/TaskOutput.js' import { getCwd } from './cwd.js' import { randomUUID } from 'crypto' import { formatShellPrefixCommand } from './bash/shellPrefix.js' import { getHookEnvFilePath, invalidateSessionEnvCache, } from './sessionEnvironment.js' import { subprocessEnv } from './subprocessEnv.js' import { getPlatform } from './platform.js' import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js' import { getCachedPowerShellPath } from './shell/powershellDetection.js' import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js' import { buildPowerShellArgs } from './shell/powershellProvider.js' import { loadPluginOptions, substituteUserConfigVariables, } from './plugins/pluginOptionsStorage.js' import { getPluginDataDir } from './plugins/pluginDirectories.js' import { getSessionId, getProjectRoot, getIsNonInteractiveSession, getRegisteredHooks, getStatsStore, addToTurnHookDuration, getOriginalCwd, getMainThreadAgentType, } from '../bootstrap/state.js' import { checkHasTrustDialogAccepted } from './config.js' import { getHooksConfigFromSnapshot, shouldAllowManagedHooksOnly, shouldDisableAllHooksIncludingManaged, } from './hooks/hooksConfigSnapshot.js' import { getTranscriptPathForSession, getAgentTranscriptPath, } from './sessionStorage.js' import type { AgentId } from '../types/ids.js' import { getSettings_DEPRECATED, getSettingsForSource, } from './settings/settings.js' import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } from 'src/services/analytics/index.js' import { logOTelEvent } from './telemetry/events.js' import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js' import { startHookSpan, endHookSpan, isBetaTracingEnabled, } from './telemetry/sessionTracing.js' import { hookJSONOutputSchema, promptRequestSchema, type HookCallback, type HookCallbackMatcher, type PromptRequest, type PromptResponse, isAsyncHookJSONOutput, isSyncHookJSONOutput, type PermissionRequestResult, } from '../types/hooks.js' import type { HookEvent, HookInput, HookJSONOutput, NotificationHookInput, PostToolUseHookInput, PostToolUseFailureHookInput, PermissionDeniedHookInput, PreCompactHookInput, PostCompactHookInput, PreToolUseHookInput, SessionStartHookInput, SessionEndHookInput, SetupHookInput, StopHookInput, StopFailureHookInput, SubagentStartHookInput, SubagentStopHookInput, TeammateIdleHookInput, TaskCreatedHookInput, TaskCompletedHookInput, ConfigChangeHookInput, CwdChangedHookInput, FileChangedHookInput, InstructionsLoadedHookInput, UserPromptSubmitHookInput, PermissionRequestHookInput, ElicitationHookInput, ElicitationResultHookInput, PermissionUpdate, ExitReason, SyncHookJSONOutput, AsyncHookJSONOutput, } from 'src/entrypoints/agentSdkTypes.js' import type { StatusLineCommandInput } from '../types/statusLine.js' import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js' import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js' import type { HookResultMessage } from 'src/types/message.js' import chalk from 'chalk' import type { HookMatcher, HookCommand, PluginHookMatcher, SkillHookMatcher, } from './settings/types.js' import { getHookDisplayText } from './hooks/hooksSettings.js' import { logForDebugging } from './debug.js' import { logForDiagnosticsNoPII } from './diagLogs.js' import { firstLineOf } from './stringUtils.js' import { normalizeLegacyToolName, getLegacyToolNames, permissionRuleValueFromString, } from './permissions/permissionRuleParser.js' import { logError } from './log.js' import { createCombinedAbortSignal } from './combinedAbortSignal.js' import type { PermissionResult } from './permissions/PermissionResult.js' import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js' import { enqueuePendingNotification } from './messageQueueManager.js' import { extractTextContent, getLastAssistantMessage, wrapInSystemReminder, } from './messages.js' import { emitHookStarted, emitHookResponse, startHookProgressInterval, } from './hooks/hookEvents.js' import { createAttachmentMessage } from './attachments.js' import { all } from './generators.js' import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' import { execPromptHook } from './hooks/execPromptHook.js' import type { Message, AssistantMessage } from '../types/message.js' import { execAgentHook } from './hooks/execAgentHook.js' import { execHttpHook } from './hooks/execHttpHook.js' import type { ShellCommand } from './ShellCommand.js' import { getSessionHooks, getSessionFunctionHooks, getSessionHookCallback, clearSessionHooks, type SessionDerivedHookMatcher, type FunctionHook, } from './hooks/sessionHooks.js' import type { AppState } from '../state/AppState.js' import { jsonStringify, jsonParse } from './slowOperations.js' import { isEnvTruthy } from './envUtils.js' import { errorMessage, getErrnoCode } from './errors.js' const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 /** * SessionEnd hooks run during shutdown/clear and need a much tighter bound * than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both * the per-hook default timeout AND the overall AbortSignal cap (hooks run in * parallel, so one value suffices). Overridable via env var for users whose * teardown scripts need more time. */ const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500 export function getSessionEndHookTimeoutMs(): number { const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS const parsed = raw ? parseInt(raw, 10) : NaN return Number.isFinite(parsed) && parsed > 0 ? parsed : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT } function executeInBackground({ processId, hookId, shellCommand, asyncResponse, hookEvent, hookName, command, asyncRewake, pluginId, }: { processId: string hookId: string shellCommand: ShellCommand asyncResponse: AsyncHookJSONOutput hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' hookName: string command: string asyncRewake?: boolean pluginId?: string }): boolean { if (asyncRewake) { // asyncRewake hooks bypass the registry entirely. On completion, if exit // code 2 (blocking error), enqueue as a task-notification so it wakes the // model via useQueueProcessor (idle) or gets injected mid-query via // queued_command attachments (busy). // // NOTE: We deliberately do NOT call shellCommand.background() here, because // it calls taskOutput.spillToDisk() which breaks in-memory stdout/stderr // capture (getStderr() returns '' in disk mode). The StreamWrappers stay // attached and pipe data into the in-memory TaskOutput buffers. The abort // handler already no-ops on 'interrupt' reason (user submitted a new // message), so the hook survives new prompts. A hard cancel (Escape) WILL // kill the hook via the abort handler, which is the desired behavior. void shellCommand.result.then(async result => { // result resolves on 'exit', but stdio 'data' events may still be // pending. Yield to I/O so the StreamWrapper data handlers drain into // TaskOutput before we read it. await new Promise(resolve => setImmediate(resolve)) const stdout = await shellCommand.taskOutput.getStdout() const stderr = shellCommand.taskOutput.getStderr() shellCommand.cleanup() emitHookResponse({ hookId, hookName, hookEvent, output: stdout + stderr, stdout, stderr, exitCode: result.code, outcome: result.code === 0 ? 'success' : 'error', }) if (result.code === 2) { enqueuePendingNotification({ value: wrapInSystemReminder( `Stop hook blocking error from command "${hookName}": ${stderr || stdout}`, ), mode: 'task-notification', }) } }) return true } // TaskOutput on the ShellCommand accumulates data — no stream listeners needed if (!shellCommand.background(processId)) { return false } registerPendingAsyncHook({ processId, hookId, asyncResponse, hookEvent, hookName, command, shellCommand, pluginId, }) return true } /** * Checks if a hook should be skipped due to lack of workspace trust. * * ALL hooks require workspace trust because they execute arbitrary commands from * .claude/settings.json. This is a defense-in-depth security measure. * * Context: Hooks are captured via captureHooksConfigSnapshot() before the trust * dialog is shown. While most hooks won't execute until after trust is established * through normal program flow, enforcing trust for ALL hooks prevents: * - Future bugs where a hook might accidentally execute before trust * - Any codepath that might trigger hooks before trust dialog * - Security issues from hook execution in untrusted workspaces * * Historical vulnerabilities that prompted this check: * - SessionEnd hooks executing when user declines trust dialog * - SubagentStop hooks executing when subagent completes before trust * * @returns true if hook should be skipped, false if it should execute */ export function shouldSkipHookDueToTrust(): boolean { // In non-interactive mode (SDK), trust is implicit - always execute const isInteractive = !getIsNonInteractiveSession() if (!isInteractive) { return false } // In interactive mode, ALL hooks require trust const hasTrust = checkHasTrustDialogAccepted() return !hasTrust } /** * Creates the base hook input that's common to all hook types */ export function createBaseHookInput( permissionMode?: string, sessionId?: string, // Typed narrowly (not ToolUseContext) so callers can pass toolUseContext // directly via structural typing without this function depending on Tool.ts. agentInfo?: { agentId?: string; agentType?: string }, ): { session_id: string transcript_path: string cwd: string permission_mode?: string agent_id?: string agent_type?: string } { const resolvedSessionId = sessionId ?? getSessionId() // agent_type: subagent's type (from toolUseContext) takes precedence over // the session's --agent flag. Hooks use agent_id presence to distinguish // subagent calls from main-thread calls in a --agent session. const resolvedAgentType = agentInfo?.agentType ?? getMainThreadAgentType() return { session_id: resolvedSessionId, transcript_path: getTranscriptPathForSession(resolvedSessionId), cwd: getCwd(), permission_mode: permissionMode, agent_id: agentInfo?.agentId, agent_type: resolvedAgentType, } } export interface HookBlockingError { blockingError: string command: string } /** Re-export ElicitResult from MCP SDK as ElicitationResponse for backward compat. */ export type ElicitationResponse = ElicitResult export interface HookResult { message?: HookResultMessage systemMessage?: string blockingError?: HookBlockingError outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled' preventContinuation?: boolean stopReason?: string permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough' hookPermissionDecisionReason?: string additionalContext?: string initialUserMessage?: string updatedInput?: Record updatedMCPToolOutput?: unknown permissionRequestResult?: PermissionRequestResult elicitationResponse?: ElicitationResponse watchPaths?: string[] elicitationResultResponse?: ElicitationResponse retry?: boolean hook: HookCommand | HookCallback | FunctionHook } export type AggregatedHookResult = { message?: HookResultMessage blockingError?: HookBlockingError preventContinuation?: boolean stopReason?: string hookPermissionDecisionReason?: string hookSource?: string permissionBehavior?: PermissionResult['behavior'] additionalContexts?: string[] initialUserMessage?: string updatedInput?: Record updatedMCPToolOutput?: unknown permissionRequestResult?: PermissionRequestResult watchPaths?: string[] elicitationResponse?: ElicitationResponse elicitationResultResponse?: ElicitationResponse retry?: boolean } /** * Parse and validate a JSON string against the hook output Zod schema. * Returns the validated output or formatted validation errors. */ function validateHookJson( jsonString: string, ): { json: HookJSONOutput } | { validationError: string } { const parsed = jsonParse(jsonString) const validation = hookJSONOutputSchema().safeParse(parsed) if (validation.success) { logForDebugging('Successfully parsed and validated hook JSON output') return { json: validation.data } } const errors = validation.error.issues .map(err => ` - ${err.path.join('.')}: ${err.message}`) .join('\n') return { validationError: `Hook JSON output validation failed:\n${errors}\n\nThe hook's output was: ${jsonStringify(parsed, null, 2)}`, } } function parseHookOutput(stdout: string): { json?: HookJSONOutput plainText?: string validationError?: string } { const trimmed = stdout.trim() if (!trimmed.startsWith('{')) { logForDebugging('Hook output does not start with {, treating as plain text') return { plainText: stdout } } try { const result = validateHookJson(trimmed) if ('json' in result) { return result } // For command hooks, include the schema hint in the error message const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify( { continue: 'boolean (optional)', suppressOutput: 'boolean (optional)', stopReason: 'string (optional)', decision: '"approve" | "block" (optional)', reason: 'string (optional)', systemMessage: 'string (optional)', permissionDecision: '"allow" | "deny" | "ask" (optional)', hookSpecificOutput: { 'for PreToolUse': { hookEventName: '"PreToolUse"', permissionDecision: '"allow" | "deny" | "ask" (optional)', permissionDecisionReason: 'string (optional)', updatedInput: 'object (optional) - Modified tool input to use', }, 'for UserPromptSubmit': { hookEventName: '"UserPromptSubmit"', additionalContext: 'string (required)', }, 'for PostToolUse': { hookEventName: '"PostToolUse"', additionalContext: 'string (optional)', }, }, }, null, 2, )}` logForDebugging(errorMessage) return { plainText: stdout, validationError: errorMessage } } catch (e) { logForDebugging(`Failed to parse hook output as JSON: ${e}`) return { plainText: stdout } } } function parseHttpHookOutput(body: string): { json?: HookJSONOutput validationError?: string } { const trimmed = body.trim() if (trimmed === '') { const validation = hookJSONOutputSchema().safeParse({}) if (validation.success) { logForDebugging( 'HTTP hook returned empty body, treating as empty JSON object', ) return { json: validation.data } } } if (!trimmed.startsWith('{')) { const validationError = `HTTP hook must return JSON, but got non-JSON response body: ${trimmed.length > 200 ? trimmed.slice(0, 200) + '\u2026' : trimmed}` logForDebugging(validationError) return { validationError } } try { const result = validateHookJson(trimmed) if ('json' in result) { return result } logForDebugging(result.validationError) return result } catch (e) { const validationError = `HTTP hook must return valid JSON, but parsing failed: ${e}` logForDebugging(validationError) return { validationError } } } function processHookJSONOutput({ json, command, hookName, toolUseID, hookEvent, expectedHookEvent, stdout, stderr, exitCode, durationMs, }: { json: SyncHookJSONOutput command: string hookName: string toolUseID: string hookEvent: HookEvent expectedHookEvent?: HookEvent stdout?: string stderr?: string exitCode?: number durationMs?: number }): Partial { const result: Partial = {} // At this point we know it's a sync response const syncJson = json // Handle common elements if (syncJson.continue === false) { result.preventContinuation = true if (syncJson.stopReason) { result.stopReason = syncJson.stopReason } } if (json.decision) { switch (json.decision) { case 'approve': result.permissionBehavior = 'allow' break case 'block': result.permissionBehavior = 'deny' result.blockingError = { blockingError: json.reason || 'Blocked by hook', command, } break default: // Handle unknown decision types as errors throw new Error( `Unknown hook decision type: ${json.decision}. Valid types are: approve, block`, ) } } // Handle systemMessage field if (json.systemMessage) { result.systemMessage = json.systemMessage } // Handle PreToolUse specific if ( json.hookSpecificOutput?.hookEventName === 'PreToolUse' && json.hookSpecificOutput.permissionDecision ) { switch (json.hookSpecificOutput.permissionDecision) { case 'allow': result.permissionBehavior = 'allow' break case 'deny': result.permissionBehavior = 'deny' result.blockingError = { blockingError: json.reason || 'Blocked by hook', command, } break case 'ask': result.permissionBehavior = 'ask' break default: // Handle unknown decision types as errors throw new Error( `Unknown hook permissionDecision type: ${json.hookSpecificOutput.permissionDecision}. Valid types are: allow, deny, ask`, ) } } if (result.permissionBehavior !== undefined && json.reason !== undefined) { result.hookPermissionDecisionReason = json.reason } // Handle hookSpecificOutput if (json.hookSpecificOutput) { // Validate hook event name matches expected if provided if ( expectedHookEvent && json.hookSpecificOutput.hookEventName !== expectedHookEvent ) { throw new Error( `Hook returned incorrect event name: expected '${expectedHookEvent}' but got '${json.hookSpecificOutput.hookEventName}'. Full stdout: ${jsonStringify(json, null, 2)}`, ) } switch (json.hookSpecificOutput.hookEventName) { case 'PreToolUse': // Override with more specific permission decision if provided if (json.hookSpecificOutput.permissionDecision) { switch (json.hookSpecificOutput.permissionDecision) { case 'allow': result.permissionBehavior = 'allow' break case 'deny': result.permissionBehavior = 'deny' result.blockingError = { blockingError: json.hookSpecificOutput.permissionDecisionReason || json.reason || 'Blocked by hook', command, } break case 'ask': result.permissionBehavior = 'ask' break } } result.hookPermissionDecisionReason = json.hookSpecificOutput.permissionDecisionReason // Extract updatedInput if provided if (json.hookSpecificOutput.updatedInput) { result.updatedInput = json.hookSpecificOutput.updatedInput } // Extract additionalContext if provided result.additionalContext = json.hookSpecificOutput.additionalContext break case 'UserPromptSubmit': result.additionalContext = json.hookSpecificOutput.additionalContext break case 'SessionStart': result.additionalContext = json.hookSpecificOutput.additionalContext result.initialUserMessage = json.hookSpecificOutput.initialUserMessage if ( 'watchPaths' in json.hookSpecificOutput && json.hookSpecificOutput.watchPaths ) { result.watchPaths = json.hookSpecificOutput.watchPaths } break case 'Setup': result.additionalContext = json.hookSpecificOutput.additionalContext break case 'SubagentStart': result.additionalContext = json.hookSpecificOutput.additionalContext break case 'PostToolUse': result.additionalContext = json.hookSpecificOutput.additionalContext // Extract updatedMCPToolOutput if provided if (json.hookSpecificOutput.updatedMCPToolOutput) { result.updatedMCPToolOutput = json.hookSpecificOutput.updatedMCPToolOutput } break case 'PostToolUseFailure': result.additionalContext = json.hookSpecificOutput.additionalContext break case 'PermissionDenied': result.retry = json.hookSpecificOutput.retry break case 'PermissionRequest': // Extract the permission request decision if (json.hookSpecificOutput.decision) { result.permissionRequestResult = json.hookSpecificOutput.decision // Also update permissionBehavior for consistency result.permissionBehavior = json.hookSpecificOutput.decision.behavior === 'allow' ? 'allow' : 'deny' if ( json.hookSpecificOutput.decision.behavior === 'allow' && json.hookSpecificOutput.decision.updatedInput ) { result.updatedInput = json.hookSpecificOutput.decision.updatedInput } } break case 'Elicitation': if (json.hookSpecificOutput.action) { result.elicitationResponse = { action: json.hookSpecificOutput.action, content: json.hookSpecificOutput.content as | ElicitationResponse['content'] | undefined, } if (json.hookSpecificOutput.action === 'decline') { result.blockingError = { blockingError: json.reason || 'Elicitation denied by hook', command, } } } break case 'ElicitationResult': if (json.hookSpecificOutput.action) { result.elicitationResultResponse = { action: json.hookSpecificOutput.action, content: json.hookSpecificOutput.content as | ElicitationResponse['content'] | undefined, } if (json.hookSpecificOutput.action === 'decline') { result.blockingError = { blockingError: json.reason || 'Elicitation result blocked by hook', command, } } } break } } return { ...result, message: result.blockingError ? createAttachmentMessage({ type: 'hook_blocking_error', hookName, toolUseID, hookEvent, blockingError: result.blockingError, }) : createAttachmentMessage({ type: 'hook_success', hookName, toolUseID, hookEvent, // JSON-output hooks inject context via additionalContext → // hook_additional_context, not this field. Empty content suppresses // the trivial "X hook success: Success" system-reminder that // otherwise pollutes every turn (messages.ts:3577 skips on ''). content: '', stdout, stderr, exitCode, command, durationMs, }), } } /** * Execute a command-based hook using bash or PowerShell. * * Shell resolution: hook.shell → 'bash'. PowerShell hooks spawn pwsh * with -NoProfile -NonInteractive -Command and skip bash-specific prep * (POSIX path conversion, .sh auto-prepend, CLAUDE_CODE_SHELL_PREFIX). * See docs/design/ps-shell-selection.md §5.1. */ async function execCommandHook( hook: HookCommand & { type: 'command' }, hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion', hookName: string, jsonInput: string, signal: AbortSignal, hookId: string, hookIndex?: number, pluginRoot?: string, pluginId?: string, skillRoot?: string, forceSyncExecution?: boolean, requestPrompt?: (request: PromptRequest) => Promise, ): Promise<{ stdout: string stderr: string output: string status: number aborted?: boolean backgrounded?: boolean }> { // Gated to once-per-session events to keep diag_log volume bounded. // started/completed live inside the try/finally so setup-path throws // don't orphan a started marker — that'd be indistinguishable from a hang. const shouldEmitDiag = hookEvent === 'SessionStart' || hookEvent === 'Setup' || hookEvent === 'SessionEnd' const diagStartMs = Date.now() let diagExitCode: number | undefined let diagAborted = false const isWindows = getPlatform() === 'windows' // -- // Per-hook shell selection (phase 1 of docs/design/ps-shell-selection.md). // Resolution order: hook.shell → DEFAULT_HOOK_SHELL. The defaultShell // fallback (settings.defaultShell) is phase 2 — not wired yet. // // The bash path is the historical default and stays unchanged. The // PowerShell path deliberately skips the Windows-specific bash // accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted // SHELL_PREFIX). const shellType = hook.shell ?? DEFAULT_HOOK_SHELL const isPowerShell = shellType === 'powershell' // -- // Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe. // // This means every path we put into env vars or substitute into the command // string MUST be a POSIX path (/c/Users/foo), not a Windows path // (C:\Users\foo or C:/Users/foo). Git Bash cannot resolve Windows paths. // // windowsPathToPosixPath() is pure-JS regex conversion (no cygpath shell-out): // C:\Users\foo -> /c/Users/foo, UNC preserved, slashes flipped. Memoized // (LRU-500) so repeated calls are cheap. // // PowerShell path: use native paths — skip the conversion entirely. // PowerShell expects Windows paths on Windows (and native paths on // Unix where pwsh is also available). const toHookPath = isWindows && !isPowerShell ? (p: string) => windowsPathToPosixPath(p) : (p: string) => p // Set CLAUDE_PROJECT_DIR to the stable project root (not the worktree path). // getProjectRoot() is never updated when entering a worktree, so hooks that // reference $CLAUDE_PROJECT_DIR always resolve relative to the real repo root. const projectDir = getProjectRoot() // Substitute ${CLAUDE_PLUGIN_ROOT} and ${user_config.X} in the command string. // Order matches MCP/LSP (plugin vars FIRST, then user config) so a user- // entered value containing the literal text ${CLAUDE_PLUGIN_ROOT} is treated // as opaque — not re-interpreted as a template. let command = hook.command let pluginOpts: ReturnType | undefined if (pluginRoot) { // Plugin directory gone (orphan GC race, concurrent session deleted it): // throw so callers yield a non-blocking error. Running would fail — and // `python3 .py` exits 2, the hook protocol's "block" code, which // bricks UserPromptSubmit/Stop until restart. The pre-check is necessary // because exit-2-from-missing-script is indistinguishable from an // intentional block after spawn. if (!(await pathExists(pluginRoot))) { throw new Error( `Plugin directory does not exist: ${pluginRoot}` + (pluginId ? ` (${pluginId} — run /plugin to reinstall)` : ''), ) } // Inline both ROOT and DATA substitution instead of calling // substitutePluginVariables(). That helper normalizes \ → / on Windows // unconditionally — correct for bash (toHookPath already produced /c/... // so it's a no-op) but wrong for PS where toHookPath is identity and we // want native C:\... backslashes. Inlining also lets us use the function- // form .replace() so paths containing $ aren't mangled by $-pattern // interpretation (rare but possible: \\server\c$\plugin). const rootPath = toHookPath(pluginRoot) command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath) if (pluginId) { const dataPath = toHookPath(getPluginDataDir(pluginId)) command = command.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => dataPath) } if (pluginId) { pluginOpts = loadPluginOptions(pluginId) // Throws if a referenced key is missing — that means the hook uses a key // that's either not declared in manifest.userConfig or not yet configured. // Caught upstream like any other hook exec failure. command = substituteUserConfigVariables(command, pluginOpts) } } // On Windows (bash only), auto-prepend `bash` for .sh scripts so they // execute instead of opening in the default file handler. PowerShell // runs .ps1 files natively — no prepend needed. if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) { if (!command.trim().startsWith('bash ')) { command = `bash ${command}` } } // CLAUDE_CODE_SHELL_PREFIX wraps the command via POSIX quoting // (formatShellPrefixCommand uses shell-quote). This makes no sense for // PowerShell — see design §8.1. For now PS hooks ignore the prefix; // a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up. const finalCommand = !isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX ? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command) : command const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : TOOL_HOOK_EXECUTION_TIMEOUT_MS // Build env vars — all paths go through toHookPath for Windows POSIX conversion const envVars: NodeJS.ProcessEnv = { ...subprocessEnv(), CLAUDE_PROJECT_DIR: toHookPath(projectDir), } // Plugin and skill hooks both set CLAUDE_PLUGIN_ROOT (skills use the same // name for consistency — skills can migrate to plugins without code changes) if (pluginRoot) { envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot) if (pluginId) { envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId)) } } // Expose plugin options as env vars too, so hooks can read them without // ${user_config.X} in the command string. Sensitive values included — hooks // run the user's own code, same trust boundary as reading keychain directly. if (pluginOpts) { for (const [key, value] of Object.entries(pluginOpts)) { // Sanitize non-identifier chars (bash can't ref $FOO-BAR). The schema // at schemas.ts:611 now constrains keys to /^[A-Za-z_]\w*$/ so this is // belt-and-suspenders, but cheap insurance if someone bypasses the schema. const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase() envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value) } } if (skillRoot) { envVars.CLAUDE_PLUGIN_ROOT = toHookPath(skillRoot) } // CLAUDE_ENV_FILE points to a .sh file that the hook writes env var // definitions into; getSessionEnvironmentScript() concatenates them and // bashProvider injects the content into bash commands. A PS hook would // naturally write PS syntax ($env:FOO = 'bar'), which bash can't parse. // Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are // already bash-only above. if ( !isPowerShell && (hookEvent === 'SessionStart' || hookEvent === 'Setup' || hookEvent === 'CwdChanged' || hookEvent === 'FileChanged') && hookIndex !== undefined ) { envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex) } // When agent worktrees are removed, getCwd() may return a deleted path via // AsyncLocalStorage. Validate before spawning since spawn() emits async // 'error' events for missing cwd rather than throwing synchronously. const hookCwd = getCwd() const safeCwd = (await pathExists(hookCwd)) ? hookCwd : getOriginalCwd() if (safeCwd !== hookCwd) { logForDebugging( `Hooks: cwd ${hookCwd} not found, falling back to original cwd`, { level: 'warn' }, ) } // -- // Spawn. Two completely separate paths: // // Bash: spawn(cmd, [], { shell: }) — the shell // option makes Node pass the whole string to the shell for parsing. // // PowerShell: spawn(pwshPath, ['-NoProfile', '-NonInteractive', // '-Command', cmd]) — explicit argv, no shell option. -NoProfile // skips user profile scripts (faster, deterministic). // -NonInteractive fails fast instead of prompting. // // The Git Bash hard-exit in findGitBashPath() is still in place for // bash hooks. PowerShell hooks never call it, so a Windows user with // only pwsh and shell: 'powershell' on every hook could in theory run // without Git Bash — but init.ts still calls setShellIfWindows() on // startup, which will exit first. Relaxing that is phase 1 of the // design's implementation order (separate PR). let child: ChildProcessWithoutNullStreams if (shellType === 'powershell') { const pwshPath = await getCachedPowerShellPath() if (!pwshPath) { throw new Error( `Hook "${hook.command}" has shell: 'powershell' but no PowerShell ` + `executable (pwsh or powershell) was found on PATH. Install ` + `PowerShell, or remove "shell": "powershell" to use bash.`, ) } child = spawn(pwshPath, buildPowerShellArgs(finalCommand), { env: envVars, cwd: safeCwd, // Prevent visible console window on Windows (no-op on other platforms) windowsHide: true, }) as ChildProcessWithoutNullStreams } else { // On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax). // On other platforms, shell: true uses /bin/sh. const shell = isWindows ? findGitBashPath() : true child = spawn(finalCommand, [], { env: envVars, cwd: safeCwd, shell, // Prevent visible console window on Windows (no-op on other platforms) windowsHide: true, }) as ChildProcessWithoutNullStreams } // Hooks use pipe mode — stdout must be streamed into JS so we can parse // the first response line to detect async hooks ({"async": true}). const hookTaskOutput = new TaskOutput(`hook_${child.pid}`, null) const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput) // Track whether shellCommand ownership was transferred (e.g., to async hook registry) let shellCommandTransferred = false // Track whether stdin has already been written (to avoid "write after end" errors) let stdinWritten = false if ((hook.async || hook.asyncRewake) && !forceSyncExecution) { const processId = `async_hook_${child.pid}` logForDebugging( `Hooks: Config-based async hook, backgrounding process ${processId}`, ) // Write stdin before backgrounding so the hook receives its input. // The trailing newline matches the sync path (L1000). Without it, // bash `read -r line` returns exit 1 (EOF before delimiter) — the // variable IS populated but `if read -r line; then ...` skips the // branch. See gh-30509 / CC-161. child.stdin.write(jsonInput + '\n', 'utf8') child.stdin.end() stdinWritten = true const backgrounded = executeInBackground({ processId, hookId, shellCommand, asyncResponse: { async: true, asyncTimeout: hookTimeoutMs }, hookEvent, hookName, command: hook.command, asyncRewake: hook.asyncRewake, pluginId, }) if (backgrounded) { return { stdout: '', stderr: '', output: '', status: 0, backgrounded: true, } } } let stdout = '' let stderr = '' let output = '' // Set up output data collection with explicit UTF-8 encoding child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') let initialResponseChecked = false let asyncResolve: | ((result: { stdout: string stderr: string output: string status: number }) => void) | null = null const childIsAsyncPromise = new Promise<{ stdout: string stderr: string output: string status: number aborted?: boolean }>(resolve => { asyncResolve = resolve }) // Track trimmed prompt-request lines we processed so we can strip them // from final stdout by content match (no index tracking → no index drift) const processedPromptLines = new Set() // Serialize async prompt handling so responses are sent in order let promptChain = Promise.resolve() // Line buffer for detecting prompt requests in streaming output let lineBuffer = '' child.stdout.on('data', data => { stdout += data output += data // When requestPrompt is provided, parse stdout line-by-line for prompt requests if (requestPrompt) { lineBuffer += data const lines = lineBuffer.split('\n') lineBuffer = lines.pop() ?? '' // last element is an incomplete line for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue try { const parsed = jsonParse(trimmed) const validation = promptRequestSchema().safeParse(parsed) if (validation.success) { processedPromptLines.add(trimmed) logForDebugging( `Hooks: Detected prompt request from hook: ${trimmed}`, ) // Chain the async handling to serialize prompt responses const promptReq = validation.data const reqPrompt = requestPrompt promptChain = promptChain.then(async () => { try { const response = await reqPrompt(promptReq) child.stdin.write(jsonStringify(response) + '\n', 'utf8') } catch (err) { logForDebugging(`Hooks: Prompt request handling failed: ${err}`) // User cancelled or prompt failed — close stdin so the hook // process doesn't hang waiting for input child.stdin.destroy() } }) continue } } catch { // Not JSON, just a normal line } } } // Check for async response on first line of output. The async protocol is: // hook emits {"async":true,...} as its FIRST line, then its normal output. // We must parse ONLY the first line — if the process is fast and writes more // before this 'data' event fires, parsing the full accumulated stdout fails // and an async hook blocks for its full duration instead of backgrounding. if (!initialResponseChecked) { const firstLine = firstLineOf(stdout).trim() if (!firstLine.includes('}')) return initialResponseChecked = true logForDebugging(`Hooks: Checking first line for async: ${firstLine}`) try { const parsed = jsonParse(firstLine) logForDebugging( `Hooks: Parsed initial response: ${jsonStringify(parsed)}`, ) if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) { const processId = `async_hook_${child.pid}` logForDebugging( `Hooks: Detected async hook, backgrounding process ${processId}`, ) const backgrounded = executeInBackground({ processId, hookId, shellCommand, asyncResponse: parsed, hookEvent, hookName, command: hook.command, pluginId, }) if (backgrounded) { shellCommandTransferred = true asyncResolve?.({ stdout, stderr, output, status: 0, }) } } else if (isAsyncHookJSONOutput(parsed) && forceSyncExecution) { logForDebugging( `Hooks: Detected async hook but forceSyncExecution is true, waiting for completion`, ) } else { logForDebugging( `Hooks: Initial response is not async, continuing normal processing`, ) } } catch (e) { logForDebugging(`Hooks: Failed to parse initial response as JSON: ${e}`) } } }) child.stderr.on('data', data => { stderr += data output += data }) const stopProgressInterval = startHookProgressInterval({ hookId, hookName, hookEvent, getOutput: async () => ({ stdout, stderr, output }), }) // Wait for stdout and stderr streams to finish before considering output complete // This prevents a race condition where 'close' fires before all 'data' events are processed const stdoutEndPromise = new Promise(resolve => { child.stdout.on('end', () => resolve()) }) const stderrEndPromise = new Promise(resolve => { child.stderr.on('end', () => resolve()) }) // Write to stdin, making sure to handle EPIPE errors that can happen when // the hook command exits before reading all input. // Note: EPIPE handling is difficult to set up in testing since Bun and Node // have different behaviors. // TODO: Add tests for EPIPE handling. // Skip if stdin was already written (e.g., by config-based async hook path) const stdinWritePromise = stdinWritten ? Promise.resolve() : new Promise((resolve, reject) => { child.stdin.on('error', err => { // When requestPrompt is provided, stdin stays open for prompt responses. // EPIPE errors from later writes (after process exits) are expected -- suppress them. if (!requestPrompt) { reject(err) } else { logForDebugging( `Hooks: stdin error during prompt flow (likely process exited): ${err}`, ) } }) // Explicitly specify UTF-8 encoding to ensure proper handling of Unicode characters child.stdin.write(jsonInput + '\n', 'utf8') // When requestPrompt is provided, keep stdin open for prompt responses if (!requestPrompt) { child.stdin.end() } resolve() }) // Create promise for child process error const childErrorPromise = new Promise((_, reject) => { child.on('error', reject) }) // Create promise for child process close - but only resolve after streams end // to ensure all output has been collected const childClosePromise = new Promise<{ stdout: string stderr: string output: string status: number aborted?: boolean }>(resolve => { let exitCode: number | null = null child.on('close', code => { exitCode = code ?? 1 // Wait for both streams to end before resolving with the final output void Promise.all([stdoutEndPromise, stderrEndPromise]).then(() => { // Strip lines we processed as prompt requests so parseHookOutput // only sees the final hook result. Content-matching against the set // of actually-processed lines means prompt JSON can never leak // through (fail-closed), regardless of line positioning. const finalStdout = processedPromptLines.size === 0 ? stdout : stdout .split('\n') .filter(line => !processedPromptLines.has(line.trim())) .join('\n') resolve({ stdout: finalStdout, stderr, output, status: exitCode!, aborted: signal.aborted, }) }) }) }) // Race between stdin write, async detection, and process completion try { if (shouldEmitDiag) { logForDiagnosticsNoPII('info', 'hook_spawn_started', { hook_event_name: hookEvent, index: hookIndex, }) } await Promise.race([stdinWritePromise, childErrorPromise]) // Wait for any pending prompt responses before resolving const result = await Promise.race([ childIsAsyncPromise, childClosePromise, childErrorPromise, ]) // Ensure all queued prompt responses have been sent await promptChain diagExitCode = result.status diagAborted = result.aborted ?? false return result } catch (error) { // Handle errors from stdin write or child process const code = getErrnoCode(error) diagExitCode = 1 if (code === 'EPIPE') { logForDebugging( 'EPIPE error while writing to hook stdin (hook command likely closed early)', ) const errMsg = 'Hook command closed stdin before hook input was fully written (EPIPE)' return { stdout: '', stderr: errMsg, output: errMsg, status: 1, } } else if (code === 'ABORT_ERR') { diagAborted = true return { stdout: '', stderr: 'Hook cancelled', output: 'Hook cancelled', status: 1, aborted: true, } } else { const errorMsg = errorMessage(error) const errOutput = `Error occurred while executing hook command: ${errorMsg}` return { stdout: '', stderr: errOutput, output: errOutput, status: 1, } } } finally { if (shouldEmitDiag) { logForDiagnosticsNoPII('info', 'hook_spawn_completed', { hook_event_name: hookEvent, index: hookIndex, duration_ms: Date.now() - diagStartMs, exit_code: diagExitCode, aborted: diagAborted, }) } stopProgressInterval() // Clean up stream resources unless ownership was transferred (e.g., to async hook registry) if (!shellCommandTransferred) { shellCommand.cleanup() } } } /** * Check if a match query matches a hook matcher pattern * @param matchQuery The query to match (e.g., 'Write', 'Edit', 'Bash') * @param matcher The matcher pattern - can be: * - Simple string for exact match (e.g., 'Write') * - Pipe-separated list for multiple exact matches (e.g., 'Write|Edit') * - Regex pattern (e.g., '^Write.*', '.*', '^(Write|Edit)$') * @returns true if the query matches the pattern */ function matchesPattern(matchQuery: string, matcher: string): boolean { if (!matcher || matcher === '*') { return true } // Check if it's a simple string or pipe-separated list (no regex special chars except |) if (/^[a-zA-Z0-9_|]+$/.test(matcher)) { // Handle pipe-separated exact matches if (matcher.includes('|')) { const patterns = matcher .split('|') .map(p => normalizeLegacyToolName(p.trim())) return patterns.includes(matchQuery) } // Simple exact match return matchQuery === normalizeLegacyToolName(matcher) } // Otherwise treat as regex try { const regex = new RegExp(matcher) if (regex.test(matchQuery)) { return true } // Also test against legacy names so patterns like "^Task$" still match for (const legacyName of getLegacyToolNames(matchQuery)) { if (regex.test(legacyName)) { return true } } return false } catch { // If the regex is invalid, log error and return false logForDebugging(`Invalid regex pattern in hook matcher: ${matcher}`) return false } } type IfConditionMatcher = (ifCondition: string) => boolean /** * Prepare a matcher for hook `if` conditions. Expensive work (tool lookup, * Zod validation, tree-sitter parsing for Bash) happens once here; the * returned closure is called per hook. Returns undefined for non-tool events. */ async function prepareIfConditionMatcher( hookInput: HookInput, tools: Tools | undefined, ): Promise { if ( hookInput.hook_event_name !== 'PreToolUse' && hookInput.hook_event_name !== 'PostToolUse' && hookInput.hook_event_name !== 'PostToolUseFailure' && hookInput.hook_event_name !== 'PermissionRequest' ) { return undefined } const toolName = normalizeLegacyToolName(hookInput.tool_name) const tool = tools && findToolByName(tools, hookInput.tool_name) const input = tool?.inputSchema.safeParse(hookInput.tool_input) const patternMatcher = input?.success && tool?.preparePermissionMatcher ? await tool.preparePermissionMatcher(input.data) : undefined return ifCondition => { const parsed = permissionRuleValueFromString(ifCondition) if (normalizeLegacyToolName(parsed.toolName) !== toolName) { return false } if (!parsed.ruleContent) { return true } return patternMatcher ? patternMatcher(parsed.ruleContent) : false } } type FunctionHookMatcher = { matcher: string hooks: FunctionHook[] } /** * A hook paired with optional plugin context. * Used when returning matched hooks so we can apply plugin env vars at execution time. */ type MatchedHook = { hook: HookCommand | HookCallback | FunctionHook pluginRoot?: string pluginId?: string skillRoot?: string hookSource?: string } function isInternalHook(matched: MatchedHook): boolean { return matched.hook.type === 'callback' && matched.hook.internal === true } /** * Build a dedup key for a matched hook, namespaced by source context. * * Settings-file hooks (no pluginRoot/skillRoot) share the '' prefix so the * same command defined in user/project/local still collapses to one — the * original intent of the dedup. Plugin/skill hooks get their root as the * prefix, so two plugins sharing an unexpanded `${CLAUDE_PLUGIN_ROOT}/hook.sh` * template don't collapse: after expansion they point to different files. */ function hookDedupKey(m: MatchedHook, payload: string): string { return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}` } /** * Build a map of {sanitizedPluginName: hookCount} from matched hooks. * Only logs actual names for official marketplace plugins; others become 'third-party'. */ function getPluginHookCounts( hooks: MatchedHook[], ): Record | undefined { const pluginHooks = hooks.filter(h => h.pluginId) if (pluginHooks.length === 0) { return undefined } const counts: Record = {} for (const h of pluginHooks) { const atIndex = h.pluginId!.lastIndexOf('@') const isOfficial = atIndex > 0 && ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(h.pluginId!.slice(atIndex + 1)) const key = isOfficial ? h.pluginId! : 'third-party' counts[key] = (counts[key] || 0) + 1 } return counts } /** * Build a map of {hookType: count} from matched hooks. */ function getHookTypeCounts(hooks: MatchedHook[]): Record { const counts: Record = {} for (const h of hooks) { counts[h.hook.type] = (counts[h.hook.type] || 0) + 1 } return counts } function getHooksConfig( appState: AppState | undefined, sessionId: string, hookEvent: HookEvent, ): Array< | HookMatcher | HookCallbackMatcher | FunctionHookMatcher | PluginHookMatcher | SkillHookMatcher | SessionDerivedHookMatcher > { // HookMatcher is a zod-stripped {matcher, hooks} so snapshot matchers can be // pushed directly without re-wrapping. const hooks: Array< | HookMatcher | HookCallbackMatcher | FunctionHookMatcher | PluginHookMatcher | SkillHookMatcher | SessionDerivedHookMatcher > = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])] // Check if only managed hooks should run (used for both registered and session hooks) const managedOnly = shouldAllowManagedHooksOnly() // Process registered hooks (SDK callbacks and plugin native hooks) const registeredHooks = getRegisteredHooks()?.[hookEvent] if (registeredHooks) { for (const matcher of registeredHooks) { // Skip plugin hooks when restricted to managed hooks only // Plugin hooks have pluginRoot set, SDK callbacks do not if (managedOnly && 'pluginRoot' in matcher) { continue } hooks.push(matcher) } } // Merge session hooks for the current session only // Function hooks (like structured output enforcement) must be scoped to their session // to prevent hooks from one agent leaking to another (e.g., verification agent to main agent) // Skip session hooks entirely when allowManagedHooksOnly is set — // this prevents frontmatter hooks from agents/skills from bypassing the policy. // strictPluginOnlyCustomization does NOT block here — it gates at the // REGISTRATION sites (runAgent.ts:526 for agent frontmatter hooks) where // agentDefinition.source is known. A blanket block here would also kill // plugin-provided agents' frontmatter hooks, which is too broad. // Also skip if appState not provided (for backwards compatibility) if (!managedOnly && appState !== undefined) { const sessionHooks = getSessionHooks(appState, sessionId, hookEvent).get( hookEvent, ) if (sessionHooks) { // SessionDerivedHookMatcher already includes optional skillRoot for (const matcher of sessionHooks) { hooks.push(matcher) } } // Merge session function hooks separately (can't be persisted to HookMatcher format) const sessionFunctionHooks = getSessionFunctionHooks( appState, sessionId, hookEvent, ).get(hookEvent) if (sessionFunctionHooks) { for (const matcher of sessionFunctionHooks) { hooks.push(matcher) } } } return hooks } /** * Lightweight existence check for hooks on a given event. Mirrors the sources * assembled by getHooksConfig() but stops at the first hit without building * the full merged config. * * Intentionally over-approximates: returns true if any matcher exists for the * event, even if managed-only filtering or pattern matching would later * discard it. A false positive just means we proceed to the full matching * path; a false negative would skip a hook, so we err on the side of true. * * Used to skip createBaseHookInput (getTranscriptPathForSession path joins) * and getMatchingHooks on hot paths where hooks are typically unconfigured. * See hasInstructionsLoadedHook / hasWorktreeCreateHook for the same pattern. */ function hasHookForEvent( hookEvent: HookEvent, appState: AppState | undefined, sessionId: string, ): boolean { const snap = getHooksConfigFromSnapshot()?.[hookEvent] if (snap && snap.length > 0) return true const reg = getRegisteredHooks()?.[hookEvent] if (reg && reg.length > 0) return true if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true return false } /** * Get hook commands that match the given query * @param appState The current app state (optional for backwards compatibility) * @param sessionId The current session ID (main session or agent ID) * @param hookEvent The hook event * @param hookInput The hook input for matching * @returns Array of matched hooks with optional plugin context */ export async function getMatchingHooks( appState: AppState | undefined, sessionId: string, hookEvent: HookEvent, hookInput: HookInput, tools?: Tools, ): Promise { try { const hookMatchers = getHooksConfig(appState, sessionId, hookEvent) // If you change the criteria below, then you must change // src/utils/hooks/hooksConfigManager.ts as well. let matchQuery: string | undefined = undefined switch (hookInput.hook_event_name) { case 'PreToolUse': case 'PostToolUse': case 'PostToolUseFailure': case 'PermissionRequest': case 'PermissionDenied': matchQuery = hookInput.tool_name break case 'SessionStart': matchQuery = hookInput.source break case 'Setup': matchQuery = hookInput.trigger break case 'PreCompact': case 'PostCompact': matchQuery = hookInput.trigger break case 'Notification': matchQuery = hookInput.notification_type break case 'SessionEnd': matchQuery = hookInput.reason break case 'StopFailure': matchQuery = hookInput.error break case 'SubagentStart': matchQuery = hookInput.agent_type break case 'SubagentStop': matchQuery = hookInput.agent_type break case 'TeammateIdle': case 'TaskCreated': case 'TaskCompleted': break case 'Elicitation': matchQuery = hookInput.mcp_server_name break case 'ElicitationResult': matchQuery = hookInput.mcp_server_name break case 'ConfigChange': matchQuery = hookInput.source break case 'InstructionsLoaded': matchQuery = hookInput.load_reason break case 'FileChanged': matchQuery = basename(hookInput.file_path) break default: break } logForDebugging( `Getting matching hook commands for ${hookEvent} with query: ${matchQuery}`, { level: 'verbose' }, ) logForDebugging(`Found ${hookMatchers.length} hook matchers in settings`, { level: 'verbose', }) // Extract hooks with their plugin context (if any) const filteredMatchers = matchQuery ? hookMatchers.filter( matcher => !matcher.matcher || matchesPattern(matchQuery, matcher.matcher), ) : hookMatchers const matchedHooks: MatchedHook[] = filteredMatchers.flatMap(matcher => { // Check if this is a PluginHookMatcher (has pluginRoot) or SkillHookMatcher (has skillRoot) const pluginRoot = 'pluginRoot' in matcher ? matcher.pluginRoot : undefined const pluginId = 'pluginId' in matcher ? matcher.pluginId : undefined const skillRoot = 'skillRoot' in matcher ? matcher.skillRoot : undefined const hookSource = pluginRoot ? 'pluginName' in matcher ? `plugin:${matcher.pluginName}` : 'plugin' : skillRoot ? 'skillName' in matcher ? `skill:${matcher.skillName}` : 'skill' : 'settings' return matcher.hooks.map(hook => ({ hook, pluginRoot, pluginId, skillRoot, hookSource, })) }) // Deduplicate hooks by command/prompt/url within the same source context. // Key is namespaced by pluginRoot/skillRoot (see hookDedupKey above) so // cross-plugin template collisions don't drop hooks (gh-29724). // // Note: new Map(entries) keeps the LAST entry on key collision, not first. // For settings hooks this means the last-merged scope wins; for // same-plugin duplicates the pluginRoot is identical so it doesn't matter. // Fast-path: callback/function hooks don't need dedup (each is unique). // Skip the 6-pass filter + 4×Map + 4×Array.from below when all hooks are // callback/function — the common case for internal hooks like // sessionFileAccessHooks/attributionHooks (44x faster in microbench). if ( matchedHooks.every( m => m.hook.type === 'callback' || m.hook.type === 'function', ) ) { return matchedHooks } // Helper to extract the `if` condition from a hook for dedup keys. // Hooks with different `if` conditions are distinct even if otherwise identical. const getIfCondition = (hook: { if?: string }): string => hook.if ?? '' const uniqueCommandHooks = Array.from( new Map( matchedHooks .filter( ( m, ): m is MatchedHook & { hook: HookCommand & { type: 'command' } } => m.hook.type === 'command', ) // shell is part of identity: {command:'echo x', shell:'bash'} // and {command:'echo x', shell:'powershell'} are distinct hooks, // not duplicates. Default to 'bash' so legacy configs (no shell // field) still dedup against explicit shell:'bash'. .map(m => [ hookDedupKey( m, `${m.hook.shell ?? DEFAULT_HOOK_SHELL}\0${m.hook.command}\0${getIfCondition(m.hook)}`, ), m, ]), ).values(), ) const uniquePromptHooks = Array.from( new Map( matchedHooks .filter(m => m.hook.type === 'prompt') .map(m => [ hookDedupKey( m, `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, ), m, ]), ).values(), ) const uniqueAgentHooks = Array.from( new Map( matchedHooks .filter(m => m.hook.type === 'agent') .map(m => [ hookDedupKey( m, `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, ), m, ]), ).values(), ) const uniqueHttpHooks = Array.from( new Map( matchedHooks .filter(m => m.hook.type === 'http') .map(m => [ hookDedupKey( m, `${(m.hook as { url: string }).url}\0${getIfCondition(m.hook as { if?: string })}`, ), m, ]), ).values(), ) const callbackHooks = matchedHooks.filter(m => m.hook.type === 'callback') // Function hooks don't need deduplication - each callback is unique const functionHooks = matchedHooks.filter(m => m.hook.type === 'function') const uniqueHooks = [ ...uniqueCommandHooks, ...uniquePromptHooks, ...uniqueAgentHooks, ...uniqueHttpHooks, ...callbackHooks, ...functionHooks, ] // Filter hooks based on their `if` condition. This allows hooks to specify // conditions like "Bash(git *)" to only run for git commands, avoiding // process spawning overhead for non-matching commands. const hasIfCondition = uniqueHooks.some( h => (h.hook.type === 'command' || h.hook.type === 'prompt' || h.hook.type === 'agent' || h.hook.type === 'http') && (h.hook as { if?: string }).if, ) const ifMatcher = hasIfCondition ? await prepareIfConditionMatcher(hookInput, tools) : undefined const ifFilteredHooks = uniqueHooks.filter(h => { if ( h.hook.type !== 'command' && h.hook.type !== 'prompt' && h.hook.type !== 'agent' && h.hook.type !== 'http' ) { return true } const ifCondition = (h.hook as { if?: string }).if if (!ifCondition) { return true } if (!ifMatcher) { logForDebugging( `Hook if condition "${ifCondition}" cannot be evaluated for non-tool event ${hookInput.hook_event_name}`, ) return false } if (ifMatcher(ifCondition)) { return true } logForDebugging( `Skipping hook due to if condition "${ifCondition}" not matching`, ) return false }) // HTTP hooks are not supported for SessionStart/Setup events. In headless // mode the sandbox ask callback deadlocks because the structuredInput // consumer hasn't started yet when these hooks fire. const filteredHooks = hookEvent === 'SessionStart' || hookEvent === 'Setup' ? ifFilteredHooks.filter(h => { if (h.hook.type === 'http') { logForDebugging( `Skipping HTTP hook ${(h.hook as { url: string }).url} — HTTP hooks are not supported for ${hookEvent}`, ) return false } return true }) : ifFilteredHooks logForDebugging( `Matched ${filteredHooks.length} unique hooks for query "${matchQuery || 'no match query'}" (${matchedHooks.length} before deduplication)`, { level: 'verbose' }, ) return filteredHooks } catch { return [] } } /** * Format a list of blocking errors from a PreTool hook's configured commands. * @param hookName The name of the hook (e.g., 'PreToolUse:Write', 'PreToolUse:Edit', 'PreToolUse:Bash') * @param blockingErrors Array of blocking errors from hooks * @returns Formatted blocking message */ export function getPreToolHookBlockingMessage( hookName: string, blockingError: HookBlockingError, ): string { return `${hookName} hook error: ${blockingError.blockingError}` } /** * Format a list of blocking errors from a Stop hook's configured commands. * @param blockingErrors Array of blocking errors from hooks * @returns Formatted message to give feedback to the model */ export function getStopHookMessage(blockingError: HookBlockingError): string { return `Stop hook feedback:\n${blockingError.blockingError}` } /** * Format a blocking error from a TeammateIdle hook. * @param blockingError The blocking error from the hook * @returns Formatted message to give feedback to the model */ export function getTeammateIdleHookMessage( blockingError: HookBlockingError, ): string { return `TeammateIdle hook feedback:\n${blockingError.blockingError}` } /** * Format a blocking error from a TaskCreated hook. * @param blockingError The blocking error from the hook * @returns Formatted message to give feedback to the model */ export function getTaskCreatedHookMessage( blockingError: HookBlockingError, ): string { return `TaskCreated hook feedback:\n${blockingError.blockingError}` } /** * Format a blocking error from a TaskCompleted hook. * @param blockingError The blocking error from the hook * @returns Formatted message to give feedback to the model */ export function getTaskCompletedHookMessage( blockingError: HookBlockingError, ): string { return `TaskCompleted hook feedback:\n${blockingError.blockingError}` } /** * Format a list of blocking errors from a UserPromptSubmit hook's configured commands. * @param blockingErrors Array of blocking errors from hooks * @returns Formatted blocking message */ export function getUserPromptSubmitHookBlockingMessage( blockingError: HookBlockingError, ): string { return `UserPromptSubmit operation blocked by hook:\n${blockingError.blockingError}` } /** * Common logic for executing hooks * @param hookInput The structured hook input that will be validated and converted to JSON * @param toolUseID The ID for tracking this hook execution * @param matchQuery The query to match against hook matchers * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @param toolUseContext Optional ToolUseContext for prompt-based hooks (required if using prompt hooks) * @param messages Optional conversation history for prompt/function hooks * @returns Async generator that yields progress messages and hook results */ async function* executeHooks({ hookInput, toolUseID, matchQuery, signal, timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, toolUseContext, messages, forceSyncExecution, requestPrompt, toolInputSummary, }: { hookInput: HookInput toolUseID: string matchQuery?: string signal?: AbortSignal timeoutMs?: number toolUseContext?: ToolUseContext messages?: Message[] forceSyncExecution?: boolean requestPrompt?: ( sourceName: string, toolInputSummary?: string | null, ) => (request: PromptRequest) => Promise toolInputSummary?: string | null }): AsyncGenerator { if (shouldDisableAllHooksIncludingManaged()) { return } if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { return } const hookEvent = hookInput.hook_event_name const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent // Bind the prompt callback to this hook's name and tool input summary so the UI can display context const boundRequestPrompt = requestPrompt?.(hookName, toolInputSummary) // SECURITY: ALL hooks require workspace trust in interactive mode // This centralized check prevents RCE vulnerabilities for all current and future hooks if (shouldSkipHookDueToTrust()) { logForDebugging( `Skipping ${hookName} hook execution - workspace trust not accepted`, ) return } const appState = toolUseContext ? toolUseContext.getAppState() : undefined // Use the agent's session ID if available, otherwise fall back to main session const sessionId = toolUseContext?.agentId ?? getSessionId() const matchingHooks = await getMatchingHooks( appState, sessionId, hookEvent, hookInput, toolUseContext?.options?.tools, ) if (matchingHooks.length === 0) { return } if (signal?.aborted) { return } const userHooks = matchingHooks.filter(h => !isInternalHook(h)) if (userHooks.length > 0) { const pluginHookCounts = getPluginHookCounts(userHooks) const hookTypeCounts = getHookTypeCounts(userHooks) logEvent(`tengu_run_hook`, { hookName: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, numCommands: userHooks.length, hookTypeCounts: jsonStringify( hookTypeCounts, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(pluginHookCounts && { pluginHookCounts: jsonStringify( pluginHookCounts, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }) } else { // Fast-path: all hooks are internal callbacks (sessionFileAccessHooks, // attributionHooks). These return {} and don't use the abort signal, so we // can skip span/progress/abortSignal/processHookJSONOutput/resultLoop. // Measured: 6.01µs → ~1.8µs per PostToolUse hit (-70%). const batchStartTime = Date.now() const context = toolUseContext ? { getAppState: toolUseContext.getAppState, updateAttributionState: toolUseContext.updateAttributionState, } : undefined for (const [i, { hook }] of matchingHooks.entries()) { if (hook.type === 'callback') { await hook.callback(hookInput, toolUseID, signal, i, context) } } const totalDurationMs = Date.now() - batchStartTime getStatsStore()?.observe('hook_duration_ms', totalDurationMs) addToTurnHookDuration(totalDurationMs) logEvent(`tengu_repl_hook_finished`, { hookName: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, numCommands: matchingHooks.length, numSuccess: matchingHooks.length, numBlocking: 0, numNonBlockingError: 0, numCancelled: 0, totalDurationMs, }) return } // Collect hook definitions for beta tracing telemetry const hookDefinitionsJson = isBetaTracingEnabled() ? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks)) : '[]' // Log hook execution start to OTEL (only for beta tracing) if (isBetaTracingEnabled()) { void logOTelEvent('hook_execution_start', { hook_event: hookEvent, hook_name: hookName, num_hooks: String(matchingHooks.length), managed_only: String(shouldAllowManagedHooksOnly()), hook_definitions: hookDefinitionsJson, hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', }) } // Start hook span for beta tracing const hookSpan = startHookSpan( hookEvent, hookName, matchingHooks.length, hookDefinitionsJson, ) // Yield progress messages for each hook before execution for (const { hook } of matchingHooks) { yield { message: { type: 'progress', data: { type: 'hook_progress', hookEvent, hookName, command: getHookDisplayText(hook), ...(hook.type === 'prompt' && { promptText: hook.prompt }), ...('statusMessage' in hook && hook.statusMessage != null && { statusMessage: hook.statusMessage, }), }, parentToolUseID: toolUseID, toolUseID, timestamp: new Date().toISOString(), uuid: randomUUID(), }, } } // Track wall-clock time for the entire hook batch const batchStartTime = Date.now() // Lazy-once stringify of hookInput. Shared across all command/prompt/agent/http // hooks in this batch (hookInput is never mutated). Callback/function hooks // return before reaching this, so batches with only those pay no stringify cost. let jsonInputResult: | { ok: true; value: string } | { ok: false; error: unknown } | undefined function getJsonInput() { if (jsonInputResult !== undefined) { return jsonInputResult } try { return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) }) } catch (error) { logError( Error(`Failed to stringify hook ${hookName} input`, { cause: error }), ) return (jsonInputResult = { ok: false, error }) } } // Run all hooks in parallel with individual timeouts const hookPromises = matchingHooks.map(async function* ( { hook, pluginRoot, pluginId, skillRoot }, hookIndex, ): AsyncGenerator { if (hook.type === 'callback') { const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs const { signal: abortSignal, cleanup } = createCombinedAbortSignal( signal, { timeoutMs: callbackTimeoutMs }, ) yield executeHookCallback({ toolUseID, hook, hookEvent, hookInput, signal: abortSignal, hookIndex, toolUseContext, }).finally(cleanup) return } if (hook.type === 'function') { if (!messages) { yield { message: createAttachmentMessage({ type: 'hook_error_during_execution', hookName, toolUseID, hookEvent, content: 'Messages not provided for function hook', }), outcome: 'non_blocking_error', hook, } return } // Function hooks only come from session storage with callback embedded yield executeFunctionHook({ hook, messages, hookName, toolUseID, hookEvent, timeoutMs, signal, }) return } // Command and prompt hooks need jsonInput const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { timeoutMs: commandTimeoutMs, }) const hookId = randomUUID() const hookStartMs = Date.now() const hookCommand = getHookDisplayText(hook) try { const jsonInputRes = getJsonInput() if (!jsonInputRes.ok) { yield { message: createAttachmentMessage({ type: 'hook_error_during_execution', hookName, toolUseID, hookEvent, content: `Failed to prepare hook input: ${errorMessage(jsonInputRes.error)}`, command: hookCommand, durationMs: Date.now() - hookStartMs, }), outcome: 'non_blocking_error', hook, } cleanup() return } const jsonInput = jsonInputRes.value if (hook.type === 'prompt') { if (!toolUseContext) { throw new Error( 'ToolUseContext is required for prompt hooks. This is a bug.', ) } const promptResult = await execPromptHook( hook, hookName, hookEvent, jsonInput, abortSignal, toolUseContext, messages, toolUseID, ) // Inject timing fields for hook visibility if (promptResult.message?.type === 'attachment') { const att = promptResult.message.attachment if ( att.type === 'hook_success' || att.type === 'hook_non_blocking_error' ) { att.command = hookCommand att.durationMs = Date.now() - hookStartMs } } yield promptResult cleanup?.() return } if (hook.type === 'agent') { if (!toolUseContext) { throw new Error( 'ToolUseContext is required for agent hooks. This is a bug.', ) } if (!messages) { throw new Error( 'Messages are required for agent hooks. This is a bug.', ) } const agentResult = await execAgentHook( hook, hookName, hookEvent, jsonInput, abortSignal, toolUseContext, toolUseID, messages, 'agent_type' in hookInput ? (hookInput.agent_type as string) : undefined, ) // Inject timing fields for hook visibility if (agentResult.message?.type === 'attachment') { const att = agentResult.message.attachment if ( att.type === 'hook_success' || att.type === 'hook_non_blocking_error' ) { att.command = hookCommand att.durationMs = Date.now() - hookStartMs } } yield agentResult cleanup?.() return } if (hook.type === 'http') { emitHookStarted(hookId, hookName, hookEvent) // execHttpHook manages its own timeout internally via hook.timeout or // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so pass the parent signal directly // to avoid double-stacking timeouts with abortSignal. const httpResult = await execHttpHook( hook, hookEvent, jsonInput, signal, ) cleanup?.() if (httpResult.aborted) { emitHookResponse({ hookId, hookName, hookEvent, output: 'Hook cancelled', stdout: '', stderr: '', exitCode: undefined, outcome: 'cancelled', }) yield { message: createAttachmentMessage({ type: 'hook_cancelled', hookName, toolUseID, hookEvent, }), outcome: 'cancelled' as const, hook, } return } if (httpResult.error || !httpResult.ok) { const stderr = httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}` emitHookResponse({ hookId, hookName, hookEvent, output: stderr, stdout: '', stderr, exitCode: httpResult.statusCode, outcome: 'error', }) yield { message: createAttachmentMessage({ type: 'hook_non_blocking_error', hookName, toolUseID, hookEvent, stderr, stdout: '', exitCode: httpResult.statusCode ?? 0, }), outcome: 'non_blocking_error' as const, hook, } return } // HTTP hooks must return JSON — parse and validate through Zod const { json: httpJson, validationError: httpValidationError } = parseHttpHookOutput(httpResult.body) if (httpValidationError) { emitHookResponse({ hookId, hookName, hookEvent, output: httpResult.body, stdout: httpResult.body, stderr: `JSON validation failed: ${httpValidationError}`, exitCode: httpResult.statusCode, outcome: 'error', }) yield { message: createAttachmentMessage({ type: 'hook_non_blocking_error', hookName, toolUseID, hookEvent, stderr: `JSON validation failed: ${httpValidationError}`, stdout: httpResult.body, exitCode: httpResult.statusCode ?? 0, }), outcome: 'non_blocking_error' as const, hook, } return } if (httpJson && isAsyncHookJSONOutput(httpJson)) { // Async response: treat as success (no further processing) emitHookResponse({ hookId, hookName, hookEvent, output: httpResult.body, stdout: httpResult.body, stderr: '', exitCode: httpResult.statusCode, outcome: 'success', }) yield { outcome: 'success' as const, hook, } return } if (httpJson) { const processed = processHookJSONOutput({ json: httpJson, command: hook.url, hookName, toolUseID, hookEvent, expectedHookEvent: hookEvent, stdout: httpResult.body, stderr: '', exitCode: httpResult.statusCode, }) emitHookResponse({ hookId, hookName, hookEvent, output: httpResult.body, stdout: httpResult.body, stderr: '', exitCode: httpResult.statusCode, outcome: 'success', }) yield { ...processed, outcome: 'success' as const, hook, } return } return } emitHookStarted(hookId, hookName, hookEvent) const result = await execCommandHook( hook, hookEvent, hookName, jsonInput, abortSignal, hookId, hookIndex, pluginRoot, pluginId, skillRoot, forceSyncExecution, boundRequestPrompt, ) cleanup?.() const durationMs = Date.now() - hookStartMs if (result.backgrounded) { yield { outcome: 'success' as const, hook, } return } if (result.aborted) { emitHookResponse({ hookId, hookName, hookEvent, output: result.output, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, outcome: 'cancelled', }) yield { message: createAttachmentMessage({ type: 'hook_cancelled', hookName, toolUseID, hookEvent, command: hookCommand, durationMs, }), outcome: 'cancelled' as const, hook, } return } // Try JSON parsing first const { json, plainText, validationError } = parseHookOutput( result.stdout, ) if (validationError) { emitHookResponse({ hookId, hookName, hookEvent, output: result.output, stdout: result.stdout, stderr: `JSON validation failed: ${validationError}`, exitCode: 1, outcome: 'error', }) yield { message: createAttachmentMessage({ type: 'hook_non_blocking_error', hookName, toolUseID, hookEvent, stderr: `JSON validation failed: ${validationError}`, stdout: result.stdout, exitCode: 1, command: hookCommand, durationMs, }), outcome: 'non_blocking_error' as const, hook, } return } if (json) { // Async responses were already backgrounded during execution if (isAsyncHookJSONOutput(json)) { yield { outcome: 'success' as const, hook, } return } // Process JSON output const processed = processHookJSONOutput({ json, command: hookCommand, hookName, toolUseID, hookEvent, expectedHookEvent: hookEvent, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, durationMs, }) // Handle suppressOutput (skip for async responses) if ( isSyncHookJSONOutput(json) && !json.suppressOutput && plainText && result.status === 0 ) { // Still show non-JSON output if not suppressed const content = `${chalk.bold(hookName)} completed` emitHookResponse({ hookId, hookName, hookEvent, output: result.output, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, outcome: 'success', }) yield { ...processed, message: processed.message || createAttachmentMessage({ type: 'hook_success', hookName, toolUseID, hookEvent, content, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, command: hookCommand, durationMs, }), outcome: 'success' as const, hook, } return } emitHookResponse({ hookId, hookName, hookEvent, output: result.output, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, outcome: result.status === 0 ? 'success' : 'error', }) yield { ...processed, outcome: 'success' as const, hook, } return } // Fall back to existing logic for non-JSON output if (result.status === 0) { emitHookResponse({ hookId, hookName, hookEvent, output: result.output, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, outcome: 'success', }) yield { message: createAttachmentMessage({ type: 'hook_success', hookName, toolUseID, hookEvent, content: result.stdout.trim(), stdout: result.stdout, stderr: result.stderr, exitCode: result.status, command: hookCommand, durationMs, }), outcome: 'success' as const, hook, } return } // Hooks with exit code 2 provide blocking feedback if (result.status === 2) { emitHookResponse({ hookId, hookName, hookEvent, output: result.output, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, outcome: 'error', }) yield { blockingError: { blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`, command: hook.command, }, outcome: 'blocking' as const, hook, } return } // Any other non-zero exit code is a non-critical error that should just // be shown to the user. emitHookResponse({ hookId, hookName, hookEvent, output: result.output, stdout: result.stdout, stderr: result.stderr, exitCode: result.status, outcome: 'error', }) yield { message: createAttachmentMessage({ type: 'hook_non_blocking_error', hookName, toolUseID, hookEvent, stderr: `Failed with non-blocking status code: ${result.stderr.trim() || 'No stderr output'}`, stdout: result.stdout, exitCode: result.status, command: hookCommand, durationMs, }), outcome: 'non_blocking_error' as const, hook, } return } catch (error) { // Clean up on error cleanup?.() const errorMessage = error instanceof Error ? error.message : String(error) emitHookResponse({ hookId, hookName, hookEvent, output: `Failed to run: ${errorMessage}`, stdout: '', stderr: `Failed to run: ${errorMessage}`, exitCode: 1, outcome: 'error', }) yield { message: createAttachmentMessage({ type: 'hook_non_blocking_error', hookName, toolUseID, hookEvent, stderr: `Failed to run: ${errorMessage}`, stdout: '', exitCode: 1, command: hookCommand, durationMs: Date.now() - hookStartMs, }), outcome: 'non_blocking_error' as const, hook, } return } }) // Track outcomes for logging const outcomes = { success: 0, blocking: 0, non_blocking_error: 0, cancelled: 0, } let permissionBehavior: PermissionResult['behavior'] | undefined // Run all hooks in parallel and wait for all to complete for await (const result of all(hookPromises)) { outcomes[result.outcome]++ // Check for preventContinuation early if (result.preventContinuation) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) requested preventContinuation`, ) yield { preventContinuation: true, stopReason: result.stopReason, } } // Handle different result types if (result.blockingError) { yield { blockingError: result.blockingError, } } if (result.message) { yield { message: result.message } } // Yield system message separately if present if (result.systemMessage) { yield { message: createAttachmentMessage({ type: 'hook_system_message', content: result.systemMessage, hookName, toolUseID, hookEvent, }), } } // Collect additional context from hooks if (result.additionalContext) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided additionalContext (${result.additionalContext.length} chars)`, ) yield { additionalContexts: [result.additionalContext], } } if (result.initialUserMessage) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided initialUserMessage (${result.initialUserMessage.length} chars)`, ) yield { initialUserMessage: result.initialUserMessage, } } if (result.watchPaths && result.watchPaths.length > 0) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided ${result.watchPaths.length} watchPaths`, ) yield { watchPaths: result.watchPaths, } } // Yield updatedMCPToolOutput if provided (from PostToolUse hooks) if (result.updatedMCPToolOutput) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) replaced MCP tool output`, ) yield { updatedMCPToolOutput: result.updatedMCPToolOutput, } } // Check for permission behavior with precedence: deny > ask > allow if (result.permissionBehavior) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) returned permissionDecision: ${result.permissionBehavior}${result.hookPermissionDecisionReason ? ` (reason: ${result.hookPermissionDecisionReason})` : ''}`, ) // Apply precedence rules switch (result.permissionBehavior) { case 'deny': // deny always takes precedence permissionBehavior = 'deny' break case 'ask': // ask takes precedence over allow but not deny if (permissionBehavior !== 'deny') { permissionBehavior = 'ask' } break case 'allow': // allow only if no other behavior set if (!permissionBehavior) { permissionBehavior = 'allow' } break case 'passthrough': // passthrough doesn't set permission behavior break } } // Yield permission behavior and updatedInput if provided (from allow or ask behavior) if (permissionBehavior !== undefined) { const updatedInput = result.updatedInput && (result.permissionBehavior === 'allow' || result.permissionBehavior === 'ask') ? result.updatedInput : undefined if (updatedInput) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(updatedInput).join(', ')}]`, ) } yield { permissionBehavior, hookPermissionDecisionReason: result.hookPermissionDecisionReason, hookSource: matchingHooks.find(m => m.hook === result.hook)?.hookSource, updatedInput, } } // Yield updatedInput separately for passthrough case (no permission decision) // This allows hooks to modify input without making a permission decision // Note: Check result.permissionBehavior (this hook's behavior), not the aggregated permissionBehavior if (result.updatedInput && result.permissionBehavior === undefined) { logForDebugging( `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(result.updatedInput).join(', ')}]`, ) yield { updatedInput: result.updatedInput, } } // Yield permission request result if provided (from PermissionRequest hooks) if (result.permissionRequestResult) { yield { permissionRequestResult: result.permissionRequestResult, } } // Yield retry flag if provided (from PermissionDenied hooks) if (result.retry) { yield { retry: result.retry, } } // Yield elicitation response if provided (from Elicitation hooks) if (result.elicitationResponse) { yield { elicitationResponse: result.elicitationResponse, } } // Yield elicitation result response if provided (from ElicitationResult hooks) if (result.elicitationResultResponse) { yield { elicitationResultResponse: result.elicitationResultResponse, } } // Invoke session hook callback if this is a command/prompt/function hook (not a callback hook) if (appState && result.hook.type !== 'callback') { const sessionId = getSessionId() // Use empty string as matcher when matchQuery is undefined (e.g., for Stop hooks) const matcher = matchQuery ?? '' const hookEntry = getSessionHookCallback( appState, sessionId, hookEvent, matcher, result.hook, ) // Invoke onHookSuccess only on success outcome if (hookEntry?.onHookSuccess && result.outcome === 'success') { try { hookEntry.onHookSuccess(result.hook, result as AggregatedHookResult) } catch (error) { logError( Error('Session hook success callback failed', { cause: error }), ) } } } } const totalDurationMs = Date.now() - batchStartTime getStatsStore()?.observe('hook_duration_ms', totalDurationMs) addToTurnHookDuration(totalDurationMs) logEvent(`tengu_repl_hook_finished`, { hookName: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, numCommands: matchingHooks.length, numSuccess: outcomes.success, numBlocking: outcomes.blocking, numNonBlockingError: outcomes.non_blocking_error, numCancelled: outcomes.cancelled, totalDurationMs, }) // Log hook execution completion to OTEL (only for beta tracing) if (isBetaTracingEnabled()) { const hookDefinitionsComplete = getHookDefinitionsForTelemetry(matchingHooks) void logOTelEvent('hook_execution_complete', { hook_event: hookEvent, hook_name: hookName, num_hooks: String(matchingHooks.length), num_success: String(outcomes.success), num_blocking: String(outcomes.blocking), num_non_blocking_error: String(outcomes.non_blocking_error), num_cancelled: String(outcomes.cancelled), managed_only: String(shouldAllowManagedHooksOnly()), hook_definitions: jsonStringify(hookDefinitionsComplete), hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', }) } // End hook span for beta tracing endHookSpan(hookSpan, { numSuccess: outcomes.success, numBlocking: outcomes.blocking, numNonBlockingError: outcomes.non_blocking_error, numCancelled: outcomes.cancelled, }) } export type HookOutsideReplResult = { command: string succeeded: boolean output: string blocked: boolean watchPaths?: string[] systemMessage?: string } export function hasBlockingResult(results: HookOutsideReplResult[]): boolean { return results.some(r => r.blocked) } /** * Execute hooks outside of the REPL (e.g. notifications, session end) * * Unlike executeHooks() which yields messages that are exposed to the model as * system messages, this function only logs errors via logForDebugging (visible * with --debug). Callers that need to surface errors to users should handle * the returned results appropriately (e.g. executeSessionEndHooks writes to * stderr during shutdown). * * @param getAppState Optional function to get the current app state (for session hooks) * @param hookInput The structured hook input that will be validated and converted to JSON * @param matchQuery The query to match against hook matchers * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Array of HookOutsideReplResult objects containing command, succeeded, and output */ async function executeHooksOutsideREPL({ getAppState, hookInput, matchQuery, signal, timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, }: { getAppState?: () => AppState hookInput: HookInput matchQuery?: string signal?: AbortSignal timeoutMs: number }): Promise { if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { return [] } const hookEvent = hookInput.hook_event_name const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent if (shouldDisableAllHooksIncludingManaged()) { logForDebugging( `Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`, ) return [] } // SECURITY: ALL hooks require workspace trust in interactive mode // This centralized check prevents RCE vulnerabilities for all current and future hooks if (shouldSkipHookDueToTrust()) { logForDebugging( `Skipping ${hookName} hook execution - workspace trust not accepted`, ) return [] } const appState = getAppState ? getAppState() : undefined // Use main session ID for outside-REPL hooks const sessionId = getSessionId() const matchingHooks = await getMatchingHooks( appState, sessionId, hookEvent, hookInput, ) if (matchingHooks.length === 0) { return [] } if (signal?.aborted) { return [] } const userHooks = matchingHooks.filter(h => !isInternalHook(h)) if (userHooks.length > 0) { const pluginHookCounts = getPluginHookCounts(userHooks) const hookTypeCounts = getHookTypeCounts(userHooks) logEvent(`tengu_run_hook`, { hookName: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, numCommands: userHooks.length, hookTypeCounts: jsonStringify( hookTypeCounts, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(pluginHookCounts && { pluginHookCounts: jsonStringify( pluginHookCounts, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }) } // Validate and stringify the hook input let jsonInput: string try { jsonInput = jsonStringify(hookInput) } catch (error) { logError(error) return [] } // Run all hooks in parallel with individual timeouts const hookPromises = matchingHooks.map( async ({ hook, pluginRoot, pluginId }, hookIndex) => { // Handle callback hooks if (hook.type === 'callback') { const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs const { signal: abortSignal, cleanup } = createCombinedAbortSignal( signal, { timeoutMs: callbackTimeoutMs }, ) try { const toolUseID = randomUUID() const json = await hook.callback( hookInput, toolUseID, abortSignal, hookIndex, ) cleanup?.() if (isAsyncHookJSONOutput(json)) { logForDebugging( `${hookName} [callback] returned async response, returning empty output`, ) return { command: 'callback', succeeded: true, output: '', blocked: false, } } const output = hookEvent === 'WorktreeCreate' && isSyncHookJSONOutput(json) && json.hookSpecificOutput?.hookEventName === 'WorktreeCreate' ? json.hookSpecificOutput.worktreePath : json.systemMessage || '' const blocked = isSyncHookJSONOutput(json) && json.decision === 'block' logForDebugging(`${hookName} [callback] completed successfully`) return { command: 'callback', succeeded: true, output, blocked, } } catch (error) { cleanup?.() const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging( `${hookName} [callback] failed to run: ${errorMessage}`, { level: 'error' }, ) return { command: 'callback', succeeded: false, output: errorMessage, blocked: false, } } } // TODO: Implement prompt stop hooks outside REPL if (hook.type === 'prompt') { return { command: hook.prompt, succeeded: false, output: 'Prompt stop hooks are not yet supported outside REPL', blocked: false, } } // TODO: Implement agent stop hooks outside REPL if (hook.type === 'agent') { return { command: hook.prompt, succeeded: false, output: 'Agent stop hooks are not yet supported outside REPL', blocked: false, } } // Function hooks require messages array (only available in REPL context) // For -p mode Stop hooks, use executeStopHooks which supports function hooks if (hook.type === 'function') { logError( new Error( `Function hook reached executeHooksOutsideREPL for ${hookEvent}. Function hooks should only be used in REPL context (Stop hooks).`, ), ) return { command: 'function', succeeded: false, output: 'Internal error: function hook executed outside REPL context', blocked: false, } } // Handle HTTP hooks (no toolUseContext needed - just HTTP POST). // execHttpHook handles its own timeout internally via hook.timeout or // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so we pass signal directly. if (hook.type === 'http') { try { const httpResult = await execHttpHook( hook, hookEvent, jsonInput, signal, ) if (httpResult.aborted) { logForDebugging(`${hookName} [${hook.url}] cancelled`) return { command: hook.url, succeeded: false, output: 'Hook cancelled', blocked: false, } } if (httpResult.error || !httpResult.ok) { const errMsg = httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}` logForDebugging(`${hookName} [${hook.url}] failed: ${errMsg}`, { level: 'error', }) return { command: hook.url, succeeded: false, output: errMsg, blocked: false, } } // HTTP hooks must return JSON — parse and validate through Zod const { json: httpJson, validationError: httpValidationError } = parseHttpHookOutput(httpResult.body) if (httpValidationError) { throw new Error(httpValidationError) } if (httpJson && !isAsyncHookJSONOutput(httpJson)) { logForDebugging( `Parsed JSON output from HTTP hook: ${jsonStringify(httpJson)}`, { level: 'verbose' }, ) } const jsonBlocked = httpJson && !isAsyncHookJSONOutput(httpJson) && isSyncHookJSONOutput(httpJson) && httpJson.decision === 'block' // WorktreeCreate's consumer reads `output` as the bare filesystem // path. Command hooks provide it via stdout; http hooks provide it // via hookSpecificOutput.worktreePath. Without worktreePath, emit '' // so the consumer's length filter skips it instead of treating the // raw '{}' body as a path. const output = hookEvent === 'WorktreeCreate' ? httpJson && isSyncHookJSONOutput(httpJson) && httpJson.hookSpecificOutput?.hookEventName === 'WorktreeCreate' ? httpJson.hookSpecificOutput.worktreePath : '' : httpResult.body return { command: hook.url, succeeded: true, output, blocked: !!jsonBlocked, } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging( `${hookName} [${hook.url}] failed to run: ${errorMessage}`, { level: 'error' }, ) return { command: hook.url, succeeded: false, output: errorMessage, blocked: false, } } } // Handle command hooks const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs const { signal: abortSignal, cleanup } = createCombinedAbortSignal( signal, { timeoutMs: commandTimeoutMs }, ) try { const result = await execCommandHook( hook, hookEvent, hookName, jsonInput, abortSignal, randomUUID(), hookIndex, pluginRoot, pluginId, ) // Clear timeout if hook completes cleanup?.() if (result.aborted) { logForDebugging(`${hookName} [${hook.command}] cancelled`) return { command: hook.command, succeeded: false, output: 'Hook cancelled', blocked: false, } } logForDebugging( `${hookName} [${hook.command}] completed with status ${result.status}`, ) // Parse JSON for any messages to print out. const { json, validationError } = parseHookOutput(result.stdout) if (validationError) { // Validation error is logged via logForDebugging and returned in output throw new Error(validationError) } if (json && !isAsyncHookJSONOutput(json)) { logForDebugging( `Parsed JSON output from hook: ${jsonStringify(json)}`, { level: 'verbose' }, ) } // Blocked if exit code 2 or JSON decision: 'block' const jsonBlocked = json && !isAsyncHookJSONOutput(json) && isSyncHookJSONOutput(json) && json.decision === 'block' const blocked = result.status === 2 || !!jsonBlocked // For successful hooks (exit code 0), use stdout; for failed hooks, use stderr const output = result.status === 0 ? result.stdout || '' : result.stderr || '' const watchPaths = json && isSyncHookJSONOutput(json) && json.hookSpecificOutput && 'watchPaths' in json.hookSpecificOutput ? json.hookSpecificOutput.watchPaths : undefined const systemMessage = json && isSyncHookJSONOutput(json) ? json.systemMessage : undefined return { command: hook.command, succeeded: result.status === 0, output, blocked, watchPaths, systemMessage, } } catch (error) { // Clean up on error cleanup?.() const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging( `${hookName} [${hook.command}] failed to run: ${errorMessage}`, { level: 'error' }, ) return { command: hook.command, succeeded: false, output: errorMessage, blocked: false, } } }, ) // Wait for all hooks to complete and collect results return await Promise.all(hookPromises) } /** * Execute pre-tool hooks if configured * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') * @param toolUseID The ID of the tool use * @param toolInput The input that will be passed to the tool * @param permissionMode Optional permission mode from toolPermissionContext * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @param toolUseContext Optional ToolUseContext for prompt-based hooks * @returns Async generator that yields progress messages and returns blocking errors */ export async function* executePreToolHooks( toolName: string, toolUseID: string, toolInput: ToolInput, toolUseContext: ToolUseContext, permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, requestPrompt?: ( sourceName: string, toolInputSummary?: string | null, ) => (request: PromptRequest) => Promise, toolInputSummary?: string | null, ): AsyncGenerator { const appState = toolUseContext.getAppState() const sessionId = toolUseContext.agentId ?? getSessionId() if (!hasHookForEvent('PreToolUse', appState, sessionId)) { return } logForDebugging(`executePreToolHooks called for tool: ${toolName}`, { level: 'verbose', }) const hookInput: PreToolUseHookInput = { ...createBaseHookInput(permissionMode, undefined, toolUseContext), hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseID, } yield* executeHooks({ hookInput, toolUseID, matchQuery: toolName, signal, timeoutMs, toolUseContext, requestPrompt, toolInputSummary, }) } /** * Execute post-tool hooks if configured * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') * @param toolUseID The ID of the tool use * @param toolInput The input that was passed to the tool * @param toolResponse The response from the tool * @param toolUseContext ToolUseContext for prompt-based hooks * @param permissionMode Optional permission mode from toolPermissionContext * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Async generator that yields progress messages and blocking errors for automated feedback */ export async function* executePostToolHooks( toolName: string, toolUseID: string, toolInput: ToolInput, toolResponse: ToolResponse, toolUseContext: ToolUseContext, permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): AsyncGenerator { const hookInput: PostToolUseHookInput = { ...createBaseHookInput(permissionMode, undefined, toolUseContext), hook_event_name: 'PostToolUse', tool_name: toolName, tool_input: toolInput, tool_response: toolResponse, tool_use_id: toolUseID, } yield* executeHooks({ hookInput, toolUseID, matchQuery: toolName, signal, timeoutMs, toolUseContext, }) } /** * Execute post-tool-use-failure hooks if configured * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') * @param toolUseID The ID of the tool use * @param toolInput The input that was passed to the tool * @param error The error message from the failed tool call * @param toolUseContext ToolUseContext for prompt-based hooks * @param isInterrupt Whether the tool was interrupted by user * @param permissionMode Optional permission mode from toolPermissionContext * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Async generator that yields progress messages and blocking errors */ export async function* executePostToolUseFailureHooks( toolName: string, toolUseID: string, toolInput: ToolInput, error: string, toolUseContext: ToolUseContext, isInterrupt?: boolean, permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): AsyncGenerator { const appState = toolUseContext.getAppState() const sessionId = toolUseContext.agentId ?? getSessionId() if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) { return } const hookInput: PostToolUseFailureHookInput = { ...createBaseHookInput(permissionMode, undefined, toolUseContext), hook_event_name: 'PostToolUseFailure', tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseID, error, is_interrupt: isInterrupt, } yield* executeHooks({ hookInput, toolUseID, matchQuery: toolName, signal, timeoutMs, toolUseContext, }) } export async function* executePermissionDeniedHooks( toolName: string, toolUseID: string, toolInput: ToolInput, reason: string, toolUseContext: ToolUseContext, permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): AsyncGenerator { const appState = toolUseContext.getAppState() const sessionId = toolUseContext.agentId ?? getSessionId() if (!hasHookForEvent('PermissionDenied', appState, sessionId)) { return } const hookInput: PermissionDeniedHookInput = { ...createBaseHookInput(permissionMode, undefined, toolUseContext), hook_event_name: 'PermissionDenied', tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseID, reason, } yield* executeHooks({ hookInput, toolUseID, matchQuery: toolName, signal, timeoutMs, toolUseContext, }) } /** * Execute notification hooks if configured * @param notificationData The notification data to pass to hooks * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Promise that resolves when all hooks complete */ export async function executeNotificationHooks( notificationData: { message: string title?: string notificationType: string }, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): Promise { const { message, title, notificationType } = notificationData const hookInput: NotificationHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'Notification', message, title, notification_type: notificationType, } await executeHooksOutsideREPL({ hookInput, timeoutMs, matchQuery: notificationType, }) } export async function executeStopFailureHooks( lastMessage: AssistantMessage, toolUseContext?: ToolUseContext, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): Promise { const appState = toolUseContext?.getAppState() // executeHooksOutsideREPL hardcodes main sessionId (:2738). Agent frontmatter // hooks (registerFrontmatterHooks) key by agentId; gating with agentId here // would pass the gate but fail execution. Align gate with execution. const sessionId = getSessionId() if (!hasHookForEvent('StopFailure', appState, sessionId)) return const lastAssistantText = extractTextContent(lastMessage.message.content, '\n').trim() || undefined // Some createAssistantAPIErrorMessage call sites omit `error` (e.g. // image-size at errors.ts:431). Default to 'unknown' so matcher filtering // at getMatchingHooks:1525 always applies. const error = lastMessage.error ?? 'unknown' const hookInput: StopFailureHookInput = { ...createBaseHookInput(undefined, undefined, toolUseContext), hook_event_name: 'StopFailure', error, error_details: lastMessage.errorDetails, last_assistant_message: lastAssistantText, } await executeHooksOutsideREPL({ getAppState: toolUseContext?.getAppState, hookInput, timeoutMs, matchQuery: error, }) } /** * Execute stop hooks if configured * @param toolUseContext ToolUseContext for prompt-based hooks * @param permissionMode permission mode from toolPermissionContext * @param signal AbortSignal to cancel hook execution * @param stopHookActive Whether this call is happening within another stop hook * @param isSubagent Whether the current execution context is a subagent * @param messages Optional conversation history for prompt/function hooks * @returns Async generator that yields progress messages and blocking errors */ export async function* executeStopHooks( permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, stopHookActive: boolean = false, subagentId?: AgentId, toolUseContext?: ToolUseContext, messages?: Message[], agentType?: string, requestPrompt?: ( sourceName: string, toolInputSummary?: string | null, ) => (request: PromptRequest) => Promise, ): AsyncGenerator { const hookEvent = subagentId ? 'SubagentStop' : 'Stop' const appState = toolUseContext?.getAppState() const sessionId = toolUseContext?.agentId ?? getSessionId() if (!hasHookForEvent(hookEvent, appState, sessionId)) { return } // Extract text content from the last assistant message so hooks can // inspect the final response without reading the transcript file. const lastAssistantMessage = messages ? getLastAssistantMessage(messages) : undefined const lastAssistantText = lastAssistantMessage ? extractTextContent(lastAssistantMessage.message.content, '\n').trim() || undefined : undefined const hookInput: StopHookInput | SubagentStopHookInput = subagentId ? { ...createBaseHookInput(permissionMode), hook_event_name: 'SubagentStop', stop_hook_active: stopHookActive, agent_id: subagentId, agent_transcript_path: getAgentTranscriptPath(subagentId), agent_type: agentType ?? '', last_assistant_message: lastAssistantText, } : { ...createBaseHookInput(permissionMode), hook_event_name: 'Stop', stop_hook_active: stopHookActive, last_assistant_message: lastAssistantText, } // Trust check is now centralized in executeHooks() yield* executeHooks({ hookInput, toolUseID: randomUUID(), signal, timeoutMs, toolUseContext, messages, requestPrompt, }) } /** * Execute TeammateIdle hooks when a teammate is about to go idle. * If a hook blocks (exit code 2), the teammate should continue working instead of going idle. * @param teammateName The name of the teammate going idle * @param teamName The team this teammate belongs to * @param permissionMode Optional permission mode * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Async generator that yields progress messages and blocking errors */ export async function* executeTeammateIdleHooks( teammateName: string, teamName: string, permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): AsyncGenerator { const hookInput: TeammateIdleHookInput = { ...createBaseHookInput(permissionMode), hook_event_name: 'TeammateIdle', teammate_name: teammateName, team_name: teamName, } yield* executeHooks({ hookInput, toolUseID: randomUUID(), signal, timeoutMs, }) } /** * Execute TaskCreated hooks when a task is being created. * If a hook blocks (exit code 2), the task creation should be prevented and feedback returned. * @param taskId The ID of the task being created * @param taskSubject The subject/title of the task * @param taskDescription Optional description of the task * @param teammateName Optional name of the teammate creating the task * @param teamName Optional team name * @param permissionMode Optional permission mode * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId * @returns Async generator that yields progress messages and blocking errors */ export async function* executeTaskCreatedHooks( taskId: string, taskSubject: string, taskDescription?: string, teammateName?: string, teamName?: string, permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, toolUseContext?: ToolUseContext, ): AsyncGenerator { const hookInput: TaskCreatedHookInput = { ...createBaseHookInput(permissionMode), hook_event_name: 'TaskCreated', task_id: taskId, task_subject: taskSubject, task_description: taskDescription, teammate_name: teammateName, team_name: teamName, } yield* executeHooks({ hookInput, toolUseID: randomUUID(), signal, timeoutMs, toolUseContext, }) } /** * Execute TaskCompleted hooks when a task is being marked as completed. * If a hook blocks (exit code 2), the task completion should be prevented and feedback returned. * @param taskId The ID of the task being completed * @param taskSubject The subject/title of the task * @param taskDescription Optional description of the task * @param teammateName Optional name of the teammate completing the task * @param teamName Optional team name * @param permissionMode Optional permission mode * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId * @returns Async generator that yields progress messages and blocking errors */ export async function* executeTaskCompletedHooks( taskId: string, taskSubject: string, taskDescription?: string, teammateName?: string, teamName?: string, permissionMode?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, toolUseContext?: ToolUseContext, ): AsyncGenerator { const hookInput: TaskCompletedHookInput = { ...createBaseHookInput(permissionMode), hook_event_name: 'TaskCompleted', task_id: taskId, task_subject: taskSubject, task_description: taskDescription, teammate_name: teammateName, team_name: teamName, } yield* executeHooks({ hookInput, toolUseID: randomUUID(), signal, timeoutMs, toolUseContext, }) } /** * Execute start hooks if configured * @param prompt The user prompt that will be passed to the tool * @param permissionMode Permission mode from toolPermissionContext * @param toolUseContext ToolUseContext for prompt-based hooks * @returns Async generator that yields progress messages and hook results */ export async function* executeUserPromptSubmitHooks( prompt: string, permissionMode: string, toolUseContext: ToolUseContext, requestPrompt?: ( sourceName: string, toolInputSummary?: string | null, ) => (request: PromptRequest) => Promise, ): AsyncGenerator { const appState = toolUseContext.getAppState() const sessionId = toolUseContext.agentId ?? getSessionId() if (!hasHookForEvent('UserPromptSubmit', appState, sessionId)) { return } const hookInput: UserPromptSubmitHookInput = { ...createBaseHookInput(permissionMode), hook_event_name: 'UserPromptSubmit', prompt, } yield* executeHooks({ hookInput, toolUseID: randomUUID(), signal: toolUseContext.abortController.signal, timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, toolUseContext, requestPrompt, }) } /** * Execute session start hooks if configured * @param source The source of the session start (startup, resume, clear) * @param sessionId Optional The session id to use as hook input * @param agentType Optional The agent type (from --agent flag) running this session * @param model Optional The model being used for this session * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Async generator that yields progress messages and hook results */ export async function* executeSessionStartHooks( source: 'startup' | 'resume' | 'clear' | 'compact', sessionId?: string, agentType?: string, model?: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, forceSyncExecution?: boolean, ): AsyncGenerator { const hookInput: SessionStartHookInput = { ...createBaseHookInput(undefined, sessionId), hook_event_name: 'SessionStart', source, agent_type: agentType, model, } yield* executeHooks({ hookInput, toolUseID: randomUUID(), matchQuery: source, signal, timeoutMs, forceSyncExecution, }) } /** * Execute setup hooks if configured * @param trigger The trigger type ('init' or 'maintenance') * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @param forceSyncExecution If true, async hooks will not be backgrounded * @returns Async generator that yields progress messages and hook results */ export async function* executeSetupHooks( trigger: 'init' | 'maintenance', signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, forceSyncExecution?: boolean, ): AsyncGenerator { const hookInput: SetupHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'Setup', trigger, } yield* executeHooks({ hookInput, toolUseID: randomUUID(), matchQuery: trigger, signal, timeoutMs, forceSyncExecution, }) } /** * Execute subagent start hooks if configured * @param agentId The unique identifier for the subagent * @param agentType The type/name of the subagent being started * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Async generator that yields progress messages and hook results */ export async function* executeSubagentStartHooks( agentId: string, agentType: string, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): AsyncGenerator { const hookInput: SubagentStartHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'SubagentStart', agent_id: agentId, agent_type: agentType, } yield* executeHooks({ hookInput, toolUseID: randomUUID(), matchQuery: agentType, signal, timeoutMs, }) } /** * Execute pre-compact hooks if configured * @param compactData The compact data to pass to hooks * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Object with optional newCustomInstructions and userDisplayMessage */ export async function executePreCompactHooks( compactData: { trigger: 'manual' | 'auto' customInstructions: string | null }, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): Promise<{ newCustomInstructions?: string userDisplayMessage?: string }> { const hookInput: PreCompactHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'PreCompact', trigger: compactData.trigger, custom_instructions: compactData.customInstructions, } const results = await executeHooksOutsideREPL({ hookInput, matchQuery: compactData.trigger, signal, timeoutMs, }) if (results.length === 0) { return {} } // Extract custom instructions from successful hooks with non-empty output const successfulOutputs = results .filter(result => result.succeeded && result.output.trim().length > 0) .map(result => result.output.trim()) // Build user display messages with command info const displayMessages: string[] = [] for (const result of results) { if (result.succeeded) { if (result.output.trim()) { displayMessages.push( `PreCompact [${result.command}] completed successfully: ${result.output.trim()}`, ) } else { displayMessages.push( `PreCompact [${result.command}] completed successfully`, ) } } else { if (result.output.trim()) { displayMessages.push( `PreCompact [${result.command}] failed: ${result.output.trim()}`, ) } else { displayMessages.push(`PreCompact [${result.command}] failed`) } } } return { newCustomInstructions: successfulOutputs.length > 0 ? successfulOutputs.join('\n\n') : undefined, userDisplayMessage: displayMessages.length > 0 ? displayMessages.join('\n') : undefined, } } /** * Execute post-compact hooks if configured * @param compactData The compact data to pass to hooks, including the summary * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Object with optional userDisplayMessage */ export async function executePostCompactHooks( compactData: { trigger: 'manual' | 'auto' compactSummary: string }, signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): Promise<{ userDisplayMessage?: string }> { const hookInput: PostCompactHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'PostCompact', trigger: compactData.trigger, compact_summary: compactData.compactSummary, } const results = await executeHooksOutsideREPL({ hookInput, matchQuery: compactData.trigger, signal, timeoutMs, }) if (results.length === 0) { return {} } const displayMessages: string[] = [] for (const result of results) { if (result.succeeded) { if (result.output.trim()) { displayMessages.push( `PostCompact [${result.command}] completed successfully: ${result.output.trim()}`, ) } else { displayMessages.push( `PostCompact [${result.command}] completed successfully`, ) } } else { if (result.output.trim()) { displayMessages.push( `PostCompact [${result.command}] failed: ${result.output.trim()}`, ) } else { displayMessages.push(`PostCompact [${result.command}] failed`) } } } return { userDisplayMessage: displayMessages.length > 0 ? displayMessages.join('\n') : undefined, } } /** * Execute session end hooks if configured * @param reason The reason for ending the session * @param options Optional parameters including app state functions and signal * @returns Promise that resolves when all hooks complete */ export async function executeSessionEndHooks( reason: ExitReason, options?: { getAppState?: () => AppState setAppState?: (updater: (prev: AppState) => AppState) => void signal?: AbortSignal timeoutMs?: number }, ): Promise { const { getAppState, setAppState, signal, timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, } = options || {} const hookInput: SessionEndHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'SessionEnd', reason, } const results = await executeHooksOutsideREPL({ getAppState, hookInput, matchQuery: reason, signal, timeoutMs, }) // During shutdown, Ink is unmounted so we can write directly to stderr for (const result of results) { if (!result.succeeded && result.output) { process.stderr.write( `SessionEnd hook [${result.command}] failed: ${result.output}\n`, ) } } // Clear session hooks after execution if (setAppState) { const sessionId = getSessionId() clearSessionHooks(setAppState, sessionId) } } /** * Execute permission request hooks if configured * These hooks are called when a permission dialog would be displayed to the user. * Hooks can approve or deny the permission request programmatically. * @param toolName The name of the tool requesting permission * @param toolUseID The ID of the tool use * @param toolInput The input that would be passed to the tool * @param toolUseContext ToolUseContext for the request * @param permissionMode Optional permission mode from toolPermissionContext * @param permissionSuggestions Optional permission suggestions (the "always allow" options) * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Async generator that yields progress messages and returns aggregated result */ export async function* executePermissionRequestHooks( toolName: string, toolUseID: string, toolInput: ToolInput, toolUseContext: ToolUseContext, permissionMode?: string, permissionSuggestions?: PermissionUpdate[], signal?: AbortSignal, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, requestPrompt?: ( sourceName: string, toolInputSummary?: string | null, ) => (request: PromptRequest) => Promise, toolInputSummary?: string | null, ): AsyncGenerator { logForDebugging(`executePermissionRequestHooks called for tool: ${toolName}`) const hookInput: PermissionRequestHookInput = { ...createBaseHookInput(permissionMode, undefined, toolUseContext), hook_event_name: 'PermissionRequest', tool_name: toolName, tool_input: toolInput, permission_suggestions: permissionSuggestions, } yield* executeHooks({ hookInput, toolUseID, matchQuery: toolName, signal, timeoutMs, toolUseContext, requestPrompt, toolInputSummary, }) } export type ConfigChangeSource = | 'user_settings' | 'project_settings' | 'local_settings' | 'policy_settings' | 'skills' /** * Execute config change hooks when configuration files change during a session. * Fired by file watchers when settings, skills, or commands change on disk. * Enables enterprise admins to audit/log configuration changes for security. * * Policy settings are enterprise-managed and must never be blockable by hooks. * Hooks still fire (for audit logging) but blocking results are ignored — callers * will always see an empty result for policy sources. * * @param source The type of config that changed * @param filePath Optional path to the changed file * @param timeoutMs Optional timeout in milliseconds for hook execution */ export async function executeConfigChangeHooks( source: ConfigChangeSource, filePath?: string, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): Promise { const hookInput: ConfigChangeHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'ConfigChange', source, file_path: filePath, } const results = await executeHooksOutsideREPL({ hookInput, timeoutMs, matchQuery: source, }) // Policy settings are enterprise-managed — hooks fire for audit logging // but must never block policy changes from being applied if (source === 'policy_settings') { return results.map(r => ({ ...r, blocked: false })) } return results } async function executeEnvHooks( hookInput: HookInput, timeoutMs: number, ): Promise<{ results: HookOutsideReplResult[] watchPaths: string[] systemMessages: string[] }> { const results = await executeHooksOutsideREPL({ hookInput, timeoutMs }) if (results.length > 0) { invalidateSessionEnvCache() } const watchPaths = results.flatMap(r => r.watchPaths ?? []) const systemMessages = results .map(r => r.systemMessage) .filter((m): m is string => !!m) return { results, watchPaths, systemMessages } } export function executeCwdChangedHooks( oldCwd: string, newCwd: string, timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): Promise<{ results: HookOutsideReplResult[] watchPaths: string[] systemMessages: string[] }> { const hookInput: CwdChangedHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: newCwd, } return executeEnvHooks(hookInput, timeoutMs) } export function executeFileChangedHooks( filePath: string, event: 'change' | 'add' | 'unlink', timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ): Promise<{ results: HookOutsideReplResult[] watchPaths: string[] systemMessages: string[] }> { const hookInput: FileChangedHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'FileChanged', file_path: filePath, event, } return executeEnvHooks(hookInput, timeoutMs) } export type InstructionsLoadReason = | 'session_start' | 'nested_traversal' | 'path_glob_match' | 'include' | 'compact' export type InstructionsMemoryType = 'User' | 'Project' | 'Local' | 'Managed' /** * Check if InstructionsLoaded hooks are configured (without executing them). * Callers should check this before invoking executeInstructionsLoadedHooks to avoid * building hook inputs for every instruction file when no hook is configured. * * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). Session- * derived hooks (structured output enforcement etc.) are internal and not checked. */ export function hasInstructionsLoadedHook(): boolean { const snapshotHooks = getHooksConfigFromSnapshot()?.['InstructionsLoaded'] if (snapshotHooks && snapshotHooks.length > 0) return true const registeredHooks = getRegisteredHooks()?.['InstructionsLoaded'] if (registeredHooks && registeredHooks.length > 0) return true return false } /** * Execute InstructionsLoaded hooks when an instruction file (CLAUDE.md or * .claude/rules/*.md) is loaded into context. Fire-and-forget — this hook is * for observability/audit only and does not support blocking. * * Dispatch sites: * - Eager load at session start (getMemoryFiles in claudemd.ts) * - Eager reload after compaction (getMemoryFiles cache cleared by * runPostCompactCleanup; next call reports load_reason: 'compact') * - Lazy load when Claude touches a file that triggers nested CLAUDE.md or * conditional rules with paths: frontmatter (memoryFilesToAttachments in * attachments.ts) */ export async function executeInstructionsLoadedHooks( filePath: string, memoryType: InstructionsMemoryType, loadReason: InstructionsLoadReason, options?: { globs?: string[] triggerFilePath?: string parentFilePath?: string timeoutMs?: number }, ): Promise { const { globs, triggerFilePath, parentFilePath, timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, } = options ?? {} const hookInput: InstructionsLoadedHookInput = { ...createBaseHookInput(undefined), hook_event_name: 'InstructionsLoaded', file_path: filePath, memory_type: memoryType, load_reason: loadReason, globs, trigger_file_path: triggerFilePath, parent_file_path: parentFilePath, } await executeHooksOutsideREPL({ hookInput, timeoutMs, matchQuery: loadReason, }) } /** Result of an elicitation hook execution (non-REPL path). */ export type ElicitationHookResult = { elicitationResponse?: ElicitationResponse blockingError?: HookBlockingError } /** Result of an elicitation-result hook execution (non-REPL path). */ export type ElicitationResultHookResult = { elicitationResultResponse?: ElicitationResponse blockingError?: HookBlockingError } /** * Parse elicitation-specific fields from a HookOutsideReplResult. * Mirrors the relevant branches of processHookJSONOutput for Elicitation * and ElicitationResult hook events. */ function parseElicitationHookOutput( result: HookOutsideReplResult, expectedEventName: 'Elicitation' | 'ElicitationResult', ): { response?: ElicitationResponse blockingError?: HookBlockingError } { // Exit code 2 = blocking (same as executeHooks path) if (result.blocked && !result.succeeded) { return { blockingError: { blockingError: result.output || `Elicitation blocked by hook`, command: result.command, }, } } if (!result.output.trim()) { return {} } // Try to parse JSON output for structured elicitation response const trimmed = result.output.trim() if (!trimmed.startsWith('{')) { return {} } try { const parsed = hookJSONOutputSchema().parse(JSON.parse(trimmed)) if (isAsyncHookJSONOutput(parsed)) { return {} } if (!isSyncHookJSONOutput(parsed)) { return {} } // Check for top-level decision: 'block' (exit code 0 + JSON block) if (parsed.decision === 'block' || result.blocked) { return { blockingError: { blockingError: parsed.reason || 'Elicitation blocked by hook', command: result.command, }, } } const specific = parsed.hookSpecificOutput if (!specific || specific.hookEventName !== expectedEventName) { return {} } if (!specific.action) { return {} } const response: ElicitationResponse = { action: specific.action, content: specific.content as ElicitationResponse['content'] | undefined, } const out: { response?: ElicitationResponse blockingError?: HookBlockingError } = { response } if (specific.action === 'decline') { out.blockingError = { blockingError: parsed.reason || (expectedEventName === 'Elicitation' ? 'Elicitation denied by hook' : 'Elicitation result blocked by hook'), command: result.command, } } return out } catch { return {} } } export async function executeElicitationHooks({ serverName, message, requestedSchema, permissionMode, signal, timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, mode, url, elicitationId, }: { serverName: string message: string requestedSchema?: Record permissionMode?: string signal?: AbortSignal timeoutMs?: number mode?: 'form' | 'url' url?: string elicitationId?: string }): Promise { const hookInput: ElicitationHookInput = { ...createBaseHookInput(permissionMode), hook_event_name: 'Elicitation', mcp_server_name: serverName, message, mode, url, elicitation_id: elicitationId, requested_schema: requestedSchema, } const results = await executeHooksOutsideREPL({ hookInput, matchQuery: serverName, signal, timeoutMs, }) let elicitationResponse: ElicitationResponse | undefined let blockingError: HookBlockingError | undefined for (const result of results) { const parsed = parseElicitationHookOutput(result, 'Elicitation') if (parsed.blockingError) { blockingError = parsed.blockingError } if (parsed.response) { elicitationResponse = parsed.response } } return { elicitationResponse, blockingError } } export async function executeElicitationResultHooks({ serverName, action, content, permissionMode, signal, timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, mode, elicitationId, }: { serverName: string action: 'accept' | 'decline' | 'cancel' content?: Record permissionMode?: string signal?: AbortSignal timeoutMs?: number mode?: 'form' | 'url' elicitationId?: string }): Promise { const hookInput: ElicitationResultHookInput = { ...createBaseHookInput(permissionMode), hook_event_name: 'ElicitationResult', mcp_server_name: serverName, elicitation_id: elicitationId, mode, action, content, } const results = await executeHooksOutsideREPL({ hookInput, matchQuery: serverName, signal, timeoutMs, }) let elicitationResultResponse: ElicitationResponse | undefined let blockingError: HookBlockingError | undefined for (const result of results) { const parsed = parseElicitationHookOutput(result, 'ElicitationResult') if (parsed.blockingError) { blockingError = parsed.blockingError } if (parsed.response) { elicitationResultResponse = parsed.response } } return { elicitationResultResponse, blockingError } } /** * Execute status line command if configured * @param statusLineInput The structured status input that will be converted to JSON * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns The status line text to display, or undefined if no command configured */ export async function executeStatusLineCommand( statusLineInput: StatusLineCommandInput, signal?: AbortSignal, timeoutMs: number = 5000, // Short timeout for status line logResult: boolean = false, ): Promise { // Check if all hooks (including statusLine) are disabled by managed settings if (shouldDisableAllHooksIncludingManaged()) { return undefined } // SECURITY: ALL hooks require workspace trust in interactive mode // This centralized check prevents RCE vulnerabilities for all current and future hooks if (shouldSkipHookDueToTrust()) { logForDebugging( `Skipping StatusLine command execution - workspace trust not accepted`, ) return undefined } // When disableAllHooks is set in non-managed settings, only managed statusLine runs // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) let statusLine if (shouldAllowManagedHooksOnly()) { statusLine = getSettingsForSource('policySettings')?.statusLine } else { statusLine = getSettings_DEPRECATED()?.statusLine } if (!statusLine || statusLine.type !== 'command') { return undefined } // Use provided signal or create a default one const abortSignal = signal || AbortSignal.timeout(timeoutMs) try { // Convert status input to JSON const jsonInput = jsonStringify(statusLineInput) const result = await execCommandHook( statusLine, 'StatusLine', 'statusLine', jsonInput, abortSignal, randomUUID(), ) if (result.aborted) { return undefined } // For successful hooks (exit code 0), use stdout if (result.status === 0) { // Trim and split output into lines, then join with newlines const output = result.stdout .trim() .split('\n') .flatMap(line => line.trim() || []) .join('\n') if (output) { if (logResult) { logForDebugging( `StatusLine [${statusLine.command}] completed with status ${result.status}`, ) } return output } } else if (logResult) { logForDebugging( `StatusLine [${statusLine.command}] completed with status ${result.status}`, { level: 'warn' }, ) } return undefined } catch (error) { logForDebugging(`Status hook failed: ${error}`, { level: 'error' }) return undefined } } /** * Execute file suggestion command if configured * @param fileSuggestionInput The structured input that will be converted to JSON * @param signal Optional AbortSignal to cancel hook execution * @param timeoutMs Optional timeout in milliseconds for hook execution * @returns Array of file paths, or empty array if no command configured */ export async function executeFileSuggestionCommand( fileSuggestionInput: FileSuggestionCommandInput, signal?: AbortSignal, timeoutMs: number = 5000, // Short timeout for typeahead suggestions ): Promise { // Check if all hooks are disabled by managed settings if (shouldDisableAllHooksIncludingManaged()) { return [] } // SECURITY: ALL hooks require workspace trust in interactive mode // This centralized check prevents RCE vulnerabilities for all current and future hooks if (shouldSkipHookDueToTrust()) { logForDebugging( `Skipping FileSuggestion command execution - workspace trust not accepted`, ) return [] } // When disableAllHooks is set in non-managed settings, only managed fileSuggestion runs // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) let fileSuggestion if (shouldAllowManagedHooksOnly()) { fileSuggestion = getSettingsForSource('policySettings')?.fileSuggestion } else { fileSuggestion = getSettings_DEPRECATED()?.fileSuggestion } if (!fileSuggestion || fileSuggestion.type !== 'command') { return [] } // Use provided signal or create a default one const abortSignal = signal || AbortSignal.timeout(timeoutMs) try { const jsonInput = jsonStringify(fileSuggestionInput) const hook = { type: 'command' as const, command: fileSuggestion.command } const result = await execCommandHook( hook, 'FileSuggestion', 'FileSuggestion', jsonInput, abortSignal, randomUUID(), ) if (result.aborted || result.status !== 0) { return [] } return result.stdout .split('\n') .map(line => line.trim()) .filter(Boolean) } catch (error) { logForDebugging(`File suggestion helper failed: ${error}`, { level: 'error', }) return [] } } async function executeFunctionHook({ hook, messages, hookName, toolUseID, hookEvent, timeoutMs, signal, }: { hook: FunctionHook messages: Message[] hookName: string toolUseID: string hookEvent: HookEvent timeoutMs: number signal?: AbortSignal }): Promise { const callbackTimeoutMs = hook.timeout ?? timeoutMs const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { timeoutMs: callbackTimeoutMs, }) try { // Check if already aborted if (abortSignal.aborted) { cleanup() return { outcome: 'cancelled', hook, } } // Execute callback with abort signal const passed = await new Promise((resolve, reject) => { // Handle abort signal const onAbort = () => reject(new Error('Function hook cancelled')) abortSignal.addEventListener('abort', onAbort) // Execute callback Promise.resolve(hook.callback(messages, abortSignal)) .then(result => { abortSignal.removeEventListener('abort', onAbort) resolve(result) }) .catch(error => { abortSignal.removeEventListener('abort', onAbort) reject(error) }) }) cleanup() if (passed) { return { outcome: 'success', hook, } } return { blockingError: { blockingError: hook.errorMessage, command: 'function', }, outcome: 'blocking', hook, } } catch (error) { cleanup() // Handle cancellation if ( error instanceof Error && (error.message === 'Function hook cancelled' || error.name === 'AbortError') ) { return { outcome: 'cancelled', hook, } } // Log for monitoring logError(error) return { message: createAttachmentMessage({ type: 'hook_error_during_execution', hookName, toolUseID, hookEvent, content: error instanceof Error ? error.message : 'Function hook execution error', }), outcome: 'non_blocking_error', hook, } } } async function executeHookCallback({ toolUseID, hook, hookEvent, hookInput, signal, hookIndex, toolUseContext, }: { toolUseID: string hook: HookCallback hookEvent: HookEvent hookInput: HookInput signal: AbortSignal hookIndex?: number toolUseContext?: ToolUseContext }): Promise { // Create context for callbacks that need state access const context = toolUseContext ? { getAppState: toolUseContext.getAppState, updateAttributionState: toolUseContext.updateAttributionState, } : undefined const json = await hook.callback( hookInput, toolUseID, signal, hookIndex, context, ) if (isAsyncHookJSONOutput(json)) { return { outcome: 'success', hook, } } const processed = processHookJSONOutput({ json, command: 'callback', // TODO: If the hook came from a plugin, use the full path to the plugin for easier debugging hookName: `${hookEvent}:Callback`, toolUseID, hookEvent, expectedHookEvent: hookEvent, // Callbacks don't have stdout/stderr/exitCode stdout: undefined, stderr: undefined, exitCode: undefined, }) return { ...processed, outcome: 'success', hook, } } /** * Check if WorktreeCreate hooks are configured (without executing them). * * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). * * Must mirror the managedOnly filtering in getHooksConfig() — when * shouldAllowManagedHooksOnly() is true, plugin hooks (pluginRoot set) are * skipped at execution, so we must also skip them here. Otherwise this returns * true but executeWorktreeCreateHook() finds no matching hooks and throws, * blocking the git-worktree fallback. */ export function hasWorktreeCreateHook(): boolean { const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeCreate'] if (snapshotHooks && snapshotHooks.length > 0) return true const registeredHooks = getRegisteredHooks()?.['WorktreeCreate'] if (!registeredHooks || registeredHooks.length === 0) return false // Mirror getHooksConfig(): skip plugin hooks in managed-only mode const managedOnly = shouldAllowManagedHooksOnly() return registeredHooks.some( matcher => !(managedOnly && 'pluginRoot' in matcher), ) } /** * Execute WorktreeCreate hooks. * Returns the worktree path from hook stdout. * Throws if hooks fail or produce no output. * Callers should check hasWorktreeCreateHook() before calling this. */ export async function executeWorktreeCreateHook( name: string, ): Promise<{ worktreePath: string }> { const hookInput = { ...createBaseHookInput(undefined), hook_event_name: 'WorktreeCreate' as const, name, } const results = await executeHooksOutsideREPL({ hookInput, timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, }) // Find the first successful result with non-empty output const successfulResult = results.find( r => r.succeeded && r.output.trim().length > 0, ) if (!successfulResult) { const failedOutputs = results .filter(r => !r.succeeded) .map(r => `${r.command}: ${r.output.trim() || 'no output'}`) throw new Error( `WorktreeCreate hook failed: ${failedOutputs.join('; ') || 'no successful output'}`, ) } const worktreePath = successfulResult.output.trim() return { worktreePath } } /** * Execute WorktreeRemove hooks if configured. * Returns true if hooks were configured and ran, false if no hooks are configured. * * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). */ export async function executeWorktreeRemoveHook( worktreePath: string, ): Promise { const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeRemove'] const registeredHooks = getRegisteredHooks()?.['WorktreeRemove'] const hasSnapshotHooks = snapshotHooks && snapshotHooks.length > 0 const hasRegisteredHooks = registeredHooks && registeredHooks.length > 0 if (!hasSnapshotHooks && !hasRegisteredHooks) { return false } const hookInput = { ...createBaseHookInput(undefined), hook_event_name: 'WorktreeRemove' as const, worktree_path: worktreePath, } const results = await executeHooksOutsideREPL({ hookInput, timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, }) if (results.length === 0) { return false } for (const result of results) { if (!result.succeeded) { logForDebugging( `WorktreeRemove hook failed [${result.command}]: ${result.output.trim()}`, { level: 'error' }, ) } } return true } function getHookDefinitionsForTelemetry( matchedHooks: MatchedHook[], ): Array<{ type: string; command?: string; prompt?: string; name?: string }> { return matchedHooks.map(({ hook }) => { if (hook.type === 'command') { return { type: 'command', command: hook.command } } else if (hook.type === 'prompt') { return { type: 'prompt', prompt: hook.prompt } } else if (hook.type === 'http') { return { type: 'http', command: hook.url } } else if (hook.type === 'function') { return { type: 'function', name: 'function' } } else if (hook.type === 'callback') { return { type: 'callback', name: 'callback' } } return { type: 'unknown' } }) }