首先澄清对 Hook 的几个核心认知:它是一个确定性治理层,而非另一个 Agent。这一点必须明确,否则后续设计思路容易偏离方向。
Hook 必须满足四道硬性约束,缺一不可:代码量精简(不超过 50 行,复杂度评分 ≤ 20),行为完全确定(相同的 stdin 输入始终产生相同的退出码),逻辑清晰可解释(每个 exit 2 必须附带规则编号和替代方案),以及可回滚(能够单独禁用,禁用后立即生效)。任何一条未达标,上线前必须重写。
Hook 的治理角色定位
在 Claude Code 工程体系中,Hook 系统的角色非常清晰:它位于 CLAUDE.md 的非确定性约束与 Permission System 的粗粒度控制之间,本质上是一个确定性脚本层。这三层治理机制各有明确的边界,互不越界。
| 治理层 | 机制 | 确定性 | 粒度 | 可审计 |
|---|---|---|---|---|
| CLAUDE.md | 提示词指令 | 低 | 自由文本 | 无 |
| Rules | 路径作用域指令 | 低 | 目录级 | 无 |
| Hooks | 脚本 | 高 | 调用级 | 有退出码+输出 |
| Permission | 权限系统 | 高 | 工具级 | 有 |
需要认清一个现实:Hook 并非万能。它无法进行语义判断——例如“这段代码是否安全”这类问题,它无法理解。它也不具备上下文感知能力——“这次修改是否符合当前任务”这种判断,不应依赖它。Hook 仅能执行模式匹配:路径模式、命令模式、文件名模式。如果试图让 Hook 超越模式匹配,实际上是在构建第二个 AI,而这个“AI”没有任何推理能力,注定行不通。
决策矩阵:Hook、Rules 与 CLAUDE.md 的选择
那么,面对具体需求时该如何决策?下面这个矩阵能够提供参考:
| 需求特征 | CLAUDE.md | Rules | Hook | Permission |
|---|---|---|---|---|
| "不要修改 .env 文件" | 次选 | - | 首选 | - |
| "修改 src/auth/ 后要跑测试" | 次选 | 次选 | 首选 | - |
| "代码风格遵循 ESLint" | 首选 | - | - | - |
| "这个项目使用 React 18" | 首选 | 次选 | - | - |
| "禁止使用 rm -rf" | - | - | 首选 | - |
| "Bash 工具需要授权" | - | - | - | 首选 |
| "生成的测试放在 tests/ 目录" | 首选 | 次选 | - | - |
| "PR 描述必须包含变更范围" | 次选 | - | 首选 | - |
| "子袋里必须输出 JSON 格式" | 次选 | - | 首选 | - |
| "禁止安装新依赖" | 次选 | - | 首选 | - |
选择逻辑可以归纳为一条决策路径:首先判断是否需要阻断工具调用。如果是,则走 Hook(PreToolUse)或 Permission。如果阻断条件能用模式匹配表达,选 Hook;如果是工具级别的阻断,选 Permission。接下来判断是否需要路径作用域的上下文,若是,则使用 Rules。再判断是否为项目级别的行为偏好,若是,则用 CLAUDE.md。最后,若需要在事件触发时自动执行操作,则用 Hook(PostToolUse/Stop)。
核心原则就一句话:能用 CLAUDE.md 解决的不用 Hook,能用 Hook 解决的不依赖提示词。CLAUDE.md 的优势在于灵活、零执行成本、可迭代;Hook 的优势在于确定性、可审计、零上下文消耗。当约束的违反代价很高时——比如密钥泄露、生产故障——必须使用 Hook 兜底,仅靠提示词无法扛住。
原则一:小
工程定义
一个 Hook 只做一件事,这是铁律。量化指标如下:
| 指标 | 硬性上限 | 超限后果 | 检测方法 |
|---|---|---|---|
| 有效代码行数 | ≤ 50 行 | 审计成本指数增长 | wc -l(去掉注释和空行) |
| 条件分支 | ≤ 5 个 if | 测试组合爆炸,覆盖率不足 | grep -c 'if|case' |
| 正则表达式 | ≤ 3 个 | 维护困难,误判风险高 | 正则检测脚本 |
| 外部命令调用 | 0 个 | 执行时间不可控,确定性丧失 | grep 检查 |
| 环境依赖 | ≤ 2 个 | 移植性差 | 检查 command -v |
| 执行时间 | ≤ 500ms | 每次工具调用增加延迟 | time 测试 |
复杂度评分公式
这里提供一个评分公式,用于量化 Hook 的复杂程度:复杂度 = (代码行数/10) + (分支数×2) + (正则数×3) + (外部命令数×5)。合格阈值是 ≤ 20,20~30 为警告区间,超过 30 直接拒绝。
例如:一个 30 行、3 个分支、2 个正则、0 个外部命令的 Hook,评分是 3+6+6+0=15,合格。但一个 80 行、8 个分支、5 个正则、2 个外部命令的 Hook,评分是 8+16+15+10=49,必须拆分。
复杂度超标的拆分策略
当一个 Hook 的复杂度评分超过 20,按职责边界拆分即可。每个拆分后的 Hook 覆盖一个独立的判定维度。
举个实际案例:一个 80 行的 block-sensitive-files.sh,里面混着文件路径模式检查(30 行)、文件内容密钥扫描(30 行)、白名单豁免逻辑(20 行),复杂度高达 49。拆分后变成三个独立的 Hook:block-sensitive-paths.sh(25 行,复杂度 8)、scan-secret-patterns.sh(28 行,复杂度 10)、check-file-whitelist.sh(18 行,复杂度 5)。每个都配置在同一个 matcher 下,Claude Code 按顺序执行,任何一个返回 exit 2 即阻断。
这种拆分带来的工程收益显著:每个 Hook 独立测试、独立部署、独立禁用;任何一个出问题,只影响对应的检查维度;团队可以并行维护不同 Hook,不会产生合并冲突;新增检查维度时直接添加新 Hook,无需修改已有代码。
原则二:确定
工程定义
确定性意味着:同样的 stdin JSON 输入,永远产生同样的退出码和 stdout 输出。所有非确定性来源都必须排除干净。
| 非确定性来源 | 典型代码模式 | 后果 | 替代方案 |
|---|---|---|---|
| 网络调用 | curl, wget | 超时/失败时行为不一致 | 本地模式匹配 |
| 时间依赖 | date用于条件分支 | 不同时间行为不同 | 移除时间条件 |
| 文件系统状态 | test -f, ls | 状态变化导致行为变化 | 只读 stdin 输入 |
| 随机数 | $RANDOM, shuf | 同一调用结果不同 | 全量检查或模式匹配 |
| 外部进程 | git status, npm ls | 权限/网络问题导致失败 | 静态配置 |
| 环境变量 | $ENV_VAR | 不同机器配置不同 | 硬编码常量 |
确定性审查检查清单
审查每个 Hook 脚本中的每条命令时,可依据以下清单:curl/wget/nc 必须移除,API 超时会阻塞整个系统。date 用于条件判断的必须移除,时间规则应放入 Stop Hook 做提醒。test -f/ls/stat 必须移除,Hook 只处理 stdin。$RANDOM/shuf/awk 'rand()' 必须移除,检查逻辑不能有随机性。git/npm/docker 必须移除,外部进程的输出不可控。环境变量(非 PATH/jq)改为硬编码,环境差异会导致不一致。jq/grep/sed 可以保留,纯文本处理,输入确定则输出确定。
可接受的有限例外
有两个场景允许有限的不确定性,但前提是不得影响 PreToolUse 的阻断决策。具体的例外场景这里不再展开,但需牢记:如果影响到阻断决策,那就不是例外,而是违规。
原则三:可解释
工程定义
每个 exit 2(阻断)路径的 stdout 输出必须包含四个字段,缺任何一个都不合格。这四个字段是:操作描述(一句话说明被拦截了什么)、原因(具体哪个模式被匹配)、规则标识(规则编号或名称,可追溯到文档)、建议(Claude 可以执行的替代方案)。
可解释性之所以关键,是因为 Claude 会读取 Hook 的 stdout 输出来调整后续行为。消息质量直接决定 Claude 的纠错效率。
对比一下就能明白:低质量的阻断消息只输出一个"BLOCK",Claude 完全不知道问题所在,只能反复尝试,白白消耗 token。中等质量的会说"BLOCK: 不能修改 .env 文件",Claude 知道 .env 被保护了,但不知道为什么被保护,也不知道替代方案。高质量的阻断消息会给出完整信息:"BLOCK: 尝试修改 .env.production。原因:文件路径匹配模式 [.env.]。规则:SEC-003 环境变量文件保护。建议:使用 vault CLI 或 AWS SSM 更新生产环境变量。"——Claude 看到后,便清楚规则、原因和替代路径。
阻断消息质量度量
| 度量维度 | 合格标准 | 检查方法 |
|---|---|---|
| exit 2 路径包含操作描述 | 100% | 检查每个 exit 2 前的 echo 语句 |
| exit 2 路径包含规则标识 | 100% | grep 检查 "规则:" 字段 |
| exit 2 路径包含替代方案 | ≥ 80% | grep 检查 "建议:" 字段 |
| 消息长度 ≤ 4 行 | ≥ 90% | 过长消息降低 Claude 处理效率 |
| exit 0 路径有 stdout 输出 | 0% | exit 0 不应产生任何输出 |
原则四:可回滚
工程定义
每个 Hook 必须满足三个回滚性条件。这里重点说明禁用机制。
禁用机制
第一种,配置移除,这是首选方案。直接从 settings.json 中移除 Hook 配置项,效果即时生效。例如要临时禁用 scan-secret-patterns,直接移除第一个 hooks 数组中的条目,其他 Hook 不受影响。
第二种,脚本级 feature flag。在脚本入口处添加快速退出逻辑,通过文件存在性控制。例如使用 DISABLED_FLAG=".claude/hooks/disabled-$(basename "$0" .sh)",然后判断文件是否存在,存在就直接 exit 0。一个 touch 命令就能禁用,一个 rm 就能恢复。
第三种,规则级 feature flag。适用于包含多条规则的 Hook,按规则编号控制。例如维护一个 disabled-rules 文件,每行一个规则编号,grep -q "^$1$" 检查是否被禁用。这种方式更精细,可以单独关闭某条规则而不影响其他规则。
回滚性验证清单
每个 Hook 上线前必须通过以下测试:从 settings.json 移除 Hook 配置后,Claude Code 正常运行;Hook 脚本文件不存在时,Claude Code 不报错(fail-open);Hook 脚本有语法错误时,Claude Code 不报错(fail-open);Hook 执行超时,Claude Code 超时后继续(fail-open);禁用 Hook A 后,Hook B 正常执行;恢复 Hook A 后,Hook A 恢复正常执行。
完整的 settings.json 配置示例
下面是一个经过生产验证的完整 Hook 配置,覆盖四个事件类型:PreToolUse、PostToolUse、Stop。PreToolUse 的 Edit|Write matcher 下挂两个 Hook,按顺序执行,第一个返回 exit 2 则整体阻断,不执行第二个。PreToolUse 的 Bash matcher 只挂一个命令检查 Hook。PostToolUse 只做格式化,不做阻断(PostToolUse 的 exit 2 无阻断语义)。Stop 事件使用空 matcher 匹配所有事件,生成会话摘要。
反模式:生产环境中的 Hook 失败模式
反模式 1:外部 API 调用导致全局阻塞
这个场景很典型:团队需要一个 Hook 检测文件内容中的密钥和凭证,文件名模式匹配不够用,于是调用内部分类 API。结果呢?API 服务器部署新版本重启,所有 curl 请求超时 10 秒,Claude Code 每次文件修改都要等 10 秒。一次会话改 15 个文件,就是 150 秒额外等待。更严重的是,API 偶尔返回 500 时,classification 解析为 "unknown",Hook 直接放行,安全检查被完全绕过。
根本原因很清楚:Hook 依赖外部服务,可用性不受控制;10 秒超时对高频调用的 Hook 不可接受;错误路径默认放行,等于 API 故障时检查失效;同样内容,API 正常时阻断,API 故障时放行——这直接违反了确定性原则。
修复方案很直接:移除 API 调用,改用本地正则模式匹配。使用 jq 和 grep 这些标准工具做模式匹配,最坏执行时间从 10 秒降到 100ms 以内,复杂度评分也从 22 降到 11.5。
反模式 2:过宽的 Matcher 导致所有操作被扫描
这个也常见:一个团队在 PreToolUse 上配置了空 matcher,匹配所有工具调用。如果 Hook 执行耗时 200ms,一次会话 80 次工具调用,就是 16 秒额外延迟。而且每次调用都触发完整检查逻辑,即使工具调用是 Read(只读操作,不存在安全风险)。
修复方法很简单:精确设置 matcher,只为有风险的工具类型配置 Hook。Read、Glob、Grep 等只读工具不触发任何 Hook,零额外延迟。
反模式 3:Hook 内部状态管理
有人可能会想:在 Hook 里维护一个"已提醒次数"计数器,超过 3 次后从提醒升级为阻断。这个想法听起来合理,但实际问题很多:计数器在不同会话间累积,某次手动清理后行为突变;文件权限问题导致写入失败时计数器归零;并发执行时计数器存在竞态条件。所有这些都是非确定性的来源。
修复方案也很简单:Hook 不维护状态。提醒型 Hook 每次都提醒,阻断型 Hook 每次都阻断。状态管理是 CLAUDE.md 或 Rules 的职责,不是 Hook 的职责。
反模式 4:阻断合法操作导致工作流中断
比如一个 Hook 阻断所有对 package.json 的修改。结果很明显:Claude 无法安装依赖、无法更新版本号、无法修复 vulnerability。开发者不得不频繁禁用 Hook,最终 Hook 形同虚设。
修复方案:降级为提醒型 Hook,不做阻断。或者在 PreToolUse 中只对高风险字段做提醒,在 Stop Hook 中做全局检查。
Hook 测试策略
单元测试:固定输入验证退出码
每个 Hook 必须有独立的单元测试脚本。测试框架无需复杂,几个 bash 函数就够了。核心是 assert_block 和 assert_pass 两个函数,分别验证阻断和放行场景。正向测试覆盖每个匹配模式,反向测试覆盖相似但不匹配的输入,边界测试覆盖空 JSON、null 字段等异常情况。
测试覆盖矩阵
每个 Hook 的测试用例必须覆盖三个维度:正向测试(应该阻断),每个匹配模式至少 1 个,最少 2 个用例;反向测试(应该放行),相似但不匹配的输入至少 1 个,最少 2 个用例;边界测试(异常输入),空输入、null、缺失字段,最少 1 个用例。
集成测试:与 Claude Code 的端到端验证
单元测试验证的是 Hook 逻辑的正确性,集成测试验证的是 Hook 在 Claude Code 运行时中的实际行为。集成测试需要手动执行,覆盖阻断验证、放行验证、故障降级验证、禁用验证四个维度。
CI 中的 Hook 测试
将 Hook 单元测试集成到 CI pipeline 中,每次推送或 PR 时自动验证所有 Hook 的行为一致性。配置一个 GitHub Actions workflow,安装 jq,然后遍历运行所有测试脚本。
Hook 故障模式与降级
故障分类
Hook 可能遇到的故障类型有五种:语法错误(Hook 无法启动,频率低,Claude Code fail-open 放行,检查失效);运行时崩溃(set -e 触发,中途中断,频率中,同样 fail-open 放行);执行超时(Hook 挂起无响应,频率低,超时后放行);逻辑错误(正常执行但判断错误,频率高,按错误结果执行,误阻断或误放行);依赖缺失(jq 等工具不可用,频率中,Hook 报错,放行)。
降级层级
正常运行时,PreToolUse 做模式检查,PostToolUse 做增量验证,Stop 做全局检查。故障时,PreToolUse fail-open 放行所有调用,兜底靠 Permission System(工具级权限仍在);PostToolUse 跳过,兜底靠 CI/CD pipeline(事后验证);Stop 跳过,兜底靠 git diff 加人工 review。这里的设计决策是 fail-open 而非 fail-closed,因为 Hook 是附加控制层,不是核心依赖。故障时回退到无 Hook 状态,而非锁定所有操作。
逻辑错误的监测
语法错误和运行时错误会导致 fail-open,影响可控。但逻辑错误更危险——Hook 正常执行但判断错误。监测策略这里不展开,但需要引起重视。
Hook 复杂度评估模板
每个 Hook 上线前用标准评估流程过一遍:基本信息、复杂度评分、确定性审查、可解释性审查、可回滚性审查、性能审查、测试状态。全部通过才能上线。
Hook 审计模板
审计记录需要包含基本信息、输入格式、输出格式、匹配规则、禁用方法、变更历史、误判记录。这些信息对于后续的维护和排查非常关键。
部署分层策略
Hook 的部署应该从低风险到高风险逐步升级,不要跳层。第一层是记录型,事件用 PostToolUse,行为是记录工具调用到日志文件,风险为零,在项目开始使用 Claude Code 时部署,目的是建立行为基线,为后续规则制定提供数据。第二层是提醒型,事件用 PostToolUse 和 Stop,行为是输出提示信息不改变行为,风险低,在记录型运行 2~4 周后部署,目的是改善 Claude 的工作习惯,验证规则准确性。第三层是窄规则阻断型,事件用 PreToolUse,行为是 exit 2 阻断特定操作,风险中,规则范围只覆盖明确的危险操作(.env, rm -rf, --force),在提醒型验证无误后部署,目的是保护不可逆操作。第四层是宽规则阻断型,事件用 PreToolUse,行为是 exit 2 阻断大范围操作,风险高,规则范围覆盖整个目录、整个工具类别,在窄规则稳定运行 3 个月后部署,目的是全面安全合规。
跳层的后果很直接:直接部署第三层而没有经过第一二层,规则中的误判会直接阻断合法操作,团队对 Hook 体系的信任会受损。
Hook 与 CI/CD 的边界
Hook 和 CI/CD 是互补的两层防护,不重叠也不替代。Hook 在工具调用前后执行,毫秒级反馈,覆盖单次工具调用,做模式匹配,在开发者本地运行,可靠性依赖脚本质量。CI/CD 在代码推送后执行,分钟级反馈,覆盖完整代码变更,可以做任意深度的检查,在 CI 服务器上运行,可靠性依赖 CI 配置。
分工原则很简单:Hook 做不了深度检查(静态分析、安全扫描)的,交给 CI;CI 做不了实时拦截(阻止当前操作)的,交给 Hook。Hook 检测"改了不该改的文件",靠模式匹配,毫秒级;CI 检测"代码有没有安全问题",靠语义分析,分钟级。各司其职,互不干扰。
季度治理检查清单
每个季度做一次 Hook 治理检查,覆盖有效性、复杂度、文档、测试四个维度。有效性要看阻断率是否合理(>10% 过宽,=0% 可能不工作),误判率是否可控(误阻断 ≤ 5 次/月),是否有新增的需保护的操作。复杂度要看是否有 Hook 超过 50 行、复杂度评分超过 20、新增了外部依赖、正则超过 3 个。文档要看每个 Hook 是否有审计记录,上次审查日期是否在 3 个月内,禁用方法是否仍然有效。测试要看单元测试是否通过,是否覆盖了近期的规则变更,CI pipeline 是否包含 Hook 测试。
交叉参考
- 22 Hooks 入门:Hook 系统架构、事件列表和执行流程
- 23 PreToolUse 防护:PreToolUse Hook 的完整实现和阻断模式
- 24 PostToolUse / Stop 验证:工具执行后的自动验证和会话摘要
- 25 Subagent Hooks:子袋里上下文注入和结果收集
- 33 组织治理:团队级别的 Claude Code 治理框架
权衡
Hook 太少,治理不足;Hook 太多,系统脆弱。一个只保护 .env 的 15 行 Hook,比一个试图分类所有文件的 300 行 Hook 更有价值。四原则互相强化:小的 Hook 更容易确定,确定的 Hook 更容易解释,可解释的 Hook 更容易回滚。反过来,复杂的 Hook 引入不确定性,不确定的行为难以解释,无法解释的 Hook 不敢回滚。保持小,其他三条原则自然跟上。
