Zero 架构 · 第十三课

沙箱:策略 + 授权 + 检查,合成一个 Allow/Prompt/Deny

第二课的 executeToolCall 有一道关卡问沙箱:「这个工具调用能跑吗?」这一课就拆开回答那句话的引擎。 它的全部产出是一个 Decision{Action: Allow | Prompt | Deny} —— 放行、追问用户、或直接拒。 难点不在任何单条规则,而在这些规则以什么顺序叠加:一条严格有序的判定链 Engine.Evaluate engine.go:277

对齐 mission:一个自动跑命令的 coding agent,最危险的一步就是「执行」。既不能每条命令都烦用户点确认(那就退化成手工), 也不能默默放行一条 rm -rf。这一课的功夫,是把「什么时候敢自动放行、什么时候必须追问、什么时候直接拒」 编码成一条顺序即语义的链 —— 顺序错了,安全就漏了。

核心形状:短路优先,越危险的判断越靠前

Evaluate 从上到下一路 return,谁先命中谁说了算。顺序是刻意设计的 —— 拒绝类的判断必须排在放行类之前, 否则一条本该拒的请求会被前面的「放行」提前接走。链条骨架:

顺位判断结果
1ctx 已取消 :281Deny
2engine 为 nil / ModeDisabled :285,:299Allow(沙箱关)
3Permission==Deny :302Deny
4持久化 deny 授权命中 :312Deny
5路径检查(工作区边界 / Deny·Allow 路径表):334,:338Deny 或攒起可追问块
6网络拒绝 :350shell→Prompt,否则 Deny
7破坏性 shell 命令 :356Prompt / Deny
8持久化 allow → 会话 allow :374,:383Allow
9攒起的可追问路径块 :392Prompt
10工作区内写自动放行 :398Allow
11原生沙箱激活 → shell 自动放行 :405Allow
12兜底 :411Prompt
为什么 deny 授权在检查之前、allow 授权在检查之后 持久化 deny 在第 4 位就短路(:312):用户说过「永远别碰这个」,那连路径、网络检查都不必跑,直接拒。 但持久化/会话 allow 却压到第 8 位(:374)—— 在网络、破坏性、路径检查之后。 含义很硬:一条「允许授权」绝不能盖过网络封禁或破坏性拦截。用户授权跑某工具 ≠ 授权它连外网或跑 rm -rf。 拒绝能被授权提前豁免,放行不能提前豁免安全检查 —— 这就是顺序编码的安全语义。

顺序即正确性:一处「先查 ctx」的呼应

和第十二课那条「恢复逻辑先查 ctx.Err()」一模一样的纪律,在这里是判定链的第一步 :281:取消优先于一切业务判断。 一个在关停中的 agent,不该还在纠结「这条命令要不要追问」—— 先认取消,直接 deny 收场。

最精妙的一条:原生沙箱激活,shell 才敢自动放行

第 11 位是整条链的点睛之笔 :405:一条普通 shell 命令(网络/破坏性/路径都已在前面放过) 到底自动放行、还是追问用户?答案取决于 shellSandboxActive :223:

if !(backend.Available && backend.Executable != "" &&
     backend.CommandWrapping && backend.NativeIsolation) {
    return false          // ← engine.go:231:四个条件缺一不可
}
安全边界从「问用户」换成了「操作系统」 逻辑是:如果有一个真实的 OS 级隔离(Linux 的 landlock/seccomp、macOS 的 sandbox-exec、Windows ACL)包着这条命令, 那命令就算乱来,也被内核关在笼子里 —— 此时每条都追问用户纯属噪音,可以自动放行。 反过来,后端不可用(没装、没初始化),就没有笼子,只能退回「每条命令问一次」的审批底线 :404安全边界要么是用户的点击、要么是内核的隔离 —— 二者必居其一,绝不落空。

Windows 上有个诚实的降级 :240:命令要等 zero sandbox setup 写下标记后才真正被包裹; 标记没写时,引擎拒绝把它当「已沙箱化」自动放行,而是 fall through 去追问。 注释点破了原因:否则引擎会把一条其实在裸奔的命令当成「有沙箱保护」放行,直接击穿上面那条不变量。 引擎的判断必须与运行时真实发生的包裹严格镜像 —— 声称的安全和实际的安全必须一致。

不重复策略:Precheck 复用 Evaluate

想在执行先问一句「这会被拦吗?」的调用方(批量确认、预检),不另写一套判断,而是 Precheck :252 直接调 Evaluate 再把 deny 抽成 Block :262。 一份策略,一个真相来源 —— 预检和真判定永不漂移。这和第十一课「流式与非流式共用一次遍历」是同一种「单一实现」的洁癖。

动手回忆

为什么持久化 deny 授权短路在前,而 allow 授权排在检查之后?

一条普通 shell 命令什么时候被自动放行而不再追问用户?

Windows 上标记未写时,引擎为何拒绝把 shell 命令当已沙箱化放行?

接下来该读的一手源码

less +277 ../zero/internal/sandbox/engine.go   # Evaluate:严格有序的判定链(全课核心)
less +312 ../zero/internal/sandbox/engine.go   # 持久化 deny 短路 vs :374 allow 后置
less +223 ../zero/internal/sandbox/engine.go   # shellSandboxActive:四条件齐备才算激活
less +240 ../zero/internal/sandbox/engine.go   # Windows 标记未写→降级追问
less +252 ../zero/internal/sandbox/engine.go   # Precheck 复用 Evaluate:一份策略不重复

读的顺序:先通读 Evaluate 从上到下的每个 return,记住「拒在前、放在后」这条脊椎。 再把第 4 位的持久化 deny 和第 8 位的 allow 对读 —— 体会「授权能豁免拒绝、不能豁免检查」。 最后啃 shellSandboxActive 那四个 && 条件和 Windows 降级注释 —— 那是「安全边界要么点击要么内核,绝不落空」的全部落地。

我是你的老师 —— 随时问我。 沙箱这一包很大,这课只讲了「判定」。适合现在追问: 「判定通过后,runner.go 怎么用 landlock/seccomp/sandbox-exec 真正把命令关进笼子?」、 「Classify(risk.go)怎么认出一条命令是破坏性/触网的?」, 或者「授权是怎么持久化、又怎么按 scope 匹配的(grants.go/grant_scope.go)?」