主流 coding agent 各有不同的 deny-list 语法和机制,文章详细梳理了各工具实际读取的配置文件及正确配置方式,防止 .env 泄露。
你的 agent 以你的身份运行。如果你能够 cat .env,它也可以。它并不需要是恶意的:一次为了获取上下文的 grep,一次调试时的 env,一次附加了环境的堆栈跟踪。值最终会出现在别人服务器上的对话记录里。
拒绝规则阻止读取。一旦值已经进入进程,它无法阻止值泄露。把两个问题都记在心里;这篇文章只讲第一个。
每个 harness 想要什么
两个模式,到处都是:.env 和 .env.*。不是 *.env,也不是展开的 .env.local。第二个才是关键:仅针对 .env 的规则对 .env.local、.env.production、.env.bak 毫无作用。
文件:.claude/settings.json(项目级)。两种机制,你两个都需要。
{
"permissions": { "deny": ["Read(./.env)", "Read(./.env.*)"] },
"sandbox": { "filesystem": { "denyRead": ["./.env", "./.env.*"] } },
"hooks": {
"PreToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "penv hook claude-code" }] }]
}
}
permissions.deny 由工具层检查。sandbox.filesystem.denyRead 在 macOS 和 Linux 上由操作系统沙盒强制执行。原生的 Windows 没有 Claude Code 沙盒,所以那里只适用拒绝规则和 hook。
还有一个用户级文件 ~/.claude/settings.json,你可以在里面列出要掩码的环境变量 {"name": KEY, "mode": "mask"}。penv 打印这个块并告诉你粘贴它;它不会写到你仓库以外的地方。
hook 在 stdout 上回复 permissionDecision: "deny" 并 exit 0。搞错这个,hook 会被忽略。
文件:.codex/config.toml。
[sandbox_workspace_write]
deny_read = ["**/.env", "**/.env.*"]
[shell_environment_policy]
inherit = "core"
ignore_default_excludes = false
Glob,不是路径。**/ 是因为 Codex 从工作区根目录解析。shell_environment_policy 块的分量不亚于 deny:inherit = "core" 防止 Codex 把你的整个环境交给每个子进程。
两个文件。.cursor/cli.json:
{ "permissions": { "deny": ["Read(.env)", "Read(.env.*)"] } }
以及 .cursor/hooks.json:
{
"hooks": {
"beforeReadFile": [{ "command": "penv hook cursor", "failClosed": true }],
"beforeShellExecution": [{ "command": "penv hook cursor", "failClosed": true }]
}
}
failClosed: true 是关键的那一行。没有它,一个崩溃的 hook 就是一个放行的 hook。
文件:.github/copilot/permissions-config.json。
{ "permissions": { "deny": ["Read(**/.env)", "Read(**/.env.*)"] } }
坦白说,而且 penv 自己的模板注释里也写了:Copilot 尚未发布这个文件的 schema。上述形状是直到他们发布之前的最佳努力。
文件:.gemini/settings.json。Hook 在 run_shell_command、PreToolUse 上。和 Claude Code 思路相同,只是 matcher 名称不同。
文件:.amp/settings.json。
{ "amp.guardedFiles.allowlist": [] }
Amp 反转了模型:默认对文件进行保护,你来允许例外。一个空的 allowlist 意味着没有任何文件豁免。penv 只在这个文件不存在时才写入它,所以你已经整理好的 allowlist 不会被触碰。
文件:.clinerules/hooks/PreToolUse。一个两行的 /bin/sh 脚本:
#!/bin/sh
exec penv hook cline "$@"
必须可执行。拒绝时输出到 stderr,exit 2。
文件:.windsurf/hooks.json。两个 hook:pre_run_command 和 pre_read_code,都指向 penv hook windsurf。
我第一次搞错的三件事
hook 必须是二进制文件,不是脚本。缺少解释器或者版本不对时,node hook.js 或 python hook.py 会直接放行。penv hook <harness> 是 penv 二进制本身本身;整个集合里唯一的脚本是 Cline 的两行垫片,因为那是 Cline 唯一接受的形态。
拒绝回复不统一。Claude Code 和 Cursor 希望决策在 stdout 上并 exit 0。其他所有工具都想要 stderr 和 exit 2。在两个流上都回复,或者都不回复,你就是在赌这个 harness 怎么处理。
空的 stdin 是唯一的放行。如果 hook 无法解析 payload 但本来有东西可以匹配,它就拒绝。始终 fail closed。
penv guard 对这一切做了什么
它不会在你的机器上写八个文件。它探测已安装了什么:.claude 或 ~/.claude 文件夹、PATH 上的 codex,以此类推遍历每一个。然后只为那些写了 guard,而且只为那些。
$ penv guard
HARNESS INSTALLED FILE STATUS
claude-code yes .claude/settings.json written
codex yes .codex/config.toml written
penv guard --check 显示同样的表但不写入,如果任何内容过期则非零退出,所以它可以作为 CI 步骤。penv guard cursor 针对单个。penv guard --all 为 penv 知道的所有 harness 写入配置,适合模板仓库。
合并是按文件而非按 harness。JSON 拒绝列表与你已有的内容做并集。TOML 和 Cline 脚本是追加去重。Amp 是存在则替换。两个不变量在每次渲染时都经过测试:模式恰好是 .env 和 .env.*,从不是文件名列表;渲染出的 guard 中不能包含 .env.local(列举文件名正是你漏掉一个的方式)也不能包含 .env.schema(schema 是 agent 应该读取的那个文件)。
拒绝列表做不到的那部分
一旦值进入了你的进程,没有任何拒绝规则能帮上忙。这是另一半:penv run -- your-app 在 exec 时注入值,并且在检测到 agent 在驱动时,用 14 种编码掩码化子进程 stdout 和 stderr 中的每个 secret(raw、hex、三种阶段的 base64、URL 编码、JSON 转义)。那是另一篇文章的事了。
penv 打印的安全声明,逐字来自 penv guard --check:
penv validates your .env, keeps values out of your agent's output, and blocks it from reading the file where its harness allows.
"where its harness allows" 在那句话里是真正干活的。就是这篇文章讲的内容。
penv 是 MIT 协议,一个静态 Rust 二进制文件,本地模式不需要账号。
OSS: https://github.com/penvhq/penvhq Install: curl -fsSL penv.cloud/install | sh or npm i -g @penvhq/cli
如果你的 harness 不在列表里,或者这些格式里有任何一个不对,请在评论区指出。guard 文件夹是数据,所以修复就是一个 PR 到一个 guard.toml。