基于Zod的AI编程助手Hook统一处理框架设计与实践
1. 项目概述:为AI编程助手构建统一的Hook处理框架
如果你正在为Claude Code、Cursor或者Codex这类AI编程助手编写自定义Hook脚本,那你一定遇到过这样的场景:每个平台发来的JSON数据结构都不一样,字段名、事件类型、甚至布尔值的表示方式都可能有细微差别。你不得不为每个平台写一套解析逻辑,小心翼翼地处理类型转换,还得时刻提防着配置文件的合并规则。这活儿干起来不仅重复,还容易出错,尤其是在需要跨平台维护一套钩子逻辑的时候。
agent-hook-schemas 这个npm包就是为了解决这个痛点而生的。它本质上是一个基于Zod的、类型安全的“翻译器”和“协调员”。它把Claude Code、OpenAI Codex、Google Gemini CLI以及Cursor这几个主流AI编程助手的Hook输入输出(stdin/stdout)协议,以及它们的配置文件格式,统一抽象成了一组TypeScript类型和验证器。这意味着,你可以用一套几乎相同的代码,去处理来自不同AI助手的Hook事件,而不用再关心底层协议的差异。
这个包的核心价值在于“标准化”和“安全性”。通过Zod Schema,它确保了从不可信的stdin JSON到完全类型安全的TypeScript对象之间的转换是可靠且无痛的。同时,它提供的配置合并与解析工具,让你能清晰地管理用户级、项目级、本地级等多层Hook配置,确保最终生效的规则符合预期优先级。无论是想拦截一个危险的 rm -rf 命令,还是在代码生成后自动运行linter,或是根据项目上下文动态修改AI的提示词, agent-hook-schemas 都能为你提供坚实、类型安全的基础设施。
2. 核心设计思路与架构解析
2.1 为什么选择Zod作为基石
在TypeScript生态中,运行时类型校验库有好几个选择,比如 io-ts 、 yup 、 ajv 等。 agent-hook-schemas 选择Zod,是经过深思熟虑的。首先,Zod的API设计非常符合TypeScript开发者的直觉,它的链式调用(如 .optional() 、 .nullable() )写起来很流畅。更重要的是,Zod能直接从Schema推断出极其精确的TypeScript类型,这种“Schema即类型”的特性,让开发体验达到了无缝衔接。你定义一个Zod Schema,就同时拥有了运行时校验器和编译时类型,无需手动维护两套定义。
其次,Zod的性能和包体积在同类库中表现均衡,既保证了校验速度,又不会显著增加最终构建产物的体积。这对于一个可能被众多Hook脚本引用的基础库来说很重要。最后,Zod的社区活跃度和生态完善度很高,有丰富的第三方集成和工具链支持,这为 agent-hook-schemas 未来的扩展(比如支持更多AI助手)减少了阻力。
注意 :
agent-hook-schemas要求Zod版本在v4以上,TypeScript版本在v5以上。这是因为包内部大量使用了Zod v4的高级特性(如.pipe()、更完善的联合类型推断)和TS 5.0的const类型参数等特性,以确保类型推断的精确性。如果你的项目还在用旧版本,升级是必要的。
2.2 多平台支持的架构策略
面对四个设计各异的AI助手平台, agent-hook-schemas 没有采用一个“大一统”的超级Schema来强行兼容所有差异,而是采用了更务实、更清晰的“子路径导出”(Subpath Exports)架构。
根模块(Barrel Export) : import from "agent-hook-schemas" 会导出一个包含Claude Code、Codex、Cursor、Gemini以及所有集成模块的混合体。这适合快速上手或编写通用工具。但为了获得最佳的类型安全和明确的平台依赖,官方推荐使用子路径导入。
平台专属模块 :每个平台都有自己独立的入口点,例如:
agent-hook-schemas/claude: Claude Code的所有Schema和工具。agent-hook-schemas/codex: OpenAI Codex的所有Schema和工具。agent-hook-schemas/gemini: Gemini CLI的所有Schema和工具。agent-hook-schemas/cursor: Cursor的所有Schema。
这种设计的好处显而易见。首先,它做到了 关注点分离 。当你只为Claude Code写Hook时,你只导入Claude相关的类型,不会不小心用到Codex特有的字段,代码意图更清晰,Tree-shaking也更高效。其次,它 保留了平台特性 。每个平台的Schema都忠实地反映了其官方协议,包括那些平台独有的字段(如Cursor的 cursor_version 、Claude的 watchPaths ),不会为了统一而牺牲信息。最后,它提供了 渐进式复杂度 。新手可以从根模块开始,快速验证想法;而在构建生产级Hook时,切换到平台专属模块能获得更严格的类型约束和更准确的API提示。
集成模块(Integration Modules) :这是架构中另一个精妙的设计。对于支持复杂配置的Claude、Codex和Gemini, agent-hook-schemas 额外提供了 -integration 后缀的模块(如 claude-hooks-integration )。这些模块不包含基础的协议Schema,而是专注于“业务逻辑”:如何合并多层配置、如何根据当前事件和上下文解析出应该运行的Handler、如何计算有效的超时时间等。将“协议描述”和“业务逻辑”分离,使得库的结构更清晰,也方便未来单独更新某一部分。
2.3 宽松(Loose)与严格(Strict)模式解析
在包的文档和代码中,你会频繁看到 .loose() 和 .strict() 的调用。这不是性能上的宽松或严格,而是针对JSON数据校验的两种策略。
宽松模式(.loose()) :这是所有平台处理stdin输入的默认方式。在宽松模式下,Schema会使用 .passthrough() 或类似方法,允许并保留JSON中那些未在Schema中定义的额外字段。这是至关重要的,因为AI助手的协议可能会在未来版本中添加新字段。如果使用严格模式,未知字段会导致校验失败,你的Hook脚本可能会在新版本助手发布时突然崩溃。宽松模式确保了 向前兼容性 ,你的Hook可以安全地忽略它不关心的新字段,同时继续处理它认识的核心字段。
严格模式(.strict()) :主要用于 输出(stdout) ,特别是对于Codex平台。Codex对Hook脚本的输出格式要求非常精确,多一个或少一个字段,或者字段值为 undefined ,都可能导致通信失败。严格模式会剔除任何未在Schema中声明的字段,并将可选字段的 undefined 值转换为JSON null 或直接省略(取决于配置),确保输出的JSON完全符合Codex的预期格式。对于Claude和Gemini,它们的输出协议相对宽松,所以通常也使用宽松模式。
实操心得 :理解这两种模式是正确使用该库的关键。一个常见的错误是在解析输入时误用严格模式,导致升级AI助手后Hook失效。记住这个口诀: 输入用宽松(保兼容),输出看平台(Codex要严格) 。库的API设计已经帮你做了默认选择,例如 ParseHookInput 内部用的就是宽松模式,你一般不需要操心。
3. 从零开始:Hook脚本的完整开发流程
3.1 环境准备与项目初始化
假设我们要为一个Node.js项目创建一个Claude Code的 PreToolUse Hook,用于检查Bash命令的安全性。
首先,初始化项目并安装依赖:
# 创建一个新的Hook项目目录
mkdir my-security-hook && cd my-security-hook
# 初始化npm项目(或用你喜欢的包管理器)
npm init -y
# 安装 agent-hook-schemas 及其必需的peer dependency
npm install agent-hook-schemas zod
# 安装TypeScript和类型定义(如果你用TS)
npm install --save-dev typescript @types/node
# 初始化tsconfig.json
npx tsc --init
编辑 tsconfig.json ,确保设置适合脚本开发,比如 "target": "ES2022" , "module": "NodeNext" , 以及 "outDir": "./dist" 。
接下来,创建我们的Hook入口文件,例如 src/index.ts 。我们将从平台专属模块导入,以获得最精确的类型支持。
3.2 解析Hook输入:安全第一道防线
Hook脚本是一个独立的可执行程序,AI助手会通过stdin向它发送一个JSON对象。第一步,也是最重要的一步,就是安全地解析这个输入。
// src/index.ts
import { ParseHookInput } from 'agent-hook-schemas/claude';
// 注意:这里从`/claude`子路径导入,而非根路径。
async function main() {
// 1. 读取stdin。使用Bun、Node.js或其他运行时的API。
// 这里以Node.js为例:
const stdinData = await readStdin();
let rawInput: unknown;
try {
rawInput = JSON.parse(stdinData);
} catch (error) {
// 如果连JSON都不是,直接报错退出
console.error(JSON.stringify({
decision: 'block',
reason: `Failed to parse stdin as JSON: ${error.message}`
}));
process.exit(1);
}
// 2. 使用库提供的解析函数进行校验和类型窄化
const parseResult = ParseHookInput(rawInput);
if (!parseResult.success) {
// 校验失败!输入数据不符合Claude Code的协议。
// 将详细的Zod错误信息输出,方便调试。
console.error(JSON.stringify({
decision: 'block',
reason: `Invalid hook input: ${JSON.stringify(parseResult.error.format())}`
}));
process.exit(1);
}
// 3. 至此,input已被成功解析,并且TypeScript知道它的具体类型
const input = parseResult.data;
// 例如,input.hook_event_name 现在是如 "PreToolUse" 这样的字面量类型
// input.session_id, input.cwd 等字段都有明确的类型
// ... 后续处理逻辑
}
// Node.js下读取stdin的辅助函数
function readStdin(): Promise<string> {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
data += chunk;
}
});
process.stdin.on('end', () => {
resolve(data);
});
});
}
main().catch((error) => {
console.error(JSON.stringify({ decision: 'block', reason: `Unhandled error: ${error.message}` }));
process.exit(1);
});
关键点解析 :
- 错误处理 :在解析JSON和校验Schema阶段就做好错误处理至关重要。如果输入格式错误,必须通过stdout返回一个合法的拒绝响应(
decision: 'block')并退出,而不是抛出未捕获的异常,否则AI助手可能无法正确处理Hook的失败。 - 类型安全 :
ParseHookInput的返回值类型是Zod.SafeParseReturnType<...>。通过检查success属性,TypeScript能够进行**可辨识联合(Discriminated Union)**类型收窄。在if (parseResult.success)分支内,parseResult.data就是完全类型安全的Hook输入对象。这是Zod的核心优势。 - 平台特异性 :我们导入了
agent-hook-schemas/claude中的ParseHookInput。如果我们要支持Codex,则需要从agent-hook-schemas/codex导入ParseCodexHookInput。函数名和类型都是平台定制的。
3.3 实现核心逻辑:命令安全检查
假设我们的Hook只在 PreToolUse 事件且工具名为 Bash 时触发,检查命令是否包含危险模式。
// 接上面的 main 函数
if (input.hook_event_name === 'PreToolUse') {
// 进一步检查工具类型
if (input.tool_name === 'Bash') {
// 使用库提供的工具输入解析器,获取结构化的命令信息
const bashParseResult = ParseBashToolInput(input.tool_input);
if (!bashParseResult.success) {
// 理论上,如果ParseHookInput成功了,这里也应该成功。
// 但为了健壮性,仍做处理。
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny' as const,
permissionDecisionReason: `无法解析Bash工具输入`,
},
}));
process.exit(0);
}
const bashInput = bashParseResult.data;
const command = bashInput.command;
// 定义危险命令模式
const dangerousPatterns = [
/rm\s+(-rf|--recursive\s+--force)\s+\//, // 递归强制删除根目录
/^dd\s+if=.*\s+of=\/dev\/sd[a-z]/, // 磁盘擦写
/chmod\s+[0-7]{3,4}\s+\/etc\/passwd/, // 修改关键系统文件权限
/mv\s+.*\s+\/dev\/null/, // 移动文件到黑洞
/^:\s*{\s*\|:&\s*};/, // Fork炸弹简化模式
];
const isDangerous = dangerousPatterns.some(pattern => pattern.test(command));
if (isDangerous) {
// 拒绝执行
const denyOutput = HookCommandOutputSchema.parse({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `命令被安全策略阻止: ${command.substring(0, 100)}...`,
},
});
console.log(JSON.stringify(denyOutput));
} else {
// 允许执行
const allowOutput = HookCommandOutputSchema.parse({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: `命令安全检查通过`,
},
});
console.log(JSON.stringify(allowOutput));
}
} else {
// 对于非Bash工具,我们选择“询问”(ask)或“默认”(default)决策。
// 这里使用`defer`,表示不干预,交由Claude Code的默认权限逻辑处理。
const deferOutput = HookCommandOutputSchema.parse({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'defer',
permissionDecisionReason: `非Bash工具,交由系统处理`,
},
});
console.log(JSON.stringify(deferOutput));
}
process.exit(0); // 处理完毕,退出脚本
}
// 如果不是PreToolUse事件,直接退出,不输出任何内容(表示不干预)
process.exit(0);
注意事项 :
- 权限决策(permissionDecision) :Claude Code支持
allow、deny、ask、defer。deny直接阻止;ask会弹出对话框询问用户;defer表示Hook不做出决定,由系统或更低优先级的Hook处理。合理使用defer可以让你编写的Hook只关注特定场景。 - 输出验证 :使用
HookCommandOutputSchema.parse()或.safeParse()来构建输出对象。这确保了输出的JSON格式完全符合Claude Code的要求,即使是可选字段(如reason)也会被正确处理。直接手动拼接JSON字符串很容易出错。 - 工具输入解析 :
ParseBashToolInput是一个平台提供的便捷函数。对于Bash工具,tool_input可能是一个简单的{ command: string }对象,但也可能包含cwd、env等字段。使用这个解析器比直接访问input.tool_input.command更安全,因为它处理了类型转换和可能的字段缺失情况。
3.4 构建、测试与部署
编写完脚本后,需要将其编译(如果是TypeScript)并设置为可执行文件。
# 编译TypeScript
npx tsc
# 或者使用更快的esbuild/tsup,这里以tsup为例(需安装)
# npx tsup src/index.ts --format cjs --platform node --out-dir dist
假设编译后的文件是 dist/index.js 。使其可执行,并在Claude Code中配置Hook。
首先,创建一个Hook配置文件,例如在项目根目录的 .claude/hooks.json (用户级配置在 ~/.claude/desktop-config.json 中)。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node /absolute/path/to/your/project/dist/index.js",
"timeoutSec": 5
}
]
}
]
}
}
关键配置解释 :
matcher: "Bash":表示这个Hook组只匹配工具名为Bash的PreToolUse事件。type: "command":表示执行一个本地命令。timeoutSec:非常重要!设置一个合理的超时时间(如5秒)。如果Hook脚本挂起或执行过慢,超时后会被终止,AI助手会按超时行为处理(通常是继续执行)。超时时间不宜过长,以免阻塞用户操作。
测试你的Hook :
- 在终端启动Claude Code。
- 在支持的编辑器中,尝试让Claude执行一个危险命令,如
rm -rf /tmp/somefile(先在安全环境测试!)。 - 观察Claude的响应。如果Hook工作正常,你应该会看到操作被阻止,并且可能看到你提供的
permissionDecisionReason。
调试技巧 :在开发初期,可以在Hook脚本中把解析后的 input 对象和你的决策逻辑打印到 stderr ( console.error )。Claude Code通常会在其日志中捕获这些输出,方便你排查问题。但生产环境中应避免输出过多调试信息。
4. 高级应用:多层配置管理与动态解析
对于团队或复杂项目,Hook配置往往不是单一的。Claude Code、Codex和Gemini都支持配置的层级覆盖(通常顺序是:项目本地配置 > 项目配置 > 用户全局配置 > 系统默认)。 agent-hook-schemas 的集成模块提供了强大的工具来管理这种复杂性。
4.1 合并多层配置
假设我们有一个项目,在项目级配置中定义了一个通用的代码风格检查Hook,而某个开发者想在自己的本地配置中针对特定目录禁用这个Hook,或者添加一个额外的安全检查。
// config-manager.ts
import { mergeClaudeHooksFiles, parseClaudeSettings } from 'agent-hook-schemas/claude-hooks-integration';
import fs from 'fs/promises';
import path from 'path';
async function loadEffectiveConfig(projectRoot: string) {
// 1. 定义配置加载顺序(从低优先级到高优先级)
const configPaths = [
// 系统默认(假设没有,或我们提供一个基础配置)
{ source: 'default', config: { hooks: {} } },
// 用户全局配置
path.join(process.env.HOME || '~', '.claude/desktop-config.json'),
// 项目级配置
path.join(projectRoot, '.claude/hooks.json'),
// 项目本地配置(通常.gitignore)
path.join(projectRoot, '.claude/hooks.local.json'),
];
const configLayers = [];
for (const configPath of configPaths) {
if (typeof configPath === 'string') {
try {
const data = await fs.readFile(configPath, 'utf-8');
const json = JSON.parse(data);
// 使用库的解析器验证单个配置文件
const parsed = parseClaudeSettings(json);
if (parsed.ok) {
configLayers.push(parsed.settings);
} else {
console.warn(`Invalid config at ${configPath}:`, parsed.error);
}
} catch (error) {
// 文件不存在是预期内的,忽略;其他错误告警
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`Failed to load config from ${configPath}:`, error.message);
}
}
} else {
// 传入的默认配置对象
configLayers.push(configPath.config);
}
}
// 2. 合并所有有效的配置层
const mergeResult = mergeClaudeHooksFiles(configLayers);
if (!mergeResult.ok) {
throw new Error(`Failed to merge configs: ${mergeResult.error.message}`);
}
return mergeResult.config; // 这就是最终生效的Hook配置
}
mergeClaudeHooksFiles 函数的核心作用不仅仅是简单的对象合并。它遵循Claude Code官方的合并语义:
- 对于
hooks下的每个事件(如PreToolUse),配置是**追加(append)**的。这意味着项目配置的PreToolUseHook会排在用户配置的后面执行(取决于具体实现,有时顺序可能反过来,需查证文档,但合并逻辑会处理)。 - 特殊的
disableAllHooks: true设置具有**重置(reset)**效果。如果某一层配置设置了此选项,它会清空所有更低优先级的Hook配置,然后将自己(可能为空)的配置作为起点。这在需要完全禁用上级配置的场景下非常有用。
4.2 运行时解析匹配的Handler
有了合并后的配置,当Hook事件发生时,我们需要知道具体要执行哪些命令或HTTP请求。这就是 resolveMatchingClaudeHandlersFromInput 的用武之地。
// 在Hook脚本中,或在一个中心化的Hook调度器中
import { resolveMatchingClaudeHandlersFromInput } from 'agent-hook-schemas/claude-hooks-integration';
// 假设我们已经有了合并后的配置 `effectiveConfig` 和解析好的输入 `input`
const matchedHandlers = resolveMatchingClaudeHandlersFromInput(effectiveConfig, input);
console.log(`将为事件 ${input.hook_event_name} 执行 ${matchedHandlers.length} 个处理器。`);
for (const handler of matchedHandlers) {
switch (handler.type) {
case 'command':
console.log(` - 执行命令: ${handler.command}`);
// 这里可以调用 child_process.spawn 来执行命令
// 注意处理超时(handler.timeoutSec)和输入输出
break;
case 'http':
console.log(` - 发送HTTP请求到: ${handler.url}`);
// 使用fetch或axios发送请求
break;
case 'prompt':
console.log(` - 使用提示词模板: ${handler.prompt}`);
// 通常用于动态修改系统提示词,逻辑更复杂
break;
case 'agent':
console.log(` - 调用子Agent: ${handler.agent}`);
// 调用另一个AI Agent进行处理
break;
}
}
这个解析过程考虑了:
- 事件匹配 :只选择配置中针对当前
hook_event_name的处理器组。 - Matcher匹配 :每个处理器组可以有一个
matcher字段(通常是正则表达式),用于匹配subject(对于PreToolUse,subject通常就是tool_name)。只有匹配的组才会被选中。 - If守卫 :Claude Code还支持更细粒度的
if条件,例如if: "Tool(glob)",它可以对tool_input的内容进行匹配(如检查命令是否匹配rm *)。解析函数也会评估这些条件。 - 超时继承 :处理器可以定义自己的
timeoutSec,如果没有,则会从配置的更高层级继承,最终会有一个库函数effectiveClaudeHandlerTimeoutSec来计算确切的超时时间。
实操心得 : resolveMatchingClaudeHandlersFromInput 是一个非常强大的函数,它把复杂的配置匹配逻辑封装了起来。在编写一个“Hook网关”或“Hook运行器”时,直接使用这个函数可以确保你的执行逻辑与Claude Code Desktop客户端的逻辑保持一致,避免出现“配置里配了但没执行”的诡异问题。
5. 跨平台开发策略与兼容性处理
当你需要编写一个能在多个AI助手平台上运行的Hook时, agent-hook-schemas 提供了两种主要的策略。
5.1 策略一:平台抽象层
创建一个抽象层,根据运行环境或输入特征,动态选择对应的平台模块。
// src/platform-adaptor.ts
import type { SafeParseReturnType } from 'zod';
import type { ClaudeHookInput } from 'agent-hook-schemas/claude';
import type { CodexHookInput } from 'agent-hook-schemas/codex';
// ... 导入其他平台类型
export type UniversalHookInput = ClaudeHookInput | CodexHookInput /* | ... */;
export type HookPlatform = 'claude' | 'codex' | 'gemini' | 'cursor';
export function detectPlatform(rawInput: any): HookPlatform | null {
// 启发式检测:检查输入中特有的字段
if (rawInput.hook_event_name) {
// Claude, Codex, Gemini 使用 PascalCase
if (rawInput.agent_id !== undefined) return 'claude'; // Claude 特有
if (rawInput.turn_id !== undefined) return 'codex'; // Codex 特有
if (rawInput.timestamp !== undefined) return 'gemini'; // Gemini CLI 特有
// 默认推测为 Claude(作为最常见情况)
return 'claude';
} else if (rawInput.hookEventName) {
// Cursor 使用 camelCase
return 'cursor';
}
return null;
}
export async function parseUniversalInput(rawInput: any): Promise<{platform: HookPlatform; input: UniversalHookInput}> {
const platform = detectPlatform(rawInput);
if (!platform) {
throw new Error('无法识别Hook输入来源的平台');
}
switch (platform) {
case 'claude': {
const { ParseHookInput } = await import('agent-hook-schemas/claude');
const result = ParseHookInput(rawInput);
if (!result.success) throw new Error(`Claude输入解析失败: ${result.error.message}`);
return { platform, input: result.data };
}
case 'codex': {
const { ParseCodexHookInput } = await import('agent-hook-schemas/codex');
const result = ParseCodexHookInput(rawInput);
if (!result.success) throw new Error(`Codex输入解析失败: ${result.error.message}`);
return { platform, input: result.data };
}
case 'gemini': {
const { ParseGeminiHookInput } = await import('agent-hook-schemas/gemini');
const result = ParseGeminiHookInput(rawInput);
if (!result.success) throw new Error(`Gemini输入解析失败: ${result.error.message}`);
return { platform, input: result.data };
}
case 'cursor': {
const { ParseCursorHookInput } = await import('agent-hook-schemas/cursor');
const result = ParseCursorHookInput(rawInput);
if (!result.success) throw new Error(`Cursor输入解析失败: ${result.error.message}`);
return { platform, input: result.data };
}
}
}
然后在你的主逻辑中,使用这个适配器:
import { parseUniversalInput } from './platform-adaptor';
import { handleClaudeEvent } from './handlers/claude';
import { handleCodexEvent } from './handlers/codex';
// ...
const { platform, input } = await parseUniversalInput(rawInput);
switch (platform) {
case 'claude':
await handleClaudeEvent(input);
break;
case 'codex':
await handleCodexEvent(input);
break;
// ...
}
这种策略的优势是逻辑清晰,每个平台的处理器完全独立,便于维护和测试。缺点是会有一些代码重复。
5.2 策略二:通用逻辑与平台细节分离
提取出所有平台共有的核心逻辑,然后将平台特有的输入/输出转换封装成小的适配函数。
// src/core-logic.ts
// 定义我们业务逻辑需要的通用上下文
export interface ToolUseContext {
platform: string;
sessionId: string;
toolName: string;
command?: string; // 对于Bash工具
// ... 其他业务相关字段
}
export interface SecurityRule {
pattern: RegExp;
action: 'block' | 'warn' | 'allow';
reason: string;
}
export function evaluateSecurityRules(context: ToolUseContext, rules: SecurityRule[]): { action: string; reason: string } {
if (context.command) {
for (const rule of rules) {
if (rule.pattern.test(context.command)) {
return { action: rule.action, reason: rule.reason };
}
}
}
return { action: 'allow', reason: 'No matching security rules' };
}
// src/platform-adaptors.ts
import type { ClaudeHookInput } from 'agent-hook-schemas/claude';
import type { CodexHookInput } from 'agent-hook-schemas/codex';
import { ToolUseContext } from './core-logic';
export function adaptClaudeInputToContext(input: ClaudeHookInput): ToolUseContext | null {
if (input.hook_event_name === 'PreToolUse' && input.tool_name === 'Bash') {
// 使用库的解析器获取命令
const bashParseResult = ParseBashToolInput(input.tool_input);
if (bashParseResult.success) {
return {
platform: 'claude',
sessionId: input.session_id,
toolName: input.tool_name,
command: bashParseResult.data.command,
};
}
}
return null;
}
export function createClaudeOutput(action: string, reason: string) {
// 根据action和Claude的协议生成对应的输出对象
const decisionMap = { block: 'deny', warn: 'ask', allow: 'allow' } as const;
return HookCommandOutputSchema.parse({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: decisionMap[action as keyof typeof decisionMap] || 'defer',
permissionDecisionReason: reason,
},
});
}
// 类似地,为Codex、Gemini等编写adaptXxxInputToContext和createXxxOutput函数
主入口文件则负责桥接:
const { platform, input } = await parseUniversalInput(rawInput);
const context = await adaptInputToContext(platform, input); // 调用对应的适配函数
if (context) {
const securityResult = evaluateSecurityRules(context, mySecurityRules);
const output = createPlatformOutput(platform, securityResult); // 调用对应的输出创建函数
console.log(JSON.stringify(output));
}
这种策略减少了重复的业务逻辑,但增加了适配层的复杂度。适合那些核心规则统一,但输入输出格式差异大的场景。
选择建议 :如果你的Hook逻辑简单,且希望为每个平台做深度优化,用策略一。如果你的安全规则、日志逻辑等核心功能很复杂,且在不同平台间一致,用策略二。
6. 常见问题排查与性能优化
6.1 典型问题与解决方案
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| Hook脚本无任何输出,AI助手直接执行了命令 | 1. Hook脚本执行超时并被终止。 2. 脚本抛出未捕获的异常,进程崩溃。 3. 配置文件路径错误或格式错误,Hook未生效。 |
1. 检查超时设置 :在Hook配置中增加 timeoutSec (如30秒),并在脚本开始时用 console.error 输出日志,看是否能被捕获。 2. 加强错误处理 :用 try-catch 包裹整个 main() 函数,在 catch 中输出一个合法的拒绝JSON到 stdout 。 3. 验证配置 :使用 parseClaudeSettings() 等库函数验证你的配置文件。检查Claude Code的日志文件,看是否有加载Hook配置的错误信息。 |
| Hook输出了内容,但AI助手报“无效的Hook响应” | 输出的JSON格式不符合平台要求。可能是缺少必需字段、字段类型不对、或使用了错误的字段名。 | 1. 使用Schema验证输出 :务必使用如 HookCommandOutputSchema.parse() 或 safeParse() 来生成输出对象,而不是手动拼接JSON。 2. 注意严格模式 :对于Codex,确保使用严格模式生成输出(库的Codex输出Schema通常已内置 .strict() )。 3. 检查字段值 :例如,Codex的 decision 字段可能只接受 "approve" 或 "block" ,而Claude用 "allow" / "deny" 。 |
| 只有部分命令被Hook拦截,有些漏掉了 | 1. Matcher配置不正确,没有匹配到所有 Bash 工具调用。 2. Hook配置的优先级被其他配置覆盖了。 3. 命令在子Shell或复杂管道中, tool_input 的格式可能不同。 |
1. 检查Matcher :尝试将 matcher 设置为 ".*" (匹配所有subject)进行测试。确认你的正则表达式是否正确。 2. 检查配置合并 :使用 mergeClaudeHooksFiles 和 resolveMatchingClaudeHandlersFromInput 在本地模拟测试,看最终哪些Handler生效。 3. 深入解析tool_input :打印出 tool_input 的完整内容,检查命令字符串的格式。有些AI助手可能会对命令进行转义或拆分。 |
| Hook脚本执行速度慢,影响用户体验 | 1. 脚本启动慢(如TypeScript即时编译)。 2. 脚本内执行了同步的耗时操作(如大量文件IO、网络请求)。 3. 配置了太多Hook或复杂Matcher,解析匹配耗时。 |
1. 预编译 :将TypeScript预编译为JavaScript再部署,避免运行时编译开销。 2. 异步与非阻塞 :确保所有IO操作都是异步的。对于网络请求,设置合理的超时和重试。 3. 优化匹配逻辑 :避免在Hook脚本内进行复杂的文件系统遍历或数据库查询。如果必须,考虑缓存结果。 4. 精简配置 :评估是否每个事件都需要Hook。对于高性能要求的场景,考虑将多个检查合并到一个脚本中。 |
| 在团队中,某个成员的Hook不生效 | 1. 配置文件未提交到版本库,或路径不一致。 2. 成员本地环境缺少Hook脚本的运行时(如Node.js版本不对)。 3. 成员本地的更高优先级配置(如用户全局配置)禁用了Hook。 |
1. 标准化配置位置 :在项目文档中明确Hook配置( .claude/hooks.json )应放在项目根目录,并加入版本控制。 2. 提供安装脚本 :在 package.json 中提供 postinstall 脚本,检查或安装必要依赖。 3. 教育团队成员 :说明配置的优先级。可以提供一个脚本,帮助成员检查最终生效的配置(使用本库的合并函数)。 |
6.2 性能优化实践
- 懒加载与动态导入 :如果你的Hook脚本很大,但只在特定事件触发,可以考虑使用动态导入(
import())来按需加载处理模块,减少启动时的内存占用和解析时间。 - 缓存配置解析结果 :
mergeClaudeHooksFiles和解析配置文件的操作可能比较耗时。如果Hook脚本是常驻进程(某些高级用法),或者会被频繁调用(虽然不常见),可以将解析后的配置缓存起来。 - 优化正则表达式 :Matcher中使用的正则表达式要尽量简单高效。避免使用贪婪匹配
.*开头或结尾,这可能导致回溯灾难。对于简单的字符串匹配,使用matcher: "ExactString"比matcher: "^ExactString$"更直接。 - 避免同步操作 :在Node.js中,同步的
fs.readFileSync、JSON.parse(对大对象)会阻塞事件循环。始终使用异步API,确保Hook能快速响应。 - 设置合理的超时 :不仅要在配置中设置
timeoutSec,在Hook脚本内部发起任何子进程或网络请求时,也要设置超时。一个卡住的子进程会导致整个Hook超时,但内部超时可以提供更清晰的错误信息。
6.3 调试与日志记录
构建健壮的Hook离不开日志。建议建立一个简单的日志工具,根据环境变量控制日志级别。
// src/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVEL = (process.env.HOOK_LOG_LEVEL || 'warn') as LogLevel;
const LEVEL_PRIORITY: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
function shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[LOG_LEVEL];
}
export function log(level: LogLevel, message: string, data?: any) {
if (!shouldLog(level)) return;
const entry = {
timestamp: new Date().toISOString(),
level,
message,
sessionId: process.env.CLAUDE_SESSION_ID, // 如果环境变量中有的话
...(data && { data: JSON.stringify(data) }), // 避免循环引用
};
// 输出到stderr,避免干扰stdout的JSON响应
console.error(JSON.stringify(entry));
}
// 使用
import { log } from './logger';
log('info', 'Hook started', { hookEvent: input.hook_event_name });
log('debug', 'Parsed tool input', bashInput);
在开发时,设置 HOOK_LOG_LEVEL=debug ,在生产环境设置为 HOOK_LOG_LEVEL=error 。这些日志可以从AI助手的日志窗口或系统日志中查看,是排查问题的宝贵资料。
最后,记住Hook脚本是用户与AI助手交互的关键拦截点,它的稳定性和性能直接影响用户体验。从简单的命令检查开始,逐步迭代,充分利用 agent-hook-schemas 提供的类型安全性和工具函数,可以让你构建出既强大又可靠的AI助手扩展。
更多推荐


所有评论(0)