toolHooks.ts
services/tools/toolHooks.ts
651
Lines
22333
Bytes
2
Exports
17
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with tool-system, integrations. It contains 651 lines, 17 detected imports, and 2 detected exports.
Important relationships
Detected exports
PostToolUseHooksResultresolveHookPermissionDecision
Keywords
toolresultmessagetoolusecontextnametooluseidyieldhookhookpermissionresultanalyticsmetadata_i_verified_this_is_not_code_or_filepaths
Detected imports
src/services/analytics/index.jssrc/services/analytics/metadata.jszod/v4../../hooks/useCanUseTool.js../../Tool.js../../types/hooks.js../../types/message.js../../types/permissions.js../../utils/attachments.js../../utils/debug.js../../utils/hooks.js../../utils/log.js../../utils/permissions/PermissionResult.js../../utils/permissions/permissions.js../../utils/toolErrors.js../mcp/utils.js./toolExecution.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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
}
}