Skip to content

第 9 章 跑得安全:沙箱与权限约束 ​

本章要解决的问题

第 7、8 两章让 Agent 能修改文件、执行命令——副作用从这一刻起是真实的。Agent 读到的网页、issue 和日志还可能诱导它读取私有文件、运行危险命令或向外发送数据;来源标记和提示(第 6 章)只能降低误判概率,不能强制阻止操作。

本章回答四个问题:攻击需要哪些条件、操作系统怎样限制命令、执行前怎样检查、用户怎样批准一次额外权限。

9.1 哪三种能力同时出现时最危险? ​

检查 Agent 是否高危,可以直接看 Simon Willison 总结的三件事。三条同时具备,攻击就能完成:

text
① 访问私有数据     —— Agent 能读你的文件、SSH key、环境变量
② 暴露于不可信内容 —— 它读的网页/issue/日志里可能藏着恶意指令(提示注入)
③ 具备外部通信能力 —— 它能发请求、执行命令,数据出得去

攻击路径:恶意指令藏在网页里 → 注入 Agent → 读取 ~/.ssh → curl 外传

第四维:持久记忆是放大器

长期记忆还会扩大影响范围:恶意内容写入 MEMORY.md 后,可能在几天后的会话中再次进入模型输入。所以记忆写入需要与外部内容同等的信任审查(第 15 章详述)。

需要在三个位置分别处理:读取内容时标明来源并分开指令与数据(第 6 章);执行操作时使用沙箱并请求审批(本章);启动进程时不要把不需要的凭证交给它。本章重点讨论第二项。

9.2 操作系统怎样限制文件、网络和系统调用? ​

前三个项目组合使用下面几种操作系统能力和用户态工具;pi 的内置工具默认直接使用当前用户权限,需要隔离时由容器、微型虚拟机或扩展接管:

机制平台限制什么特点
Seatbelt(sandbox-exec)macOS文件/进程/mach 服务的系统调用声明式策略(SBPL),(deny default) 起步逐项放行
LandlockLinux文件路径的读写(内核自带,无需 root)不可逆:进程对自己上了锁,后代全继承
seccompLinux按系统调用及部分参数放行/拒绝可阻止新建 socket 等调用;已有文件描述符和未覆盖调用仍要单独考虑
bwrap(bubblewrap)Linux 用户态工具借助 mount/user/pid 等 namespace 组装文件系统与进程视图可用 --ro-bind / / 把宿主根挂为只读,但 bwrap 本身不等于完整容器平台

为什么要先限制自己再 exec? ​

正确的上锁顺序是:先给自己上锁,然后才启动你的命令。限制规则会跨进程启动继承,所以目标进程及其所有后代从第一个字节起就被限制——不存在"先启动、再上锁"之间的空窗期。sudo 在 exec 前切换凭据、sandbox-exec 在启动目标前应用策略,遵循的都是这个顺序;把流程画成"目标先执行、之后才限制"就错了。

配套一条容易漏的工程细节:沙箱自身启动失败时,调用方必须能区分"沙箱没装上"和"命令自己失败了"——只看退出码不够(两者可能撞同一个码),要用专门的诊断标记判断;安装失败时绝不能继续运行目标。

9.3 用户需要看懂哪三档权限? ​

9.2 的 Seatbelt、Landlock、seccomp 和 bwrap 是程序实现强制边界的底层机制,普通用户不应该靠读系统调用规则来判断风险。产品还需要把它们组合成少量可理解的权限档位。为了横向比较,本书把前三家的配置近似映射成三类。它们不是行业统一标准,实际边界要看平台和后端:

text
read-only           只读意图:除运行所需的特殊设备/临时位置外不允许文件写入
workspace-write     工作区写意图:允许指定工作区和后端定义的临时位置
danger-full-access  绕过这一层文件沙箱;不代表系统其他权限自动提升

前三家的档位名称和粒度各不相同:有的在三档之外加了网络开关旋钮;有的档位更细、还允许继承内置档位做自定义。pi 核心没有对应档位,默认就是宿主当前用户权限。映射成三类只是为了横向讨论,两点必须记住:档位词汇只描述文件效果,网络与进程可见性不在其中;各家默认档位还可能被部署配置覆盖。

三类便于向读者解释"只许看 / 在项目里干活 / 绕过文件限制",但不能代替具体能力清单。Windows 后端目前只限制写入、不限制读取与网络,属于部分实现——所以界面必须展示实际生效的后端和限制范围,而不是一个档位名。

9.4 命令执行前由谁判断能不能跑? ​

9.3 的档位定义了当前会话的能力上限,但每一条具体命令仍要判断是直接放行、询问用户还是拒绝。这个判断可以在用户态提前做,也可以主要依靠内核拒绝越界。四家采用不同检查方式,下图直接比较检查发生的位置:

命令由执行前检查还是操作系统限制

先把四个经常混用的词分开:

text
权限判断  规则认为这次调用可放行、需询问或禁止
用户审批  用户对一份具体操作表达允许或拒绝
OS 沙箱   即使模型或规则判断错了,内核仍强制限制资源
权限升级  第一次在受限环境失败后,是否允许以更宽边界再执行一次

审批通过不等于沙箱放开,沙箱拒绝也不一定允许升级。把四层揉成一个 allowed 布尔值,会导致“用户允许在工作区写入”被错误解释成“可以读取所有目录并联网”。

codex:一次调用最多走两次执行尝试 ​

codex 在命令执行前让它依次经过多层判断:

text
把命令拆成多段(管道/子 shell 拆开逐段看)
  → 规则引擎(声明式前缀规则:放行 / 需批 / 禁止)
  → 无规则命中 → 安全命令白名单 + 危险命令启发式
  → 结合审批档位,输出:放行 / 需人工批 / 禁止(附原因)
批准后还能把新前缀"学"回规则库

真正的执行路径不是“批准后直接裸跑”,而是:

text
权限判断: Skip / NeedsApproval / Forbidden
  → 必要时先审批
  → 第一次:按当前沙箱执行
       ├─ 成功或普通命令错误 → 直接结束,不升级
       └─ 确认是沙箱拒绝   → 检查是否允许升级
            → 必要时再次审批
            → 第二次:使用更宽的沙箱策略执行

只有能识别为 sandbox denied 的失败才进入第二次尝试。测试失败、参数错误、程序退出码非零都不会因为“第一次失败”就自动扩权。权限判断还有 Forbidden 终局,模型不能通过换一种申请格式绕过。

几个边界来自访问控制本身,而不是用户体验:

  • 当前文件策略包含禁止读取路径时,不允许通过 unsandboxed 重跑,因为那会顺手丢掉 denied-read 约束。
  • 附件或执行环境自带的网络策略不能被普通 sandbox escalation 绕过。
  • 明确升级的调用不会继续使用普通受管网络代理;网络是否允许要由对应的网络审批和环境策略重新决定。
  • 审批缓存按具体 action key 保存;一段 patch 涉及多个权限对象时,批准其中一项不会自动批准剩余项。

配套沙箱同样分层:先用系统调用过滤自限,再做文件系统隔离;macOS 上还保护 .git/hooks——防止 Agent 通过写 git 钩子实现提权持久化。可记住的审批前缀也不能是 bash、python、rm、sudo 这类能承载任意行为的宽入口,否则“一次记住”就变成长期任意代码执行。

grok:启动时上锁,单条命令不能临时解锁 ​

grok 在 Agent 进程启动阶段就应用沙箱(Landlock 或 Seatbelt,部分配置再叠加 bwrap),已施加的限制不可逆,后代进程继承。因此某条命令即使得到权限层批准,也不能像 codex 那样单独脱离沙箱;需要更宽边界只能结束会话,用新 profile 启动。恢复旧会话时还会固定原 profile,拒绝用另一个 profile 续跑,防止“恢复”悄悄扩大权限。

之后在命令执行前做静态检查:用 AST 检查文件写入路径、环境变量泄露、exec 调用,以及内容无法提前知道的 shell(bash -c "$X")。两条铁律:解析失败就必须人工审批,绝不自动放行;hooks 目录要真正只读,而不只是配置里写写。

平台能力并不对称:Linux 可以用 seccomp 限制子进程新建网络连接,macOS 对应路径目前不提供同等网络强制;内置的网页和模型请求运行在主进程中,也不受“子进程断网”自动覆盖。macOS 的路径 glob 可以在运行时拦住以后新建的匹配文件;Linux 启动时展开 glob,沙箱启动后才创建的匹配文件可能漏掉,敏感目标应使用明确路径。

失败处理也不是统一的"警告后继续":自定义 deny 规则无法解析或无法落实时拒绝启动;部分内置 profile 应用失败的路径则可能警告后继续。实现时必须区分“强制要求”和“尽力而为”,并把实际生效状态展示给用户。它还有一个 auto 档:先让 LLM 分类器预检,超时、不可用或连续拒绝时转人工。

dsh 和 pi 都把"理解命令内容"这条路让了出去,但让给的对象不同——dsh 让给操作系统,pi 让给部署环境。

dsh:不分析命令,让操作系统拒绝越界 ​

dsh 选择另一种做法:不解析命令,也不使用黑名单。每次工具调用时,按平台选择后端在执行边界限制文件访问(Linux 用 bwrap 或 Landlock,macOS 用 Seatbelt,Windows 用 ACL——注意 ACL 后端是部分实现:只限写入,不限读取、网络与进程可见性);启动前先实际检查所需后端能否使用,缺失就拒绝执行。是否允许写文件,最终由操作系统判断。

pi:核心不做审批,把隔离放到宿主之外 ​

pi 的 bash、read、edit、write 默认以启动它的用户权限运行,没有内置命令分类器、权限弹窗或统一沙箱档位。项目可以通过 Gondolin 微型虚拟机扩展、Docker、OpenShell 等环境运行 pi,也可以由扩展在工具调用前后增加检查。

要分清两种信任:pi 的“项目可信”决定是否自动加载项目设置、扩展、Skill 和包,并不把一个已信任项目自动放进沙箱;相反,一个未信任项目的上下文文件仍可能被读取。扩展本身可以执行任意代码,因此安装来源、版本锁定和运行环境边界都属于安全设计的一部分。

三种做法分别得到什么、付出什么?

codexgrokdshpi
分析时机执行前(规则+AST)执行前(AST+环境/exec 检查)不分析命令文本核心不分析
强制位置命令分析 + OS 沙箱命令分析 + 启动时 OS 限制每次调用选择平台后端外部容器/微型虚拟机/扩展
判断偏差规则和 AST 可能误判规则覆盖广,仍可能误判路径策略可能拒绝合法操作核心不判断意图;边界取决于部署
提前告知用户可说明可说明通常被拒后说明取决于扩展
主要代价维护规则、解析器和沙箱维护更多规则与 profile维护多平台后端需要部署者统一扩展和环境策略

dsh 不分析 Shell 命令含义,而是在执行边界选择平台后端限制文件访问。 这样不需要维护命令分析引擎,但实现并不“零复杂度”,模型也通常要在执行被拒后才知道具体越界位置。codex/grok 在执行前分析命令,能提前向用户说明风险;代价是要持续覆盖 Shell 众多可组合的写法。

9.5 命令被拒后怎样申请一次额外权限? ​

安全机制的另一半是人机协议:用户怎么批准?模型被拒后能做什么?

grok 的 description 最终显示在哪里? 第 8 章提到 grok bash 工具必须填写 description。它会成为审批弹窗的标题,让用户看到模型为什么要运行命令,例如 “Allow run tests for the auth module?”。因此这个字段既是工具参数,也是给用户看的说明。

codex:既有预先申请,也有沙箱拒绝后的二次尝试。 request_permissions 让模型预先请求额外文件系统/网络权限;审批档位为 Never 或该功能未启用时,handler 返回空的额外权限集合,不是执行成功。另一条路径是 9.4 的 orchestrator:第一次确认因沙箱而失败后,再根据审批策略决定是否以更宽边界重跑。两条路径最终都受环境级 denied-read 和网络策略约束。

dsh:在原调用上申请一次更高权限。 命令在沙箱内被拒时,返回体自动附加两个标记:拒绝原因 + 申请提示(教模型"同一命令带目标权限和理由重试一次")。重试要过三道校验:

text
参数成对:目标权限档 + 申请理由,缺一不可
严格变宽:read-only → workspace-write → danger-full-access,只能单向升
结果两态:allowed-once → 仅本次调用生效(下次照旧受限)
          rejected     → 终局,禁止绕行

严格变宽保证权限只能单向升,单次生效保证不产生持久污染——一次审批恰好覆盖一次越界,粒度最干净。审批的一问一答全部落会话日志,可审计。

高风险操作为什么要先准备,批准后再提交? ​

如果审批弹窗只展示模型几轮前写下的参数,用户批准时真实状态可能已经变化。发布、删除、转账、修改云资源等操作可以拆成两个工具阶段:

text
prepare  读取当前真实状态 → 规范化参数 → 生成预览
         → 返回 operationId + stateRevision + expiresAt
approve  用户批准这份确定的预览
commit   再读当前状态 → 核对 operationId、revision、有效期和权限 → 执行一次

commit 必须使用幂等键,重复请求只能得到同一个结果。若客户端在提交后超时,先用 operationId 查询状态,不要直接重试副作用。状态版本已经变化时应让旧批准失效并重新准备,不能把用户对旧预览的同意套到新内容上。

这套做法不是四个参考项目的统一接口,而是面向高风险业务工具的可选设计。普通只读查询不必都增加两阶段操作。

浏览器断线后,审批请求怎样恢复? ​

Web UI 里最容易漏掉的是:审批不是一条普通 toast,而是服务端发给客户端、等待恰好一次回答的 request。协议至少需要 approvalId + sessionId + turnId + toolCallId + expiresAt:

text
requested → decided(allow-once/reject)   正常结束
          → resolved(被其他路径解决)     客户端关闭旧弹窗
          → expired/disconnected         拒绝执行,绝不默认允许

稳定的 approvalId 不能直接等同于 toolCallId:一个长命令或 shell bridge 可能在同一工具调用中产生多次审批。

断线重连后,只有仍未解决、并且服务端仍保存着回答入口的请求才能重放。客户端回答必须带原 request id;同一个回答重复到达时,服务端只能处理一次。

内置审批的三个实现在这里也分出了差距:有的会用同一请求标识重放尚未回答的审批;有的断线后不保留回答入口,不能承诺恢复原弹窗;有的会主动撤回已失效的交互请求。pi 的核心没有审批请求协议,扩展若加入审批也必须自行实现这些状态。自建时,这一格的差异要在断线场景里专门测。

第 20 章会把这些状态落到前端的状态更新函数中。

9.6 只限制文件写入还会漏掉什么? ​

前三档词汇主要描述文件读写权限,但正式产品还要分别设置下面五项。只写 workspace-write 而不说明其他限制,用户很容易高估沙箱能做什么:

需要限制的对象起步做法常见问题
文件系统源码只读或独立工作副本;只开放明确 writable roots把宿主 $HOME、.git/hooks、Docker socket 一起挂进去
网络默认断网;按域名/协议/端口白名单放行只禁公网 IP,却留下 DNS/SSRF、代理或本地元数据服务
凭证由凭证服务按调用短期下发最少权限继承完整环境变量,子进程顺手拿到云 key/npm token
进程与设备限制可见进程、IPC、设备和子进程树文件隔离了,却能 attach 宿主进程或访问高权限 socket
资源CPU、内存、磁盘、PID 数、输出和 wall clock 配额死循环、fork bomb 或日志灌满磁盘仍能拖垮宿主

资源超限要返回明确的错误类型(cpu_limit / memory_limit / disk_limit / deadline),而不是统一伪装成“命令失败”;模型只有看到真实类别才能换方案。

服务端还要区分保存任务状态与让进程一直存活。把 Goal、日志、job manifest 和产物保存下来,不等于为了“可恢复”让一个无限权限的 shell 永远运行。服务重启时,先按第 14 章恢复记录和实际状态,再按本章规则重新检查权限。

如果再加一个辅助 LLM 审查器检查危险操作,它只应读取结构化的工具名、规范化参数、解析后的资源和风险标签,不要把主模型的自由文本“我保证这是安全的”当证据。审查器超时或连续失败时转人工或拒绝执行;不同模型互审可以增加一票意见,不能替代内核、确定性规则和审批。

一份完整的沙箱声明

不要只展示 workspace-write;同时展示 fs=workspace-write, network=deny, credentials=scoped, process=isolated, limits={cpu,memory,disk,pids,wall}。权限界面说得越具体,用户越知道自己批准了什么。

9.7 与后续运行时有关的两个补充 ​

下面两点分别会在第 20 章的界面状态和第 19 章的故障处理部分继续展开。这里先说明它们不能越过安全边界。

乐观 UI(先显示无副作用状态):界面可以先显示“正在检查读取 src/main.py…”,后台执行权限判断;通过后再真正运行,不通过则改为“等待确认”。先发生的只能是 UI 更新,不能提前读文件或启动命令;这不是“推测执行”副作用。

并行失败要看依赖关系:已经确认互不依赖的调用中,一个失败可以只报告该调用,不必取消其他调用。若后一个调用依赖前一个结果,则应停止或标记“因依赖失败未执行”,不能一律隔离(第 19 章展开)。

自建起步:怎样实现最小审批检查? ​

完整沙箱需要原生依赖,但审批检查(本章的人机协议部分)零依赖可实现。它依次按三步判断,每一步都刻意保守:

  1. 不能完整理解的 Shell 字符串 → 人工审批或拒绝。; | & $() > 只是部分例子;换行、重定向、变量展开、子 shell 和程序自身配置都会改变行为。命令解析只能帮助解释风险,不能证明安全。
  2. 只有经过审计的具体调用才能免审批。判断对象应包含可执行文件、子命令、全部参数、cwd、环境变量和允许访问的资源。例如 pnpm test 会执行仓库脚本,不能默认安全;ls <path> 可能读取敏感目录;git diff 也可能受 pager、external diff、attributes 和配置影响。不要仅凭命令名或字符串前缀放行。
  3. 其余 → 人工审批;无人应答 = 拒绝执行。无交互终端(如 headless/CI)时直接返回"需要人工审批但当前无交互终端",而不是默认放行。

把检查放在 Shell 工具外面,Agent 就能在执行前请求审批。字符串前缀白名单(cmd.startsWith("git status"))会被 git status; echo bypass 绕过。解析 AST/argv 可以改善提示,但程序还会读取配置、插件和脚本;最终仍需 OS 沙箱限制可访问资源。审批只负责询问用户,不负责强制边界。

【对比小结】 ​

codexgrok-builddshpi
沙箱粒度每条命令单独套进程启动一次性每次调用选择平台后端核心无沙箱;部署环境决定
档位三档 + 网络旋钮更细且可自定义三档,只管文件效果无内置档位
命令分析规则引擎 + ASTAST 检查最多,失败转人工不分析,由平台后端限制核心不分析
升级路径预先请求;或沙箱拒绝后审批再尝试进程级沙箱不可逆,需新会话/profile原调用申请单次升级由扩展/外部环境定义
防提权细节保护 .git/hookshooks 写保护先限制再 exec项目信任限制自动加载扩展,不限制宿主权限

需要决定的是检查放在哪里。用户态分析(codex/grok)能在执行前说明风险、改善审批体验,但很难覆盖 shell 的所有组合写法;操作系统限制(dsh)不可被命令文本绕过,但只能在执行被拒后说明问题。高风险、强交互产品通常同时使用两层:分析代码决定是否询问用户,操作系统负责阻止越界;受控环境也可以只使用后者,减少实现和维护成本。

到这里,骨架已经完整:循环、工具、上下文、文件写入、Shell,以及权限和沙箱兜底。下一章回到工具层,处理更难的一步:同一轮里的多个调用怎样调度和取消,失败时状态怎样保持一致;崩溃后的状态记录与恢复见第 14 章。

基于 MIT 协议发布,欢迎转载与贡献。