在Claude Code中将对.env文件的Read权限拒绝后,通过已授权的Bash命令(grep/cat)仍可读取敏感内容,权限规则按工具独立隔离。
我把 .env 加到了拒绝列表里,觉得这样就万无一失了,然后回去继续工作。
一个小时后,我问 Claude Code 为什么测试运行器里 DATABASE_URL 是 undefined。它尝试用 Read 工具读取 .env,被拒绝了,说「我无法直接读取那个文件」,然后就在 Bash 里跑了 grep DATABASE_URL .env。几周前我就把 Bash(grep:*) 放进了允许列表,因为每次搜索都要点「是」太烦人了。我的生产连接字符串就这样在对话记录里滚过去了。
什么都没坏。Claude Code 的拒绝规则干的就是它们被设计来干的事。我只是误以为它们还能干别的事。
Claude Code 的拒绝规则是按工具划分的。Read(./.env) 阻断的是内置的 Read 工具。它挡不住 cat .env、grep KEY .env,或者通过 Bash 运行的 python -c "open('.env')"。
Bash 权限匹配的是命令字符串,不是文件。Bash 规则永远不知道一个命令会访问哪些文件。
路径语法也是个坑。权限规则里,/path 是相对于设置文件的路径,//path 才是绝对路径,而 ./.env 只匹配项目根目录的 .env。
修复要分层:正确设置文件工具的 glob、在 Bash 上加一个 PreToolUse hook 来拦截提及 .env 的命令、以及对于真正的密钥——别把它们放在 agent 工作目录里。
Claude Code 的拒绝规则阻止特定工具对特定目标执行操作。权限规则的形状是 Tool(specifier),而 specifier 对每个工具含义不同。
Read(...) 和 Edit(...) 接受文件路径模式,写法类似 gitignore。
Bash(...) 接受命令字符串模式,比如 Bash(npm run test:*)。
WebFetch(...) 接受域名,比如 WebFetch(domain:example.com)。
规则检查顺序是:先 deny,再 ask,最后 allow。所以同一个工具里 deny 永远优先于 allow。这一块运作正常。
关键在于「同一个工具」这几个字。Read 的拒绝规则是对 Read 工具的声明。它对 Bash 工具没有任何影响,而 Bash 运行的是任意程序,任意程序可以打开你用户账户能访问的任何文件。
因为 Bash 是另一个工具,有另一套规则命名空间。当 Claude 运行 grep DATABASE_URL .env 时,Claude Code 是把你的命令字符串拿去和你的 Bash(...) 规则匹配的。你的 Read(./.env) 规则根本不会被查询。
我坐下来把 agent 读取文件的每种无聊方式都试了一遍,全部在一个项目里——Read(./.env) 被拒绝,常见的 Bash 命令被允许:
六种方式里只有一种会经过我写的规则。另外五种完全取决于我有没有仔细看每个 Bash 提示。当天下午批准到第四十个的时候,我已经不看了。
说句公道话,Claude 并没有在偷偷摸摸。它在调试,Read 工具失败了,而 grep 是一个模型回答我问题时的自然下一步。越过单个工具的失败正是你对一个 agent 的期望——直到那个文件是你的密钥。
Bash(cat .env) 这种 Bash 拒绝规则能解决问题吗?不能。Bash 规则匹配的是命令文本,所以它只挡住一种写法,其他全都漏掉了。Bash(cat .env) 对以下情况毫无作用:
less .env
head .env
tail .env
awk 1 .env
while read l; do echo "$l"; done < .env
cd config && cat ../.env
Claude Code 文档自己就警告过,试图用 Bash 模式约束参数是脆弱的,并给了一个 curl URL 的例子——可以通过重新排列参数或使用变量来绕过。按拼写来拒绝文件名是同样的问题。你写的是你机器上每个程序的反向白名单。
这是我的第二个错误,而且更隐蔽,因为规则看起来是对的。文件规则遵循 gitignore 风格的模式,有四种锚点:
如果你从终端复制一个绝对路径粘贴成 Read(/Users/me/app/.env),你写的是相对于设置文件所在位置的路径。看起来没问题但什么也匹配不到。
而 ./.env 只匹配根目录的文件。在一个存在 apps/api/.env 和 apps/web/.env.local 的 monorepo 里,这两个它都覆盖不到。你需要 ** 模式:
{
"permissions": {
"deny": [
"Read(**/.env*)",
"Edit(**/.env*)"
]
}
}
这也会拦住 .env.example,通常没问题。如果需要让 .env.example 可读,Claude 仍然可以从你的配置加载器代码里获取变量名。
用三层。每一层覆盖前一层的漏洞。
第一层:正确设置文件工具的 glob。 就是上面的 JSON。这能拦住整个目录树下 Read 和 Edit 工具。
第二层:在 Bash 上加一个 PreToolUse hook。 Hook 在命令运行前看到完整命令,可以拦截它,而且它适用于你之前允许过的命令。把它保存为 .claude/hooks/block-env.sh 并 chmod +x:
#!/usr/bin/env bash
# Block Bash commands that mention a .env file.
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]_])\.env'; then
echo "Blocked: this command touches a .env file. Ask the user for the specific value you need." >&2
exit 2
fi
exit 0
然后在 .claude/settings.json 里注册:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-env.sh"
}
]
}
]
}
}
这个正则能捕获 .env、./.env、../.env、.env.local 和 < .env,但不捕获 process.env,因为那前面是字母。stderr 消息会返回给 Claude,所以它通常会停下来问我而不是尝试第六种变体。那条消息是在干正事的。告诉模型该怎么做,而不只是说「不行」。
第三层:把真正的密钥放在够不着的地方。 这个 hook 是字符串匹配。cat .e""nv、base64 编码的文件名、或者内部加载 dotenv 的脚本都能绕过它。Claude 不会尝试这些,但字符串匹配不能保证它不会。所以对于真的会造成危害的凭证:
把生产值放在工作目录外面。使用密钥管理 CLI 在进程启动时注入变量,只把开发值留在仓库的 .env 里。
在从不挂载该文件的容器或 VM 里运行 agent。进程读不到不存在的东西。
不一定,但要清楚你换来了什么。Bash(grep:*) 意思是「无需询问地 grep 任何东西、任何位置」,包括你的 home 目录、~/.aws/credentials 和 ~/.ssh。在一个沙盒化的边项目里这是合理的交换。在一个生产 .env 放在根目录的仓库里,这就不太好了。
我目前的设置:只在临时仓库里允许宽泛的 Bash 命令。在真实项目里,hook 是开着的,真正的密钥放在目录树外面,而且我会真的去看涉及点文件的提示。
部分能。Read(**/.env*) 这种 Claude Code 拒绝规则能阻止内置的 Read 和 Edit 工具,但它阻止不了 Bash,因为 Bash 权限匹配命令字符串,永远不知道一个命令会打开哪个文件。你批准过的或预先允许过的任何 cat、grep、head 或 python 命令仍然可以读取那个文件。还要检查你的路径锚点:/path 是相对于设置文件的,//path 才是绝对路径。要真正保护密钥,需要把文件工具的 ** glob 拒绝规则、Bash 上拦截提及 .env 命令的 PreToolUse hook、以及对于真正敏感的东西——把文件完全放在 agent 工作目录之外——这三层结合起来。
作者是 Preterview 的开发者,Preterview 是一个面试准备平台。