本页判断标准

把脚本临时改成 sys.exit(3),那个危险动作还被拦住吗?

答「没拦住」,说明 failClosed 没写成 true。脚本崩溃时默认放行,这条钩子等于没设。

什么时候拦得住

钩子在工具真正执行之前跑,所以它能看到参数、能改主意。它拦不拦得住只取决于两件事:脚本给出的是哪种退出结果,以及失败时敲定放行还是拦住。

脚本的结束方式结果原因写在哪
退出码 0,标准输出输出 permissionDecision 为 deny 的 JSON拦住这次工具调用JSON 的 permissionDecisionReason 字段
退出码 2拦住这次工具调用标准错误
退出码 0 且没有 deny 输出放行无
其他退出码放行无
脚本崩溃或超时默认放行;显式设 failClosed: true 才拦住无
failClosed 默认值
false
必须显式设 true,脚本崩溃或超时才不放行。不写就是放行。
拒绝的退出码
0 或 2
0 配标准输出 JSON,2 配标准错误。选一种,不要混用。
脚本定位方式
$CLAUDE_PROJECT_DIR
命令在仓库根执行。用 "$CLAUDE_PROJECT_DIR"/.claude/hooks/....py 定位脚本,不要写相对路径。

配好不等于生效。每季度做一次下面这五步对照实验,十分钟,验的是这一层到底存不存在。

造一次违规在会话里让助手读 .env,或直接跑 cat .env。
看是否被拦预期看到拒绝原因「密钥只放在 .env,不能读,也不能提交。」没出现就是没触发,先查事件名大小写。
核对脚本真的跑过在脚本开头向 /tmp/hook.log 追加一行,跑完 cat /tmp/hook.log,应看到新增的一行。
让脚本故意报错把脚本临时改成 sys.exit(3),再试一次;failClosed 为 true 时应被拦住,为 false 时放行。
试一个不该拦的读 .env.example,应通过。如果也被拦,说明脚本用了子串判断,回去改成精确比对。

边界:钩子只看得到这次工具调用的参数。助手用 shell 重定向绕过文件写入钩子时,钩子看不到——所以 matcher 要把 Bash 一并纳入,在命令串里检查受保护路径。拿不准的字段(例如 timeout 的默认值),在脚本里加一段 sleep 跑一次实测,不要凭猜。

钩子在三个工具里的配置差异

三套钩子最容易混的三点都在表里:配置文件叫什么、事件名大小写、脚本失败时放不放行。写错不报错,只是不触发或不拦截。迁之前先对一遍,迁完照上面那五步对照实验跑一次。

项Claude CodeCursorTraeCode
项目配置.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(本机所有项目共用)
事件名PreToolUsepreToolUse(小写 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。

.claude/settings.json

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
          }
        ]
      }
    ]
  }
}

.claude/hooks/block-env.py

用 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" 覆盖读、写与命令
对应知识点 这一页只是 钩子 在 Claude Code 里的落地写法。通用规则看 钩子(第一层 · 上下文);规则挡不住的动作才交给钩子,规则写法见 规则;指令类文件自身的保护见 指令与配置保护。