# Windows 沙箱 · 沙箱用户、ACL 与防火墙

> Windows 没有现成的“按策略跑命令”工具，Codex 用三种实现拼出沙箱：unelevated 以当前用户的写受限令牌运行，靠能力 SID 与 ACL 限制写入；elevated 先经 UAC 建两个本地沙箱用户、配好 ACL 与防火墙，再以沙箱用户身份启动 codex-command-runner，由它在私有桌面上派生受限令牌跑命令；mxc 则交给系统的进程安全环境。本篇讲清三者的分工与各自的边界。

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

# Windows 沙箱 · 沙箱用户、ACL 与防火墙

前两篇的 macOS 与 Linux 都有内核现成的隔离原语可用。Windows 这边，`codex-windows-sandbox`（目录 `windows-sandbox-rs`，去掉测试约 1.9 万行）把令牌、ACL、本地用户、防火墙、桌面对象这些 Win32 安全机制组合起来，拼出与另外两个平台同等语义的沙箱。

## 用户看到的样子

在 `config.toml` 的 `[windows]` 表里设 `sandbox = "elevated"`、`"unelevated"` 或 `"mxc"`。选 elevated 时第一次需要管理员授权（UAC 弹窗），TUI 里的 `/setup-default-sandbox` 就是做这件事的。没有启用 Windows 沙箱时，`sandbox_mode = "workspace-write"` 会被降级为只读，隐式默认的配置档也只给 `:read-only`。排查问题看 `CODEX_HOME/.sandbox/` 下按天滚动的日志 `sandbox.<日期>.log`，最多保留 90 份。各模式的取舍见手册的[沙箱、审批与安全](https://daiw.net/manual/codex/sandbox-approvals)。

## 三种实现怎么选

```rust
impl WindowsSandboxLevelExt for WindowsSandboxLevel {
    fn from_config(config: &Config) -> WindowsSandboxLevel {
        match config.permissions.windows_sandbox_mode {
            Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated,
            Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken,
            Some(WindowsSandboxModeToml::Mxc) => WindowsSandboxLevel::Disabled,
            None => Self::from_features(&config.features),
        }
    }
```

（`codex-rs/core/src/windows_sandbox.rs:61`）

elevated 与 unelevated 在 `SandboxManager` 眼里是同一种 `SandboxType::WindowsRestrictedToken`，由级别区分后端；MXC 的级别是 `Disabled`，另用独立的 `SandboxType::WindowsMxc` 表示（`effective_windows_sandbox_type`，`codex-rs/protocol/src/sandbox.rs:31`）。没写 `[windows] sandbox` 时，还会读已移除的旧特性开关 `elevated_windows_sandbox`、`experimental_windows_sandbox` 做兼容。

```mermaid
flowchart TB
  CFG[windows.sandbox] --> SEL{实现}
  SEL -->|unelevated| U[当前用户的写受限令牌<br/>能力 SID 加 ACL]
  SEL -->|elevated| S1[一次性 setup<br/>UAC 提权]
  S1 --> S2[两个本地沙箱用户<br/>ACL 防火墙 WFP]
  S2 --> S3[以沙箱用户登录<br/>启动 codex-command-runner]
  S3 --> S4[runner 派生写受限令牌<br/>在私有桌面上起命令]
  SEL -->|mxc| M[codex 以 MXC 助手身份<br/>交给进程安全环境]
```

## 共同底座：写受限令牌与能力 SID

两种非 MXC 实现最终都用 `CreateRestrictedToken` 造一个令牌来跑命令：

```rust
    // Exact order: Capabilities..., ExtraRestricting..., Logon, Everyone
    // ...
    let mut new_token: HANDLE = 0;
    let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED;
    let ok = CreateRestrictedToken(
        base_token,
        flags,
        0,
        std::ptr::null(),
        0,
        std::ptr::null(),
        entries.len() as u32,
        entries.as_mut_ptr(),
        &mut new_token,
```

（`codex-rs/windows-sandbox-rs/src/token.rs:481`）

`DISABLE_MAX_PRIVILEGE` 去掉特权，`LUA_TOKEN` 按受限用户处理，关键是 `WRITE_RESTRICTED`：访问检查时，“限制 SID 列表”只参与写操作的判定。列表里放的是若干“能力 SID”，外加登录 SID 与 Everyone。能力 SID 是 Codex 随机生成的 `S-1-5-21-...` 形式的 SID，持久化在 `CODEX_HOME/cap_sid` 里：每个工作目录一个，每个额外可写根一个（`codex-rs/windows-sandbox-rs/src/cap.rs`）。Codex 在可写根上给对应的能力 SID 加允许写的 ACE，在 `.codex`、`.agents` 这类保护目录上加拒绝写的 ACE，于是持有这个令牌的进程只能写进 ACL 明确放行的地方，读则不受影响。令牌的默认 DACL 还被改成只授予本次登录会话，注释的理由是能力 SID 可能被多次启动共享，不能让一次启动碰到另一次的进程、线程和 IPC 对象。

`WRITE_RESTRICTED` 也划出了这条路的边界：拒绝读的 ACE 对能力 SID 不起作用，所以读禁区只能由 elevated 后端执行。unelevated 遇到带读限制的配置档会直接拒绝运行（“refusing to run unsandboxed”，`codex-rs/sandboxing/src/windows.rs:109` 的注释说明了原因），不会假装做到了。

## unelevated：当前用户，隔离较弱

unelevated（代码里叫 legacy 后端）不需要管理员权限：以当前用户的令牌为底派生写受限令牌，直接用 ConPTY 或管道起进程，放进作业对象（job object）统一管理。它的弱点在网络：网络受限时，`apply_no_network_to_env` 只是改写环境变量，把 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 指向 `127.0.0.1:9`，给 pip、npm、cargo 打开离线开关，把 `GIT_SSH_COMMAND` 设成一个必然失败的命令，再把一个放着 `ssh`、`scp` 替身的目录插到 `PATH` 最前面（`codex-rs/windows-sandbox-rs/src/env.rs:126`）。不理会代理变量的程序照样能联网。受管网络代理也要求 elevated 后端，unelevated 遇到会直接报错。

## elevated：两个专用的沙箱用户

elevated 的前提是一次性的 setup。`run_elevated_setup` 用 `ShellExecuteExW` 的 `runas` 动词拉起 `codex-windows-sandbox-setup.exe`，弹出 UAC，把要做的事以 base64 负载传过去：

- 建本地组 `CodexSandboxUsers` 与两个用户 `CodexSandboxOffline`、`CodexSandboxOnline`，密码是 24 位随机字符，经 DPAPI 加密后存进 `CODEX_HOME/.sandbox-secrets/sandbox_users.json`；两个账户还登记到 Winlogon 的 `SpecialAccounts\UserList`，不出现在登录界面。
- 给沙箱用户配读 ACL。它们是另一个账户，本来读不了你的文件，所以按配置档授予读权限：全盘可读的配置档对应 `C:\Windows`、`Program Files`、`ProgramData`、工作区与可写根，以及用户目录下的第一层条目，但排除 `.ssh`、`.aws`、`.azure`、`.kube`、`.docker`、`.gnupg`、`.config` 等十二个目录（`USERPROFILE_ROOT_EXCLUSIONS`）。
- 只针对离线用户的 SID 装 Windows 防火墙规则：拦截非回环的出站与入站、除代理端口外的回环 TCP、全部回环 UDP（允许本地绑定时去掉两条回环规则）；再用 Windows 筛选平台（WFP）拦截该用户的 ICMP、DNS（53、853 端口）与 SMB（445、139 端口），普通 setup 中这一步尽力而为。
- 写入 `CODEX_HOME/.sandbox/setup_marker.json`，版本号 `SETUP_VERSION` 当前为 5，版本不符就要重新 setup。

之后每次执行前，只需不提权地刷新一遍当前工作区的 ACL。选哪个账户取决于网络：

```rust
impl SandboxNetworkIdentity {
    pub(crate) fn from_permissions(
        permissions: &ResolvedWindowsSandboxPermissions,
        proxy_enforced: bool,
    ) -> Self {
        if proxy_enforced || !permissions.network_policy().is_enabled() {
            Self::Offline
        } else {
            Self::Online
        }
    }
```

（`codex-rs/windows-sandbox-rs/src/setup.rs:745`）

网络的隔离就这样落在“以哪个用户身份运行”上：离线用户受防火墙约束，受管代理时也用它，只放行到代理端口的回环连接；在线用户不受这些规则约束。

执行时，Codex 先建一对命名管道，DACL 只允许沙箱用户连接，然后以沙箱用户的账号密码调用 `CreateProcessWithLogonW`，启动 `codex-command-runner.exe --pipe-in=... --pipe-out=...`：

```rust
    let spawn_res = unsafe {
        CreateProcessWithLogonW(
            user_w.as_ptr(),
            domain_w.as_ptr(),
            password_w.as_ptr(),
            if registered_alias.is_some() {
                LOGON_WITH_PROFILE
            } else {
                0
            },
            exe_w.as_ptr(),
            cmdline_vec.as_mut_ptr(),
            windows_sys::Win32::System::Threading::CREATE_NO_WINDOW
                | windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT,
            // ...
        )
```

（`codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs:392`）

runner 连上管道，读取带长度前缀的 JSON 帧（协议版本 `IPC_PROTOCOL_VERSION` 为 6）：父进程发 `SpawnRequest`、`Stdin`、`CloseStdin`、`Resize`、`Terminate`，runner 回 `SpawnReady`、`Output`、`Exit`、`Error`。收到 `SpawnRequest` 后，runner 从沙箱用户自己的令牌派生写受限令牌（能力 SID 之外再加上用户 SID），要终端就用 ConPTY，否则用管道，把命令放进作业对象启动，输出一帧帧流回去。unified exec 的后台终端在 Windows 上就是这样跨账户工作的。

## 私有桌面

窗口站里的桌面是 Windows 的 GUI 隔离边界，同一桌面上的进程可以互相发窗口消息、装钩子。两种非 MXC 实现一律在私有桌面上运行：`CreateDesktopW` 建一个名为 `CodexSandboxDesktop-` 加 32 位十六进制随机数的桌面，DACL 只给当前用户全部权限、给沙箱账户去掉改 DACL、改属主和删除之外的参与权限，进程以 `Winsta0\<名字>` 为启动桌面。桌面按“沙箱账户 + 生效权限”复用，注释说明不同策略分开放，GUI 钩子就不会自动跨越策略。旧配置项 `windows.sandbox_private_desktop` 已经删除，残留时 strict config 会提示移除。

## MXC：交给系统

`mxc` 走另一条路。`SandboxManager` 先确认 `codex_mxc_sandbox::is_available()`：系统能创建原生的进程安全环境，而且注释写明刻意排除了 MXC 旧的 AppContainer 后备后端；不可用就直接报错，不退回其他实现。命令被改写成 `codex --__codex-windows-mxc`，配置档、策略 cwd、受管网络和原始 argv 编码进几个只给启动器用的环境变量，助手进程解码后生成 MXC 的执行请求，交给 `BaseContainerRunner` 启动；配置档里有拒绝路径时，还要求这个 Windows 版本支持拒绝路径。受管网络在 MXC 下必须有专用的代理端口，而且 `allow_local_binding` 必须为 true，因为 MXC 的主机回环访问是双向的，做不到“只出不进”（`codex-rs/mxc-sandbox/src/lib.rs:25`）。开发中的 `prefer_mxc` 开关打开后，系统可用时会优先选 MXC。

另外，`codex-windows-sandbox-service` 是一个注册到 SCM 的 Windows 服务，经本机 IPC 替已认证的客户端完成 elevated 沙箱的配置（provisioning）；对应的 `windows_sandbox_service` 特性仍在开发中，默认关闭。

## 和《从 LLM 到 Coding Agent》对照

那本书的[权限系统](https://daiw.net/manual/llm-to-agent/permissions)提醒 bypass 只该在一次性容器里开。Windows 上 elevated 实现做的事，正是在同一台机器上造出这样一个“别人”：独立的账户、独立的防火墙身份、独立的桌面，让命令天然碰不到你的凭据目录和窗口。代价是 setup 需要管理员权限、每次执行多一跳进程与管道；unelevated 省掉这些，换来的是只靠环境变量约束网络。三个平台里，Windows 是唯一需要“先建用户”的一个，这也是 macOS 与 Linux 两篇里没有的复杂度。

---

上一篇：[Linux 沙箱 · bubblewrap 与 Landlock](https://daiw.net/manual/codex-source/linux-sandbox) · 下一篇：[网络代理 · 联网权限怎么落地](https://daiw.net/manual/codex-source/network-proxy)
