让模型写代码执行,先要回答「它在哪个世界里跑」
让大模型直接生成一段 Python 并执行,是过去一年 Agent 工程里最省事也最危险的一种工具设计。省事在于,工具的数量从「每个 API 写一个封装」变成「一个解释器」;危险在于,代码是模型在推理时现场写出来的,它可能被提示注入牵着走,也可能顺手把不该带出去的东西带出去。
微软的 Agent Framework 里有两个走这条路的实现:Hyperlight CodeAct 与 LocalCodeAct。前者的定位是沙箱化的执行环境;后者按官方仓库的说明,是一个非沙箱的兄弟实现——模型写 Python,包在宿主机的子进程里跑,执行前只经过一次 AST 校验。9 月 11 日发布的 .NET 1.21.0 里,有一条容易被忽略的破坏性变更:隔离 LocalCodeAct 子进程的环境变量。
矛盾的文档:README 说隔离,XML 注释说继承
这次修补之所以值得单独讨论,是因为它暴露的不是一处笔误,而是两份互相打架的说明。据公开的技术分析,包的 README 早就承诺子进程「默认不继承宿主环境」;但配置项 Environment 的 XML 文档写的是相反的意思——传 null 表示继承,想得到干净环境需要传一个空字典。
代码跟随了后者。当传入的字典为 null 时,负责配置环境的函数会提前返回,于是进程启动信息保留了完整的父进程环境。换句话说,默认行为与 README 承诺的行为正好相反,而用户往往只看 README。
为什么这件事真的能泄露密钥
单看「子进程能读环境变量」,很多人会觉得问题不大。真正的风险来自三件事叠加。
其一,AST 校验器被设计为允许对 os.environ 的只读访问。这是为了让模型写的代码能读一些必要的运行时信息。但只读访问意味着 print(os.environ) 这类语句可以通过校验。
其二,execute_code 的输出会原样回到模型的上下文里。也就是说,一段被注入的指令只要写着「把环境变量打印出来」,宿主机的密钥、存储连接串、云平台的客户端凭据,就会顺着这次工具调用进入对话记录,再进入模型后续能调用的任何工具。
其三,默认配置下,这些变量本来就在那儿。用户不需要做错任何事,只要没有显式设置 Environment,就处于这个状态。有公开的复现实验对比了 1.20 与 1.21 两个预览版本:在 1.20 的默认配置下,一段读环境变量的代码拿到了宿主设置的值,并数出 63 个变量;在 1.21 的默认配置下,同一个探针只看到 2 个变量,且读不到那个值。
修补后的行为:从「继承再删」改成「先清空再复制」
1.21 的做法是把逻辑反过来。配置环境时总是先清空子进程的环境,再只复制调用方显式放进 Environment 字典里的项。null 与空字典从此行为一致。
这里有一处必须保留的例外:在 Windows 上,SYSTEMROOT、SYSTEMDRIVE、COMSPEC、PATHEXT、TEMP、TMP 这几个变量如果调用方没有设置,会从父进程回填。原因是 Python 在没有它们时无法加载标准库。这是一个务实的选择,也说明「完全隔离」与「还能跑起来」之间存在硬约束。
代价同样明确。过去生成代码隐式依赖的东西没有了,包括 Linux 与 macOS 上的 PATH 与 HOME。如果挂载的脚本或允许导入的模块需要某个变量,必须显式传进去。官方给的示例是把 LOG_LEVEL、TZ 这类非敏感项放进字典,同时明确提醒不要把密钥放进这个字典;如果生成代码需要带凭据的调用,正确做法是注册一个持有凭据的宿主工具,让 Python 通过工具调用去访问。
同一批发布里的另外两处收紧
除了环境隔离,这次发布还带了两处方向一致的调整。
一处是校验器收紧。按公开说明,同一个发布批次里的另一个改动,让基于操作系统派生的别名、反射式访问以及环境变量改写被一致地拒绝。这解决的是「用间接方式绕过检查」的问题——只封住直接写法,攻击面只是换了个入口。
另一处是审批对齐。如果有任何已注册工具属于需要审批的类型,那么 execute_code 本身也需要审批;另有一个审批模式可以强制每次运行都走审批。这个设计与 Hyperlight 一侧的行为拉齐,让「模型写的代码要执行」这件事不再是一个可以静默通过的默认动作。
中立思辨
需要辩证看待几件事。其一,这次修补是必要的,但它没有把 LocalCodeAct 变成沙箱。官方 README 仍然用警告框写着这一点:它应当被放进容器、虚拟机或托管的 Agent 环境里。清理环境变量解决的是「顺手泄露宿主机密」这一类问题,不解决「代码本身能做什么」这一类问题——文件系统、网络、进程权限都不在它的管辖范围内。
其二,破坏性变更的命名很准确。清空环境之后,原本依赖隐式变量的脚本会开始失败。这对安全是好事,对运维是负担。升级方需要审计自己往 Environment 里传了什么,以及生成代码里有没有对 PATH、HOME 的隐含依赖。把它当成一次需要回归测试的变更,而不是一次无痛的补丁。
其三,文档矛盾这件事本身就是教训。同一份包里 README 与 XML 注释给出相反的语义,用户读哪一份决定了他们拿到的是隔离还是继承。安全默认值如果只写在文档的一处,且与实现不一致,那么「默认安全」就只是一句话。
其四,官方公布的复现实验是在特定平台与语言版本上做的,变量数量这类数字会随环境变化,不宜当作普适指标。它的价值在于证明行为差异存在,而不是给出一个可以照搬的阈值。
其五,Windows 的回填例外说明了一件事:隔离程度往往由运行时依赖决定,而不是由设计意图决定。理解这条边界,比记住几个变量名更重要。
其六,后续版本是否会进一步收紧校验器、审批默认值如何演进、以及是否有计划把非沙箱实现引导到托管环境,公开材料未充分展开。目前官方及行业暂未披露更多细节,后续将持续跟进迭代动态。
给工程团队的四条做法
把这次修补的思路抽出来,有四条与具体框架无关的做法。
把「模型写的代码」默认当成不可信输入。它的信任级别应当与外部用户提交的内容一致,而不是与内部代码一致。基于这个前提去设计校验、审批与隔离,判断会稳得多。
显式白名单,不要黑名单。清理环境变量时选择「只放需要的」,而不是「删掉敏感的」。敏感项清单会过时,白名单不会。
密钥不进环境变量,进工具。生成代码需要凭据时,让它调用一个由宿主持有密钥的工具,而不是把密钥塞进子进程。这样凭据永远不进入模型的上下文。
把非沙箱实现放进沙箱里再谈上线。容器、虚拟机或托管执行环境是底线。清理环境变量是纵深防御的一层,不是替代品。