Zero 架构 · 第十四课

把命令关进笼子:默认拒绝 + 一张长到眼晕的白名单

第十三课的引擎判完「放行」,只是说了句「可以跑」。这一课接着问:「跑」到底怎么被那层原生沙箱包起来? 答案在 runner.go:它把一个 CommandSpec(要跑什么)先编译成一个纯数据的 CommandPlan runner.go:32,再由 CommandContext runner.go:70 变成真正的 exec.Cmd

对齐 mission:第十三课说「有真实 OS 隔离包着命令,才敢自动放行」。这一课就是那层隔离的实体。 你会看到一个残酷的工程真相:一个「默认全拒」的沙箱理论上最安全,但会把几乎所有真实工具都跑挂。 所以真正的沙箱 = 默认拒绝 + 一张经验长出来的允许清单。这张清单怎么长的,就是这一课。

先分两步:计划(纯数据)与执行(真进程)

BuildCommandPlan runner.go:106 不启动任何东西,只产出一个可 JSON 序列化的 CommandPlan。 计划里带着几个诚实字段:Wrapped(到底包没包)、EnforcementLevelDowngradeReason runner.go:38。 把「算出怎么跑」和「真的去跑」拆开,好处是计划可被检视、审计、测试 —— 不必真执行就能看清「这条命令会不会被包裹」。

诚实降级:计划绝不谎报 Wrapped 若强制级别是 disabled/degraded、或后端不可用,buildPlatformCommandPlan runner.go:163 直接返回一个 directCommandPlan —— Wrapped: false runner.go:247,原样透传、不包裹。 这正是第十三课 shellSandboxActive镜像的同一事实:引擎判断说「已沙箱化」,当且仅当 runner 真的会包。 声称的安全和实际的安全,在这两处必须一致 —— 否则就把裸奔命令当有保护放行了。

按平台分派:一个后端一台包裹机

buildPlatformCommandPlan 的 switch runner.go:166 按后端选笼子:Linux 的 bwrap(bubblewrap,:167)、macOS 的 seatbelt / sandbox-exec(:171)、 Windows 的受限令牌(:175)。 包裹方式各不同,但产出的都是同一个 CommandPlan 形状 —— 上层拿到的接口一致,平台怪癖关在各自的 plan 构造里。 (这与第九课「一个 Provider 接口喂饱各家模型」是同一种手法。)

绝不二次包裹:re-entrancy 守卫 被包裹的进程再 spawn 子命令时,若检测到自己已在沙箱里(ZERO_SANDBOXED=1 + backend 环境变量,见 IsAlreadySandboxed runner.go:134), 就把偏好设成 Forbid、返回透传计划。原因很实在:嵌套的平台包裹器会直接失败,而且第二层沙箱纯属冗余。

笼子的实体:(deny default) 打头,其余全靠显式允许

以 macOS seatbelt 为例,整张 profile 由 seatbeltProfileFromPermissionProfile runner.go:561 拼出,第一条就是默认全拒 runner.go:565:

(version 1)
(deny default)              // ← 什么都不许,除非下面显式 allow
(allow process*)
(allow signal)              // 见下方注释:内核仍按 UID 兜底
...
readRule / writeRule / networkRule

文件系统边界就是这里的 writeRule runner.go:682:受限时只允许写工作区那几个根 + 临时目录 + 一批标准字符设备 (/dev/null 之类,runner.go:476);网络则由 networkRuleForProfile runner.go:803 一句 (deny network*) 默认掐断。笼子的四壁 = 默认拒绝;门上的洞 = 逐条 allow。

为什么这张白名单长到眼晕 —— 每一行都在补一个真实翻车

纯粹的 (deny default) 会让真实工具寸步难行,于是 profile 里堆了大量看似莫名的允许,而每一条都对应一次被诊断出的失败:

架构启示:沙箱的松紧是经验长出来的,不是设计出来的 你没法一次想全「一个 shell 命令会碰哪些系统资源」。所以这张 allowlist 是被真实工具的崩溃反向逼出来的, 每个洞都带一句注释说明它修的是哪次翻车。太紧的沙箱不是安全,是不可用。 真正的功夫,是在「默认拒绝」的骨架上,精准地只开必需的洞 —— 且每个洞都能论证它破坏文件/网络边界。
边界是文件系统/网络,不是环境变量 sandboxEnvironmentForCommand runner.go:377 默认继承调用方环境(:380)—— 沙箱的边界是 profile 里的文件/网络规则,不靠擦环境变量。同理 (allow signal) runner.go:592 敢放行发信号,是因为内核仍按 UID 兜底:沙箱命令以用户身份跑,只能杀用户自己的进程。 每处放宽,都靠「另一层机制仍在兜底」来论证其安全。

动手回忆

为什么 BuildCommandPlan 只产出纯数据计划,而不直接启动进程?

seatbelt profile 里那一大堆看似莫名的 allow 规则,本质是什么?

一个已被沙箱包裹的进程再 spawn 子命令时,为什么不再包一层?

接下来该读的一手源码

less +106 ../zero/internal/sandbox/runner.go   # BuildCommandPlan:编译成纯数据计划
less +156 ../zero/internal/sandbox/runner.go   # buildPlatformCommandPlan:按后端分派 + 诚实降级
less +134 ../zero/internal/sandbox/runner.go   # IsAlreadySandboxed 守卫:绝不二次包裹
less +561 ../zero/internal/sandbox/runner.go   # seatbeltProfile:(deny default) + allowlist 拼装
less +830 ../zero/internal/sandbox/runner.go   # macosPlatformReadRoots / :896 祖先 stat 洞:每行补一次翻车

读的顺序:先看 CommandPlanWrapped/EnforcementLevel 字段 —— 理解「计划诚实报告包裹与否」。 再看 buildPlatformCommandPlan 的 switch 怎么按后端选笼子、怎么在 degrade 时透传。 最后逐条读 seatbelt profile 里的注释 —— 那是全课精华:(deny default) 之后每一个 allow 都在讲「不开这个洞,哪个真实工具会怎么挂」。

我是你的老师 —— 随时问我。 适合现在追问: 「PermissionProfile 怎么从 Policy + scope 算出来(profile.go/pathlists.go)?」、 「Classify(risk.go)怎么认出破坏性/触网命令,好让第十三课的判定链拦下它?」, 或者「MonitorDenials 那套 denial tag + log stream 怎么把沙箱到底拦了什么捞回来给用户?」