项目钩子(Hook)写在 .trae/hooks.json,只对当前项目生效;本机所有项目共用的写在 ~/.trae-cn/hooks.json。拦住 .env 是仓库约定,把配置放进项目文件,提交进组里的仓库。配置里 version 目前只用 1;一组钩子有 matcher 和 hooks;type 只用 command;脚本从标准输入读 JSON;timeout 不写时默认 30 秒。
有三种情况会打开这一页:想让助手读不到 .env → 抄「示例:拦住 .env」的两份文件,再走「怎么知道它真的在拦」的四步;钩子配好了却一次都没触发 → 直接对「没生效时的现象与修法」;从 Cursor 或 Claude Code 转过来,想知道 matcher 和事件名换成什么 → 看「钩子在三个工具里的差别」。
写错最多的是三处,先记住。一,TraeCode 没有 failClosed 字段。脚本一旦崩溃或超时,这个钩子就等于不存在,不是「拦住」,也不是「放行后报错」——这是「钩子写了但形同虚设」最主要的原因。二,matcher 是正则,匹配的是工具名。这里的工具名是 RunCommand / Read / Write / Edit,写成 Bash 不会报错,只是永远不触发。三,把约定放错位置。放进 ~/.trae-cn/hooks.json,换一个同学就没了。
在对话里让助手把 .env 的内容贴出来,它能不能被挡住?
答「能贴出来」,这条钩子等于没设。TraeCode 没有 failClosed,脚本崩了、超时了都算放行。先单独跑一次脚本做对照,再去查 matcher。
钩子只有两个出口:拦住,或放行。写的配置都围绕这两件事:脚本怎么表达拦住,以及脚本出问题时算哪一边。
| 项 | TraeCode 的写法 | 写错的后果 |
|---|---|---|
| 项目配置 | .trae/hooks.json | 放到别处,当前项目不生效 |
| 用户级配置 | ~/.trae-cn/hooks.json,本机所有项目共用 | 小组约定只在自己电脑上 |
version | 目前只用 1 | —— |
| 事件名 | PreToolUse,在工具执行前触发 | 大小写写错不会报错,只是不触发 |
matcher | 是正则,匹配工具名:RunCommand、Read、Write、Edit | 写成 Bash 之类别的名字,钩子永不触发 |
| 配置形状 | matcher 配 hooks: [{type, command, timeout}] 两层 | 层级放错,钩子不执行 |
type | 只用 command | —— |
| 输入 | 脚本从标准输入读 JSON | 读不到输入还继续往下判断,容易崩 |
| 拒绝方式 | 退出码 0 并在标准输出输出 permissionDecision 为 deny 的 JSON | 只 print("deny") 不够,要按 JSON 结构输出 |
| 另一种拒绝 | 退出码 2,把原因写在标准错误,交给模型 | —— |
| 其他退出码 | 不拦截 | 脚本抛异常退出码为 1,等于放行 |
| 超时 | timeout 不写默认 30 秒;超时等于没拦住 | 脚本里做网络请求,超时后形同虚设 |
字段不用背:建一条钩子,跑一遍「怎么知道它真的在拦」的四步,实测一次就知道了。先看最后两行:TraeCode 没有 failClosed 字段,也没有「脚本失败时按拒绝处理」的开关,所以脚本必须自己不崩溃——下面示例里用 try/except 保证这一点。
最容易混三处:文件是不是 hooks.json、事件名的大小写、脚本失败时算哪一边。最后一行是这三家里差别最大的一条。
| 项 | 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",…} | 退出码 0 且标准输出输出 permissionDecision: deny 的 JSON;或退出码 2(标准错误交给模型) |
| 其他退出码 | 不拦截 | 默认放行 | 不拦截 |
| 脚本崩溃或超时 | failClosed 默认 false,须显式设 true 才不放行 | failClosed 默认 false,须显式设 true | 没有 failClosed;脚本崩溃就等于没设这个钩子 |
| 超时 | —— | —— | timeout 不写默认 30 秒 |
| 执行目录 | 仓库根(命令里用 "$CLAUDE_PROJECT_DIR" 定位脚本) | 仓库根 | —— |
| 额外能力 | permissions.deny 里可写 Read(.env) 直接禁止读取该文件名,.env.example 不受影响 | —— | 多个项目根同时打开时,会合并各项目里已启用的钩子 |
写完配置,先对照「事件名」与「脚本崩溃或超时」两行自查一遍。整体对照见TraeCode 总览的七处位置总表。
先确认本机有 python3。文件名恰好是 .env 就拒绝,.env.example 可以读。脚本遍历输入里的所有字符串值,所以不依赖具体的字段名;解析失败时它也输出 deny,避免崩溃被当成放行。
{
"version": 1,
"hooks": {
"PreToolUse": [
{
"matcher": "RunCommand|Read|Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 .trae/hooks/block-env.py",
"timeout": 30
}
]
}
]
}
}
它只有一件事要做:一旦拿不准,就让它输出 deny,绝不以异常退出。
#!/usr/bin/env python3
"""读工具调用参数,命中 .env 就拒绝。
TraeCode 没有 failClosed:脚本崩溃或超时,这条钩子就等于不存在。
所以任何一处不确定,都要走输出 deny 这条路,不要让它以异常退出。
"""
import json
import os
import sys
DENY = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "密钥只放在 .env,不能读,也不能提交。",
}
}
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
def main():
try:
data = json.load(sys.stdin)
except Exception:
json.dump(DENY, sys.stdout, ensure_ascii=False)
return 0
if mentions_env(data):
json.dump(DENY, sys.stdout, ensure_ascii=False)
return 0
if __name__ == "__main__":
sys.exit(main())
配好不等于生效。下面四步每季度做一次,十分钟能做完,验证的是「拦住」这件事到底存不存在。
.env,或让它把 .env 的内容贴出来。正常表现是它收到拒绝理由,读不到内容。echo '{"any": ".env"}' | python3 .trae/hooks/block-env.py,它应当输出带 "permissionDecision": "deny" 的 JSON;把 .env 换成 .env.example 再跑一次,应当没有任何输出。import time; time.sleep(40),再触发一次读 .env。过了 30 秒它会放行——这就是没有 failClosed 的直接后果。测完把那句删掉。docs/decisions/,下次换一个同学来做。第三步最容易跳过,但它解释了这个页面开头那句话:脚本出问题,钩子就不存在。所以脚本要短、要没有网络请求、要把异常都兜住。
钩子的失败几乎都不报错——它只是安静地不做事。照下面五条对号最快。
| 现象 | 原因 | 修法 |
|---|---|---|
脚本里明明写了 deny,助手照样读 .env | 脚本崩了:JSON 解析失败或文件路径不存在,以非零码退出,被当成放行;TraeCode 没有 failClosed | 用 try/except 包住解析,异常分支也输出 deny JSON 并 exit 0;见上方第三步的对照实验 |
matcher 写了 Bash|Write|Edit,钩子一次都没触发 | TraeCode 的工具名是 RunCommand、Read、Write、Edit;matcher 按正则匹配工具名,写成 Bash 不报错,只是不触发 | 改成 "matcher": "RunCommand|Read|Write|Edit",再用第一步的违规动作确认被拦 |
读 .env 有时被拦、有时放行 | 脚本超时。TraeCode 没有 failClosed,超时等于这个钩子不存在 | 让脚本保持轻量:不做网络请求、不扫大目录;timeout 写 30(秒)够用即可 |
| 自己电脑上拦得住,换一个同学的电脑就不拦 | 配置写进了 ~/.trae-cn/hooks.json,那是本机用户级目录,不进仓库 | 把配置放到项目的 .trae/hooks.json 与 .trae/hooks/block-env.py,一起提交 |
| 同时开了两个项目,另一个项目的关也被拦了 | TraeCode 会合并各项目里已启用的钩子 | 把判断条件收窄到明确的路径,不要只靠「文件名叫 .env」这种通用规则去命中其他项目 |