coding agent 权限沙箱:从 ask 模式到 Seatbelt/bwrap 的两层防线
hooks 拦截机制 给工具调用装上了 pre 裁决链:不合资格的调用在 handler 之前就被拒绝,零副作用且留审计。但这套机制有一个隐含前提——模型只能通过注册表暴露的工具碰系统。真实的 coding agent 往往手里还握着 shell:一旦模型让子进程直接 open() 一个工作区外的文件,进程内的策略钩子根本看不见这次访问。要回答「agent 能不能被限制在工作区里」,单靠一层策略不够,需要进程内策略与操作系统沙箱组成的 coding agent 权限沙箱双层防线。
本篇是原理系列的第六篇,在纯标准库工程 mini-agent 上完成 M6:第一层复用 M5 的 PermissionPolicy,第二层在 macOS 上用 sandbox-exec(Seatbelt)把整个探针进程关进 deny default 的白名单剖面,并用同一组写探针在沙箱内外各跑一次做对照。所有 ALLOWED/DENIED 证据都来自同一条 make run-m6 的真机输出;Linux 的 bubblewrap(bwrap)只作为等价对照工具讨论,本篇没有任何 Linux 实测数据。
读这张对比卡先抓住分界:第一层是进程自己的自律,裁决发生在 Python 代码里,模型理论上可以诱导进程走出工具层;第二层是内核强加的他律,剖面一旦随进程安装,进程内任何代码——包括模型通过 shell 拉起的陌生二进制——发起的系统调用都要过白名单。本篇后面的全部实验,都是为了用真机输出证明这条分界不是纸面设计。
本文要回答的问题
- 只有进程内权限策略、没有 OS 沙箱时,一次越权写可以怎么绕过钩子?
PermissionPolicy的 allow、deny、ask 三种模式各自是什么语义,无人值守时 ask 怎么落地?- Seatbelt 剖面为什么从
(deny default)起步,白名单里 process-fork、realpath 这些细节漏写会怎样? - 同一组写探针在沙箱内外跑出的 ALLOWED/DENIED 差异,哪些是沙箱的功劳、哪些本来就被系统权限挡住?
- Linux 上没有 sandbox-exec,bwrap 用什么机制提供等价边界,两层防线的独立性如何理解?
一、威胁模型:策略层为什么挡不住「不走工具」的写
M5 的 pre 钩子链位置很靠前,但它仍然只是注册表内部的一道关卡。HookedToolRegistry.dispatch 能裁决的前提,是这次动作以一次工具调用的形式进来:模型发出 tool_call,注册表查到工具名,钩子才有机会看参数、下裁决。考虑下面这条路径——agent 的工具集里有一个 shell 执行工具(这在真实 coding agent 里是常态,跑测试、装依赖都离不开它),模型让 shell 执行一段 Python 单行脚本,脚本里直接 Path.home().joinpath("x").write_text(...):
python3 -c 'from pathlib import Path; Path.home().joinpath("probe.txt").write_text("hi")'对注册表来说,这只是一次参数合法的 shell 工具调用,pre 钩子看到的是命令字符串,不是最终被打开的文件路径;等字符串进了 shell,新进程已经在 agent 进程的全部权限下运行,写 home 目录、写临时目录都和用户手工执行没有区别。策略层对此既不可见,也无法在不解析 shell 语义的情况下裁决,而可靠地解析任意 shell 命令的副作用本身就是个无解问题。
这正是 coding agent 权限沙箱需要第二层的原因:把「允许写哪些路径」的判断从进程内代码下沉到内核。内核看到的是系统调用而不是字符串,无论写请求来自工具 handler、shell 子进程还是被注入的陌生代码,open 带 O_WRONLY 进内核时统一按剖面裁决,绕不过去。两层的关系不是替代,而是各自覆盖对方的盲区。
| 维度 | 进程内策略层 | OS 沙箱层 |
|---|---|---|
| 裁决位置 | 用户态,dispatch 路径上 | 内核态,系统调用入口 |
| 可见对象 | 工具名与参数字典 | 系统调用号与路径、信号等实体 |
| 被绕过方式 | 走出工具层、直接起子进程 | 剖面白名单写得过宽 |
| 拒绝形态 | 结构化 hook_denied 错误 | EPERM,用户态表现为 PermissionError |
| 配置粒度 | 按工具、按路径谓词 | 按 subpath、按操作类别 |
| 审计归属 | post 审计钩子逐调用记录 | 内核侧拒绝由探针捕获后回流 |
二、第一层:allow/deny/ask 三模式与无人值守语义
M6 直接复用 M5 的 PermissionPolicy,构造时只给一个工具配模式:modes={"run_test": "ask"},其余工具默认 allow。裁决逻辑在 before 方法里只有三个分支, deny 模式返回固定原因,ask 模式在无人值守下按拒绝处理并写明本应询问,allow 才继续走路径谓词检查:
def before(self, name, arguments):
mode = self.modes.get(name, "allow")
if mode == "deny":
return PreVerdict.deny("策略禁止调用工具 %s" % name)
if mode == "ask":
# 无人值守不挂起会话:按拒绝处理,原因里写明本应询问。
return PreVerdict.deny("工具 %s 需要人工确认,无人值守下自动拒绝" % name)
if name in self.WRITE_TOOLS:
path = arguments.get("path", "")
for rule in self.path_rules:
reason = rule(name, path)
if reason:
return PreVerdict.deny(reason)
return PreVerdict.pass_()三种模式的语义值得逐个对齐,因为它们对应三种截然不同的人机关系。allow 是信任,调用直接放行;deny 是禁止,连问都不问,适合「测试文件永远不许 agent 改」这类硬边界;ask 是有条件信任,交互式 harness 在这里弹确认框等人的决策,而无人值守场景没有「人」可问——挂起会话等于制造一个永远等不到输入的僵尸任务,所以教学工程把 ask 收敛成拒绝,并在原因字符串里保留「本应人工确认」的信息,事后审计能区分「策略硬禁止」与「没人可问所以拒绝」。
| 模式 | 交互式场景 | 无人值守场景 | 典型用途 |
|---|---|---|---|
| allow | 直接放行 | 直接放行 | 只读工具、工作区内常规写 |
| deny | 直接拒绝并说明原因 | 同左 | 保护测试文件、禁止危险工具 |
| ask | 弹确认框,人决策后放行或拒绝 | 按拒绝处理,原因注明需人工确认 | 跑测试、装依赖等需人工背书的动作 |
M6 驱动脚本在第一层安排了两次调用做对照:run_test 落在 ask 模式下被拒,read_file 读工作区文件正常放行。两次调用无论成败都经过 post 审计钩子,这是 M5 固定下来的语义——拒绝事件是权限体系最该留下的信号,不能让越权尝试无痕消失。本次运行审计流落盘两行,工具与结果分别是 run_test/denied 与 read_file/ok,第七节的终端记录里可以逐行对上。
三、第二层:deny default 白名单剖面与两个真实踩坑
OS 层的全部秘密在 drive_m6.py 里的 SANDBOX_PROFILE 字符串。这是一段 macOS Seatbelt 策略语言(SBPL)剖面,通过 sandbox-exec -p 随探针进程安装:
SANDBOX_PROFILE = (
'(version 1)(deny default)'
'(allow process-exec)(allow process-fork)(allow signal)(allow sysctl-read)'
'(allow file-read*)(allow ipc*)(allow mach-lookup)'
'(allow file-write* (subpath "%(workspace)s"))'
'(allow file-write* (subpath "/private/tmp"))'
'(allow file-write* (subpath "/dev"))'
)剖面的姿态是理解一切的起点:(deny default) 把所有未显式授权的操作全部判否,后面的 allow 一条条把最小必要能力捡回来。这与「默认允许、出了事再封」的黑名单思路正好相反——黑名单永远落后于攻击面,而白名单漏放的最坏结果是程序自己跑不起来,故障朝内、安全朝外。
读这张图抓住颜色分工:蓝色是探针发起的探测动作,灰色是内核里的裁决点,绿色分组里的两个目标命中白名单得以写入,红色分组里的两个目标在剖面里找不到任何授权条目,写操作在内核阶段直接失败。注意探针进程自己不做任何权限判断,它只管发起写、捕获异常、打印 ALLOWED 或 DENIED——判断完全发生在灰色节点里,这正是第二层与第一层的架构差异。
剖面能在真机上一次跑通,背后有两个值得展开的踩坑。第一个是进程授权要成对给。process-exec 与 process-fork 都是 Seatbelt 策略语言里的合法授权名,前者管执行新程序、后者管 fork 出自进程;Python 运行期与标准库在多条路径上会触发进程创建,漏写任意一条,进程可能连启动阶段都过不去,报的还是晦涩的剖面违规而不是普通 Python 异常。signal、sysctl-read、mach-lookup 同理,都是一个正常进程在 macOS 上活着的最低开销,不是额外放权。
第二个坑更隐蔽:subpath 按真实路径(realpath)匹配,不按字符串前缀匹配。macOS 上 /var、/tmp 都是指向 /private/var、/private/tmp 的符号链接,内核裁决时看到的是解析后的真实路径。如果剖面里写 (subpath "/tmp") 而进程实际打开 /private/tmp/...,字符串对不上就会被拒。drive_m6.py 的处理是工作区一律走 Path.resolve() 先解析符号链接、拿到真实路径再插进剖面字符串,临时目录则直接授权真实路径 /private/tmp。后面对照实验里一个细节正好印证这点:无沙箱基线的临时文件落在 /var/folders/.../T/ 下,而沙箱内进程的临时目录落到 /tmp(真实路径 /private/tmp),两条路径都因为剖面授权的是真实路径才没有误伤正常运行。
读权限这一侧剖面放得相对宽:file-read* 全放、ipc* 与 mach-lookup 放行,保证动态库加载、系统服务查询这类基础机能可用;写权限则严格收敛到工作区、临时目录与 /dev 三个 subpath。读写不对称是刻意的——这个探针进程的任务是验证写边界,读全放能让剖面保持短小可读,真实生产剖面通常会把读也收敛到必要前缀。
四、探针实测:沙箱内外八次写入的对照实录
第二层是否真的生效,不能靠读剖面推断,要用同一组探针在两种环境下各跑一次。m6_probe.py 的逻辑很直白:对工作区、home、/etc、临时目录四个目标依次尝试写一个固定内容的小文件,成功打印 ALLOWED 与完整路径,失败捕获 OSError 打印 DENIED 与异常类型,最后清理掉成功写入的探针文件。退出码不参与判定,证据全在结构化输出行里。
终端记录分上下两段。上段是第一层:ask 模式下 run_test 收到 ok=False 与「需要人工确认,无人值守下自动拒绝」的原因,同一会话的 read_file 正常读到工作区文件首行,审计落盘两行且工具序列与两次调用一一对应。下段是第二层的两次探针运行,先跑无沙箱基线,再套上 Seatbelt 剖面跑同样的探针,把八次写入的结果摊在一张表里对账:
| 写目标 | 无沙箱基线 | Seatbelt 沙箱内 | 差异归因 |
|---|---|---|---|
| workspace 工作区 | ALLOWED | ALLOWED | 白名单 subpath 命中,业务能力保留 |
| home 用户目录 | ALLOWED | DENIED | 沙箱新增的拦截,基线本可写 |
| /etc 系统目录 | DENIED | DENIED | 普通用户本就不可写,非沙箱功劳 |
| 临时目录 | ALLOWED | ALLOWED | /private/tmp 在白名单内,正常运行不受误伤 |
-- 无沙箱基线 --
write workspace: ALLOWED /Users/derekzhuo/Project/performance_profile/demos/mini-agent/workspace/sandbox_probe.txt
write home: ALLOWED /Users/derekzhuo/mini-agent-m6-probe.txt
write etc: DENIED /etc/mini-agent-m6-probe.txt (PermissionError)
write tmp: ALLOWED /var/folders/vz/84xsj3d95pvccm0sjrq60bpc0000gn/T/mini-agent-m6-probe.txt
-- Seatbelt 沙箱内(deny default + 只放行工作区与临时目录写)--
write workspace: ALLOWED /Users/derekzhuo/Project/performance_profile/demos/mini-agent/workspace/sandbox_probe.txt
write home: DENIED /Users/derekzhuo/mini-agent-m6-probe.txt (PermissionError)
write tmp: ALLOWED /tmp/mini-agent-m6-probe.txt这张表里最需要冷静解读的是 /etc 那一行。无沙箱基线下它同样 DENIED,原因是普通用户对系统目录本来就没有写权限——这条拒绝不能算在沙箱账上,否则就把操作系统自带的 UNIX 权限误当成了沙箱成效。真正由第二层贡献的增量只有一条:home 目录从基线的 ALLOWED 翻成沙箱内的 DENIED。与此同时,工作区与临时目录两次 ALLOWED 证明白名单没有误伤,探针进程该干的活一样没少。评估一个沙箱剖面,必须同时数清「新增拦住了什么」和「有没有误杀正常路径」,只看 DENIED 数量会得出完全错误的结论。
五、一次沙箱内写被拒的调用时序
把 home 目录那次 DENIED 展开成时序,可以看清拒绝究竟发生在谁和谁之间。驱动脚本把完整剖面作为参数交给 sandbox-exec,剖面在新进程启动前进入内核;探针进程此后每一次文件写都要过同一道裁决,命中白名单放行,未命中返回 EPERM,用户态拿到的就是标准库包装后的 PermissionError。

时序里有三个关键点。其一,剖面安装发生在探针进程启动之前,进程从第一条指令起就处于约束之下,不存在「先自由跑一阵再关进沙箱」的窗口。其二,home 写入的拒绝由内核直接给出,探针进程内没有任何代码参与这个决定——这就是第一节里 shell 绕过路径同样被拦住的原因:内核只认系统调用,不认调用者是工具 handler 还是陌生子进程。其三,失败以普通 PermissionError 异常的形态回到用户态,探针不需要任何沙箱专用 API,用标准 try/except OSError 就能捕获,这让第二层对业务代码几乎零侵入。
六、两层防线的独立性与 Linux bwrap 对照
把两层放在一起看,价值不在「双保险」这个词,而在失效模式独立——两层依赖的信任根不同、被突破的方式不同,因此同一类故障很难同时击穿两者。
图里两条路径对应两种真实事故形态。场景甲是策略层的典型失效:模型诱导出一个不经过注册表的子进程,进程内钩子完全失明,但剖面是随进程安装在内核里的,子进程继承同一约束,写 home 照样拿到 EPERM。场景乙反过来:管理员把剖面配宽了,内核这层形同虚设,但只要动作还走工具层,pre 钩子的模式与路径谓词照拦不误,而且审计流会留下 denied 记录,给事后收紧剖面提供线索。红色节点标出被突破的那一层,绿色节点标出仍然站住的那一层——任一层被突破,另一层仍在,这就是结论行「两层失效模式独立」的全部含义。
Linux 上没有 Seatbelt,常用等价工具是 bubblewrap(bwrap)。需要先说清边界:bwrap 在本篇是对照工具,不是实测对象,上文全部 ALLOWED/DENIED 证据都来自 macOS 真机;这里只做机制映射,不在 Linux 上跑任何数据。drive_m6.py 在非 Darwin 平台打印的等价命令骨架是 bwrap --ro-bind / / --bind <workspace> <workspace> --dev /dev --unshare-all ...,两者的概念对应关系如下:
| 对照维度 | macOS Seatbelt | Linux bubblewrap |
|---|---|---|
| 底层机制 | 内核强制访问控制剖面 | mount/user/pid namespace + 绑定挂载 |
| 默认姿态 | (deny default) 显式白名单 | 根目录只读绑定,可写点逐个 --bind 开放 |
| 工作区可写 | (allow file-write* (subpath ...)) | --bind 工作区 工作区 |
| 设备与临时目录 | 授权 /dev、/private/tmp subpath | --dev /dev、--tmpfs /tmp |
| 隔离范围 | 单进程剖面,随 sandbox-exec 安装 | --unshare-all 切断共享命名空间后逐项接回 |
| 拒绝形态 | EPERM / PermissionError | 挂载视图外不可见或 EPERM |
两套机制的哲学一致:都是默认收紧、逐项开最小口子,把「能写哪里」从 agent 的自觉变成内核或命名空间强制的事实。工业级 harness 也是同一思路,例如 Codex 的 sandbox_mode 提供 read-only、workspace-write、danger-full-access 三档边界,配置字段的完整语义见 Codex config.toml 字段速查——档位名称不同,本质仍是在 OS 层定义写边界,而不是指望模型永远守规矩。
实验环境
- 机器/系统:MacBook Pro(6 核 Intel Core i7-9750H,16 GB 内存)/ macOS 26.5.2(Build 25F84)
- 工具版本:Python 3.15.2(仅标准库,无第三方依赖);系统自带
sandbox-exec(Seatbelt) - 日期:2026-11-14
- 完整命令:
cd demos/mini-agent
make run-m6 # 两层防线对照演示,等价 python3 scripts/drive_m6.py
python3 -m unittest discover -s tests # M1-M6 全部 headless 回归用例演示工作区由 drive_m6.py 每次运行先清空再重建,写入 task.txt 作为只读探针的目标;策略层审计流落 traces/m6-policy.jsonl,探针文件无论 ALLOWED 还是 DENIED 都在脚本收尾时清理,不残留现场。
代码获取
mini-agent M6 的沙箱剖面、驱动脚本、探针与全部测试已同步到公开仓库:
- 仓库地址:https://github.com/derekzhuo/geek-doc.cn
- 目录:mini-agent/
- 复现方式:进入
mini-agent/,执行make run-m6查看本篇两层防线的完整对照输出;make test跑全部回归用例。想改剖面做实验,只需编辑scripts/drive_m6.py里的SANDBOX_PROFILE字符串后重跑,探针会立刻把新增的误伤或漏网打印成 ALLOWED/DENIED 行。
这一篇在知识体系里的位置
本篇是 hooks 拦截机制(权限沙箱的第一道防线) 的直接续篇:M5 解决进程内「这次工具调用该不该发生」,M6 补上进程外「不走工具的系统调用能不能落地」,两层共用同一套审计与结构化错误口径。工具调用从模型发出到进 handler 的完整路径,见 Agent Loop 解剖 与 function calling 工具系统;当 子代理编排 Fork 出多个并发代理时,每个子进程应继承同一份沙箱剖面,隔离边界随进程树天然传导。本系列的里程碑地图与阅读顺序见 agent-internals 索引:coding agent 权限沙箱系列导航。
总结
coding agent 权限沙箱必须分两层设计:进程内的 PermissionPolicy 用 allow、deny、ask 三模式在工具分发前裁决,ask 在无人值守下按拒绝处理且拒绝事件照入审计;操作系统沙箱则以 (deny default) 白名单剖面随进程安装进内核,让包括 shell 子进程在内的所有写操作都按真实路径接受裁决,profile 编写要处理好 process-fork 等进程授权与 /var 到 /private/var 的 realpath 匹配。真机对照显示,Seatbelt 在不影响工作区与临时目录写入的前提下,把 home 目录从基线 ALLOWED 收成 DENIED,而 /etc 的拒绝本就是 UNIX 权限的功劳、不能冒认;两层的信任根与失效路径相互独立,策略层被绕过时沙箱仍在,剖面配宽时策略仍拦,Linux 上的 bwrap 以命名空间和绑定挂载提供同构的默认收紧边界。