同一份权限档案,为什么在三台机器上跑出三种命令形态?
配置里写的是同一份档案(工作区可写,网络收紧),模型发出同一条 git status:
| 平台 | 实际形态 |
|---|---|
| macOS | 套上 /usr/bin/sandbox-exec,策略走 -p,路径走 -D,用户命令在 -- 后 |
| Linux | 整份档案序列化进 --permission-profile,交给 helper |
| Windows | 开关关着 → SandboxType::None,原 argv 出门 |
因为从档案到命令之间隔着一次编译,而这次编译要先回答两个问题:这台机器要不要上沙箱?这台机器有没有可用后端?
排障最容易在这里走歪:日志里档案还在,模型上下文里的 environment_context 也还在,看起来一切正常——只是平台层没把档案编译成包装命令。你以为沙箱失效了,实际上它从来没被装上过。
| 能力 | 判据 | 对应源码位置 |
|---|---|---|
| 两问拆分 | 拿到一次沙箱未生效,能分别说出"要不要"和"有没有"的答案 | sandboxing/src/manager.rs L293–L329 |
| 出口识别 | 能说清同一份档案的两个出口各自服务谁、边界在哪 | manager.rs L365–L459、context/environment_context.rs L96–L116 |
| 运算选择 | 能判断一个权限合并该用并集还是求交 | policy_transforms.rs L90–L214 |
| 模板隔离 | 能解释为什么路径必须走 -D 而不是拼进 SBPL 正文 | sandboxing/src/seatbelt.rs L1022–L1028 |
| 挖空完整性 | 能背出挖空一条路径要挡住的三种绕法 | seatbelt.rs L548–L555、L504–L509 |
| 失败倒向 | 能对比三个项目在"缺后端"时各自倒向哪一边 | manager.rs L62–L76;DSH / Grok 对比章节 |
函数一:should_sandbox → 布尔(manager.rs L310–L329、policy_transforms.rs L541–L561)
| 三态 | 结果 | 附加条件 |
|---|---|---|
Forbid | 恒假 | — |
Require | 恒真 | — |
Auto | 看档案形状 | 有托管网络要求 → 必须上;网络收紧且非"调用方自管文件系统" → 上;网络放开且文件系统不受限 → 跳过 |
函数二:get_platform_sandbox → 可选后端(manager.rs L62–L76)
MacosSeatbelt(没有总开关)空的语义是"这台主机没有可派发的实现",不是"不需要"。
select_initial 先问前者,再把后者的空收成 SandboxType::None。档案说需要,主机给不出,类型仍是 None。
为什么不报错:要不要是策略问题,有没有是能力问题,失败后果不同。策略要但能力给不出,是可接受的降级——决策权交给后面的策略层,而不是让沙箱层直接掐死命令。
PermissionProfile 三变体(protocol/src/models.rs L411–L425)写的是谁负责建外层沙箱:
| 变体 | 含义 |
|---|---|
Managed | Codex 自己拼包装命令 |
Disabled | 不要外层 |
External | 文件系统由调用方负责,网络仍可能归 Codex 管 |
出口一:包装命令(manager.rs L365–L459)
None → 原样传出 argv,连本机 cwd 都不准备(未沙箱的请求可能带着外机路径)/usr/bin/sandbox-exec,策略 -p、路径 -D、用户命令 -- 后;PATH 上放同名二进制没用--permission-profile,helper 缺失直接报错,不按无沙箱降级transform_for_direct_spawn出口二:模型可见的 environment_context(environment_context.rs L96–L116)
Disabled 渲染成 type="disabled" + unrestricted 文件系统。
模型可见文本挡不住越权,只减少无效尝试。 真正的墙在子进程入口。
平台给不出实现时,渲染给模型的说明不会改口——它按档案描述意图,不按主机能力撒谎。
| 运算 | 规则 | 回答的问题 |
|---|---|---|
| 并集(合并) | 任一侧开网 → 结果开网;文件系统条目首尾相接去重 | 这条命令本次开多大的口 |
| 求交(批准) | 网络需两侧都开才留下;文件系统只保留落在请求范围内的已批条目 | 人批下来的有没有宽过请求 |
为什么批准必须求交:用并集的话,一次误批就能把请求里根本没有的路径写进会话授权。开口是这次的事,批准会沉淀成后续每一轮的默认——后果量级完全不同。
空集的含义:会话不记账,基座档案继续生效 → 这次额外开口没留下任何加宽。不要解释成"改成最严",也不要解释成"整条命令被禁止";命令能不能跑要看后面的策略和沙箱。
SBPL 里括号、引号、分号都是语法。工程放在 ~/work/app (copy) 这类目录里,路径一旦插进策略正文,用户目录名就变成语法 → sandbox-exec 解析失败,模型看到的是包装器错误,不是沙箱拒绝(最难查的一类失败)。
解法:动态读写规则只往正文写 (param "WRITABLE_ROOT_0") 这类键名;真实路径写成 -DKEY=value,成为独立 argv。排除子路径同样走参数表。正文和 -D 必须同时出现——测试把这件事锁成合同。
通用原则:安全策略如果最终是一段文本,用户可控的字符串不要进这段文本。模板只含占位符,真实路径/域名/端口走另一份参数表。与 Seatbelt 语法无关。
四份 .sbpl 用 include_str! 编进二进制当静态基线(seatbelt.rs L21–L27),第一句业务规则是 (deny default)——没写明的事一律拒绝。子进程继承这份底稿,sandbox-exec 拉起的 bash 再 fork 出来的子孙仍在同一套规则里。
动态段按本次的可读可写根现拼,拼接顺序固定(seatbelt.rs L985–L1012):基线 → 读 → 写 → 网络。全盘可读才加 preferences;受限读才加平台默认路径;祖先的 file-write-unlink 放最后,避免前面更宽的 allow 把 rename 要用的 unlink 重新打开。
关键不是顺序本身,是顺序背后的合同:更窄的 deny 必须能压住前面更宽的 allow。
排除子路径时 require-not 要写两条,一起放进 require-all(seatbelt.rs L548–L555):
| 规则 | 管什么 | 少写的后果 |
|---|---|---|
literal | 目录自己 | mkdir .codex 会成功——目录还不存在,管内容的规则没有适用对象 |
subpath | 目录下面的内容 | 目录里的内容不受保护 |
deny file-write-unlink(可写根上) | 改名搬走 | rename 能把只读子树挪出挖空区,下次按原路径授权时实际写的是链接对面 |
三条一起生成,用测试锁住形状。 少一条就漏一种绕法,而这种漏法只有在有人刻意尝试时才暴露。
其他硬约束:
.git、.agents、.codex(protocol/src/permissions.rs L24–L33)Operation not permitted,能被现有关键词表接住,不要假设另外两个平台也是这句话(sandboxing/src/denial.rs L50–L58)要走代理却没有可用端口 → 返回受限网络段 + 网络基线,不给 (allow network-outbound) 这种整网放行。网络基线还在,只是没有 blanket outbound——宁可让网络不好用,也不为让它能用而放开整网。
| 项目 | 切在哪一层 | 路径进不进文本 | 缺后端 / 失败时 |
|---|---|---|---|
| Codex | 每条命令的 argv(子进程入口) | 不进,走 -D 参数表 | Unix helper 缺失 → 报错不跑;Windows 开关关着 → 收成 None 交给策略层;Seatbelt 准备失败 → 返回错误,命令不裸跑 |
| DeepSeek Harness | 子进程入口,confine 按主机选 runner | 进,经 sbplString 转义反斜杠和双引号后直接嵌进 -p,没有 -D | fail closed,不退回原 argv,文案写明 *refusing to run the command unconfined*;默认 (allow default) 再 (deny file-write*),只收回写权;本机 macOS 做不到"可写根里挖掉 .codex"这一粒度 |
| Grok | 当前进程,apply() 再 install(),注释写明不可逆 | 进文本,控制字符直接失败(避免"沙箱报 active、那条路径却没被挡住") | 平台不支持或 apply 失败 → 打警告、记 apply_failed、继续跑;代价是没法在同一进程里给两条命令两套边界 |
| Claude Code | 每条命令,从 PATH 找 sandbox-exec(不写死 /usr/bin) | 进文本,用 JSON.stringify 包一层 | 从 (allow file-read*) 往下加 deny,再在 deny 里 re-allow,后写规则优先;同样生成 deny file-write-unlink |
差别本质:把不可逆的决策放在哪一层。放在进程入口,粒度粗但简单;放在每条命令,粒度细但复杂。
默认方向也不同:Codex 从 (deny default) 往上加 allow(付生成器复杂度,换用户路径不进语法);Claude 从 allow 往下加 deny(付"后写顺序必须对",换配置面接近 allowAllExcept);DSH 默认 (allow default) 再收回写权。
沙箱管理器
SBPL 拼装
literal + subpath 两条都有?可写根上有没有 deny file-write-unlink?约束一:行号是一次快照。 全部对应 openai/codex 仓库 commit 4f39251a01,核对日期 2026-08-22。
约束二:macOS 没有总开关。 get_platform_sandbox 直接返回 MacosSeatbelt,那个布尔开关只对 Windows 有意义。别把 Windows 的开关语义套到 macOS 上。
约束三:None 不等于"不需要"。 它的语义是"这台主机没有可派发的实现"。把它当成"策略允许不开沙箱"来读,会得出错误的安全结论。
约束四:模型可见文本不是安全边界。 它只减少无效尝试。任何把"模型看到了限制描述"当成"限制已生效"的推理都是错的。
约束五:跨平台错误文案不同。 Operation not permitted 是 macOS 的常见文案,关键词表能接住;别假设 Linux / Windows 也是同一句。
约束六:Seatbelt 挡不住被写宽的 allow。 它挡得住策略里拒绝的操作,挡不住策略本身开得太宽,也挡不住模型在可写根里改业务代码。
把"要不要"和"有没有"写成两个函数,一个返回布尔,一个返回可选的后端名。这条最便宜、收益最大——它直接决定了你以后能不能在三分钟内定位沙箱问题。
模板只放占位符,真实路径、域名、端口走另一份参数表。成本接近于零,今天就能改。
开口用并集,批准用求交。两者后果量级不同,运算规则就必须不同。空集表示"没留下加宽",别解释成最严或禁止。
节点、子孙、搬家,三条一起生成。少一条就漏一种绕法,而这种漏法只有在有人刻意尝试时才暴露——所以必须用测试锁住形状,不能靠人工记住。
先把"需要隔离"和"这台机器能提供什么"写成两个函数:一个返回布尔,一个返回可选的后端名。一份权限档案同时喂给包装命令和模型——模型那一侧只负责减少无效尝试,不负责挡住越权,平台给不出实现时它也不会改口。macOS 上的沙箱是一段拼出来的 SBPL:路径走 -D,正文只留占位符;挖空要同时挡住节点、子孙和搬家。Seatbelt 挡得住策略里拒绝的操作,挡不住被写宽的 allow。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页文字稿与音频为同源二次演绎,音频由文本合成,措辞以本页为准。