跳转到内容

Hook 系统

Claude Code 的 hook 系统允许用户(以及 plugin)在 agentic 生命周期的关键节点注入自定义逻辑。Hook 是在 tool 调用前后、session 边界以及其他生命周期事件时执行的 shell 命令或回调。本章介绍 hook 架构、事件类型和执行模型。

graph TD
    A[Hook Configuration] --> B{Hook Source}
    B --> C[settings.json hooks]
    B --> D[Plugin hooks]
    B --> E[SDK callback hooks]

    C --> F[Shell command execution]
    D --> G[Plugin callback execution]
    E --> H[In-process callback]

    F --> I[JSON output parsing]
    G --> I
    H --> I

    I --> J{Decision}
    J --> K[allow / deny / ask]
    J --> L[continue / block]
    J --> M[additional context]
    J --> N[modified input]

Claude Code 定义了丰富的 hook 事件集。核心生命周期 hook 如下:

事件触发时机可修改内容
PreToolUsetool 执行前permission 决策、输入、context
PostToolUsetool 成功后附加 context、MCP 输出、是否继续
PostToolUseFailuretool 失败后附加 context、是否继续
事件触发时机可修改内容
SessionStart新 session 开始时附加 context、初始消息、监听路径
SessionEndsession 结束时—(仅通知)
Setup初始化设置时附加 context
事件触发时机
Stop模型产生最终响应(无 tool 调用)时
StopFailurestop hook 失败时
Notification生成通知时
SubagentStartsub-agent 启动时
SubagentStopsub-agent 停止时
PreCompactcontext compaction 前
PostCompactcontext compaction 后
UserPromptSubmit用户提交消息时
PermissionDeniedpermission 被拒绝时
PermissionRequest将显示 permission 提示时
TaskCreated后台任务创建时
TaskCompleted后台任务完成时
ConfigChange设置变更时
CwdChanged工作目录变更时
FileChanged被监听的文件变更时
InstructionsLoadedCLAUDE.md 文件加载时
Elicitation请求 MCP elicitation 时
ElicitationResultMCP elicitation 完成时

Hook 在 settings.json 中配置(用户、项目或企业级范围):

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 /path/to/check_command.py"
}
]
}
],
"PostToolUse": [
{
"matcher": "FileWrite",
"hooks": [
{
"type": "command",
"command": "npx prettier --write $TOOL_INPUT_file_path"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Custom context here\"}}'"
}
]
}
]
}
}

matcher 字段决定哪些 tool 调用会触发该 hook:

  • 无 matcher:hook 对该事件类型的所有 tool 调用均运行
  • Tool 名称"Bash" ——仅对 Bash tool 调用运行
  • Tool + 模式"Bash(git *)" ——对命令匹配 git * 的 Bash 调用运行

模式匹配由 tool 定义上的 preparePermissionMatcher() 处理。

主要的 hook 执行路径位于 src/utils/hooks.ts。Hook 以 shell 命令形式执行,通过环境变量和 stdin 接收 context:

// Environment variables passed to hook commands
CLAUDE_SESSION_ID // Current session ID
CLAUDE_PROJECT_ROOT // Project root directory
CLAUDE_CWD // Current working directory
CLAUDE_TOOL_NAME // Name of the tool being invoked
CLAUDE_TOOL_USE_ID // Tool use ID
CLAUDE_HOOK_EVENT // Hook event type (PreToolUse, PostToolUse, etc.)
TOOL_INPUT_* // Tool input fields (e.g., TOOL_INPUT_command for Bash)

Hook 的 stdin 接收包含完整 context 的 JSON 对象:

{
"tool_name": "Bash",
"tool_input": { "command": "git status" },
"tool_use_id": "toolu_01abc..."
}

Hook 通过 stdout 上的 JSON 传达决策:

// src/types/hooks.ts — Sync hook response
const syncHookResponseSchema = z.object({
continue: z.boolean().optional(), // Should Claude continue? (default: true)
suppressOutput: z.boolean().optional(), // Hide stdout from transcript?
stopReason: z.string().optional(), // Message when continue=false
decision: z.enum(['approve', 'block']).optional(),
reason: z.string().optional(),
systemMessage: z.string().optional(), // Warning shown to user
hookSpecificOutput: z.union([
z.object({
hookEventName: z.literal('PreToolUse'),
permissionDecision: z.enum(['allow', 'deny', 'ask']).optional(),
permissionDecisionReason: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
additionalContext: z.string().optional(),
}),
z.object({
hookEventName: z.literal('PostToolUse'),
additionalContext: z.string().optional(),
updatedMCPToolOutput: z.unknown().optional(),
}),
// ... more event-specific shapes
]).optional(),
});

Hook 可以通过返回 {"async": true} 声明自己是异步的:

const asyncHookResponseSchema = z.object({
async: z.literal(true),
asyncTimeout: z.number().optional(),
});

异步 hook 在后台运行。它们通过 registerPendingAsyncHook() 注册,结果稍后到达。

src/services/tools/toolHooks.ts 中的 runPreToolUseHooks 函数负责编排 PreToolUse hook:

sequenceDiagram
    participant TE as toolExecution
    participant TH as toolHooks
    participant H as hooks.ts
    participant CMD as Shell Command

    TE->>TH: runPreToolUseHooks(tool, input, ...)
    TH->>H: executePreToolHooks(toolName, input, ...)
    H->>H: Find matching hooks (matcher check)

    loop For each matching hook
        H->>CMD: Execute command (env vars + stdin)
        CMD-->>H: JSON stdout
        H->>H: Parse with hookJSONOutputSchema
        H-->>TH: Yield result (permission, context, etc.)
    end

    TH-->>TE: Yield aggregated results

toolHooks.ts 中的 resolveHookPermissionDecision 函数以仔细的优先级顺序解析 hook permission:

export async function resolveHookPermissionDecision(
hookPermissionResult, tool, input, toolUseContext, canUseTool, assistantMessage, toolUseID,
): Promise<{ decision: PermissionDecision; input: Record<string, unknown> }> {
if (hookPermissionResult?.behavior === 'allow') {
// Hook says allow, BUT settings.json deny/ask rules still apply
const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext);
if (ruleCheck === null) {
// No rule conflicts → allow
return { decision: hookPermissionResult, input: hookInput };
}
if (ruleCheck.behavior === 'deny') {
// Deny rule overrides hook allow
return { decision: ruleCheck, input: hookInput };
}
// Ask rule → show dialog despite hook approval
return { decision: await canUseTool(...), input: hookInput };
}
if (hookPermissionResult?.behavior === 'deny') {
return { decision: hookPermissionResult, input };
}
// No hook or 'ask' → normal permission flow
return { decision: await canUseTool(...), input };
}

PostToolUse hook 在 tool 执行成功后运行:

src/services/tools/toolHooks.ts
export async function* runPostToolUseHooks(
toolUseContext, tool, toolUseID, messageId,
toolInput, toolResponse, requestId, mcpServerType,
): AsyncGenerator<PostToolUseHooksResult> {
for await (const result of executePostToolHooks(
tool.name, toolUseID, toolInput, toolOutput, toolUseContext, permissionMode, signal,
)) {
// Handle blocking errors
if (result.blockingError) {
yield { message: createAttachmentMessage({
type: 'hook_blocking_error',
hookEvent: 'PostToolUse',
blockingError: result.blockingError,
})};
}
// Handle continuation prevention
if (result.preventContinuation) {
yield { message: createAttachmentMessage({
type: 'hook_stopped_continuation',
message: result.stopReason || 'Execution stopped by PostToolUse hook',
})};
return; // Stop processing further hooks
}
// Handle additional context injection
if (result.additionalContexts?.length > 0) {
yield { message: createAttachmentMessage({
type: 'hook_additional_context',
content: result.additionalContexts,
})};
}
// Handle MCP tool output modification
if (result.updatedMCPToolOutput && isMcpTool(tool)) {
toolOutput = result.updatedMCPToolOutput;
yield { updatedMCPToolOutput: toolOutput };
}
}
}

Hook 具有可配置的超时时间:

src/utils/hooks.ts
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes for tool hooks
// SessionEnd hooks have a much tighter timeout
const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500; // 1.5 seconds
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;
}

除 shell 命令外,hook 还可以注册为进程内回调:

src/types/hooks.ts
export type HookCallback = {
type: 'callback';
callback: (
input: HookInput,
toolUseID: string | null,
abort: AbortSignal | undefined,
hookIndex?: number,
context?: HookCallbackContext,
) => Promise<HookJSONOutput>;
timeout?: number;
internal?: boolean; // Excluded from metrics
};

回调 hook 的使用方包括:

  • SDK 消费者:以编程方式注册 hook
  • Plugin:通过其配置提供 hook
  • 内部分析:追踪 session 文件访问模式

当多个 hook 匹配同一事件时,其结果会被聚合:

src/types/hooks.ts
export type AggregatedHookResult = {
message?: Message;
blockingErrors?: HookBlockingError[];
preventContinuation?: boolean;
stopReason?: string;
permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough';
additionalContexts?: string[];
updatedInput?: Record<string, unknown>;
updatedMCPToolOutput?: unknown;
};

聚合规则:

  • Permission:最严格优先(deny > ask > allow
  • 阻塞错误:全部收集并上报
  • 附加 context:全部拼接
  • 更新输入:最后一个 hook 的更新生效
  • 继续执行:任意 hook 均可阻止继续
{
"hooks": {
"PostToolUse": [{
"matcher": "FileWrite",
"hooks": [{
"type": "command",
"command": "prettier --write \"$TOOL_INPUT_file_path\" 2>/dev/null; echo '{\"continue\":true,\"suppressOutput\":true}'"
}]
}]
}
}
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash(rm -rf *)",
"hooks": [{
"type": "command",
"command": "echo '{\"decision\":\"block\",\"reason\":\"Recursive force-delete is not allowed\"}'"
}]
}]
}
}

3. 在 Session 启动时注入项目 Context

Section titled “3. 在 Session 启动时注入项目 Context”
{
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This project uses pnpm, React 19, and TypeScript 5.7\"}}'"
}]
}]
}
}
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "python3 /path/to/security_check.py"
}]
}]
}
}

Python 脚本通过 stdin 接收命令并返回 permission 决策:

import json, sys
data = json.load(sys.stdin)
command = data['tool_input']['command']
if 'sudo' in command:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "sudo is not allowed"
}
}))
else:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
}))