# 沙箱：sandbox-runtime 的分叉

> 仓库内置的 sandbox-runtime 分叉：上游版本与改动、三个平台各自的隔离机制，以及 v2 沙箱服务怎样只在 macOS 上包住 Bash。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.org/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/minimax-code/sandbox
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`third_party/` 下除了 pi-mono，还有一个 `third_party/sandbox-runtime`：Anthropic 开源的沙箱运行时（下文简称 SRT）的分叉，Apache-2.0 许可，18482 行 TypeScript，另带几种预编译的原生 helper。它能在操作系统层面限制一条 shell 命令读写哪些路径、能否联网、能否碰本机的其他服务。

SRT 本身覆盖 macOS、Linux、Windows，但 MiniMax Code 只接上了其中一小块：沙箱默认关闭；打开后只包 Bash 工具；只注册了 macOS 后端；网络一律不限。下面先讲怎么打开，再讲分叉的来历与改动、SRT 在三个平台靠什么隔离，最后跟着 `packages/local-runtime-v2/src/service/sandbox` 看一次 Bash 调用怎样被包进沙箱。

## 怎么用

沙箱只有配置文件一个入口：`config.yaml` 的 `sandbox` 段，由 `parseSandboxConfig` 解析（`packages/config/src/config.ts:2092`）。命令行版的数据目录默认是 `~/.minimax-code`（`packages/tui/src/runtime/data-dir.ts:21`），可用 `MINIMAX_DATA_DIR` 改。TUI 没有开关沙箱的斜杠命令，也不显示沙箱状态；v2 服务虽有运行中改配置的 `applyConfig`，公开仓库里却找不到调用方，所以改完要重启 `mcode`。

| 键 | 取值 | 默认 |
| --- | --- | --- |
| `sandbox.enabled` | 布尔 | `false` |
| `sandbox.filesystem.policy.mode` | `read_only`、`workspace_write`、`delete_guard`、`full_access` | 只写 `enabled: true` 时取 `delete_guard` |
| `sandbox.filesystem.denyRead`、`denyWrite` | 路径数组，支持 `~/` 与 glob | 空 |
| `sandbox.localAccess` | `open`、`restricted` | `open` |
| `sandbox.network.policy.mode`、`sandbox.network.deniedDomains` | 旧键，仍做校验 | 解析后一律改写成不限网 |

示意配置：

```yaml
sandbox:
  enabled: true
  filesystem:
    policy:
      mode: workspace_write
    denyRead:
      - ~/.ssh
  localAccess: restricted
```

几条默认值的出处：所有平台默认关闭、级别 `full_access`（`packages/config/src/sandbox-settings.ts:15`）；开了却没写级别时取 `delete_guard`（`packages/config/src/sandbox-config.ts:91`）；“关闭”与 `full_access` 都被归一成不开沙箱（`sandbox-settings.ts:22`）；段里出现未知键直接报错。旧的网络键校验通过后被改写成 `allow_all`，注释原文（`packages/config/src/sandbox-config.ts:123`）：

> after validation, normalize to unrestricted access. Desktop Sandbox no longer actively restricts networking.

<Callout type="warn">
  目前只有 macOS 有后端。在 Linux 或 Windows 上把 `enabled` 设成 `true`，启动时选不到后端，服务进入 `failed`，此后每次 Bash 调用都以 `SANDBOX_INITIALIZATION_FAILED` 失败，不会退回无沙箱执行。
</Callout>

## 上游与分叉

| 项 | 值 | 出处 |
| --- | --- | --- |
| 上游仓库 | `anthropic-experimental/sandbox-runtime` | `third_party/sandbox-runtime/upstream.json:2` |
| 上游版本 | tag `v0.0.74`，提交 `e5fb1b93` | `upstream.json:3` |
| 分叉包 | `@minimax/mcode-sandbox-runtime` `0.0.74-mcode.2`，私有 | `third_party/sandbox-runtime/package.json:2` |
| 许可 | Apache-2.0，版权行 `Copyright 2025 Anthropic` | `third_party/sandbox-runtime/LICENSE:189` |

分叉的 `third_party/sandbox-runtime/README.md:5` 另记了一个 source revision `630552a3`，与 `upstream.json` 的提交号不同，仓库里没有说明两者的关系。v2 后端把两个版本号写成了常量：运行时 `0.0.74-mcode.2`、上游 `0.0.74`（`packages/local-runtime-v2/src/service/sandbox/backend/srt-macos.ts:65`、`:80`）。注释解释了为什么不在运行时读依赖的 `package.json`：发布出去的 TUI 包里没有这个文件，读不到会让整个沙箱初始化失败。

分叉改了什么，README 第 7、11、13 行列得清楚，代码里都有落点：

| README 所列改动 | 代码落点 |
| --- | --- |
| SecurityServer 控制 | 配置项 `allowSecurityServer` |
| 独立的删除范围 | `unlinkAllowOnly`：先全局禁止 unlink，再只放行给定根，仅 macOS（`third_party/sandbox-runtime/src/sandbox/macos-sandbox-utils.ts:650`） |
| deny 优先的全放行网络 | `allowAll`，deny 规则总是先判 |
| 每次调用传入净化过的环境 | `WrapWithSandboxOptions` 的 `baseEnv` 字段（`third_party/sandbox-runtime/src/sandbox/sandbox-manager.ts:1548`） |
| 调用方提供临时目录 | 启用文件系统策略时 `sandboxTempDir` 必填（`sandbox-manager.ts:1580`） |
| 关掉网络代理时仍保护文件 | 标记为 `MCODE-008` 的 `network.disabled`（`third_party/sandbox-runtime/src/sandbox/sandbox-config.ts:894`） |
| 查找可执行文件不再起 `which` 进程 | `whichSync` 直接遍历 PATH（`third_party/sandbox-runtime/src/utils/which.ts:5`） |
| 只支持 Node | 删掉 Bun 专用的 fetch 代理分支 |

被 MiniMax 改过的文件大多在第一行标注 `// Modified by MiniMax for MCode Sandbox Runtime.`。

### 与 Claude Code 的关系

仓库里能证实的只有这几处。第一，`upstream.json` 指向 Anthropic 的仓库，`LICENSE` 保留 Anthropic 的版权行。第二，上游查找 seccomp helper 的代码注释写着打包进 claude-cli 时的目录布局（`third_party/sandbox-runtime/src/sandbox/generate-seccomp-filter.ts:126`）。第三，必须写保护的目录清单保留了 `.claude/commands` 与 `.claude/agents`，分叉在后面补上 MiniMax Code 自己的同名目录（`third_party/sandbox-runtime/src/sandbox/sandbox-utils.ts:36`）：

```ts
export function getDangerousDirectories(): string[] {
  return [
    ...DANGEROUS_DIRECTORIES.filter((d) => d !== ".git"),
    ".claude/commands",
    ".claude/agents",
    ".mcode/commands",
    ".mcode/agents",
  ];
}
```

第四，`LICENSE-STATUS.md:19` 记录根目录的 `LICENSE` 曾整份拷自 SRT、带着 Anthropic 的署名，2026-09-12 审查时才改正。另外，同仓 pi-mono 的示例扩展 `sandbox/` 依赖的是 npm 上的 `@anthropic-ai/sandbox-runtime` 0.0.26（`third_party/pi-mono/packages/coding-agent/examples/extensions/sandbox/package.json:17`），那是 pi 自带的示例，与 MiniMax 的分叉没有关系。

## SRT 在三个平台怎样隔离

| 平台 | 机制 | 原生 helper |
| --- | --- | --- |
| macOS | 生成 SBPL（Seatbelt）配置，以 `deny default` 规则起头再逐条放行，命令经 `/usr/bin/sandbox-exec` 加 `-p` 参数启动 | 无，用系统自带的 `sandbox-exec` |
| Linux | bubblewrap：只读绑定根目录再按规则放行写入，`--unshare-pid`；要限网时 `--unshare-net`，用 socat 把沙箱里的 3128、1080 端口桥到宿主代理；最后由 `apply-seccomp` 套一层 seccomp，禁止新建 Unix socket | `vendor/seccomp/{x64,arm64}/apply-seccomp`，C 源码在 `vendor/seccomp-src/` |
| Windows | `srt-win.exe` 建一个专用本地用户 `srt-sandbox`，按它的 SID 装 WFP 过滤器管出站，经 `CreateProcessWithLogonW` 两跳启动受限令牌子进程；文件规则靠给这个 SID 加显式 ACE | `vendor/srt-win/{x64,arm64}/srt-win.exe`，Rust 源码在 `vendor/srt-win-src/` |

对应出处：macOS 配置头在 `macos-sandbox-utils.ts:718`，`sandbox-exec` 的调用在 `macos-sandbox-utils.ts:1234`；Linux 两阶段结构见 `third_party/sandbox-runtime/src/sandbox/linux-sandbox-utils.ts:1655` 的长注释，网络桥见 `linux-sandbox-utils.ts:589`；Windows 见 `third_party/sandbox-runtime/src/sandbox/windows-sandbox-utils.ts:34`。WSL1 不支持 bubblewrap，被判为不支持的平台（`sandbox-manager.ts:981`）。另一个 helper 是 JVM 代理 agent `srt-proxy-agent.jar`：JVM 不读 `HTTPS_PROXY`，SRT 通过 `JAVA_TOOL_OPTIONS` 把它挂进每个 Java 进程（`third_party/sandbox-runtime/src/sandbox/java-proxy-agent.ts:2`），分叉里它的 Java 包名是 `com.minimax.mcode.srt`。

这些二进制随源码提交，SHA-256 登记在 `third_party/sandbox-runtime/native-integrity.json`，`check:source` 逐个比对（`scripts/source-inventory.mjs:105`）；构建时整份拷进产物的 `vendor/` 目录（`scripts/build.mjs:123`）。不过 Linux 与 Windows 的 helper 虽然随包发布，下一节会看到 v2 服务根本没有为这两个平台注册后端。`rm` 垫片的注释也直说了 Windows 没有沙箱（`packages/local-runtime/src/infra/ensure-rm-shim.ts:27`）。

## 一次 Bash 调用怎样进沙箱

```mermaid
flowchart TD
  A["bash 工具（local-pi-tools.ts）"] --> B["sandboxOperationsFactory.create()"]
  B --> C["LocalSandboxService.beginInvocation()"]
  C -->|未启用| D["原生租约"]
  C -->|已启用| E["解析调用上下文：工作区、git 目录、会话临时目录"]
  E --> F["srt-macos 后端 wrap()"]
  F --> G["SandboxManager.wrapWithSandbox()"]
  G --> H["env ... /usr/bin/sandbox-exec -p 配置 bash -c 命令"]
  D --> I["pi 的原生 exec"]
  H --> I
```

1. **端口先于服务存在**：运行时启动早期就用 `createDeferredLocalSandboxBashOperationsFactory` 建好一个延迟端口（`packages/local-runtime-v2/src/runtime.ts:131`），v1 的工具图拿它构造 Bash；沙箱服务就绪后才把真正的工厂 `bind` 进去（`packages/local-runtime-v2/src/service/sandbox/initialize.ts:97`）。绑定之前，配置要求开沙箱就直接报错，否则原生执行（`packages/local-runtime-v2/src/service/sandbox/deferred-port.ts:17`）。
2. **工具侧**：前台 `bash` 用工厂 `create()` 出来的 `operations` 构造 pi 的 `createBashTool`（`packages/agent-tools/src/desktop/local-pi-tools.ts:482`），后台 Bash 也走同一个端口。调用身份分 `direct_foreground`、`managed_foreground`、`explicit_background` 三类（`packages/agent-tools/src/desktop/types.ts:299`）。
3. **受理**：`beginInvocation` 在未启用时返回原生租约，只记一条原因为 `SANDBOX_DISABLED` 的轨迹（`packages/local-runtime-v2/src/service/sandbox/local-sandbox-service.ts:368`）；启用时检查生命周期，服务处于 `failed` 或 `initializing` 就拒绝（`local-sandbox-service.ts:880`）。内置的 explore Agent 在这里被单独收窄成 `read_only`（`local-sandbox-service.ts:347`，标记来自 `packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts:257`），不影响全局配置。
4. **包装与执行**：`executeObserved` 调后端的 `wrap()`，SRT 返回一条包好的 shell 字符串，再交回 pi 原生的 `exec` 去 spawn（`deferred-port.ts:79`）。

服务状态里把覆盖面写死成 `bash-only`（`local-sandbox-service.ts:194`）：读、写、编辑这些文件工具不经过沙箱，由[权限系统](https://daiw.org/manual/minimax-code/permission)把关。

## 四档文件系统策略

策略编译器只产出符号化的根，具体路径到调用时才解析（`packages/local-runtime-v2/src/service/sandbox/effective-policy.ts:72`）：

```ts
function compileFilesystemScopes(mode: SandboxConfig['filesystem']['policy']['mode']): {
  readonly allowWrite: readonly SandboxPolicyPathRoot[];
  readonly unlinkAllowOnly: readonly SandboxPolicyPathRoot[];
} {
  switch (mode) {
    case 'read_only':
      // No trash destination: a read-only invocation must not delete at all.
      return { allowWrite: SESSION_TEMP, unlinkAllowOnly: SESSION_TEMP };
    case 'workspace_write':
      return { allowWrite: WORKSPACE_SURFACE_WITH_TRASH, unlinkAllowOnly: WORKSPACE_SURFACE };
    case 'delete_guard':
      // host-root write already covers the trash destination.
      return { allowWrite: HOST_ROOT, unlinkAllowOnly: WORKSPACE_SURFACE };
    case 'full_access':
      return { allowWrite: HOST_ROOT, unlinkAllowOnly: HOST_ROOT };
  }
}
```

这些根在 `packages/local-runtime-v2/src/service/sandbox/invocation-context.ts` 里落成真实路径：`workspace` 是工作区；`git-dir`、`common-dir` 是 `.git` 或 gitfile 指向的目录及其 commondir，还会在工作区里广度优先找嵌套的 linked worktree，深度上限 6、目录上限 2048（`invocation-context.ts:220`）；`session-temp` 是 `$TMPDIR/mcode-sandbox/runtime-<实例>/session-<哈希>`，权限 0700（`invocation-context.ts:439`）；`trash` 在 macOS 是 `~/.Trash`，其他平台是 `$XDG_DATA_HOME/Trash`（`invocation-context.ts:162`）；`host-root` 就是 `/`。

读不设白名单：SRT 的读规则是“拒绝清单加例外”，MiniMax 只填了用户的 `denyRead`，其余路径都能读。几档的区别在写和删：

- `read_only`：只能写会话临时目录，也只能在那里删除。
- `workspace_write`：能写工作区、git 元数据、会话临时目录和回收站；删除只限前三处，回收站只是移动的目的地。
- `delete_guard`：能写整个磁盘，删除（含把文件移走）仍限于工作区、git 元数据与会话临时目录。它配合 Bash 环境里的 `rm` 垫片，把删除变成移进回收站：后端注入 `MAVIS_TRASH_FORCE_MV=1`，让垫片在沙箱里用 `mv` 完成，而不是交给 Finder 或 `gio trash` 这类沙箱外进程——否则内核根本看不到这次删除（`srt-macos.ts:154`）。
- `full_access`：前面说过，等于不开沙箱。

SRT 另有一份写死的写保护清单，不受模式影响：shell 的 rc 文件、`.gitconfig`、`.gitmodules`、`.mcp.json`、`.vscode`、`.idea`、`.git/hooks` 以及上面那几个 commands、agents 目录（`sandbox-utils.ts:13`，在 `macos-sandbox-utils.ts:638` 并入 deny 规则）。`.git/config` 本来也在其中，但 MiniMax 固定传 `allowGitConfig: true` 放开它（`srt-macos.ts:215`）。`.git/hooks` 的写保护会让 `git init`、`git clone` 复制 hook 模板时失败，后端于是把 `GIT_TEMPLATE_DIR` 指向会话临时目录里的一个空目录，让 git 跳过模板复制（`srt-macos.ts:122`）。

## 网络与本机访问

网络策略在编译阶段就被钉死为“不限”（`effective-policy.ts:39`）：

```ts
    network: {
      mode: networkMode,
      // Desktop policy: the sandbox never restricts network access. Any
      // historical config (deny mode, deniedDomains) is already normalized
      // away at parse time; the compiler pins the zero-restriction IR here so
      // backends can skip their network subsystem entirely.
      enforce: false,
      allowedDomains: [],
      deniedDomains: [],
      strictAllowlist: true,
      allowAll: true,
    },
```

`srt-macos` 把 `enforce: false` 翻成 SRT 的 `network.disabled: true`（`srt-macos.ts:248`），SRT 于是不起代理、不注入代理与 CA 环境变量（`sandbox-manager.ts:1686`），Seatbelt 配置直接放行所有网络操作（`macos-sandbox-utils.ts:898`）。SRT 的域名白名单、TLS 终止、凭据掩码这些能力，MiniMax Code 都没有配置。

`localAccess` 管的是本机：`open` 时 `enableWeakerNetworkIsolation`、`allowAppleEvents`、`allowSecurityServer`、`allowPty` 全开，并放行全部 Unix socket 与本地端口绑定；`restricted` 全部关掉（`srt-macos.ts:221`、`:252`）。默认是 `open`。SRT 对 `allowAppleEvents` 的说明值得一读（`third_party/sandbox-runtime/src/sandbox/sandbox-config.ts:1145`）：

> This removes code-execution isolation: sandboxed commands can launch other applications unsandboxed with no user prompt (launched apps are not subject to the sandbox filesystem or network restrictions), and can script running apps subject to TCC automation consent.

也就是说，默认配置下沙箱里的命令仍能用 `open`、`osascript` 拉起不受约束的其他程序；想要更严的隔离，得显式写 `localAccess: restricted`。

## 生命周期与观测

- **串行事务**：配置变更排成一条事务链，写盘与发布期间关闭 commit gate，新调用在门外等待（`local-sandbox-service.ts:360`、`:586`）；每次发布 `effectiveGeneration` 必须递增，快照整体冻结（`packages/local-runtime-v2/src/service/sandbox/backend/effective-state.ts:43`）。变更按字段分三种生效时机：文件系统相关的从下一次调用起、`localAccess` 在包装时、网络立即全局生效（`packages/local-runtime-v2/src/service/sandbox/config-commit.ts:53`）。
- **进程级清理**：SRT 的清理作用于整个进程，服务给后端句柄计数，引用归零且已退役才清理（`local-sandbox-service.ts:977`，`srt-macos.ts:180`）；关闭时最多等在跑的调用 5 秒（`local-sandbox-service.ts:168`）。
- **违规记录**：macOS 上 SRT 的系统日志监视器被关掉，因为它空闲时也在扫日志（`srt-macos.ts:92`）；后端订阅 SRT 的违规存储，按行首区分 Seatbelt 与代理两类，刻意不读命令可控的路径尾部（`srt-macos.ts:338`）。不过从代码看，日志监视器关着、代理也没起，这个存储在 macOS 上实际收不到事件。
- **不拖垮启动**：初始化失败只上报一条 `sandbox-initialization`，控制面照常启动（`packages/local-runtime-v2/src/application/session/runtime-services-lifecycle.ts:131`）。
- **临时目录回收**：遗留的 `runtime-*` 目录若所属进程已退出且超过 24 小时，下次启动时删除（`invocation-context.ts:24`）。

同样基于操作系统沙箱，[Codex 的沙箱与审批](https://daiw.org/manual/codex/sandbox-approvals)把网络开关、审批策略都做成了用户可见的一等配置，可以对照着看 MiniMax 这边的取舍。

下一篇：[浏览器工具](https://daiw.org/manual/minimax-code/browser-use)——模型怎样经 CDP 驱动一台本地 headless Chrome。
