π File detail
services/tools/toolHooks.ts
π― Use case
This file lives under βservices/β, which covers long-lived services (LSP, MCP, OAuth, tool execution, memory, compaction, voice, settings sync, β¦). On the API surface it exposes PostToolUseHooksResult and resolveHookPermissionDecision β mainly functions, hooks, or classes. Dependencies touch src and schema validation. It composes internal code from hooks, Tool, types, utils, and mcp (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
π€ Exports (heuristic)
PostToolUseHooksResultresolveHookPermissionDecision
π External import roots
Package roots from from "β¦" (relative paths omitted).
srczod
π₯οΈ Source preview
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
import type z from 'zod/v4'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js'
import type { HookProgress } from '../../types/hooks.js'
import type {
AssistantMessage,
AttachmentMessage,
ProgressMessage,
} from '../../types/message.js'
import type { PermissionDecision } from '../../types/permissions.js'
import { createAttachmentMessage } from '../../utils/attachments.js'
import { logForDebugging } from '../../utils/debug.js'
import {
executePostToolHooks,
executePostToolUseFailureHooks,
executePreToolHooks,
getPreToolHookBlockingMessage,
} from '../../utils/hooks.js'
import { logError } from '../../utils/log.js'
import {
getRuleBehaviorDescription,
type PermissionDecisionReason,
type PermissionResult,
} from '../../utils/permissions/PermissionResult.js'
import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js'
import { formatError } from '../../utils/toolErrors.js'
import { isMcpTool } from '../mcp/utils.js'
import type { McpServerType, MessageUpdateLazy } from './toolExecution.js'
export type PostToolUseHooksResult<Output> =
| MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>>
| { updatedMCPToolOutput: Output }
export async function* runPostToolUseHooks<Input extends AnyObject, Output>(
toolUseContext: ToolUseContext,
tool: Tool<Input, Output>,
toolUseID: string,
messageId: string,
toolInput: Record<string, unknown>,
toolResponse: Output,
requestId: string | undefined,
mcpServerType: McpServerType,
mcpServerBaseUrl: string | undefined,
): AsyncGenerator<PostToolUseHooksResult<Output>> {
const postToolStartTime = Date.now()
try {
const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
let toolOutput = toolResponse
for await (const result of executePostToolHooks(
tool.name,
toolUseID,
toolInput,
toolOutput,
toolUseContext,
permissionMode,
toolUseContext.abortController.signal,
)) {
try {
// Check if we were aborted during hook execution
// IMPORTANT: We emit a cancelled event per hook
if (
result.message?.type === 'attachment' &&
result.message.attachment.type === 'hook_cancelled'
) {
logEvent('tengu_post_tool_hooks_cancelled', {
toolName: sanitizeToolNameForAnalytics(tool.name),
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
})
yield {
message: createAttachmentMessage({
type: 'hook_cancelled',
hookName: `PostToolUse:${tool.name}`,
toolUseID,
hookEvent: 'PostToolUse',
}),
}
continue
}
// For JSON {decision:"block"} hooks, executeHooks yields two results:
// {blockingError} and {message: hook_blocking_error attachment}. The
// blockingError path below creates that same attachment, so skip it
// here to avoid displaying the block reason twice (#31301). The
// exit-code-2 path only yields {blockingError}, so it's unaffected.
if (
result.message &&
!(
result.message.type === 'attachment' &&
result.message.attachment.type === 'hook_blocking_error'
)
) {
yield { message: result.message }
}
if (result.blockingError) {
yield {
message: createAttachmentMessage({
type: 'hook_blocking_error',
hookName: `PostToolUse:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PostToolUse',
blockingError: result.blockingError,
}),
}
}
// If hook indicated to prevent continuation, yield a stop reason message
if (result.preventContinuation) {
yield {
message: createAttachmentMessage({
type: 'hook_stopped_continuation',
message:
result.stopReason || 'Execution stopped by PostToolUse hook',
hookName: `PostToolUse:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PostToolUse',
}),
}
return
}
// If hooks provided additional context, add it as a message
if (result.additionalContexts && result.additionalContexts.length > 0) {
yield {
message: createAttachmentMessage({
type: 'hook_additional_context',
content: result.additionalContexts,
hookName: `PostToolUse:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PostToolUse',
}),
}
}
// If hooks provided updatedMCPToolOutput, yield it if this is an MCP tool
if (result.updatedMCPToolOutput && isMcpTool(tool)) {
toolOutput = result.updatedMCPToolOutput as Output
yield {
updatedMCPToolOutput: toolOutput,
}
}
} catch (error) {
const postToolDurationMs = Date.now() - postToolStartTime
logEvent('tengu_post_tool_hook_error', {
messageID:
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
toolName: sanitizeToolNameForAnalytics(tool.name),
isMcp: tool.isMcp ?? false,
duration: postToolDurationMs,
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
...(mcpServerType
? {
mcpServerType:
mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
: {}),
...(requestId
? {
requestId:
requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
: {}),
})
yield {
message: createAttachmentMessage({
type: 'hook_error_during_execution',
content: formatError(error),
hookName: `PostToolUse:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PostToolUse',
}),
}
}
}
} catch (error) {
logError(error)
}
}
export async function* runPostToolUseFailureHooks<Input extends AnyObject>(
toolUseContext: ToolUseContext,
tool: Tool<Input, unknown>,
toolUseID: string,
messageId: string,
processedInput: z.infer<Input>,
error: string,
isInterrupt: boolean | undefined,
requestId: string | undefined,
mcpServerType: McpServerType,
mcpServerBaseUrl: string | undefined,
): AsyncGenerator<
MessageUpdateLazy<AttachmentMessage | ProgressMessage<HookProgress>>
> {
const postToolStartTime = Date.now()
try {
const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
for await (const result of executePostToolUseFailureHooks(
tool.name,
toolUseID,
processedInput,
error,
toolUseContext,
isInterrupt,
permissionMode,
toolUseContext.abortController.signal,
)) {
try {
// Check if we were aborted during hook execution
if (
result.message?.type === 'attachment' &&
result.message.attachment.type === 'hook_cancelled'
) {
logEvent('tengu_post_tool_failure_hooks_cancelled', {
toolName: sanitizeToolNameForAnalytics(tool.name),
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
})
yield {
message: createAttachmentMessage({
type: 'hook_cancelled',
hookName: `PostToolUseFailure:${tool.name}`,
toolUseID,
hookEvent: 'PostToolUseFailure',
}),
}
continue
}
// Skip hook_blocking_error in result.message β blockingError path
// below creates the same attachment (see #31301 / PostToolUse above).
if (
result.message &&
!(
result.message.type === 'attachment' &&
result.message.attachment.type === 'hook_blocking_error'
)
) {
yield { message: result.message }
}
if (result.blockingError) {
yield {
message: createAttachmentMessage({
type: 'hook_blocking_error',
hookName: `PostToolUseFailure:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PostToolUseFailure',
blockingError: result.blockingError,
}),
}
}
// If hooks provided additional context, add it as a message
if (result.additionalContexts && result.additionalContexts.length > 0) {
yield {
message: createAttachmentMessage({
type: 'hook_additional_context',
content: result.additionalContexts,
hookName: `PostToolUseFailure:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PostToolUseFailure',
}),
}
}
} catch (hookError) {
const postToolDurationMs = Date.now() - postToolStartTime
logEvent('tengu_post_tool_failure_hook_error', {
messageID:
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
toolName: sanitizeToolNameForAnalytics(tool.name),
isMcp: tool.isMcp ?? false,
duration: postToolDurationMs,
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
...(mcpServerType
? {
mcpServerType:
mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
: {}),
...(requestId
? {
requestId:
requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
: {}),
})
yield {
message: createAttachmentMessage({
type: 'hook_error_during_execution',
content: formatError(hookError),
hookName: `PostToolUseFailure:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PostToolUseFailure',
}),
}
}
}
} catch (outerError) {
logError(outerError)
}
}
/**
* Resolve a PreToolUse hook's permission result into a final PermissionDecision.
*
* Encapsulates the invariant that hook 'allow' does NOT bypass settings.json
* deny/ask rules β checkRuleBasedPermissions still applies (inc-4788 analog).
* Also handles the requiresUserInteraction/requireCanUseTool guards and the
* 'ask' forceDecision passthrough.
*
* Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts
* (REPL inner calls) so the permission semantics stay in lockstep.
*/
export async function resolveHookPermissionDecision(
hookPermissionResult: PermissionResult | undefined,
tool: Tool,
input: Record<string, unknown>,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
assistantMessage: AssistantMessage,
toolUseID: string,
): Promise<{
decision: PermissionDecision
input: Record<string, unknown>
}> {
const requiresInteraction = tool.requiresUserInteraction?.()
const requireCanUseTool = toolUseContext.requireCanUseTool
if (hookPermissionResult?.behavior === 'allow') {
const hookInput = hookPermissionResult.updatedInput ?? input
// Hook provided updatedInput for an interactive tool β the hook IS the
// user interaction (e.g. headless wrapper that collected AskUserQuestion
// answers). Treat as non-interactive for the rule-check path.
const interactionSatisfied =
requiresInteraction && hookPermissionResult.updatedInput !== undefined
if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) {
logForDebugging(
`Hook approved tool use for ${tool.name}, but canUseTool is required`,
)
return {
decision: await canUseTool(
tool,
hookInput,
toolUseContext,
assistantMessage,
toolUseID,
),
input: hookInput,
}
}
// Hook allow skips the interactive prompt, but deny/ask rules still apply.
const ruleCheck = await checkRuleBasedPermissions(
tool,
hookInput,
toolUseContext,
)
if (ruleCheck === null) {
logForDebugging(
interactionSatisfied
? `Hook satisfied user interaction for ${tool.name} via updatedInput`
: `Hook approved tool use for ${tool.name}, bypassing permission prompt`,
)
return { decision: hookPermissionResult, input: hookInput }
}
if (ruleCheck.behavior === 'deny') {
logForDebugging(
`Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`,
)
return { decision: ruleCheck, input: hookInput }
}
// ask rule β dialog required despite hook approval
logForDebugging(
`Hook approved tool use for ${tool.name}, but ask rule requires prompt`,
)
return {
decision: await canUseTool(
tool,
hookInput,
toolUseContext,
assistantMessage,
toolUseID,
),
input: hookInput,
}
}
if (hookPermissionResult?.behavior === 'deny') {
logForDebugging(`Hook denied tool use for ${tool.name}`)
return { decision: hookPermissionResult, input }
}
// No hook decision or 'ask' β normal permission flow, possibly with
// forceDecision so the dialog shows the hook's ask message.
const forceDecision =
hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined
const askInput =
hookPermissionResult?.behavior === 'ask' &&
hookPermissionResult.updatedInput
? hookPermissionResult.updatedInput
: input
return {
decision: await canUseTool(
tool,
askInput,
toolUseContext,
assistantMessage,
toolUseID,
forceDecision,
),
input: askInput,
}
}
export async function* runPreToolUseHooks(
toolUseContext: ToolUseContext,
tool: Tool,
processedInput: Record<string, unknown>,
toolUseID: string,
messageId: string,
requestId: string | undefined,
mcpServerType: McpServerType,
mcpServerBaseUrl: string | undefined,
): AsyncGenerator<
| {
type: 'message'
message: MessageUpdateLazy<
AttachmentMessage | ProgressMessage<HookProgress>
>
}
| { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult }
| { type: 'hookUpdatedInput'; updatedInput: Record<string, unknown> }
| { type: 'preventContinuation'; shouldPreventContinuation: boolean }
| { type: 'stopReason'; stopReason: string }
| {
type: 'additionalContext'
message: MessageUpdateLazy<AttachmentMessage>
}
// stop execution
| { type: 'stop' }
> {
const hookStartTime = Date.now()
try {
const appState = toolUseContext.getAppState()
for await (const result of executePreToolHooks(
tool.name,
toolUseID,
processedInput,
toolUseContext,
appState.toolPermissionContext.mode,
toolUseContext.abortController.signal,
undefined, // timeoutMs - use default
toolUseContext.requestPrompt,
tool.getToolUseSummary?.(processedInput),
)) {
try {
if (result.message) {
yield { type: 'message', message: { message: result.message } }
}
if (result.blockingError) {
const denialMessage = getPreToolHookBlockingMessage(
`PreToolUse:${tool.name}`,
result.blockingError,
)
yield {
type: 'hookPermissionResult',
hookPermissionResult: {
behavior: 'deny',
message: denialMessage,
decisionReason: {
type: 'hook',
hookName: `PreToolUse:${tool.name}`,
reason: denialMessage,
},
},
}
}
// Check if hook wants to prevent continuation
if (result.preventContinuation) {
yield {
type: 'preventContinuation',
shouldPreventContinuation: true,
}
if (result.stopReason) {
yield { type: 'stopReason', stopReason: result.stopReason }
}
}
// Check for hook-defined permission behavior
if (result.permissionBehavior !== undefined) {
logForDebugging(
`Hook result has permissionBehavior=${result.permissionBehavior}`,
)
const decisionReason: PermissionDecisionReason = {
type: 'hook',
hookName: `PreToolUse:${tool.name}`,
hookSource: result.hookSource,
reason: result.hookPermissionDecisionReason,
}
if (result.permissionBehavior === 'allow') {
yield {
type: 'hookPermissionResult',
hookPermissionResult: {
behavior: 'allow',
updatedInput: result.updatedInput,
decisionReason,
},
}
} else if (result.permissionBehavior === 'ask') {
yield {
type: 'hookPermissionResult',
hookPermissionResult: {
behavior: 'ask',
updatedInput: result.updatedInput,
message:
result.hookPermissionDecisionReason ||
`Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`,
decisionReason,
},
}
} else {
// deny - updatedInput is irrelevant since tool won't run
yield {
type: 'hookPermissionResult',
hookPermissionResult: {
behavior: result.permissionBehavior,
message:
result.hookPermissionDecisionReason ||
`Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`,
decisionReason,
},
}
}
}
// Yield updatedInput for passthrough case (no permission decision)
// This allows hooks to modify input while letting normal permission flow continue
if (result.updatedInput && result.permissionBehavior === undefined) {
yield {
type: 'hookUpdatedInput',
updatedInput: result.updatedInput,
}
}
// If hooks provided additional context, add it as a message
if (result.additionalContexts && result.additionalContexts.length > 0) {
yield {
type: 'additionalContext',
message: {
message: createAttachmentMessage({
type: 'hook_additional_context',
content: result.additionalContexts,
hookName: `PreToolUse:${tool.name}`,
toolUseID,
hookEvent: 'PreToolUse',
}),
},
}
}
// Check if we were aborted during hook execution
if (toolUseContext.abortController.signal.aborted) {
logEvent('tengu_pre_tool_hooks_cancelled', {
toolName: sanitizeToolNameForAnalytics(tool.name),
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
})
yield {
type: 'message',
message: {
message: createAttachmentMessage({
type: 'hook_cancelled',
hookName: `PreToolUse:${tool.name}`,
toolUseID,
hookEvent: 'PreToolUse',
}),
},
}
yield { type: 'stop' }
return
}
} catch (error) {
logError(error)
const durationMs = Date.now() - hookStartTime
logEvent('tengu_pre_tool_hook_error', {
messageID:
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
toolName: sanitizeToolNameForAnalytics(tool.name),
isMcp: tool.isMcp ?? false,
duration: durationMs,
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
...(mcpServerType
? {
mcpServerType:
mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
: {}),
...(requestId
? {
requestId:
requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
: {}),
})
yield {
type: 'message',
message: {
message: createAttachmentMessage({
type: 'hook_error_during_execution',
content: formatError(error),
hookName: `PreToolUse:${tool.name}`,
toolUseID: toolUseID,
hookEvent: 'PreToolUse',
}),
},
}
yield { type: 'stop' }
}
}
} catch (error) {
logError(error)
yield { type: 'stop' }
return
}
}