* 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 shou
({
getAppState,
hookInput,
matchQuery,
signal,
timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
}: {
getAppState?: () => AppState
hookInput: HookInput
matchQuery?: string
signal?: AbortSignal
timeoutMs: number
})
| 3001 | * @returns Array of HookOutsideReplResult objects containing command, succeeded, and output |
| 3002 | */ |
| 3003 | async function executeHooksOutsideREPL({ |
| 3004 | getAppState, |
| 3005 | hookInput, |
| 3006 | matchQuery, |
| 3007 | signal, |
| 3008 | timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, |
| 3009 | }: { |
| 3010 | getAppState?: () => AppState |
| 3011 | hookInput: HookInput |
| 3012 | matchQuery?: string |
| 3013 | signal?: AbortSignal |
| 3014 | timeoutMs: number |
| 3015 | }): Promise<HookOutsideReplResult[]> { |
| 3016 | if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { |
| 3017 | return [] |
| 3018 | } |
| 3019 | |
| 3020 | const hookEvent = hookInput.hook_event_name |
| 3021 | const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent |
| 3022 | if (shouldDisableAllHooksIncludingManaged()) { |
| 3023 | logForDebugging( |
| 3024 | `Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`, |
| 3025 | ) |
| 3026 | return [] |
| 3027 | } |
| 3028 | |
| 3029 | // SECURITY: ALL hooks require workspace trust in interactive mode |
| 3030 | // This centralized check prevents RCE vulnerabilities for all current and future hooks |
| 3031 | if (shouldSkipHookDueToTrust()) { |
| 3032 | logForDebugging( |
| 3033 | `Skipping ${hookName} hook execution - workspace trust not accepted`, |
| 3034 | ) |
| 3035 | return [] |
| 3036 | } |
| 3037 | |
| 3038 | const appState = getAppState ? getAppState() : undefined |
| 3039 | // Use main session ID for outside-REPL hooks |
| 3040 | const sessionId = getSessionId() |
| 3041 | const matchingHooks = await getMatchingHooks( |
| 3042 | appState, |
| 3043 | sessionId, |
| 3044 | hookEvent, |
| 3045 | hookInput, |
| 3046 | ) |
| 3047 | if (matchingHooks.length === 0) { |
| 3048 | return [] |
| 3049 | } |
| 3050 | |
| 3051 | if (signal?.aborted) { |
| 3052 | return [] |
| 3053 | } |
| 3054 | |
| 3055 | const userHooks = matchingHooks.filter(h => !isInternalHook(h)) |
| 3056 | if (userHooks.length > 0) { |
| 3057 | const pluginHookCounts = getPluginHookCounts(userHooks) |
| 3058 | const hookTypeCounts = getHookTypeCounts(userHooks) |
| 3059 | logEvent(`tengu_run_hook`, { |
| 3060 | hookName: |
no test coverage detected