编程智能体曾如何间接读取你的 .env 文件,zn-gate 1.12.0 又如何关闭这些路径?

工程John Silver20 分钟阅读

对于 zn-gate 1.5.0 至 1.11.0,答案是肯定的,通过几种特定的方式。智能体 hook 会拒绝读取 .env 文件的那种显而易见的命令,但它按命令中书写的路径来判断,且相对于当前文件夹。通过另一条途径接触同一个文件的命令可以运行。1.12.0 版本在做决定之前先解析这些形式,检查链中的每条命令,并拒绝在时间限制内无法检查完的内容。该问题是 GHSA-rccq-h3xc-j9qj,严重程度为中等。zn 团队在一次内部审查中发现了它。

本文以与已发布公告相同的层次描述这些路径的类别、修复的设计,以及我们会对任何编写编程智能体防护的人说些什么。文中引用的命令仅限 1.12.0 更新日志中已公开的示例,不增加任何新形式,也不包含概念验证。在用户更新期间,完整的概念验证仍不公开。

zn-gate 的读取防护做什么?

zn-gate 在 Claude Code、Codex CLI、Cursor 和 OpenCode 中安装 hook。在智能体运行 shell 命令之前,hook 会收到命令文本并回答允许或拒绝。它的任务之一是拒绝会读取受保护文件的命令,使其内容不会进入模型。受保护文件包括含有明文值的 .env 文件,以及 ~/.aws/credentials 这样的凭据文件。

防护不运行命令。它读取文本,找出命令提到的文件名,然后做决定。这是对一种并非静态的语言所做的静态检查:shell 在运行时会展开、替换并重新解释文本。防护所见与 shell 所做之间的差距,正是下面每一条路径的来源。

zn env shadow 把其中的值替换为标记的 .env 文件,在同样意义上不再是受保护的目标。它只保存名称和 __ZN_<NAME>__ 形式的标记,所以通过任何路径读取都不会显示任何值。这就是影子化对后文很重要的原因。

防护漏掉了哪些路径?

公告描述了以下几种形式。下面按类别列出。

类别 出了什么问题 公开 changelog 中的示例
通配符 路径中的模式在检查前没有展开,所以防护去找一个以该模式为名的文件,什么也没找到 cat app*/.env
切换目录 检查从 hook 运行所在的文件夹解析路径,而不是从命令已切换到的文件夹 cd app && cat .env
命令运行时才构造的路径 变量或命令替换提供了路径的一部分,其值在检查时并不存在 以变量或命令替换开头的路径
shell 的某些引号和重定向形式 shell 有多种写同一字符串和以读方式打开文件的办法,防护只理解最简单的一种
存放在 git 中的副本 提交过一次或保存在索引中的 .env,即使磁盘上已没有,也可以从仓库中打印出来 git show HEAD:.env
很长的命令链,只检查了一部分 长命令链只检查了前面的命令,所以放在末尾的读取没有被检查,任何受保护文件都是如此
检查比 hook 超时更慢 每个智能体只等 hook 有限的时间;检查耗时超过该时间的命令未经检查就运行了

前五种是防护没有解析的、指称文件的方式。后两种性质不同:它们不是隐藏文件,而是让检查根本没有发生。长命令链和超时这两种形式适用于所有受保护文件,包括 .env 之外的凭据文件。

为什么通配符会让路径检查出问题?

因为防护和 shell 对文本含义的理解不一致。shell 在命令开始之前就把模式变成匹配的路径列表。把模式当作字面名称的防护,会问是否存在一个恰好叫这个名字的文件,答案是没有。于是命令被允许,shell 再把它展开成一个真实的文件。在 1.11.0 中,普通路径会被拒绝,而通过文件夹名称中的模式接触同一个文件则不会。

为什么切换目录很重要?

命令中的路径是相对于当前文件夹才有意义的,而命令可以在中途改变它。防护按智能体启动时所在的文件夹来解析所读的内容。一旦命令移动了位置,这个基准就错了。起始文件夹中已经被影子化的 .env 也是同样的情况:防护可能对被影子化的文件感到满意,而命令读取的却是另一个文件。

命令运行时才构造的路径有什么问题?

防护检查时,它们的值还不存在。链中前面设置的变量,或命令替换的输出,只有在 shell 运行时才变成文本。防护看到的是占位符,无法知道它将变成什么。

引号、重定向和 git 副本呢?

shell 接受同一字符串的多种写法,除了把文件名作为参数外,还有几种以读方式打开文件的办法。只规范化常见写法的防护会漏掉其他写法。git 路径是另一个观察:受保护的文件不止一个存放地点。大家想到的是工作区里的副本。已提交的副本和历史记录可以用 git 命令打印出来,即使文件被删除或被忽略,它们仍然存在。

为什么长命令链和慢检查比其他几种更要紧?

因为它们不取决于文件如何被指称。如果防护只检查长命令链的前几条命令,放在该点之后的内容就在检查之外。如果防护需要的时间超过智能体愿意等待的时间,智能体就不再等待,命令就会运行。两者都把一条对每个命令都正确的规则,变成了可以靠输入的形态关掉的规则。遵循提示注入的智能体可以产生这样的输入,而无需任何人手工编写。

影响是什么?

运行了这些命令之一的智能体,例如在读取提示注入之后,可能把受保护的文件打印到它的上下文中。这正是 hook 要防止的情形。CVSS 3.1 评分为 5.5,属中等,因为它需要本地智能体和用户操作,机密性影响高,而完整性和可用性不受影响。

有两个事实限制了它。已用 zn env shadow 影子化的文件没有通过 .env 路径暴露,因为它们只含有标记。其他凭据文件(~/.aws/credentials、~/.ssh/ 等)只通过长命令链和超时这两种形式暴露过。输出清理器是另一层,仍然会隐藏工具输出中已知格式的密钥,这减少了这些格式进入模型的内容。它是第二道防线,而不是防护的替代品:未知格式的密钥会通过。

1.12.0 如何关闭这些路径?

修复有四个部分。

1. 先解析再决定。 防护现在会在文本允许的范围内,计算 shell 会计算的内容,并检查结果。模式会相对于命令实际所在的文件夹展开。切换目录会更新后续命令的基准。由变量和替换构造的路径会尽可能解析,交给解释器或另一个 shell 的代码(bash -c "cat app/.env",或传给某种语言运行时的脚本)会被当作独立的命令来读取。同一字符串的多种写法和多种重定向形式,会被规范化为它们所打开的文件。从 git 仓库或其历史中读取受保护文件,会像从磁盘读取一样被拒绝,即使磁盘上已没有该文件。人们需要的清理命令仍然可用:git rm --cached .env、git restore --staged .env、git status 和 git check-ignore .env。

2. 检查每条命令。 早期版本只检查链中的前 1000 条简单命令(行也计算在内),其余的放行运行。1.12.0 检查链中的每条命令。

3. 拒绝无法及时检查的内容。 检查在远低于智能体 hook 超时的时间和磁盘预算内进行。需要解析的路径太多、超出预算的命令会被拒绝。智能体在不再等待之前就收到了回答,而回答是否定的。有两个细节起到支撑作用:具有受保护名称的 FIFO 或其他特殊文件会在不读取的情况下被拒绝,长命令中重复多次的名称只检查一次。

4. 以影子文件作为纵深防御。 正确解析路径是对 shell 的一种预测。zn env shadow 对它所覆盖的文件消除了对这种预测的依赖:磁盘上的文件保存的是 __ZN_API_KEY__ 这样的标记,zn 只在你允许的命令中放回真实的值。如果将来某条路径绕过了防护,它找到的也只是标记。

该版本还为保存在 ~/.ssh 之外(例如放在项目中)的私钥增加了保护:SSH 密钥名称、密钥库(.p12、.pfx、.jks、.ppk),以及含有私钥的 .pem、.key 或 .p8 文件。证书和公钥仍可读取。

1.12.0 中每条路径都有回归测试,并且全部通过。本文有意只使用公告中类别的确切名称,不再多说。如果你发现了另一条路径,请写信到 security@usezn.com。

对编写智能体防护的人,我们会说什么?

以下是我们得到的经验,按对设计的影响程度排序。

  1. 根据命令将要做的事来决定,而不是根据它写了什么。 shell 在运行前所做的每一次展开,都是文本与含义不一致的地方。把它们列出来,并对每一个决定是解析还是拒绝。
  2. 拒绝你无法解析的内容。 在可能指向受保护文件的路径中出现未解析的变量,不是放行的理由,而是要求智能体给出更简单命令的理由。
  3. 检查全部输入。 只检查前缀的防护有一条边界,而边界正是放置攻击的地方。不要对检查的命令数量设上限,或者让超过上限等于拒绝。
  4. 把时间当作策略的一部分。 弄清宿主智能体会为你的 hook 等多久,把自己的预算定得远低于它,并让超时意味着拒绝。在负载下放行的防护,只有在没人施压时才起保护作用。
  5. 一个文件有多个存放处。 磁盘、索引、历史、备份、构建缓存。如果规则针对的是内容,就要考虑内容可以从哪些地方读取。
  6. 增加一层不依赖解析器的防护。 解析 shell 很难,并且会一直有漏洞。让文件中不存在密钥(如影子化所做的),或在输出端隐藏已知格式,在解析器出错时仍然有效。
  7. 为类别写回归测试,而不只是为示例写。 第一个测试针对被报告的命令。真正有用的测试会问:还有什么与它共享同一原因。

你现在应该做什么?

  1. 更新软件包:

    npm install -g zn-gate@1.12.0
    pip install -U zn-gate
  2. 重新安装智能体 hook:

    npx -y zn-gate@1.12.0 init

    如果你使用 Claude Code 插件,待插件目录中列出 1.12.0 后从中更新。

  3. 在每个含有带值 .env 文件的文件夹中,把它影子化:

    zn env shadow .env
  4. 查看哪些智能体已安装 hook:

    zn-gate check

如果你现在无法更新,第 3 步可以覆盖 .env 路径。长命令链和超时这两种形式在旧版本中没有临时解决办法。

这没有涵盖什么?

  • 针对 shell 文本的静态检查总会有局限。有些命令不运行就无法解析,对这些命令,选择是拒绝。这可能会拒绝无害的命令,智能体需要改写它。
  • 防护涵盖经过智能体 shell hook 的读取。它不控制你自己启动的进程做什么。
  • 输出清理器隐藏已知格式的密钥。未知格式的密钥可能通过。
  • 本文没有列出每种形式的每一种写法。在用户更新期间,这是有意为之。
  • 针对 shell 文本的防护是护栏,而不是沙箱:它涵盖智能体常规工具所做的事,而坚决的进程可能找到另一种读取文件的方式。切断网络访问的智能体沙箱是有用的第二层。
  • 更新不会召回任何内容。在更新之前进入模型上下文的密钥会在该会话中保留,因此如果某个值可能曾被打印出来,请轮换它。

常见问题

哪些 zn-gate 版本可能让智能体间接读取受保护文件?

zn-gate 1.5.0 至 1.11.0。问题编号为 GHSA-rccq-h3xc-j9qj,严重程度为中等,已在 1.12.0 中修复。

我需要做什么?

用 npm install -g zn-gate@1.12.0 和 pip install -U zn-gate 更新,然后运行 npx -y zn-gate@1.12.0 init 重新安装 hook。在每个含有带值 .env 文件的文件夹中运行 zn env shadow .env。运行 zn-gate check 查看哪些智能体已安装 hook。

用 zn env shadow 保护的文件当时是否暴露?

没有通过 .env 路径暴露。被影子化的文件只包含 __ZN_NAME__ 形式的标记,所以通过任何路径读取它都不会泄露任何值。其他凭据文件,如 ~/.aws/credentials 和 ~/.ssh/,只通过长命令链和超时这两种形式暴露过。

1.12.0 之前的版本有临时解决办法吗?

对于 .env 路径,在每个含有带值 .env 文件的文件夹中运行 zn env shadow .env。长命令链和超时这两种形式在旧版本中没有临时解决办法,所以更新是对它们唯一的修复。

为什么 zn-gate 对无法及时检查的命令选择拒绝,而不是放行?

放行未能检查的内容的防护,会给攻击者一个关闭它的办法:让命令变慢或变长。拒绝的代价是智能体重试一次,放行的代价是失去保护。

在哪里可以阅读该公告?

GitHub 上的 GHSA-rccq-h3xc-j9qj。在用户更新期间,不公开完整的概念验证。


修复包含在 zn-gate 1.12.0 中(MIT 许可,本地运行,无需账号),该版本的其余内容见 zn-gate 1.12.0 新功能。关于如何让智能体接触不到你的 .env 的基础知识,请看如何用一条命令阻止 Claude Code、Codex CLI、Cursor 和 OpenCode 读取你的 .env。代码在 GitHub,每个智能体的指南见安装。

分享