PreToolUse hook 做三件事:阻止工具调用、原样放行、或修改输入后放行。每种动作需要特定的返回结构——搞错了会产生难以调试的静默失败。
三种返回模式
1. Deny:阻止工具调用
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Refund exceeds $500 limit, requires human approval"
}
}
工具不会执行。permissionDecisionReason 会送达模型,让它解释情况并采取替代动作。没有 reason 的话,模型知道调用被拦了但不知道为什么。
2. Allow with modification:透明地修改工具输入
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {"file_path": "/sandbox/src/main.py"}
}
}
工具用修改后的参数执行。模型看不到这个变化——它以为自己写到了 /src/main.py,但文件落在了 /sandbox/。用于:沙箱重定向、元数据注入(在文件内容前面加时间戳注释)、参数增强。
关键:updatedInput 是部分更新——只包含改动的字段。未改动的字段保留原始输入。
3. Allow as-is(或透传)
返回 {}、None、或直接不返回。工具使用原始参数继续执行。
最常见的 bug:空返回代替 deny
async def block_delete(input_data, tool_use_id, context):
if input_data["tool_name"] == "delete_account":
print("Blocked delete_account!") # 这行打印正确
return {} # 但这里永远放行工具调用
print() 执行了,日志显示 “Blocked!”——但函数总是走到 return {},SDK 把它解释为透传。修复:在 if 块内部返回 deny 决定,不是在它之后。
updatedInput 需要显式 “allow”
一个 hook 返回了带修改后文件路径的 updatedInput 但省略了 permissionDecision。结果:SDK 把响应当透传处理,使用了原始的未修改输入。updatedInput 被悄悄忽略了。
规则:updatedInput 只在 permissionDecision: "allow" 显式包含时才生效。没有 allow 决定 = 没有输入修改,不管 updatedInput 里有什么。
无效的返回格式
- 自定义 JSON(
{"error": "...", "blocked": true}):SDK 忽略任意键。它期望hookSpecificOutput。 - 错误值(
"reject"、"block"、"cancel"):只有"deny"和"allow"是有效的。 - 异常(
raise ValueError(...)):让 hook 崩溃。SDK 可能默认放行工具调用——和你想要的正好相反。
Deny 带重定向引导
拒绝高额退款时,不要只说”拦了”。引导模型走向正确的替代方案:
“退款超过 $500 限额。请改用 escalate_to_human 工具,包含客户 ID、订单 ID、退款金额和原因。”
模型读到这个 reason 就知道接下来该做什么。Hook 不应该直接调用工具——它们返回决定,不执行动作。模型处理替代工作流。
MCP 工具名前缀陷阱
一个 matcher="process_refund" 的 hook 能捕获内置工具但漏掉了 mcp__payments__process_refund——同一个工具通过 MCP server 暴露。生产数据:95% 捕获率(内置),5% 绕过(MCP 前缀)。
修复:matcher=".*process_refund" — 正则匹配任何以 “process_refund” 结尾的工具名,不管前缀。
多个 hook,一个拒绝原因
process_refund 上两个 hook:Hook A 检查客户验证,Hook B 检查退款限额。两个都触发、两个都拒绝,但 SDK 只传播一个拒绝原因。模型修复了金额限制却没意识到客户也未验证。
修复:把相关检查合并成一个 hook,评估所有条件后返回完整的拒绝原因:“拒绝:(1) 客户未验证,(2) 退款超限。两者都必须解决。“
静默语义修改:反模式
一个 hook 悄悄给每次提取加上 "classification": "confidential",模型不知情。模型以为自己做了普通提取,但输出总是被标记为机密。后来模型引用了自己”未分类”的提取——模型的理解和实际之间产生了错配。
规则:输入修改应该要么透明(模型知道),要么限于非语义变更(路径重定向、元数据注入)。
纵深防御:hook + prompt
一个 hook 阻止已知危险 Bash 模式(rm -rf、drop database)。System prompt 说”绝不执行破坏性命令”。两者一起提供纵深防御:hook 确定性地捕获已知威胁,prompt 为模式列表中没有的新威胁提供概率性安全网(truncate -s 0 /var/log/*)。两者都不多余。
双 hook 共享状态实现分级策略
客户退款限额取决于会员时长(新客户:$100,常规:$300,VIP:$1000)。模式:PostToolUse hook 在 get_customer 上捕获会员时长到共享状态 → PreToolUse hook 在 process_refund 上读共享状态来确定适用限额。这避免了脆弱的对话解析,也不需要修改工具 schema。
PreToolUse vs PostToolUse:正确的分配
- 阻止请求(域名屏蔽、维护窗口)→ PreToolUse。必须在请求发出之前拦截。
- 转换结果(日期标准化、格式统一)→ PostToolUse。需要原始结果来转换。
用 PostToolUse 做阻止意味着请求已经发出了。用 PreToolUse 做结果转换意味着还没有结果可转换。
一句话总结: PreToolUse 返回 deny(阻止)、allow + updatedInput(透明修改)、或空(透传)——警惕空返回代替 deny 的 bug,updatedInput 必须搭配显式 allow,MCP 前缀用正则 matcher,相关检查合并进一个 hook 避免丢失拒绝原因。