ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Claude Code Hooks实战:格式化、安全与测试的6个配置

Claude Code Hooks实战:格式化、安全与测试的6个配置 如果你已经开始用 Claude Code 处理日常编码任务大概率遇到过这样的局面它写得很快但改完的代码格式化风格跟项目规范完全不是一回事它偶尔会提出一个看起来很合理的 bash 命令但你没仔细看就批准了测试它也提可更多时候是建议你“自己跑一下试试”。这些问题不是模型能力不够而是缺少一套强制性的机制在工具调用链路里卡点。Hooks 就是干这个的。这篇文章我会用 6 个可直接抄走的配置把代码格式化、安全防护和自动测试这三件事焊死在 Claude Code 的工作流里涵盖 PreToolUse、PostToolUse、Stop、Notification 等触发时机和完整脚本适合所有用 Claude Code 写代码、并且希望少一点失控感的人。先说清楚一件事hook 不是让你在 prompt 里多写几行“请遵守项目规范”而是在 Claude 调用工具Bash、Edit、Write、Read的前后挂上外部脚本。脚本不满足条件工具调用就直接被拦下。这种“硬约束”和“软提示”的区别就是为什么很多人配置完 hooks 之后代码合入 CI 的一次通过率明显提升。1. Hooks 到底解决了什么问题先看三个最常见的失控现场1.1 失控现场一格式化规则全凭心情我用 Claude Code 做过一个小型 TypeScript 项目。模型默认的代码风格跟项目里 prettier 配置不能说一模一样只能说是各写各的。单引号、双引号混用对象末尾逗号时有时无缩进偶尔从两个空格跳成四个。最头疼的是它每次 Edit 只改一小块格式化问题被分散在十几个文件里肉眼根本盯不过来。你可以在系统提示词里写“请始终使用项目 prettier 配置”但模型记不住每一条规则的细节更不会在每次写入前主动跑一遍格式化。等 CI 跑完报错再回头修一个下午就没了。Hooks 的正确姿势是在文件落盘之后、或者写入之前由外部脚本强制执行 prettier不让模型的“个人风格”有机会进入代码库。1.2 失控现场二危险命令说跑就跑Claude Code 的 Bash 工具权限很大。它可能因为你的某句“清理一下项目”就执行rm -rf node_modules这还算可控但它也可能在改依赖时顺手执行npm install --unsafe-perm或者在你没注意的时候往~/.bashrc里追加内容。AI 没有“这个操作影响范围是否超出当前项目”的常识它只有“用户让我完成目标”的指令。我见过有人被 Claude 连续执行了git push --force覆盖远端提交也见过它在排查问题时把.env里的密钥cat到了对话上下文里这些事后都很不好收拾。Bash 类的 hook 就是最后一道闸门命令在执行前先过一遍规则命中风险项直接阻止并告诉 Claude 为什么不行。1.3 失控现场三测试永远“我建议你跑一下”另一个让我比较无语的行为模式是Claude 改完代码它的收尾往往是“测试已更新建议你运行npm test验证”。如果你不追问它就当你已经跑过了。偶尔它会主动跑但改一次跑一次全量测试几分钟就浪费在等待上。自动测试类的 hook 能解决两个层面一是强制改完代码后自动触发相关测试没有通过就继续修二是精准不是所有变更都跑全量测试而是根据变更文件反推对应的测试范围。后面我会给出具体的实现思路。2. 开工前必读settings.json 和 Hook 触发机制的基础2.1 配置文件放哪项目级与用户级Claude Code 的 hooks 配置写在settings.json里。项目级位置是.claude/settings.json用户级位置是~/.claude/settings.json。项目级配置随仓库走适合团队统一约束用户级配置只对本机生效适合放个人习惯类的 hook。我建议大部分自动化规则放项目级这样团队里任何人用 Claude Code 都会被同一套规则约束。需要说明的是项目级配置默认情况下对协作者可见你最好在 README 里写清楚每个 hook 的用途免得别人 clone 项目后第一次跑被拦截脚本吓一跳。2.2 六类事件与 matcher 匹配规则Hooks 的配置结构大致是{ hooks: { PreToolUse: [ { matcher: Edit|Write, hooks: [ { type: command, command: node .claude/hooks/check-format.js } ] } ] } }PreToolUse是事件名表示“工具执行之前”。官方支持的事件类型主要有这些事件名触发时机典型用途PreToolUse工具调用前拦截危险命令、检查写入内容PostToolUse工具调用后格式化、Lint、自动测试NotificationClaude 等待用户确认时桌面通知提醒StopClaude 回复生成完成运行完整校验、输出摘要SessionStart会话开始环境检查、项目信息注入UserPromptSubmit用户提交提示词时内容过滤、追加上下文PreCompact上下文压缩前保存任务进度摘要matcher是一段正则表达式用来限定 hook 作用于哪些工具或哪些调用。比如Edit|Write表示匹配编辑文件和新建文件操作Bash表示匹配所有 bash 命令也可以写成Bash\\(.*git.*\\)这类更精确的形态去匹配包含 git 的命令。2.3 退出码、stdin JSON 与超时三个决定成败的细节hook 命令执行时Claude Code 会通过 stdin 传入一段 JSON里面至少包含{ session_id: xxx, cwd: /home/user/project, hook_event_name: PreToolUse, tool_name: Bash, tool_input: { command: rm -rf node_modules } }脚本要做的就是读取这段 JSON然后根据tool_input内容决定返回什么退出码。我这里约定退出码0放行工具继续执行。退出码2阻止工具执行stdout 内容会返回给 Claude让它知道被拦的原因。其他非 0 退出码表示 hook 自身出错Claude Code 会记录 warning但不会强制阻止工具。所以拦截类逻辑务必用2。还有一个容易被忽略的是timeout。hook 命令默认超时时间是 60 秒超过会被终止。如果你的自动测试脚本可能要跑几分钟一定要在 hook 配置里显式调大{ type: command, command: node .claude/hooks/run-tests.js, timeout: 120 }另外hook 脚本的 stdout 和 stderr 会被 Claude Code 捕获并放进模型上下文。这意味着你可以在脚本里输出给模型看的提示信息但不要打印一堆无关日志否则会白白消耗 token还会干扰模型对工具调用结果的理解。3. 前两个配置用 PreToolUse 和 PostToolUse 把格式化和 Lint 焊死在编辑动作上3.1 配置一文件落盘后自动格式化不用再跟模型强调“别用双引号”这是我在所有项目里第一个配的 hook。先给结论用 PostToolUse 而不是 PreToolUse 做格式化。原因在于调用时序。PreToolUse 发生在 Claude 的 Edit/Write 工具真正写入文件之前这时你拿到的file_path是目标路径但文件内容还没写入或者写入的是旧版本。如果你在这个时机去prettier --write格式化的是旧文件等 Edit 执行完新内容覆盖上去格式化又被冲掉了。所以正确做法是 PostToolUse。脚本在 Edit/Write 完成后拿到文件路径立刻执行格式化命令#!/usr/bin/env node // .claude/hooks/format-on-write.js const fs require(fs); const { execSync } require(child_process); let input ; process.stdin.on(data, (chunk) (input chunk)); process.stdin.on(end, () { try { const payload JSON.parse(input); const filePath payload.tool_input?.file_path; if (!filePath) process.exit(0); const ext filePath.split(.).pop(); const supported [ts, tsx, js, jsx, json, css, md]; if (!supported.includes(ext)) process.exit(0); // 跳过 node_modules 和生成目录 if (filePath.includes(node_modules) || filePath.includes(dist)) process.exit(0); execSync(npx prettier --write ${filePath}, { cwd: payload.cwd, stdio: pipe, }); console.log([format] ${filePath} 已按项目 prettier 配置格式化); process.exit(0); } catch (e) { // 格式化失败不阻断工具调用避免恶性循环 console.error([format] 格式化失败: ${e.message}); process.exit(0); } });配置代码里注册它{ hooks: { PostToolUse: [ { matcher: Edit|Write, hooks: [ { type: command, command: node .claude/hooks/format-on-write.js } ] } ] } }你可能会担心一个问题格式化之后的文件内容和 Claude 在上下文里看到的“刚写入的内容”不一致。实际影响不大因为 Claude 下次读取文件时读到的是格式化后的内容它自然会基于这个版本继续改。还有一个坑是 Windows 环境。npx prettier --write里的路径如果有特殊字符引号转义容易出问题。建议所有 hook 脚本都用 Node.js 写避免直接依赖 bash 语法。后面所有示例我都用 Node。3.2 配置二Lint 结果自动回流给模型从源头减少“改完又错”的来回格式化解决的是风格Lint 解决的是“代码有没有明显问题”。我在这个 hook 里跑的是 ESLint并且只针对 Claude 改过的文件不做全量扫描。#!/usr/bin/env node // .claude/hooks/lint-on-write.js const { execSync } require(child_process); let input ; process.stdin.on(data, (chunk) (input chunk)); process.stdin.on(end, () { try { const payload JSON.parse(input); const filePath payload.tool_input?.file_path; if (!filePath || filePath.includes(.test.)) process.exit(0); try { const out execSync( npx eslint ${filePath} --max-warnings0 --format compact, { cwd: payload.cwd, stdio: pipe } ).toString(); // 无错误则静默退出 process.exit(0); } catch (e) { const output e.stdout?.toString() || ; // 只输出错误摘要最多截取 1500 字符避免刷爆上下文 const summary output.split(\n).slice(0, 20).join(\n).slice(0, 1500); console.error([lint] ESLint 检测到问题请修复后再继续: \n${summary}); process.exit(0); } } catch (e) { process.exit(0); } });注意这里我让 exit code 保持 0没有用 2 阻止写入。为什么不拦截因为有些 lint 错误是结构性的Claude 需要先写入代码、看到报错、再修复这是一个迭代过程。如果你在写入时就把它拦住模型会陷入“不知道代码哪里有问题”的困境。更好的做法是把错误信息喂给它让它自己判断怎么改。--max-warnings0这参数很有用。它把 warning 也当作 error 处理防止项目里积累一大堆“不痛不痒”的警告。对 Claude 这种大模型来说警告太多会稀释注意力宁可让 hook 直接暴露出来。有人会问eslint 的--fix能不能直接放在 PostToolUse 里自动修可以但建议单独跑。因为--fix可能改出模型意料之外的结果尤其是一些涉及代码结构的规则。我的经验是格式化可以自动lint 修复尽量让模型自己来否则它下次可能重复犯同样的错。4. 中间两个配置给 Bash 命令套上安全围栏给写入内容加上敏感信息闸门4.1 配置三危险命令黑名单 项目目录白名单双管齐下Bash hook 是整个安全体系里最重要的一环因为 Claude Code 的大多数破坏性操作都是通过 Bash 完成的。我的拦截脚本分两层第一层是黑名单直接命中关键词就阻止。这里列几个我实测下来比较实用的规则const BLOCKED_PATTERNS [ /rm\s-rf\s\//, // 删除根目录 /rm\s-rf\s~/, // 删除用户目录 /mkfs\./, // 格式化磁盘 /git\spush\s.*--force/, // 强推 /curl.*\|\s*(ba)?sh/, // curl 管道执行脚本 /npm\sinstall\s-g\s.*--unsafe/, /chmod\s-R\s777/, /sudo/, ];第二层是白名单思路这个更重要。对rm、mv、chmod这类具有破坏性的命令我会校验目标路径是否在项目目录内。路径不在项目内直接阻止。实现得并不复杂关键在于用 Node 的path.resolve把相对路径转成绝对路径再做前缀比较。// .claude/hooks/guard-bash.js const path require(path); let input ; process.stdin.on(data, (chunk) (input chunk)); process.stdin.on(end, () { try { const payload JSON.parse(input); const command payload.tool_input?.command || ; const cwd payload.cwd; for (const pattern of BLOCKED_PATTERNS) { if (pattern.test(command)) { console.error([guard] 命令命中危险规则: ${pattern}\n已阻止执行。如果你确实需要执行请手动在终端操作。); process.exit(2); } } // 对 rm/mv 做路径范围检查 if (/^(rm|mv|chmod)\b/.test(command)) { const unsafe command .split(/\s/) .filter((arg) arg.startsWith(/) || arg.startsWith(~) || arg.startsWith(../)) .some((arg) { const abs path.resolve(cwd, arg); return !abs.startsWith(path.resolve(cwd)); }); if (unsafe) { console.error([guard] 检测到目标路径超出当前项目目录已阻止执行。); process.exit(2); } } process.exit(0); } catch (e) { process.exit(0); } });这个脚本的思路是“默认信任但有限制”。Claude 在项目里跑npm install、git diff、ls这类命令基本不受影响一旦碰到影响范围超出项目的操作就会被拦截。exit 2的关键在于Claude 能看到 stderr 里的提示它会自己调整方案。我见过一个有意思的案例Claude 想把日志写到/tmp/debug.log被这个 hook 拦了。它看到提示后改为写到项目下的.logs/debug.log还顺手把.logs/加进了.gitignore。这说明给模型一个“为什么不行”的反馈比单纯阻止更有效。4.2 配置四敏感信息检测防止密钥被写入代码或进入对话上下文Claude 在写代码时偶尔会“好心”把真实密钥写进.env文件旁边或者在测试代码里硬编码一个 API Key。更隐蔽的是它可能在排查问题时直接cat .env把密钥读进上下文然后这些内容就可能出现在日志里。敏感信息 hook 我配在 PreToolUse分别拦截两类场景Edit/Write检查tool_input.content和file_path如果发现密钥特征阻止写入。Bash检查命令里是否有读取敏感文件或把敏感信息写入文件的操作。// .claude/hooks/guard-secrets.js const SENSITIVE_PATTERNS [ /sk-[A-Za-z0-9]{20,}/, // OpenAI / Anthropic 风格 key /AKIA[0-9A-Z]{16}/, // AWS Access Key /ghp_[A-Za-z0-9]{36,}/, // GitHub Token /BEGIN (RSA|EC|OPENSSH) PRIVATE KEY/, // 私钥块 ]; const SENSITIVE_FILES [.env, .env.local, .pem, id_rsa, id_ed25519]; let input ; process.stdin.on(data, (chunk) (input chunk)); process.stdin.on(end, () { try { const payload JSON.parse(input); const tool payload.tool_name; const inputData payload.tool_input || {}; if (tool Edit || tool Write) { const content inputData.content || ; const filePath inputData.file_path || ; const fileBasename path.basename(filePath); if (SENSITIVE_FILES.includes(fileBasename) SENSITIVE_PATTERNS.some((p) p.test(content))) { console.error([guard] 检测到疑似敏感信息被写入已阻止。请改用环境变量或 .env.local 维护密钥。); process.exit(2); } } if (tool Bash) { const command inputData.command || ; if (SENSITIVE_FILES.some((f) command.includes(cat ${f}) || command.includes(cat ./${f}))) { console.error([guard] 阻止读取敏感文件避免密钥进入上下文。); process.exit(2); } } process.exit(0); } catch (e) { process.exit(0); } });这个 hook 的难点在于误报控制。比如.env里本身可以不写密钥只放配置项名称项目文档里也可能出现类似sk-xxx的示例占位符。解决方案是只拦截“文件路径本身很敏感且内容命中密钥特征”的情况。普通代码文件里出现sk-开头的测试占位符我选择放行因为那很可能是 mock 数据。配好之后我建议你在测试环境故意触发一次确认拦截生效、模型能被正确引导。如果发现误报就调整正则的严谨度不要因为“宁可不拦也不误判”而把规则关掉安全这种事儿宁可多拦几次。5. 最后两个配置文件变更后自动跑测试、任务完成时主动提醒5.1 配置五PostToolUse 精准触发相关测试而不是傻等全量执行自动测试最简单的实现是监听PostToolUse的Edit|Write事件文件一变就跑npm test。但全量测试在稍大一点的项目里可能要好几分钟Claude 每改一次文件就触发一次交互体验会非常差。我采用的策略是根据文件路径缩小范围// .claude/hooks/test-on-change.js const { execSync } require(child_process); const path require(path); let input ; process.stdin.on(data, (chunk) (input chunk)); process.stdin.on(end, () { try { const payload JSON.parse(input); const filePath payload.tool_input?.file_path || ; const cwd payload.cwd; // 只对 src 下的业务代码触发 if (!filePath.startsWith(src/)) process.exit(0); if (filePath.includes(.test.) || filePath.includes(.spec.)) { // 改动的是测试文件直接跑这个测试 execSync(npx vitest run ${filePath} --reporterdot, { cwd, stdio: pipe, timeout: 60000, }); console.error([test] ${path.basename(filePath)} 测试通过); } else { // 改动的是业务代码跑相关测试 execSync(npx vitest run --changed --reporterdot, { cwd, stdio: pipe, timeout: 60000, }); console.error([test] 相关测试通过); } process.exit(0); } catch (e) { console.error([test] 相关测试失败请查看上面的报错并修复\n${e.stdout?.toString().slice(0, 1000)}); process.exit(0); } });如果你用的测试框架不是 vitest思路完全一致Jest 可以用jest -o只跑发生变更的文件相关的测试其他框架可以通过 git diff 计算变更文件再传给测试命令。这里有两个细节值得说。一是timeout我给的是 60 秒因为测试命令本身要预留执行时间如果你在 hook 配置里又设了一个更小的 timeout顶层的会先生效导致命令被提前 kill。所以建议两处都设成一致。二是失败时不要 exit 2。为什么因为测试失败不代表代码写入是错误的Claude 需要先完成这次工具调用、看到测试失败的反馈然后进行下一轮修复。如果我们把 exit 2 当作“阻止工具执行”相当于 Claude 写了一个代码但因为测试没过就不让它写这会陷入奇怪的状态。测试 hook 的核心价值是“反馈”不是“阻断”。每次测试失败Claude 都会在下一轮尝试修复直到通过。5.2 配置六Stop 和 Notification 事件把等待时间变成可控提醒最后两个配置解决的是“人机协作时的通知”问题。用 Claude Code 时它经常会停下来等人批准一个 bash 命令或者问一个问题这时候如果你切到别的窗口可能很久都不知道需要你确认。Notification 事件能在这里触发一个桌面通知。// .claude/hooks/notify-done.js let input ; process.stdin.on(data, (chunk) (input chunk)); process.stdin.on(end, () { try { const payload JSON.parse(input); const event payload.hook_event_name; const title event Notification ? Claude Code 需要确认 : Claude Code 待办提醒; // macOS const { execSync } require(child_process); try { execSync(osascript -e display notification 请查看 Claude Code with title ${title}); } catch (e) { // Linux try { execSync(notify-send ${title} 请查看 Claude Code); } catch (_) {} } process.exit(0); } catch (e) { process.exit(0); } });注册到 Notification 事件即可。至于 Stop 事件我把它用作“任务收尾检查”。Claude 每次回复完成后这个 hook 会检查一下 git 状态如果有未格式化的文件直接输出提醒让它主动处理。// .claude/hooks/stop-check.js const { execSync } require(child_process); let input ; process.stdin.on(data, (chunk) (input chunk)); process.stdin.on(end, () { try { const payload JSON.parse(input); const cwd payload.cwd; const changed execSync(git status --porcelain, { cwd, stdio: pipe }).toString(); const unformatted changed .split(\n) .filter((line) /\.(ts|js|tsx|jsx|json|css|md)$/.test(line) line.startsWith( M )); if (unformatted.length 0) { console.error( [check] 以下文件有修改但可能未格式化如果确认已完成所有任务请运行 prettier --write 处理\n${unformatted.slice(0, 5).join(\n)} ); } process.exit(0); } catch (e) { process.exit(0); } });这个 hook 不会阻止任何操作只是提供一个“事后提醒”。它的价值在于解决 Claude 的“总觉得自己干完了”问题——每次回复完它都能看到还有哪些代码处于脏状态从而决定是否继续收拾。6. 完整配置汇总与排错实录一份可直接抄走的 settings.json6.1 六合一配置示例把上面六个 hook 合并到项目.claude/settings.json里大概长这样{ hooks: { PostToolUse: [ { matcher: Edit|Write, hooks: [ { type: command, command: node .claude/hooks/format-on-write.js }, { type: command, command: node .claude/hooks/lint-on-write.js }, { type: command, command: node .claude/hooks/test-on-change.js, timeout: 60 } ] } ], PreToolUse: [ { matcher: Bash, hooks: [ { type: command, command: node .claude/hooks/guard-bash.js }, { type: command, command: node .claude/hooks/guard-secrets.js } ] }, { matcher: Edit|Write, hooks: [ { type: command, command: node .claude/hooks/guard-secrets.js } ] } ], Notification: [ { matcher: .*, hooks: [ { type: command, command: node .claude/hooks/notify-done.js } ] } ], Stop: [ { matcher: .*, hooks: [ { type: command, command: node .claude/hooks/stop-check.js } ] } ] } }请把format-on-write.js、lint-on-write.js、guard-bash.js、guard-secrets.js、test-on-change.js、notify-done.js、stop-check.js这几个脚本放到.claude/hooks/目录下。文件路径按你项目实际情况调整。6.2 排错实录我踩过的四个坑第一个坑是 matcher 写太宽。我一开始把 PreToolUse 的 matcher 写成.*结果 Claude 每次调用任何工具都要跑一遍 guard 脚本虽然脚本本身很快但大量 JSON 解析和正则匹配拖慢了整体交互。后来改成了Bash才清爽。第二个坑是 hook 脚本里忘了读 stdin。Claude Code 通过 stdin 传入 JSON如果你的脚本不读 stdin 直接开始执行是拿不到tool_input的。最稳妥的写法就是我上面反复用的那套process.stdin.on(data)累积后在end事件里处理。第三个坑是 exit code 语义混淆。早期我把拦截函数写成process.exit(1)结果工具并没有被阻止只是 Claude Code 报了个 warning。后来查文档确认PreToolUse 场景下必须用exit(2)才会真正拦截。所以拦截类逻辑请务必记住2。第四个坑是 Windows 下路径和 shell 双引号问题。在 Windows 上用npx prettier --write ${filePath}如果路径里带空格Node 的execSync会解析出错。我的解决办法是统一用spawnSync搭配参数数组避免 shell 转义或者把路径中的空格做转义处理。最简单的方案是让所有脚本都用 Node 编写尽量不依赖 shell 特殊语法。6.3 还能怎么扩展这 6 个配置是我的基础配置你可以按需升级。比如在SessionStart事件里注入一个“当前项目测试命令”的提示让 Claude 一开始就知道用什么命令跑测试。在UserPromptSubmit事件里检查 prompt 里是否包含“忽略所有规则”这类注入尝试遇到可疑内容直接拦截。在PreCompact事件里把当前未完成的任务摘要保存到文件上下文压缩后 Claude 还能记得之前做到哪一步。我个人在实际操作中的体会是hooks 配置完成后最明显的变化不是“代码变好了”而是“规则冲突变少了”。格式化、安全、测试这三件事从“需要时刻盯着”变成了“系统自动守门”我只需要在 Claude 被 hook 拦住时看一眼原因然后决定是调整规则还是让它换个方案。这种“定好规则再放手”的开发方式才是 Claude Code 这类工具真正让人放心的用法。最后再分享一个小技巧所有 hook 脚本里那两行process.stdin.on(data)的读取逻辑是同一个套路建议你封装成一个readPayload()公共函数放到hooks/util.js里每个脚本都 require 它。这样以后新增 hook 时代码能少写一大半也方便统一处理异常。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表