Zero 架构 · 第十四课
第十三课的引擎判完「放行」,只是说了句「可以跑」。这一课接着问:「跑」到底怎么被那层原生沙箱包起来?
答案在 runner.go:它把一个 CommandSpec(要跑什么)先编译成一个纯数据的
CommandPlan
runner.go:32,再由 CommandContext
runner.go:70 变成真正的 exec.Cmd。
对齐 mission:第十三课说「有真实 OS 隔离包着命令,才敢自动放行」。这一课就是那层隔离的实体。 你会看到一个残酷的工程真相:一个「默认全拒」的沙箱理论上最安全,但会把几乎所有真实工具都跑挂。 所以真正的沙箱 = 默认拒绝 + 一张经验长出来的允许清单。这张清单怎么长的,就是这一课。
BuildCommandPlan
runner.go:106 不启动任何东西,只产出一个可 JSON 序列化的 CommandPlan。
计划里带着几个诚实字段:Wrapped(到底包没包)、EnforcementLevel、DowngradeReason
runner.go:38。
把「算出怎么跑」和「真的去跑」拆开,好处是计划可被检视、审计、测试 —— 不必真执行就能看清「这条命令会不会被包裹」。
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 接口喂饱各家模型」是同一种手法。)
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 里堆了大量看似莫名的允许,而每一条都对应一次被诊断出的失败:
securityd/opendirectoryd/cfprefsd,碰钥匙串、查用户组、读偏好的工具全挂 —— 但这些都不给文件或网络权限,工作区边界不受影响。/opt/homebrew、/usr/local 要可读可 map-executable,否则一个 Homebrew 装的 node/python3 根本起不来。realpath() 会 lstat 路径的每一层祖先,少了它 Homebrew 的 python3 会以 "realpath: /opt/homebrew/bin/: Operation not permitted" 启动失败 —— 而只 exec() 的 node 不受影响,这个不对称正是问题被诊断出来的线索。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 洞:每行补一次翻车
读的顺序:先看 CommandPlan 的 Wrapped/EnforcementLevel 字段 —— 理解「计划诚实报告包裹与否」。
再看 buildPlatformCommandPlan 的 switch 怎么按后端选笼子、怎么在 degrade 时透传。
最后逐条读 seatbelt profile 里的注释 —— 那是全课精华:(deny default) 之后每一个 allow 都在讲「不开这个洞,哪个真实工具会怎么挂」。
PermissionProfile 怎么从 Policy + scope 算出来(profile.go/pathlists.go)?」、
「Classify(risk.go)怎么认出破坏性/触网命令,好让第十三课的判定链拦下它?」,
或者「MonitorDenials 那套 denial tag + log stream 怎么把沙箱到底拦了什么捞回来给用户?」