5023 lines
156 KiB
TypeScript
5023 lines
156 KiB
TypeScript
// 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<string, unknown>
|
||
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<string, unknown>
|
||
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<HookResult> {
|
||
const result: Partial<HookResult> = {}
|
||
|
||
// 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<PromptResponse>,
|
||
): 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<typeof loadPluginOptions> | 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 <missing>.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: <gitBashPath | true> }) — 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<string>()
|
||
// 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<void>(resolve => {
|
||
child.stdout.on('end', () => resolve())
|
||
})
|
||
|
||
const stderrEndPromise = new Promise<void>(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<void>((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<never>((_, 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<IfConditionMatcher | undefined> {
|
||
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<string, number> | undefined {
|
||
const pluginHooks = hooks.filter(h => h.pluginId)
|
||
if (pluginHooks.length === 0) {
|
||
return undefined
|
||
}
|
||
const counts: Record<string, number> = {}
|
||
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<string, number> {
|
||
const counts: Record<string, number> = {}
|
||
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<MatchedHook[]> {
|
||
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<PromptResponse>
|
||
toolInputSummary?: string | null
|
||
}): AsyncGenerator<AggregatedHookResult> {
|
||
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<HookResult> {
|
||
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<HookOutsideReplResult[]> {
|
||
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<ToolInput>(
|
||
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<PromptResponse>,
|
||
toolInputSummary?: string | null,
|
||
): AsyncGenerator<AggregatedHookResult> {
|
||
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<ToolInput, ToolResponse>(
|
||
toolName: string,
|
||
toolUseID: string,
|
||
toolInput: ToolInput,
|
||
toolResponse: ToolResponse,
|
||
toolUseContext: ToolUseContext,
|
||
permissionMode?: string,
|
||
signal?: AbortSignal,
|
||
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
|
||
): AsyncGenerator<AggregatedHookResult> {
|
||
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<ToolInput>(
|
||
toolName: string,
|
||
toolUseID: string,
|
||
toolInput: ToolInput,
|
||
error: string,
|
||
toolUseContext: ToolUseContext,
|
||
isInterrupt?: boolean,
|
||
permissionMode?: string,
|
||
signal?: AbortSignal,
|
||
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
|
||
): AsyncGenerator<AggregatedHookResult> {
|
||
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<ToolInput>(
|
||
toolName: string,
|
||
toolUseID: string,
|
||
toolInput: ToolInput,
|
||
reason: string,
|
||
toolUseContext: ToolUseContext,
|
||
permissionMode?: string,
|
||
signal?: AbortSignal,
|
||
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
|
||
): AsyncGenerator<AggregatedHookResult> {
|
||
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<void> {
|
||
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<void> {
|
||
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<PromptResponse>,
|
||
): AsyncGenerator<AggregatedHookResult> {
|
||
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<AggregatedHookResult> {
|
||
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<AggregatedHookResult> {
|
||
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<AggregatedHookResult> {
|
||
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<PromptResponse>,
|
||
): AsyncGenerator<AggregatedHookResult> {
|
||
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<AggregatedHookResult> {
|
||
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<AggregatedHookResult> {
|
||
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<AggregatedHookResult> {
|
||
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<void> {
|
||
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<ToolInput>(
|
||
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<PromptResponse>,
|
||
toolInputSummary?: string | null,
|
||
): AsyncGenerator<AggregatedHookResult> {
|
||
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<HookOutsideReplResult[]> {
|
||
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<void> {
|
||
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<string, unknown>
|
||
permissionMode?: string
|
||
signal?: AbortSignal
|
||
timeoutMs?: number
|
||
mode?: 'form' | 'url'
|
||
url?: string
|
||
elicitationId?: string
|
||
}): Promise<ElicitationHookResult> {
|
||
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<string, unknown>
|
||
permissionMode?: string
|
||
signal?: AbortSignal
|
||
timeoutMs?: number
|
||
mode?: 'form' | 'url'
|
||
elicitationId?: string
|
||
}): Promise<ElicitationResultHookResult> {
|
||
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<string | undefined> {
|
||
// 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<string[]> {
|
||
// 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<HookResult> {
|
||
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<boolean>((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<HookResult> {
|
||
// 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<boolean> {
|
||
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' }
|
||
})
|
||
}
|