乐于分享
好东西不私藏

第16章:Hooks 高级攻略——为 AI 编程助手筑起质量与安全的三道防线

第16章:Hooks 高级攻略——为 AI 编程助手筑起质量与安全的三道防线

大家好,我是伦哥。

上一章我们聊了 Hooks 的基础概念——中间件本质、事件体系、配置结构,以及最常用的 PreToolUse 和 PostToolUse 两大实战场景。PreToolUse 在工具执行前做“入口安检”,PostToolUse 在工具执行后做“过程质检”。

但还有一个关键环节我们没有覆盖:Claude 做完整个任务后,谁来验收?

这就好比工厂的流水线:安检门(PreToolUse)检查原料是否合格,质检站(PostToolUse)检查每道工序的产出。但产品最终出厂前,还需要一道终检——确认成品整体质量达标。这道终检,就是今天要讲的重头戏:Stop Hook


一、Stop Hook——任务完成时的“出厂验收”

Stop Hook 在 Claude 完成响应后运行。如果说 PreToolUse 是入口安检,PostToolUse 是过程质检,那么 Stop Hook 就是出厂验收——在 Claude 宣布“我做完了”之后,再检查一遍交付物的质量。

Stop Hook 和其他 Hook 最大的区别在于它的 continue: true 字段:

{
"decision""block",
"reason""Tests are failing, please fix them",
"continue"true
}

continue: true 意味着“不要停,继续工作”。这创造了一个自动循环:Claude 认为完成了 → Stop Hook 检查 → 发现测试失败 → 把失败信息反馈给 Claude → Claude 继续修复 → 再次完成 → 再次检查……直到所有检查通过,Claude 才被允许真正停下来。

这种机制把质量保证从“事后检查”变成了“交付前置条件”——不是做完了再检查,而是检查通过了才算做完

实战:自动测试门控

这是 Stop Hook 最经典的应用场景。为什么要在 Stop 时运行测试,而不是在每次文件修改后?因为一个功能的实现通常涉及多个文件的修改。中间状态的测试必然会失败——你改了接口但还没改实现,测试当然过不了。只有在 Claude 认为“全部完成”的时刻,再运行测试才有意义。

来看一个完整的 Shell 脚本实现(run-tests.sh):

#!/bin/bash
set -e

# 确定项目目录
if [ -n "$CLAUDE_PROJECT_DIR" ]; then
cd"$CLAUDE_PROJECT_DIR"
fi

RUN_TESTS=false
TEST_RESULT=""
TEST_PASSED=true

# Node.js 项目
if [ -f "package.json" ]; then
    RUN_TESTS=true
if grep -q '"test"' package.json; then
        TEST_RESULT=$(npm test 2>&1) || TEST_PASSED=false
else
        TEST_RESULT="No test script found"
        TEST_PASSED=true
fi
# Python 项目
elif [ -f "pytest.ini" ] || [ -f "pyproject.toml" ]; then
    RUN_TESTS=true
ifcommand -v pytest &>/dev/null; then
        TEST_RESULT=$(pytest 2>&1) || TEST_PASSED=false
fi
# Go 项目
elif [ -f "go.mod" ]; then
    RUN_TESTS=true
    TEST_RESULT=$(go test ./... 2>&1) || TEST_PASSED=false
# Rust 项目
elif [ -f "Cargo.toml" ]; then
    RUN_TESTS=true
    TEST_RESULT=$(cargo test 2>&1) || TEST_PASSED=false
fi

# 如果没有检测到测试框架,直接放行
if [ "$RUN_TESTS" = false ]; then
echo'{}'
exit 0
fi

# 转义 JSON 特殊字符
TEST_RESULT_ESCAPED=$(echo"$TEST_RESULT" | head -50 | jq -Rs '.')

if [ "$TEST_PASSED" = true ]; then
echo'{"decision": "approve", "reason": "All tests passed."}'
else
    cat <<EOF
{
"decision""block",
"reason""Tests are failing. Please fix the issues before stopping.",
"continue"true,
"systemMessage"$TEST_RESULT_ESCAPED
}
EOF
fi
exit 0

这个脚本有几个巧妙的设计:

项目类型自动检测:通过检查 package.jsonpyproject.tomlgo.modCargo.toml 等特征文件来判断项目类型,实现了“约定优于配置”的通用性。

容错处理:先检查是否有 test 脚本。如果项目根本没配置测试,不会报错而是放行——不能因为项目还没写测试就阻止 Claude 完成工作。

结果截断head -50 只取前 50 行。测试失败的输出可能非常长,但 Claude 只需要看到关键错误信息就能定位问题。这体现了第 6 讲中“信噪比”的思维。

配置方式

{
"hooks": {
"Stop": [
      {
"hooks": [
          {
"type""command",
"command""./hooks/run-tests.sh"
          }
        ]
      }
    ]
  }
}

注意 Stop 事件没有 matcher 字段——因为 Stop 是生命周期事件,不针对特定工具。

当 Claude 写完代码准备停下来时,如果测试失败,你会看到:

Stop hook returned blocking error
Tests are failing. Please fix the issues before stopping.
[测试失败的详细输出...]
Claude 继续修复代码...

即使 Claude 拼命说任务完成了,Stop Hook 都会让它继续改错。这个机制在 Claude 执行复杂重构时特别有价值——重构常常会破坏现有测试,而这个 Hook 确保 Claude 会把测试修好才停止。


二、Prompt 类型 Hook:让 AI 当代码审查员

Shell 脚本适合检查客观事实——测试通不过、文件不存在。但有时候你需要检查更“主观”的东西:代码风格是否合理?功能实现是否完整?有没有遗漏边界情况?这些判断需要“理解力”,不是模式匹配能解决的。

这时可以用 Prompt 类型的 Stop Hook,让一个小型 LLM 担任代码审查员:

{
"hooks": {
"Stop": [
      {
"hooks": [
          {
"type""prompt",
"prompt""Review the changes made in this session. Check that: 1) Code follows best practices 2) No obvious bugs 3) Error handling is adequate"
          }
        ]
      }
    ]
  }
}

这相当于在 Claude 完成工作后,让另一个 AI 做 Code Review。主 Claude 是作者,Prompt Hook 的模型是审查者。审查者往往能发现作者忽略的问题,因为它没有“我刚写的代码当然是对的”这种认知偏见。

当然,Prompt 类型的可靠性低于 Command 类型。LLM 可能漏检,也可能误报。但作为测试门控之外的第二层防线,它能覆盖一些脚本无法检查的维度。


三、防止死循环:stop_hook_active 安全开关

Stop Hook 的 continue: true 很强大,但也有风险——如果 Claude 一直修不好,就会进入死循环。

官方提供了安全字段 **stop_hook_active**:当 Claude 因为 Stop Hook 而继续工作时,下一次 Stop 事件的输入中 stop_hook_active 会被设为 true

#!/bin/bash
INPUT=$(cat)
# 检查是否已经因为 Stop Hook 继续过了
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
# 已经重试过一次了,这次让 Claude 停下来
exit 0
fi
# 正常的测试逻辑
npm test 2>&1
if [ $? -ne 0 ]; then
echo'{"decision": "block", "reason": "Tests still failing, please fix.", "continue": true}'
else
exit 0
fi

这个模式允许 Claude 重试一次——第一次 Stop 时检查测试,如果失败就让 Claude 继续修复;第二次 Stop 时,stop_hook_active 为 true,无论测试是否通过都让 Claude 停下来。


四、SubagentStart 与 SubagentStop——子代理的入口与出口

在第 3-8 讲中,我们学习了子代理的各种使用模式。现在从 Hook 的角度来看——如何在子代理启动和完成时自动执行检查?

SubagentStart:为子代理注入上下文

SubagentStart 在子代理被启动时触发。它的 matcher 匹配的是子代理类型名,而不是工具名。

Matcher 值
匹配的子代理
Bash
内置 Bash 子代理
Explore
内置 Explore 子代理
Plan
内置 Plan 子代理
自定义名称
你在 .claude/agents/ 中定义的子代理

SubagentStart 不能阻止子代理启动(这是设计决策——启动子代理是主会话的明确意图),但可以通过 additionalContext 向子代理注入上下文信息:

{
"hookSpecificOutput": {
"hookEventName""SubagentStart",
"additionalContext""当前分支是 feature/payment-refactor,请特别关注支付相关的代码"
  }
}

这个能力的价值在于自动化上下文注入。比如你有一个 code-reviewer 子代理,每次启动时都需要知道团队编码规范。没有 Hook,你得每次手动提醒“请遵循 camelCase 命名规范”;有了 Hook,这个提醒自动发生:

{
"hooks": {
"SubagentStart": [
      {
"matcher""code-reviewer",
"hooks": [
          {
"type""command",
"command""echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"请遵循团队编码规范:使用 camelCase,禁止 console.log\"}}'"
          }
        ]
      }
    ]
  }
}

这样,每次 code-reviewer 启动时都会自动收到规范——不需要手动提醒,也不占用子代理的 prompt 空间。

SubagentStop:验证子代理的工作成果

SubagentStop 在子代理完成工作后触发。它的决策控制和 Stop 事件完全一致——可以阻止子代理停止,强制它继续工作。

SubagentStop 有一个独特的字段:agent_transcript_path——子代理自己的对话记录。这意味着你的 Hook 脚本可以读取子代理的完整对话历史来判断质量,不是只看最终结果,而是能看到子代理是怎么得出结论的

实战:验证代码审查质量

下面这个脚本验证 code-reviewer 子代理的审查是否完整——如果它发现了问题但没有给出修复建议,就强制它继续工作:

#!/bin/bash
INPUT=$(cat)
AGENT_TYPE=$(echo"$INPUT" | jq -r '.agent_type')
STOP_HOOK_ACTIVE=$(echo"$INPUT" | jq -r '.stop_hook_active')
TRANSCRIPT=$(echo"$INPUT" | jq -r '.agent_transcript_path')

# 只检查 code-reviewer
if [ "$AGENT_TYPE" != "code-reviewer" ]; then
exit 0
fi

# 防止死循环
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi

# 读取子代理的输出,检查是否包含必要的审查要素
if [ -f "$TRANSCRIPT" ]; then
    HAS_ISSUES=$(grep -c "issue\|问题\|bug\|warning""$TRANSCRIPT" | true)
    HAS_SUGGESTIONS=$(grep -c "suggest\|建议\|recommend""$TRANSCRIPT" | true)

if [ "$HAS_ISSUES" -gt 0 ] && [ "$HAS_SUGGESTIONS" -eq 0 ]; then
        cat <<EOF
{
"decision""block",
"reason""You found issues but didn't provide suggestions. Please add actionable recommendations."
}
EOF
exit 0
fi
fi
exit 0

三层防护:只检查 code-reviewer → 防止死循环 → 检查是否有问题但没有建议。

当然,用关键词匹配比较粗糙。对于更精细的验证,可以用 Prompt 类型让 LLM 来评估:

{
"hooks": {
"SubagentStop": [
      {
"matcher""code-reviewer",
"hooks": [
          {
"type""prompt",
"prompt""Evaluate this code review result. Check that: 1) All identified issues have actionable suggestions 2) The suggestions are specific and implementable"
          }
        ]
      }
    ]
  }
}

五、实战项目:完整的 Hook 系统

项目一:安全钩子系统

目标:保护敏感资源,防止危险操作,记录审计日志。

.claude/
  settings.json
  hooks/
    block-dangerous.sh   # 阻止危险命令
    protect-files.sh     # 保护敏感文件
    audit-log.sh         # 记录操作日志

配置

{
"hooks": {
"PreToolUse": [
      {
"matcher""Bash",
"hooks": [
          { "type""command""command""./hooks/block-dangerous.sh" }
        ]
      },
      {
"matcher""Write",
"hooks": [
          { "type""command""command""./hooks/protect-files.sh" }
        ]
      },
      {
"matcher""Edit",
"hooks": [
          { "type""command""command""./hooks/protect-files.sh" }
        ]
      }
    ],
"PostToolUse": [
      {
"matcher""*",
"hooks": [
          { "type""command""command""./hooks/audit-log.sh" }
        ]
      }
    ]
  }
}

三道防线

  1. 命令拦截(PreToolUse → Bash):拦截 rm -rf /git push --force origin main 等灾难性操作
  2. 文件保护(PreToolUse → Write/Edit):保护 .env 等敏感文件不被修改
  3. 审计日志(PostToolUse → *):所有操作无差别记录,事后追溯

强度递减(拦截→拦截→记录),覆盖面递增(Bash→Write/Edit→所有工具)。这就是经典的纵深防御策略

项目二:质量钩子系统

目标:自动格式化代码,检查 Lint 错误,确保测试通过。

.claude/
  settings.json
  hooks/
    auto-format.sh   # 自动格式化
    lint-check.sh    # Lint 检查
    run-tests.sh     # 运行测试

两阶段质量保证

  1. 逐文件质量保证(PostToolUse → Write/Edit):每次写入或编辑后,先格式化再 Lint
  2. 全局质量门控(Stop):所有工作完成后运行完整测试套件

注意:同一个 hooks 数组中的多个 Hook 按顺序执行——先格式化再 Lint,顺序不能反。


六、高级模式与最佳实践

多 Hook 链

多个 Hook 按数组顺序依次执行。如果任何一个返回阻止决策,后续 Hook 不会执行。所以应该把“不能失败”的 Hook(如日志)放在最前面。

{
"hooks": {
"PostToolUse": [
      {
"matcher""Write",
"hooks": [
          { "type""command""command""./hooks/format.sh" },
          { "type""command""command""./hooks/lint.sh" },
          { "type""command""command""./hooks/log.sh" }
        ]
      }
    ]
  }
}

环境变量

变量
描述
CLAUDE_PROJECT_DIR
项目根目录
CLAUDE_SESSION_ID
当前会话 ID
CLAUDE_TOOL_NAME
当前工具名称
CLAUDE_FILE_PATH
操作的文件路径
CLAUDE_ENV_FILE
环境变量持久化文件路径

CLAUDE_ENV_FILE 尤其有用。SessionStart Hook 可以向这个文件写入 export 语句,在会话开始时设置全局环境:

#!/bin/bash
# session-setup.sh
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo'export DEBUG_LOG=true' >> "$CLAUDE_ENV_FILE"
fi
exit 0

异步 Hook

默认 Hook 会阻塞执行。对于耗时操作(完整测试套件、发送通知、调用外部 API),可以用 async: true

{
"type""command",
"command""./hooks/run-tests-async.sh",
"async"true,
"timeout"300
}

三个限制:只有 command 类型支持异步;不能阻止操作(因为操作在 Hook 完成前已继续);输出在下一轮才传递给 Claude。

HTTP Hook:对接外部服务

如果处理逻辑在远程服务上(团队共享的审计服务、集中式安全扫描平台),用 type: "http"

{
"type""http",
"url""http://localhost:8080/hooks/tool-use",
"headers": {
"Authorization""Bearer $MY_TOKEN"
  },
"allowedEnvVars": ["MY_TOKEN"]
}

注意:HTTP 状态码不能阻止操作,必须在 2xx 响应体里用 JSON 表达决策。


七、frontmatter 内置 Hooks——比全局配置更精准

在 Commands 和 Skills 的 frontmatter 中可以包含临时 Hooks(仅在该命令/技能执行期间有效):

---
description:Deploywithsafetychecks
hooks:
-event:PreToolUse
matcher:Bash
command:|
      if [ "$TOOL_INPUT" == *"production"* ]; then
        echo "Production deployment detected" >&2
      fi
-event:PostToolUse
matcher:Edit
command:npxprettier--write"$FILE_PATH"
once:true
---
Deploytheapplicationtostagingenvironment.

once: true 表示 Hook 只触发一次,适合“完成后运行一次测试”这类场景。

子代理 frontmatter Hooks

考虑这个场景:你有一个 db-reader 子代理执行 SQL 查询,想检查 SQL 注入风险。

方案一:全局 settings.json——所有 Bash 命令都会被检查,包括 npm installgit status 这些和 SQL 无关的命令。

方案二:子代理 frontmatter Hooks——只在 db-reader 的 Bash 命令上触发:

---
name:db-reader
description:Read-onlydatabaseexplorer
tools:Read,Grep,Glob,Bash
permissionMode:plan
hooks:
PreToolUse:
-matcher:"Bash"
hooks:
-type:command
command:"./hooks/check-sql-injection.sh"
Stop:
-hooks:
-type:prompt
prompt:"Check if any query results contain PII"
---
Youareadatabaseanalysisspecialist...
对比维度
全局 settings.json
子代理 frontmatter
作用域
所有 Bash 命令
仅 db-reader 的 Bash 命令
生命周期
永远生效
仅子代理活跃期间
精准度
可能误拦截
只在特定上下文执行

关键规则

  1. 支持所有事件类型
  2. Stop 自动转换为 SubagentStop
  3. 生命周期绑定子代理
  4. 格式与 settings.json 一致

何时用 frontmatter vs 全局?

  • Hook 只与特定子代理相关?→ frontmatter
  • Hook 需要随子代理分发?→ frontmatter
  • Hook 需要在子代理外也生效?→ 全局 settings.json

八、Hook 工程设计方法论

三维决策框架

设计 Hook 方案时,想清楚三个问题:

维度
选项
在哪里拦截?
PreToolUse / PostToolUse / Stop / SubagentStart / SubagentStop
用什么方式判断?
command / prompt / agent / http
在哪里配置?
全局 ~/.claude/ / 项目 .claude/ / 子代理 frontmatter / settings.local.json

选择原则:能用 command 解决的不用 prompt,能用 prompt 解决的不用 agent,在最小作用域内生效。

Hook + SubAgent 组合模式

组合模式
示例
SubagentStart 注入上下文
code-reviewer 启动时注入团队规范
SubagentStop 质量门控
检查审查报告是否完整
frontmatter 内置 Hook
db-reader 自带 SQL 注入检测
Stop + 子代理结果验证
用 agent Hook 检查所有修改是否通过测试

完整的组合案例——带质量门控的代码审查子代理:

  1. frontmatter Hook:子代理内部自检(“我知道自己漏了什么”)
  2. SubagentStart Hook:外部注入上下文
  3. SubagentStop Hook:外部验收(“它觉得完成了,但其实不够好”)

内部自检和外部验收视角不同,互为补充。


九、调试技巧

1. 使用 stderr 输出调试信息

echo"DEBUG: Processing file $FILE_PATH" >&2
echo'{"decision": "allow"}'# stdout 是 JSON

2. 手动测试 Hook 脚本

echo'{"tool_name": "Bash", "tool_input": {"command": "rm -rf /tmp/test"}}' | ./hooks/block-dangerous.sh
echo"Exit code: $?"

3. 使用 claude --debug 查看完整执行细节

4. 常见问题排查

  • Hook 不触发:检查 matcher 是否正确(区分大小写),重启会话
  • 权限问题:chmod +x hooks/*.sh
  • JSON 解析错误:确保输出是有效 JSON,检查 shell profile 中的 echo 语句
  • Stop Hook 死循环:检查是否遗漏 stop_hook_active 判断

总结

两讲内容完整覆盖了 Hooks 的全部知识体系:

  • 概念:AI 中间件
  • 配置:六个位置、四种类型
  • 核心事件:PreToolUse、PostToolUse、Stop
  • 高级事件:SubagentStart、SubagentStop
  • 组合系统:从单个 Hook 到多层防线
  • 工程方法论:三维决策框架

Hooks 是 Claude Code 扩展机制中唯一能拦截和修改 AI 行为的组件。用好它,就能在享受 AI 效率的同时,守住安全和质量的底线。

不要等到出了问题再补救,而要未雨绸缪。从 Stop Hook 质量门控到 SubAgent 事件验收,从 frontmatter 精准配置到三维决策框架——每一步都设防,构建滴水不漏的 Hook 工程体系。

记住:不是做完了再检查,而是检查通过了才算做完。


本系列往期文章


如果我的文章对你有帮助,请帮忙点赞、在看、转发一下,你的支持是我持续输出高质量文章的最大动力,非常感谢!