代码语言
选择后全站课程跟随

Hooks:凭什么拦得住模型

3,276 字 · 预计阅读 9 分钟

前情提要

  • Hook 是在某个事件发生时自动执行的 shell 命令,写在 settings.json 的 hooks 里:
json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": ".claude/hooks/block-push.sh" }] }
    ]
  }
}
  • Hook 从标准输入拿到一段 JSON(事件名、工具名、参数……),用退出码表态:
退出码 意思
0 放行
2 阻断,stderr 的内容交给模型
其他 出错了,但不阻断,只提示你
  • 常见用途:拦下 git push、改完文件自动格式化、结束前检查测试有没有过
  • 一句常听到的话:「CLAUDE.md 是建议,Hook 是规定」

Hook 怎么写,你可能已经会了。这一节回答另外几个问题:它挂在循环的哪里?凭什么模型绕不过去?它说「放行」,是不是就能绕开你的 deny 规则?

代码主要在 src/utils/hooks.ts(执行 Hook)和 src/services/tools/toolHooks.ts(Hook 和权限怎么配合)。


一、Hook 挂在循环的哪些缝隙

Hook 位于工具调用前后和停止点

Hook 就是挂在循环「缝隙」里的脚本。把第2节的循环和第3节的七道关画在一起,标出这些缝隙:

SessionStart ────────► 会话开始;压缩之后也会再触发一次(source = compact)
UserPromptSubmit ────► 你按下回车之后、发给模型之前

while (true) {
  调模型
  ├─ 有工具调用(toolExecution.ts):
  │    参数校验
  │    PreToolUse ─────► 校验之后、权限判定之前
  │    权限判定
  │    执行工具
  │    PostToolUse ────► 执行成功之后(失败则是 PostToolUseFailure)
  │
  └─ 没有工具调用(query.ts):
       Stop ───────────► 准备结束时;exit 2 → 把反馈塞回消息,continue 再转一圈
}

PreCompact / PostCompact ──► 压缩前后
SubagentStart / SubagentStop ──► 子代理开始、结束
SessionEnd ──────────► 会话结束

最常用的是三个:工具调用前(PreToolUse)、工具调用后(PostToolUse)、准备结束时(Stop)。

Hook 不在模型里面,而是挂在循环的缝隙上,由 Claude Code 自己执行。


二、PreToolUse 为什么拦得住

PreToolUse 在工具真正执行前拦截并反馈

看一下 PreToolUse 的位置:参数校验之后、权限判定之前。

这个位置决定了它为什么拦得住:

  • 工具还没开始执行,Hook 说不行,后面的步骤就都不走了
  • 它在权限判定之前,拦下来就不用再弹窗问你
  • 它在参数校验之后,拿到的参数一定是格式正确的,脚本不用处理乱七八糟的输入

而且整个过程,模型完全插不上手。它能做的只是「提出要调用某个工具」,这个请求要不要执行,是循环里的代码说了算,Hook 就是这段代码的一部分。

Hook 排在执行之前、跑在模型之外,所以模型绕不过去。


三、退出码:三个分支

前情提要里那张退出码表,在 src/utils/hooks.ts 里就是三个分支:

typescript
// src/utils/hooks.ts(简化)
if (result.status === 0) {
  return { outcome: 'success' }                      // 成功:输出记下来,不干预
}
if (result.status === 2) {                           // 阻断:把 stderr 的内容作为错误反馈给模型
  return { blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}` }
}
return { outcome: 'non_blocking_error' }             // 其他非零:不算严重错误,只在界面上提示

注意第三个分支:退出码是 1、127 这类「普通出错」时,不会阻断。脚本自己写错了、某个命令没装,Agent 照样往下跑,只是界面上提示一下。

这意味着:想拦,就必须明确 exit 2。 脚本中途出错退出,不等于拦下了。


四、Stop Hook:让循环再转一圈

Stop Hook 可以要求 Agent 继续一轮

Stop Hook 返回 exit 2 时,循环里发生的事情就是第2节那张表里的 stop_hook_blocking:

typescript
// src/query.ts(简化)
if (stopHookResult.blockingErrors.length > 0) {
  state = {
    messages: [...messages, ...assistantMessages, ...stopHookResult.blockingErrors],
    stopHookActive: true,          // 下一次 Stop Hook 收到的输入里,stop_hook_active 就是 true
    transition: { reason: 'stop_hook_blocking' },
  }
  continue                          // 不结束,再转一圈
}

模型说「我做完了」,Stop Hook 说「还没有」,它的 stderr 被塞回消息里,循环接着转。

本文是会员内容

全文 3,276 字,已试读约 28%。 开通专业版可阅读全文,并解锁全部课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟期前 500 名自动升级永久卡