第 8 章 / 共 10 章
钩子的完整生命周期:事件、决策与守门
8.1 一个回合里会触发哪些事件
钩子不止 PostToolUse 一个。从你按下回车到模型答完,一个回合会流经一连串生命周期事件,每个都能挂钩子:
UserPromptSubmit:你提交提示、模型开始处理之前。适合注入上下文或拦下不该发的请求。PreToolUse:某个工具调用执行之前。可以拦截——守门的关键。PostToolUse:工具成功之后。上一章的自动格式化就在这里。Stop:模型答完这一轮。适合收尾检查。SessionStart:会话开始或恢复时。适合加载环境信息。

8.2 只掌握核心几个事件就够起步
诚实地说,钩子事件如今已经有三十多个——还有子代理、模型切换、上下文压缩、工作树等各种时机。但你不需要把它们当字典背下来。
起步阶段,上面那五个事件覆盖了绝大多数真实需求。等你遇到”想在压缩上下文前保存点东西""想在子代理结束时做检查”这类具体需求,再去查那个具体事件的字段即可。工具类内容变化快,写钩子前用 /hooks 或当前文档核对一遍事件名和字段,是个好习惯。
8.3 用 permissionDecision 拦下危险操作
现在做守门。我们要的第二条纪律:绝不允许 git push --force。这得挂在 PreToolUse 上,因为只有它能在命令真正执行前拦下来。
配置 .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-push.sh"
}
]
}
]
}
}
${CLAUDE_PROJECT_DIR} 会被替换成项目根目录,这样不管从哪个子目录启动,脚本路径都对得上。脚本 .claude/hooks/guard-push.sh:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE 'git push .*(--force|-f)\b'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "禁止强制推送:请改用 --force-with-lease,或走 PR 流程。"
}
}'
else
exit 0
fi
这段的现代写法值得记住:脚本正常退出(0),但往标准输出打印一段 JSON 来表达决策。hookSpecificOutput 里的 permissionDecision 设为 deny 就拦下这次操作,permissionDecisionReason 会作为理由反馈给模型,让它知道为什么被拦、该怎么改。除了 deny,还可以是 allow(直接放行、跳过常规询问)。没匹配到危险模式时,脚本退出 0、不打印任何决策,操作就走正常权限流程。
这比单纯用”退出 2 + 标准错误”更精细:你能给出结构化的理由,甚至选择直接放行某些可信操作。
8.4 钩子会以你的权限跑任意代码
到这里必须停下来说一件严肃的事:钩子会在你的机器上,以你的权限,执行任意代码,而且不经过模型判断。这既是它的全部能力来源,也是它最大的风险。
含义有两层。第一,你自己写的钩子要小心——一条挂在 PreToolUse 上的错误命令,可能拦掉所有工具让会话瘫痪,或者反过来放行了本不该放行的东西。第二,也是更要警惕的:别人仓库里的钩子同样会以你的权限跑。当你 clone 一个陌生项目并在里面启动 Claude Code,它 .claude/settings.json 里的钩子、.claude/skills/ 里技能的 allowed-tools,都可能在你没细看的情况下生效。第 10 章会把这条信任边界讲透。
你现在的成果:三件产物齐了——一个技能、一个子代理、两个钩子。但它们现在散落在你个人目录和项目目录里,同事拿不到。最后两章,我们把它们收拢成一个能一键分发的插件,再把整套东西组合起来跑一遍。