在 macOS、Linux、Windows 上给 agent 做沙箱

对用户的承诺一句话就能说完:当 agent 运行一条 shell 命令时,它不该能读写你没允许的东西。这是产品层面的一句话。但在它底下,藏着 FutureOS 里最让人谦卑的工程之一——因为根本不存在「一个沙箱」这种东西。只有三个操作系统,各自对「隔离」有着完全不同的理解,而一套规则模型必须经得起被翻译成这三套语言。

这篇文章讲我们是怎么做的,包括那些没能完全奏效的部分。

FutureOS 编辑器中的审批模式下拉菜单,显示 Manual、Sandboxed、Unrestricted

一个设置,三种行为。同一个「Sandboxed」档位在 Mac 上是 Seatbelt,在 Linux 上是 Bubblewrap,在 Windows 上只是写保护。

一套规则模型,三个后端

一切都从一套平台无关的规则集开始。一条规则就是一个路径、一种访问(读、写,或两者)、一个动作(允许、询问,或拒绝):

{
  "version": 1,
  "rules": [
    { "path": "dist", "access": "write", "action": "allow" },
    { "path": "~/notes", "access": "write", "action": "ask" },
    { "path": "private-data", "action": "deny" }
  ]
}

规则放在两个文件里——一个在工作区、一个在主目录——这样项目规则可以提交进 git,个人规则留在自己手里。有一条优先级:内置守卫高于会话授权,会话授权高于工作区规则,工作区规则高于用户规则。一份内置守卫清单把那些显而易见的凭据(.ssh.aws.env.npmrc、kubeconfig 等等)标为读写都「总是询问」,而且再宽的目录允许也抬不动一条守卫。

这套模型是容易的部分。难的部分在于:当一条命令真正运行时,这套抽象规则必须变成内核真的会执行的东西——而内核在每个操作系统上说的是不同的语言。

macOS Linux Windows
后端 Seatbelt Bubblewrap 受限令牌 + NTFS ACL
Shell 读保护 SBPL 路径规则 遮蔽挂载 不提供
Shell 写保护 动态 SBPL 规则 只读根 + 可写挂载 能力 SID 写边界

把最后一行再读一遍。它是全篇最重要的一行。

macOS:最像你想象的那个

Seatbelt 最接近你脑子里那个沙箱。规则集被编译成一份 SBPL profile——就是操作系统给自家守护进程用的那套沙箱配置语言——然后 sandbox-exec 把命令启动在里面。你拒绝的路径是真的读不了也写不了,因为内核在系统调用那一层就拒绝了。

真正的棱角不在强制执行,而在「逃逸」。有时一条正当的命令确实需要跨出沙箱——比如某个构建工具要往意想不到的地方写。我们用一次显式的、一次性的提权来处理:整条命令在 OS 沙箱之外运行一次,且只有你在卡片上批准之后才发生。全局档位不变。我们对卡片的措辞也很较真:你批准的是「这条确切的命令,在沙箱外,仅此一次」——不是「这个路径」,更不是「永久」。

Linux:真正的隔离,外加一笔兼容性账单

Linux 上后端是 Bubblewrap。规则计划把规则集变成一个挂载命名空间:只读根、工作区和临时目录以可写方式绑定挂载、被拒路径被遮蔽。这是货真价实的隔离——user、PID、IPC 命名空间,能力被剥掉,一个崭新的 /proc

代价不在沙箱本身,而在它周围的一切。我们只支持系统自带的 Bubblewrap——不打包、不下载二进制、不静默回退到 Landlock。这意味着在这个选项能被选中之前,要先过三层可用性探测:在安全的 PATH 上找到二进制、确认版本不低于 0.9.0 且必需参数存在、然后真正跑一个微型命名空间证明宿主允许这么做。每种失败都有专属的错误码(binary_missinguser_namespace_disabledversion_too_old……),于是设置页能说清为什么以及该怎么办,而不是丢给你一句没用的「不可用」。

还有一个花了大力气才弄对的正确性细节:挂载命名空间隔离的是挂载,不是宿主目录的内容。如果一个被拒路径是被 glob 匹配到的,我们只能保护命令启动那一刻已经存在的路径。命令运行中途新建、但本该匹配该 glob 的文件,只能靠事后扫描发现,无法事先阻止。我们把这点写明了,而不是宣称 glob 是一道硬边界。

Windows:我们说真话的地方

Windows 是最诚实的一个,因为我们给不了你另外那两个能给的东西。不需要管理员权限的做法是受限令牌加 NTFS ACL:我们造一个带能力 SID 的令牌,给受保护路径加上拒绝 ACL,再用它启动 shell。它能用,也确实有用——但它是写保护,不是读沙箱。Windows 上的 shell 仍然能读你当前用户能读的任何东西,并通过网络发出去。

我们本可以装傻。设置的文案本可以写一句「沙箱」就算了。但实际上,这个档位在 Windows 上就叫「写保护」,文档里明说了不提供 shell 读保护,而且那份敏感守卫清单在 Windows 上被明确标注为不是读拒绝的保证。当用户在 Windows 上批准某件事时,卡片会列出具体的写目标——最多八个,没有「以及另外 N 个」来掩盖范围——因为在一个只控写的边界上,一次含糊的批准比没用还糟。

这是一个深思熟虑的选择。另一种真能给隔离的方案(一个提权的、独立的沙箱账户)需要配置和 UAC 弹窗,而我们判断那种摩擦比一份诚实的部分保护更糟。我们也许会重新考虑。但在它存在之前,我们不会宣称它存在。

承诺打折扣的地方

有几个缺口我们是睁着眼睛发出去的,而不是等着在 bug 报告里被发现:

  • Windows 没有 shell 读保护。 上面说过,值得重复。
  • 用户批准的整条命令提权不受 OS 规则约束。 一旦你在 macOS 或 Linux 上批准了在沙箱外运行,内置守卫对那一次运行就不再适用。
  • 凭据不得不放行。 auth.json 曾被临时移出硬拒绝清单,因为官方 CLI 自己的测试需要它。默认读可能暴露凭据,我们不会把这个临时例外宣传成安全边界。
  • 一次规范化不等于无竞态的证明。 符号链接按它的最终目标来判定,但在检查和使用之间目标可能变化;Linux 和 Windows 在执行时会重新检查对象,把这个窗口收窄。

这些没有一个是秘密。它们写在文档里、设置文案里,现在也在这里。

为什么不只挑一个?

显而易见的问题:为什么维护三套机制,而不是发布一个行为处处一致的打包沙箱?因为一个打包的、下载来的沙箱二进制本身就是攻击面和支持负担,而且每个 OS 上原生的机制,恰恰是那个已经被平台厂商维护、审计、并且已经装在你机器上的东西。代价是我们的规则模型必须能优雅降级——NTFS 的「拒绝优先」表达不了 SBPL 能表达的每一种「首个匹配例外」,所以 Windows 的计划会显式记录下哪些规则它无法执行,而不是静默丢弃。

产品目标从来不是「一座能抵御敌意宿主的堡垒」。它比那更简单、也更有用:合理的保护、顺手的开发流程,以及不在两者之间对用户撒谎。在 Mac 上你得到的是真家伙。在 Linux 上你得到真家伙外加一笔兼容性账单。在 Windows 上你得到的是诚实的写保护。这个设置看起来一样;它的含义不一样——而我们认为你有权知道这个差别。


共享规则和审批协议记录在 [SANDBOX/COMMON.md](https://github.com/futuregene/future-os/blob/main/docs/internals/desktop/SANDBOX/COMMON.md);各平台实现见 [SANDBOX/](https://github.com/futuregene/future-os/tree/main/docs/internals/desktop/SANDBOX)。Linux 后端在 [#496](https://github.com/futuregene/future-os/pull/496) 合并。