钩子(Hook)是在智能体执行动作之前或之后挂上的一段脚本,适合必须拦住的事,例如读取或提交 .env。项目配置写在 .cursor/hooks.json 里,version 写 1,事件名用小写的 preToolUse。配置里不写 matcher,判断逻辑放进脚本:脚本从标准输入读 JSON、向标准输出写 JSON,从仓库根目录执行。
三种情况下会打开这一页:要把「不许读 .env」这类红线做成硬拦截 → 照「示例:拦住 .env」抄 hooks.json 与脚本,先跑一遍下面的本地命令,再交给助手;钩子配了但从来不触发,或者脚本崩了危险动作照做 → 到「常见错误」的现象表里对一行,多半是事件名大小写或漏了 failClosed: true;从 Claude Code 或 TraeCode 搬过来 → 逐行核「钩子在三处」。
两处写错都是静默的:事件名写成 PreToolUse(大写 P),Cursor 永远不触发;漏了 failClosed: true,脚本崩溃或超时时默认放行,这个钩子等于没配。
在对话里故意让助手读一次 .env,脚本会在动作发生前把它拦住吗?
答「不确定」,说明配完之后一次都没真踩过。合并这个钩子之前,先在终端自己触发一次拒绝,看到那句提示再合。
说明性的约定仍写在 AGENTS.md 里,脚本不负责讲道理。钩子只回答一个问题:这次动作放不放行。Cursor 的配置里 command 与 failClosed 是平铺的两项,没有 matcher,也没有嵌套的 hooks 数组——要按工具名区分,就把判断写进脚本。
{"permission":"deny", …},再返回 0。failClosed: true。command 和 failClosed: true;把判断逻辑(工具名、路径)放进脚本,读标准输入的 JSON;返回 permission: deny 或退出码 2。
PreToolUse;在 hooks.json 里写 matcher;不设 failClosed;把脚本放到仓库外,或写相对位置不确定的路径。
钩子从仓库根目录执行,所以 command 里的相对路径都相对仓库根。先在仓库根跑一次 python3 --version:解释器不在,脚本根本起不来,效果等同于没配。什么时候不用钩子:这件事只需要提醒、不需要拦住时,把它写进 AGENTS.md 或规则就够了。
钩子是三套工具差异最大的一层:配置文件放在哪、事件名怎么写、有没有 matcher、失败时放不放行,四项全不一样。从别的工具搬过来时逐行核这张表。
| 项 | 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 不受影响 | —— | 多个项目根同时打开时,会合并各项目里已启用的钩子 |
| 已知副作用 | —— | 文档正文里出现该文件名时也可能被拦住,此时把判断收窄到路径字段 | —— |
按影响大小核对三处最容易混的地方:事件名大小写(只有 Cursor 是小写,写错静默不触发)、失败时是否放行(两家要显式 failClosed: true,TraeCode 根本没有这个字段)、配置文件位置(只有 Claude Code 写在 settings.json 里)。
用 preToolUse 在工具真正执行前拦一次这次调用。脚本把文件名恰好是 .env 的调用拒掉,.env.example 照常放行。文档正文里如果出现这个文件名也可能被拦住,那时再把判断收窄到路径字段。
version 为 1,事件名小写 preToolUse,只有 command 与 failClosed 两项。
{
"version": 1,
"hooks": {
"preToolUse": [
{
"command": "python3 .cursor/hooks/block-env.py",
"failClosed": true
}
]
}
}
从标准输入读这次调用的 JSON,命中 .env 时向标准输出写拒绝 JSON,最后返回 0;没有命中就直接返回 0 放行。
#!/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({
"permission": "deny",
"user_message": "密钥只放在 .env,不能读,也不能提交。",
"agent_message": "block-env: 拒绝访问 .env"
}, sys.stdout, ensure_ascii=False)
sys.exit(0)
本机没有 python3 时,把 command 换成可用解释器的绝对路径。要拦第二类文件时复制一份这个脚本,只改 mentions_env 里的判断条件,其余照抄。
钩子的失效大多是静默的:配置看着对,动作照做。从现象反查。
| 现象 | 原因 | 修法 |
|---|---|---|
钩子配了但没触发,因为事件名写成了 PreToolUse,Cursor 用的是小写的 preToolUse | 事件名大小写敏感,写错不报错也不触发 | 把 hooks.json 里的事件名改成小写 preToolUse |
| 脚本报错或超时后,危险动作照常执行 | failClosed 默认 false,其他非 0 退出码默认放行 | 在配置里显式加 "failClosed": true |
在 hooks.json 里写了 matcher 想只拦某类工具,没有任何效果 | Cursor 的钩子配置里不写 matcher,判断逻辑要放进脚本 | 删掉配置里的 matcher,改在脚本里读标准输入的 JSON,按工具名或路径判断 |
把钩子配置写进了 .claude/settings.json 或另建了一个别处的 hooks.json,Cursor 不认 | Cursor 读的项目钩子文件是 .cursor/hooks.json | 把配置移到 .cursor/hooks.json,version 写 1 |
文档正文里提到 .env 也被拦住,助手读不了文档 | 脚本对整个 JSON 做字符串匹配,正文里的文件名也被命中 | 把判断收窄到路径字段,只匹配路径结尾是 .env 的项 |