在 .claude/settings.json 的 hooks 字段里写项目钩子,这份提交给小组;只在自己电脑上试的,写 .claude/settings.local.json,不提交;所有项目共用的,写 ~/.claude/settings.json。PreToolUse 在工具执行前触发,matcher 匹配工具名,例如 Bash、Read、Write、Edit。
会在三种情况下打开这一页:要拦住一个危险动作(读 .env、提交密钥)→ 抄「一份配置,一份脚本」,再照五步对照实验验一遍;钩子配了却没反应 → 看「钩子配了却没反应的五种现象」;从 Cursor 或 TraeCode 迁钩子配置过来 → 看「钩子在三个工具里的配置差异」。
最容易写错的是「拒绝方式」和「失败时放不放行」。拒绝有两种写法,选一种:退出码 0 并在标准输出输出 permissionDecision 为 deny 的 JSON;或者退出码 2,把原因写到标准错误。其他退出码不会拦住。脚本崩溃或超时默认放行——要拦住,必须显式把 failClosed 设成 true;不写就等于没设这条钩子。
把脚本临时改成 sys.exit(3),那个危险动作还被拦住吗?
答「没拦住」,说明 failClosed 没写成 true。脚本崩溃时默认放行,这条钩子等于没设。
钩子在工具真正执行之前跑,所以它能看到参数、能改主意。它拦不拦得住只取决于两件事:脚本给出的是哪种退出结果,以及失败时敲定放行还是拦住。
| 脚本的结束方式 | 结果 | 原因写在哪 |
|---|---|---|
退出码 0,标准输出输出 permissionDecision 为 deny 的 JSON | 拦住这次工具调用 | JSON 的 permissionDecisionReason 字段 |
退出码 2 | 拦住这次工具调用 | 标准错误 |
退出码 0 且没有 deny 输出 | 放行 | 无 |
| 其他退出码 | 放行 | 无 |
| 脚本崩溃或超时 | 默认放行;显式设 failClosed: true 才拦住 | 无 |
true,脚本崩溃或超时才不放行。不写就是放行。0 配标准输出 JSON,2 配标准错误。选一种,不要混用。"$CLAUDE_PROJECT_DIR"/.claude/hooks/....py 定位脚本,不要写相对路径。配好不等于生效。每季度做一次下面这五步对照实验,十分钟,验的是这一层到底存不存在。
.env,或直接跑 cat .env。/tmp/hook.log 追加一行,跑完 cat /tmp/hook.log,应看到新增的一行。sys.exit(3),再试一次;failClosed 为 true 时应被拦住,为 false 时放行。.env.example,应通过。如果也被拦,说明脚本用了子串判断,回去改成精确比对。边界:钩子只看得到这次工具调用的参数。助手用 shell 重定向绕过文件写入钩子时,钩子看不到——所以 matcher 要把 Bash 一并纳入,在命令串里检查受保护路径。拿不准的字段(例如 timeout 的默认值),在脚本里加一段 sleep 跑一次实测,不要凭猜。
三套钩子最容易混的三点都在表里:配置文件叫什么、事件名大小写、脚本失败时放不放行。写错不报错,只是不触发或不拦截。迁之前先对一遍,迁完照上面那五步对照实验跑一次。
| 项 | Claude Code | Cursor | TraeCode |
|---|---|---|---|
| 项目配置 | .claude/settings.json 的 hooks 字段(不是 hooks.json) | .cursor/hooks.json,version: 1 | .trae/hooks.json,version 目前只用 1 |
| 本地/不提交 | .claude/settings.local.json | 无对等文件 | 无对等文件 |
| 用户级 | ~/.claude/settings.json | 无对等文件 | ~/.trae-cn/hooks.json(本机所有项目共用) |
| 事件名 | PreToolUse | preToolUse(小写 p) | PreToolUse |
| matcher | 匹配工具名,如 Bash|Write|Edit | 配置里不写 matcher,判断逻辑放进脚本 | matcher 是正则,匹配工具名 RunCommand、Read、Write、Edit |
| 配置形状 | 两层:matcher 加 hooks: [{type, command}] | command 与 failClosed 平铺,无 matcher、无嵌套 | 两层:matcher 加 hooks: [{type, command, timeout}] |
| 拒绝方式 | 退出码 0 且标准输出输出 permissionDecision: deny 的 JSON;或退出码 2(原因写标准错误) | 退出码 2;或退出码 0 且标准输出输出 {"permission":"deny","user_message":…,"agent_message":…} | 退出码 0 且标准输出输出 permissionDecision: deny 的 JSON;或退出码 2(标准错误交给模型) |
| 其他退出码 | 不拦截 | 默认放行 | 不拦截 |
| 脚本崩溃/超时 | failClosed 默认 false,须显式设 true 才不放行 | failClosed 默认 false,须显式设 true | 没有 failClosed;脚本崩溃等于没设这个钩子 |
| 超时 | 不写 timeout 时用默认值;在脚本里加一段 sleep 跑一次,看它多久被跳过 | 同上,用一个 sleep 脚本实测一次 | timeout 不写默认 30 秒 |
| 执行目录 | 仓库根(命令里用 "$CLAUDE_PROJECT_DIR" 定位脚本) | 仓库根 | 在脚本里打一行 pwd 日志,跑一次看输出 |
| 额外能力 | permissions.deny 里可写 Read(.env) 直接禁止读取该文件名,.env.example 不受影响 | 无对等写法 | 多个项目根同时打开时,会合并各项目里已启用的钩子 |
本课只提交 .claude/settings.json 一处。跨工具协作时,事件名不要互相抄——PreToolUse 抄进 Cursor 的 hooks.json 不会触发,反过来也一样。
permissions.deny 里的 Read(.env) 直接禁止读取这个文件名,.env.example 仍可读取。钩子再看终端命令和写入,拦住 cat .env、git add .env 这类调用。脚本需要可执行权限,先跑 chmod +x .claude/hooks/block-env.py,并确认本机有 python3。
matcher 与 hooks 是两层:matcher 在上一层匹配工具名,内层每个对象是 type 加 command。failClosed: true 让脚本崩溃或超时时不放行,配完照「让脚本故意报错」那一步验一次。
{
"permissions": {
"deny": [
"Read(.env)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-env.py",
"failClosed": true
}
]
}
]
}
}
用 os.path.basename(...) == ".env" 精确比对文件名,避免把 .env.example 一起拦下。保存后执行 chmod +x .claude/hooks/block-env.py。
#!/usr/bin/env python3
import json, os, sys
data = json.load(sys.stdin)
def mentions_env(value):
if isinstance(value, dict):
return any(mentions_env(v) for v in value.values())
if isinstance(value, list):
return any(mentions_env(v) for v in value)
if isinstance(value, str):
for part in value.replace("\\", "/").split():
if os.path.basename(part.strip("\"'")) == ".env":
return True
return False
if mentions_env(data):
json.dump({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "密钥只放在 .env,不能读,也不能提交。"
}
}, sys.stdout, ensure_ascii=False)
sys.exit(0)
这份脚本正常结束时退出码是 0,靠标准输出的 JSON 表达拒绝。脚本抛异常时会以非零退出码结束,此时拦不拦由上面的 failClosed 决定——这正是要显式设 true 的原因。
钩子的失效几乎都不报错。下面五种前三种是没触发或不拦截,后两种是拦多了或拦错层。按「现象 → 原因 → 修法」对号入座,改完跑一次对照实验确认。
| 现象 | 原因 | 修法 |
|---|---|---|
| 钩子配了但没触发,脚本一次没跑 | 事件名写成了 preToolUse(小写 p),Claude Code 用的是 PreToolUse;写错不报错,只是不触发 | 改成 PreToolUse;在脚本开头向 /tmp/hook.log 追加一行,跑一次再 cat /tmp/hook.log 确认 |
在仓库里另建了 hooks.json,改 .env 时没有任何拦截 | Claude Code 的钩子在 .claude/settings.json 的 hooks 字段里,单独的 hooks.json 不被读 | 把配置搬进 .claude/settings.json,再跑 python3 -c "import json;json.load(open('.claude/settings.json'))" 确认 JSON 合法 |
| 脚本抛异常,工具照样执行,密钥还是读到了 | failClosed 默认是 false,脚本崩溃等于放行 | 在该钩子对象里显式写 "failClosed": true;把脚本临时改成 sys.exit(3) 验证确实被挡住 |
所有带 .env 字样的读取都被拦,读 .env.example 也被挡住 | 脚本用 ".env" in value 这类子串判断,.env.example 里含 .env | 改成精确比对:os.path.basename(part.strip("\"'")) == ".env" |
matcher 写了却完全匹配不到工具,钩子从不运行 | matcher 放到了错误的层级,或只匹配 Bash 而实际是 Read 在读文件 | matcher 与内层 hooks 是两层,matcher 在上一层;写成 "Bash|Read|Write|Edit" 覆盖读、写与命令 |