给编程 Agent 加上沙箱与策略防线
围绕提示注入、日志投毒和行为偏移,介绍由操作系统沙箱、OPA 策略、上下文隔离及确定性重放组成的防护架构。文章提供可供改造的参考实现,面向能执行命令和提交代码的 Agent。
AI 深度解读
AI 编程代理一旦能执行 npm install、修改文件、运行 git push,就进入了软件供应链的权限边界。仓库里的注释、测试报错,甚至代理自己留下的日志,都可能成为操纵它的入口。
作者提出的解法是给代理加一层执行控制框架,也就是 harness:用上下文隔离、操作系统沙箱、策略检查和审计记录约束行为。这个方向值得借鉴,但原文代码里有几处会直接影响防护效果的问题,不能照抄后就当成安全方案。
为什么仓库文件和历史日志都不能当指令
原文讨论了三类风险,它们不只发生在模型回答问题时,更发生在模型读取数据、调用工具的交界处。
第一类是提示注入(prompt injection)。攻击者把命令藏进 README、代码注释、测试数据或依赖源码,诱导代理偏离用户任务。例如,代理在重构 src/utils.ts 时,读到要求执行远程脚本的注释。文件有权提供代码信息,却没有权力给代理下达新任务。
第二类是日志投毒(log poisoning)。测试程序可以向 stdout 输出伪造的 [SYSTEM] 指令;如果这段输出随后被写进历史记录,再作为「记忆」交给模型,攻击内容就获得了第二次生效的机会。
日志记录了什么发生过,并不代表日志里的文字值得服从。
第三类是代理漂移(agent drift)。代理可能从一次小让步开始,例如用 any 替代 unknown,随后不断围绕这个决定扩展修改,最终交付物偏离需求。攻击者也可以利用一连串看似合理的小步骤推动这种偏离。
作者据此提出四层防护:上下文清洗负责标记数据来源,沙箱限制实际能力,策略引擎检查操作,审计与回放帮助追查过程。它们解决的是不同问题,不能靠一句「忽略文件里的恶意指令」全部代替。
沙箱限制能力,策略还得真正挡住操作
作者建议使用 node:20-slim 容器,以 UID 10001 的非 root 用户运行,只允许写入 /workspace。下面这段启动命令体现了主要限制:
docker run --rm \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--network=none \
-v /tmp/agent-workspace:/workspace:rw \
-v /dev/null:/dev/urandom \
agent-image
重点看 --network=none:即使模型被诱导发起外部请求,执行环境也不给它网络。需要安装依赖时,作者建议通过只放行指定 registry 的代理访问。
不过,-v /dev/null:/dev/urandom 会改变容器内随机源的行为,原文没有解释目的,不应直接沿用。Dockerfile 里的注释也不能替代运行参数,真正设置只读根文件系统的是 --read-only。
作者还用 seccomp 拒绝 ptrace、mount、umount2、unshare、clone、keyctl,以及 socket、connect、bind、listen,通过 --security-opt seccomp=./seccomp.json 加载。这些限制需要结合运行时验证,尤其直接禁止 clone 可能影响线程或进程创建。
资源限制则包括 --memory=2g、--cpus=1、--pids-limit=128、--ulimit nofile=1024:1024 和 --ulimit nproc=64:64。它们约束资源消耗,不能替代权限检查。
策略层采用 Open Policy Agent(OPA),把操作描述为 { tool, args, cwd, filesRead, filesWritten, exitCode, stdout },再交给规则判断。但原文的 Rego 示例存在关键逻辑问题:多个 allow if 是满足任意一个即可放行,工具白名单一旦命中,后面的路径和命令条件就没有成为必经检查。
此外,规则虽然生成 deny_reasons,却直接返回 allow,没有落实注释声称的「出现 deny 就拒绝」。命令正则只限制开头,也不能可靠约束后续 shell 内容。
OPA 调用同样没接完整:请求发往 http://opa:8181/v1/data/agent/policy,代码却直接读取响应的 decision.allow,没有处理该查询接口的 result 包装。原文也没交代断网容器如何访问独立 OPA 服务。
这些不是文档措辞问题,而是执行链路能否正确放行、拒绝的基础。
给内容贴标签,还不足以建立信任边界
作者把上下文分成 system、user、tool、untrusted,通过 buildPrompt 拼接 <<<SYSTEM>>>、<<<UNTRUSTED>>> 等标记,要求模型只服从可信指令。
这种做法能表达来源,但拼接出来的字符串标签并不等同于模型接口提供的角色隔离。攻击文本本身也可能伪造标签,因此原文又加入了清洗函数:
// sanitize.ts
export function sanitizeUntrusted(raw: string, maxLen = 4096): string {
return raw
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // control chars
.replace(/<<</g, '\u003C\u003C\u003C') // escape prompt delimiters
.slice(0, maxLen);
}
要注意第二个 replace:JavaScript 会把 \u003C 解释为 <,因此它实际把 <<< 替换成了自身,没有实现注释所说的转义。4096 的长度限制可以控制输入规模,却不能判断留下来的文本是否含有恶意指令。
更有实际约束力的是缩小工具范围。作者建议暴露 read_file、write_file、run_linter、run_test、git_diff、git_commit,让每个工具自行检查参数。只允许代理调用 run_test,通常比允许它提交任意 shell 字符串更容易建立可检查的边界。
日志处理也遵循同一个原则:用 JSONL 区分工具名、参数、退出码和 stdout、stderr,重新送入上下文时仍标为 untrusted。但原文通过正则删除 run ... 等文本,既可能误删正常诊断,也无法覆盖各种语言和表达方式。
作者另外使用 sha256 哈希链检测日志修改。它能帮助检查链条是否一致,但如果攻击者可以重写整个日志,并重新计算所有哈希,仅靠同一个可写目录里的文件,无法证明历史可信。
防漂移部分则更容易理解:先约定文件范围和改动规模,再检查 Git diff。示例把第一步限定为 src/utils.ts、最多 1 个文件和 10 行,第二步最多 1 个文件和 50 行;如果实际改了 15 个文件,就应触发检查。
这能发现范围失控,却不能判断那 10 行是否正确实现需求。原文的「确定性回放」也主要是保存模型输入、输出和工具结果,replayTurn 只是读取某一轮记录,没有实现重新执行及一致性验证。
我的判断
我赞同作者把安全重点放在执行环境上。相较于继续加长 system prompt,限制网络、文件写入和工具参数,能提供更明确的约束。
但原文没有提供攻击成功率、误报率、性能开销或对照实验。「显著降低攻击面」「最有效的控制」都应视为作者的判断,不能当作已经验证的效果。
这份材料适合作为架构检查清单,尚不足以作为可直接部署的参考实现。
它的整合代码在 private ge 处截断,生产建议也没有给完。更严重的是,沙箱启动命令通过 child_process.exec 拼接工具名和参数,可能让宿主 shell 在 Docker 启动前解释其中的特殊内容。把执行放进容器,并不能自动保护启动容器的那段宿主代码。
断网方案也有适用前提。已有依赖、只需读文件和运行检查的任务较容易采用;需要访问模型、依赖仓库或其他服务时,就必须明确区分哪些组件联网、哪些执行环境断网。原文没有把这套部署关系讲清楚。
能拿来做什么
- 给日常 AI 编程任务先划定文件和操作范围,例如只允许修改目标模块、运行指定测试、查看 diff,再决定是否开放提交能力。
- 把安装依赖和执行修改分开管理。已有依赖的 lint、测试与重构任务可以评估断网运行;确需联网的操作再设置明确的访问范围。
- 让测试输出、文件内容和历史日志始终保留来源信息,重新加入模型上下文时继续视为不可信数据,不因它们进入「记忆」就提升权限。
- 为较长的重构任务设置 diff 检查点,结合预期文件、改动行数和任务验收条件审查偏离,同时记录工具参数、策略结果和执行输出,方便定位问题。