夜雨聆风学习资料网

ARTICLE · 1213438

Codex 源码-Windows 沙箱双后端(受限令牌 + 专属账户)

Codex 源码-Windows 沙箱双后端(受限令牌 + 专属账户)

Codex 源码解析系列

第 24 讲:Windows 沙箱双后端(受限令牌 + 专属账户)

基于 OpenAI Codex 源码 · 2026-10-18

💡 本讲一句话:Windows 没有 bwrap、没有 Landlock,Codex 就自己造了两套沙箱:legacy 后端用受限令牌+capability SID 把文件权限锁死在 ACL 上;elevated 后端直接创建两个专属本地账户(离线/在线),用 WFP 内核防火墙规则按账户封网。读完你能看懂 Windows 上"只读沙箱"和"workspace-write 沙箱"到底是怎么实现的。

一、为什么 Windows 需要两套后端

前几讲我们拆了 Linux 的 bwrap(第 22 讲)和 Landlock+Seatbelt 双引擎(第 23 讲)。到了 Windows,内核没有提供"给进程换一个文件系统视图"的原语——没有 mount namespace、没有 Landlock、没有 Seatbelt。Codex 的 codex-windows-sandbox crate(约 16K 行 Rust)只能站在两个更底层的原语上搭沙箱:

🔹 受限令牌(Restricted Token)+ capability SID:Windows ACL 是"白名单"模型——文件默认拒绝所有人,谁有 ACE 授权谁能访问。Codex 给每个可写根目录生成一个专属的 capability SID,只把这个 SID 写进该目录树的 ACL;进程拿到的受限令牌里只有这些 SID,于是"能写哪里"被精确锁死在文件系统层。

🔹 专属本地账户 + WFP/防火墙规则:Windows 的防火墙和 WFP(Windows Filtering Platform)都支持"按用户 SID 匹配流量"。Codex 创建两个本地账户——CodexSandboxOffline(离线,全网封禁只留 loopback 代理口)和 CodexSandboxOnline(在线),沙箱进程用哪个账户登录,网络策略就自动跟着走。

于是有了两个后端:legacy(Unelevated)——当前用户自己跑受限令牌,不需要管理员;elevated——需要 UAC 提权一次,创建专属账户、装 WFP 过滤器、配防火墙规则,之后每次启动沙箱都"登录"到这些账户上。源码入口在 core/src/windows_sandbox.rs(第 240-312 行):

📄 codex-rs/core/src/windows_sandbox.rs (第 239-312 行)

pub enum WindowsSandboxSetupMode { // 两种后端模式:Elevated=提权专属账户,Unelevated=当前用户受限令牌    Elevated, // 需要 UAC 一次,之后每次沙箱都登录到 CodexSandboxOffline/Online 账户    Unelevated, // legacy 后端:不创建任何账户,纯靠受限令牌+ACL 隔离} // 枚举本身没有方法——选择逻辑在 from_config/from_features 里按配置映射pub async fn run_windows_sandbox_setup(request: WindowsSandboxSetupRequest) -> anyhow::Result<()> { // 统一入口:core 层调它来"准备沙箱环境"    let start = Instant::now(); // 记录起点,后面算 setup 耗时打点    let mode = request.mode; // 取出后端模式(Elevated/Unelevated),决定走哪条分支    let originator_tag = sanitize_metric_tag_value(originator().value.as_str()); // 把调用来源清洗成合法 metric tag,便于按来源聚合失败率    let result = run_windows_sandbox_setup_and_persist(request).await; // 真正干活:setup + 持久化模式选择    match result { // setup 结果分叉:成功打 success 点,失败打 failure 点再抛错        Ok(()) => { // 成功分支            emit_windows_sandbox_setup_success_metrics(mode, originator_tag.as_str(), start.elapsed()); // 上报耗时+来源,线上能看出哪个入口 setup 最慢            Ok(()) // 原样返回成功        }        Err(err) => { // 失败分支:先打点再传播错误,保证监控不丢事件            emit_windows_sandbox_setup_failure_metrics(mode, start.elapsed(), &err); // failure metric 带错误详情,方便定位 UAC 取消/账户创建失败等            Err(err) // 把原始错误继续抛给调用方(TUI/app-server)展示        }    }}async fn run_windows_sandbox_setup_and_persist(request: WindowsSandboxSetupRequest) -> anyhow::Result<()> { // setup 本体:阻塞工作丢到线程池,完成后把模式写回配置    let mode = request.mode; // 解构出后端模式    let permission_profile = request.permission_profile; // 权限画像(只读/workspace-write/网络策略),决定 ACL 和防火墙怎么配    let workspace_roots = request.workspace_roots; // 工作区根目录列表,是"可写根"的基准    let command_cwd = request.command_cwd; // 命令实际 cwd,capability SID 按它+workspace 计算    let env_map = request.env_map; // 沙箱进程环境(含代理变量),setup 时要从中解析 loopback 端口    let codex_home = request.codex_home; // CODEX_HOME:.sandbox/.sandbox-secrets/setup_marker.json 都挂在这里    let setup_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { // setup 全是同步 Win32 调用(UAC/NetUserAdd),必须离开 async runtime        match mode { // 按后端模式分派到两个 crate 入口            WindowsSandboxSetupMode::Elevated => { // elevated:走 windows-sandbox-rs 的 run_elevated_setup                prepare_elevated_sandbox(&permission_profile, workspace_roots.as_slice(), command_cwd.as_path(), &env_map, setup_codex_home.as_path())?; // 触发 UAC + 创建账户 + WFP/防火墙 + ACL 刷新            }            WindowsSandboxSetupMode::Unelevated => { // legacy:只做预检,不碰任何系统级资源                run_legacy_setup_preflight(&permission_profile, workspace_roots.as_slice(), command_cwd.as_path(), &env_map, setup_codex_home.as_path())?; // 验证 capability SID/ACL 可创建即可,失败提前暴露            }        }        Ok(()) // 两条分支都成功才返回 Ok    })    .await    .map_err(|join_err| anyhow::anyhow!("windows sandbox setup task failed: {join_err}"))?; // spawn_blocking join 失败(任务 panic)包装成带上下文的错误    setup_result?; // 把阻塞任务里的真实错误传播出来    ConfigEditsBuilder::new(codex_home.as_path())        .set_windows_sandbox_mode(windows_sandbox_setup_mode_tag(mode)) // 把"用户选了哪个后端"写进 config.toml,下次启动直接复用不再询问        .clear_legacy_windows_sandbox_keys() // 清掉旧版 feature flag 残留键,避免两套配置打架        .apply().await        .map_err(|err| anyhow::anyhow!("failed to persist windows sandbox mode: {err}")); // 持久化失败也要报错——否则用户下次还会被问一遍}

为什么这样设计:setup 是"一次性重活 + 每次轻刷新"的混合体,所以入口层只做三件事:把阻塞工作挪出 async runtime(UAC 弹窗和 NetUserAdd 都是同步 Win32)、按模式分派、成功后把选择持久化。metric 打点包在 setup 外面而不是里面——这样无论哪个分支失败,线上都能看到"谁触发的、耗时多少、错在哪",这是 Windows 这种环境差异极大的平台上排障的生命线。

二、legacy 后端:受限令牌 + capability SID

legacy 后端的隔离核心在 src/token.rs。Windows 的 ACL 是白名单:一个文件默认拒绝所有访问,只有 DACL 里列出的 SID 才能读写。Codex 的思路是——给每个"可写根目录"造一个专属 capability SID(形如 S-1-22-1-{uuid}),只把这个 SID 的 WRITE ACE 写进该目录树;然后给沙箱进程发一个受限令牌,令牌里只有这些 capability SID。进程想写别的目录?ACL 里没有它的 SID,直接 ACCESS_DENIED。

📄 codex-rs/windows-sandbox-rs/src/token.rs (第 461-505 行)

let mut entries: Vec<SID_AND_ATTRIBUTES> = vec![std::mem::zeroed(); psid_capabilities.len() + extra_restricting_sids.len() + 2]; // 预分配 SID 数组:capability + 额外限制 SID + Logon + Everyone 共四类for (i, psid) in psid_capabilities.iter().enumerate() { // 第一段填 capability SID——这是"能写哪里"的钥匙    entries[i].Sid = *psid; // 写入 SID 指针(每个可写根目录一个)    entries[i].Attributes = 0; // 不设 SE_GROUP_USE_FOR_RESTRICTION:capability 是授权身份,不是限制身份}let extras_idx = psid_capabilities.len(); // 记录第二段起点索引for (i, psid) in extra_restricting_sids.iter().enumerate() { // 第二段填额外限制 SID(elevated 后端会放沙箱账户自身 SID)    entries[extras_idx + i].Sid = *psid; // 写入指针    entries[extras_idx + i].Attributes = 0; // 同样按普通组成员处理,真正的"限制"语义由 WRITE_RESTRICTED 标志承担}let logon_idx = extras_idx + extra_restricting_sids.len(); // 第三段:Logon SID(SE_GROUP_LOGON_ID),标识本次登录会话entries[logon_idx].Sid = psid_logon; // Logon SID 必须进令牌——很多系统对象按它做访问控制entries[logon_idx].Attributes = 0; // 属性同上entries[logon_idx + 1].Sid = psid_everyone; // 第四段:Everyone,保底让进程能碰到最基本的公共资源entries[logon_idx + 1].Attributes = 0; // 属性同上let mut new_token: HANDLE = 0; // 新令牌句柄占位let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; // 三件套:禁最大特权 + 低权限标记 + 写受限(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); // 从当前用户令牌派生受限令牌:写操作只认 restricting SID,读操作走普通 ACLif ok == 0 { return Err(anyhow!("CreateRestrictedToken failed: {}", GetLastError())); } // 失败直接带 Win32 错误码返回let mut dacl_sids: Vec<*mut c_void> = Vec::with_capacity(psid_capabilities.len() + 2); // 组装默认 DACL 的 SID 列表dacl_sids.push(psid_logon); // Logon SID 进默认 DACL:沙箱进程创建的管道/事件等内核对象,同会话的其他进程能访问dacl_sids.push(psid_everyone); // Everyone 也进默认 DACL——PowerShell 建 pipeline 时跨进程读命名管道才不会 ACCESS_DENIEDdacl_sids.extend_from_slice(psid_capabilities); // capability SID 同样授权:沙箱内部多进程 IPC 不互相卡死set_default_dacl(new_token, &dacl_sids)?; // SetTokenInformation(TokenDefaultDacl):给"这个令牌创建的对象"一个宽松默认 DACLenable_single_privilege(new_token, "SeChangeNotifyPrivilege")?; // 开 SeChangeNotifyPrivilege:沙箱里的文件监听(watcher)需要它,否则 ReadDirectoryChangesW 直接失败Ok(new_token) // 返回可用的受限令牌

为什么这样设计:WRITE_RESTRICTED(0x08)是整个 legacy 后端的灵魂:它让 Windows 对"写/删除/重命名"这类变更操作只检查 restricting SID,而读操作仍走普通 ACL。于是同一个令牌可以做到"全盘可读、只有 capability 目录可写"——这正是 workspace-write 沙箱的语义。默认 DACL 那段是实战踩坑的产物:受限令牌创建的命名管道如果默认 DACL 太严,PowerShell 的 pipeline(每个 cmdlet 一个进程)会互相拒绝访问,所以必须把 Logon/Everyone/capability 都塞进 TokenDefaultDacl。SeChangeNotifyPrivilege 同理——不开它,沙箱里的 watchman/fswatch 类工具全部失效。

capability SID 怎么和目录绑定?在 src/spawn_prep.rs(第 147-182 行):

📄 codex-rs/windows-sandbox-rs/src/spawn_prep.rs (第 147-182 行)

pub(crate) fn prepare_legacy_session_security(uses_write_capabilities: bool, codex_home: &Path, cwd: &Path, capability_roots: impl IntoIterator<Item = PathBuf>) -> Result<LegacySessionSecurity> { // 为一次 legacy 会话准备令牌:只读 or workspace-write 两条路    let caps = load_or_create_cap_sids(codex_home)?; // 从 .sandbox/cap_sids.json 加载(不存在则生成)readonly + write capability SID,保证跨次启动稳定    let (h_token, readonly_sid, readonly_sid_str, write_root_sids) = unsafe {        if uses_write_capabilities { // workspace-write 分支:每个可写根一个专属 capability SID            let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; // 按"根目录+cwd"派生确定性 SID(S-1-22-1-{uuid}),同一根永远同一个 SID——ACL 才能跨次启动复用            if write_root_sids.is_empty() { anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); } // 一个可写根都没有 = 配置矛盾,fail loud 而不是发个啥都写不了的令牌            let base = get_current_token_for_restriction()?; // 复制当前用户令牌作为派生基底            let cap_ptrs: Vec<*mut c_void> = write_root_sids.iter().map(|root| root.sid.as_ptr()).collect(); // 收集所有 capability SID 指针            let h_token = create_workspace_write_token_with_caps_from(base, cap_ptrs.as_slice()); // 用这些 SID 建 WRITE_RESTRICTED 令牌(见上一块)            CloseHandle(base); // 基底令牌用完即关,防句柄泄漏            let h_token = h_token?; // 错误传播            (h_token, None, None, write_root_sids) // 只读 SID 字段留空——本会话没有"全局只读身份"        } else { // 只读分支:用共享的 readonly capability SID            let psid = LocalSid::from_string(&caps.readonly)?; // 解析持久化的 readonly SID 字符串            let (h_token, _psid) = create_readonly_token_with_cap(psid.as_ptr())?; // 建只含该 SID 的受限令牌:读走普通 ACL(allow 目录已授权),写全拒            (h_token, Some(psid), Some(caps.readonly), Vec::new()) // 把 readonly SID 带出去——后面 apply_legacy_session_acl_rules 要用它挂 ACE        }    };    Ok(LegacySessionSecurity { h_token, readonly_sid, readonly_sid_str, write_root_sids }) // 打包返回:令牌 + SID 元数据,供 ACL 应用和进程创建使用}

为什么这样设计:capability SID 是"按根目录派生的确定性 UUID",不是每次随机生成——这是整个方案能跨次启动工作的关键:上次给 C:\repo 挂的 WRITE ACE 里写的是这个 SID,这次令牌里必须还是同一个 SID,否则 ACL 全部失效。只读分支复用全局唯一的 readonly SID(所有只读会话共享),而 workspace-write 每个根一个 SID——粒度不同,因为"能写哪里"是 per-root 的策略,"能读哪里"却是全局白名单。

令牌只是"钥匙",还得把"锁"装到目录上。apply_legacy_session_acl_rules(src/spawn_prep.rs 第 267-346 行)负责这件事:

📄 codex-rs/windows-sandbox-rs/src/spawn_prep.rs (第 267-346 行)

pub(crate) fn apply_legacy_session_acl_rules(permissions: &ResolvedWindowsSandboxPermissions, codex_home: &Path, current_dir: &Path, env_map: &HashMap<String, String>, additional_deny_read_paths: &[PathBuf], additional_deny_write_paths: &[PathBuf], acl_sids: LegacyAclSids<'_>) -> Result<()> { // 把权限画像翻译成目录 ACL:allow 挂 ACE、deny 挂 DENY ACE    let AllowDenyPaths { allow, mut deny } = compute_allow_paths_for_permissions(permissions, current_dir, env_map); // 从权限画像算出"可读路径集合 + 禁写路径集合"    unsafe {        for path in additional_deny_write_paths { // 显式禁写 carveout(比如 ~/.ssh)            if !path.exists() { std::fs::create_dir_all(path).with_context(|| format!("create deny-write path {}", path.display()))?; } // 路径不存在就先建出来——DENY ACE 必须挂在真实目录上,否则沙箱能"先创建再写入"绕过            deny.insert(path.clone()); // 并入 deny 集合统一处理        }        if let Some(readonly_sid) = acl_sids.readonly_sid { // 只读会话:给所有 allow 路径挂 readonly SID 的读 ACE            for p in &allow { let _ = add_allow_ace(p, readonly_sid.as_ptr()); } // 逐路径 SetEntriesInAclW;失败忽略——best-effort,真正兜底是令牌本身没有写能力        } else { // workspace-write 会话:每个 allow 路径找"包含它的最具体根"的 capability SID            for p in &allow {                let Some(root_sid) = matching_root_capability(p, acl_sids.write_root_sids) else { continue; }; // 按 specificity(最长前缀)选根——嵌套目录只认最内层根的 SID,避免外层宽授权                let _ = ensure_allow_write_aces(p, &[root_sid.sid.as_ptr()]); // 确保该路径 DACL 里有这个 capability SID 的写 ACE(幂等:已有则跳过)            }        }        for p in &deny { // deny 集合:对每个禁写路径挂 DENY WRITE ACE            for root_sid in deny_root_capabilities_for_path(p, acl_sids.write_root_sids) { // 选"与它重叠的根"的 SID;没有重叠就对所有根都挂——DENY 优先于 ALLOW,必须覆盖所有可能授权它的 capability                let _ = add_deny_write_ace(p, root_sid.sid.as_ptr()); // DENY ACE 在 Windows ACL 里先于 ALLOW 评估:即使外层目录给了写权限,这里也写不进去            }        }        if !additional_deny_read_paths.is_empty() { // deny-read(比如禁读 .env):同步落盘持久化 ACE            if let Some(readonly_sid) = acl_sids.readonly_sid { // 只读会话用 readonly SID 挂 DENY READ                let Some(readonly_sid_str) = acl_sids.readonly_sid_str else { anyhow::bail!("readonly capability SID string missing"); }; // 字符串形式用于持久化记录,指针用于 SetEntriesInAclW——两者必须来自同一次解析                sync_persistent_deny_read_acls(codex_home, readonly_sid_str, additional_deny_read_paths, readonly_sid.as_ptr())?; // "同步"是关键:deny-read 必须在命令启动前生效,不能丢给后台 helper(读授权可以异步,读拒绝不行)            } else { // workspace-write 会话:对每个写根的 capability SID 都挂 DENY READ——任何一把钥匙都不能开这把锁                for root_sid in acl_sids.write_root_sids { sync_persistent_deny_read_acls(codex_home, &root_sid.sid_str, additional_deny_read_paths, root_sid.sid.as_ptr())?; } // 逐个 capability SID 落 ACE,保证"写权限持有者"同样读不到            }        }        for root_sid in acl_sids.write_root_sids { allow_null_device(root_sid.sid.as_ptr()); } // 给每个 capability SID 放行 NUL 设备:程序里常见的 >NUL / 2>&1 重定向不能因为沙箱而炸        if let Some(readonly_sid) = acl_sids.readonly_sid { allow_null_device(readonly_sid.as_ptr()); } // 只读会话同理        if !acl_sids.write_root_sids.is_empty() && let Some(workspace_sid) = matching_root_capability(current_dir, acl_sids.write_root_sids) { // cwd 恰好是某个可写根时            let canonical_cwd = canonicalize_path(current_dir); // 规范化路径再比较,防符号链接/大小写差异绕过判断            if is_command_cwd_root(&workspace_sid.root, &canonical_cwd) { // 确认 cwd == 该根(而不是根的某个子目录)                let _ = protect_workspace_codex_dir(current_dir, workspace_sid.sid.as_ptr()); // 保护工作区里的 .codex/ 目录:沙箱不能改自己的配置/日志来"自证清白"                let _ = protect_workspace_agents_dir(current_dir, workspace_sid.sid.as_ptr()); // 同理保护 AGENTS.md 所在目录——防止 agent 篡改自己的行为准则文件            }        }    }    Ok(()) // ACL 应用完成(best-effort 部分已忽略错误,硬失败如 deny-read 同步会直接 Err)}

为什么这样设计:这段代码把 Windows ACL 的三个"坑"全踩了一遍并给出对策。第一,DENY ACE 必须挂在真实存在的目录上——所以禁写路径不存在时先 create_dir_all,否则沙箱进程可以"先创建这个文件再写入"绕过拒绝。第二,deny-read 是同步落盘、读授权走后台 helper(下一节会看到)——因为读拒绝一旦晚于命令启动就形同虚设,而读授权晚一点只是性能问题。第三,protect_workspace_codex_dir/protect_workspace_agents_dir 是防"agent 篡改自己行为准则"的自指攻击:workspace-write 沙箱本来就能写整个工作区,但 .codex/ 和 AGENTS.md 必须豁免——这是把"提示词注入→改配置→持久化后门"这条链在文件系统层掐断。

三、deny-read:glob 怎么变成 ACL 目标

权限画像里的 deny-read 可以是 glob(比如 **/.env),但 Windows ACL 只认具体路径。解析器在 src/deny_read_resolver.rs(第 25-96 行):

📄 codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs (第 25-96 行)

/// Resolve split filesystem `None` read entries into concrete Windows ACL targets. // 文档注释:把"不可读根 + glob"两类条目解析成具体 ACL 目标pub fn resolve_windows_deny_read_paths(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &AbsolutePathBuf) -> Result<Vec<AbsolutePathBuf>, String> { // 入口:策略 + cwd → 可挂 ACE 的绝对路径列表    let mut paths = Vec::new(); // 结果集    let mut seen = HashSet::new(); // 去重集合——同一文件可能被多个 glob 命中    for path in file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd.as_path()) { // 第一类:精确不可读根(非 glob)        push_absolute_path(&mut paths, &mut seen, path.into_path_buf())?; // 直接收录——即使路径还不存在也保留,后面挂 ACE 前会物化它    }    let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd.as_path()); // 第二类:glob 模式(如 **/.env)    if unreadable_globs.is_empty() { return Ok(paths); } // 没有 glob 就提前返回,省掉扫描    let Some(matcher) = ReadDenyMatcher::try_new_for_local_paths(&glob_policy, cwd.as_path())? else { return Ok(paths); }; // 构造"本地路径是否被 deny-read"的判定器;无法本地化(如远程路径)就放弃 glob 展开    let scan_plans = glob_scan_plans(&unreadable_globs, file_system_sandbox_policy.glob_scan_max_depth)?; // 把 glob 拆成"扫描根 + 最大深度 + 模式"的扫描计划    for scan_plan in scan_plans { // 逐个扫描根展开        if !scan_plan.root.exists() { continue; } // 扫描根不存在直接跳过——glob 只匹配"已存在"的文件,未来新建的由物化逻辑兜底        let directory_scan_mode = if let Some(file_paths) = ripgrep_files(&scan_plan)? { // 首选 ripgrep:比 Rust 自己 walk 快一个量级            for path in file_paths { // 遍历 rg 输出的文件列表                if matcher.is_local_path_read_denied(&path) { push_absolute_path(&mut paths, &mut seen, path)?; } // 用策略判定器二次确认(rg 的 glob 和 Codex 语义可能有细微差异)再收录            }            DirectoryScanMode::DirectoriesOnly // rg 已经覆盖了文件,目录扫描只需补"目录本身也要挂 ACE"        } else { // ripgrep 缺失或遍历不完整时            DirectoryScanMode::IncludeAllFiles // 回退到完整 walk——宁可慢也不能用半截结果挂 ACL(漏一个 .env 就是泄密)        };        collect_existing_glob_directory_matches(&scan_plan.root, &matcher, &mut paths, &mut seen, scan_plan.max_depth, directory_scan_mode); // 目录级匹配:把命中 glob 的目录也收进结果(ACE 要挂在目录上才能拦住子文件)    }    Ok(paths) // 返回"精确根 + glob 展开"的并集,交给 sync_persistent_deny_read_acls 挂 ACE}

为什么这样设计:注释里那句"Never apply ACLs from a failed scan's partial stdout"是安全底线:ripgrep 中途失败(权限错误、超时)时输出是不完整的,如果拿半截列表去挂 DENY ACE,没扫到的 .env 就漏网了。所以回退策略是"重新完整 walk"而不是"用已有结果凑合"——安全路径上宁可慢,不可错。另外 glob 只展开已存在文件、精确根可以不存在(后面物化),这个不对称是因为:glob 的语义就是"匹配现有集合",而精确 deny 是"这个路径永远不许读",后者必须对未来新建的文件也生效。

四、elevated 后端:专属账户 + WFP 内核防火墙

legacy 后端的天花板是"当前用户能读到的东西,沙箱也能读到"(ACL 只能收紧不能突破用户权限),而且 deny-read 对 capability SID 的约束力有限。elevated 后端换了一个思路:让沙箱进程以另一个本地账户的身份运行——CodexSandboxOffline 和 CodexSandboxOnline,密码用 DPAPI 加密存在 .sandbox-secrets/sandbox_users.json。账户一旦独立,Windows 的所有"按用户匹配"机制就都能用了:防火墙规则、WFP 过滤器、甚至登录界面隐藏。

setup helper(codex-windows-sandbox-setup.exe)的完整流程在 src/bin/setup_main/win.rs 第 663-725 行:

📄 codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs (第 663-725 行)

fn provision_sandbox(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { // 一次性供给:建账户 + 配网络,只在首次/版本变更时跑(refresh_only 会跳过)    let _setup_lock = acquire_sandbox_setup_lock(INFINITE)?; // 全局命名互斥锁:两个 Codex 实例同时 setup 会互相踩账户状态,必须串行    let mut repairing_disabled_accounts = false; // 检测"上次清理中断"的残留状态    for username in [&payload.offline_username, &payload.online_username] { // 检查两个沙箱账户        if local_user_flags(username)?.is_some_and(|flags| flags &amp; UF_ACCOUNTDISABLE != 0) { repairing_disabled_accounts = true; } // 任一账户被禁用 = 上次卸载/清理跑到一半,进入修复模式    }    let new_user_flags = if repairing_disabled_accounts { UF_ACCOUNTDISABLE } else { 0 }; // 修复模式下新建的替代账户先保持禁用——防止"旧保护已删、新账户已能登录"的空窗期    let provision_result = provision_sandbox_users(&payload.codex_home, &payload.offline_username, &payload.online_username, new_user_flags, log, payload.mode); // 创建/更新两个本地用户(NetUserAdd)+ DPAPI 加密密码落盘 + 加入 CodexSandboxUsers 组    if let Err(err) = provision_result { /* 错误包装成 SetupFailure 带错误码返回,helper 写 setup_error.json */ }    let users = vec![payload.offline_username.clone(), payload.online_username.clone()]; // 两个账户名打包    hide_newly_created_users(&users, sbx_dir); // 从 Winlogon 登录界面隐藏(UserList 注册表)+ 隐藏用户目录——沙箱账户不该出现在用户的视野里    let offline_sid = resolve_sid(&payload.offline_username).map_err(|err| { /* SID 解析失败 → SetupFailure */ })?; // 把离线账户名解析成 SID——后面所有防火墙/WFP 规则都按这个 SID 匹配    let offline_sid_str = string_from_sid_bytes(&offline_sid).map_err(anyhow::Error::msg)?; // S-1-5-21-... 字符串形式,写进防火墙规则的 LocalUserSpec    configure_offline_sandbox_network(payload, &offline_sid_str, log)?; // 离线账户网络策略:先放行 loopback 代理口,再封禁其余全部出站/入站    let wfp_result = install_wfp_filters(&payload.codex_home, &payload.offline_username, payload.otel.as_ref(), |message| { let _ = log_line(log, message); }); // WFP 内核级过滤器:ICMP/DNS/SMB 按账户封禁(下一节细看)    if repairing_disabled_accounts { // 修复模式的收尾顺序是安全关键        wfp_result?; // 必须先确认网络保护恢复成功——否则继续就是"裸奔的离线账户"        for username in [&payload.offline_username, &payload.online_username] { // 然后才解除账户禁用            let flags = local_user_flags(username)?.ok_or_else(|_| anyhow!("sandbox user {} disappeared during repair", username))?; // 账户中途消失(被杀软删了?)直接报错,不猜            set_local_user_flags(username, flags &amp; !UF_ACCOUNTDISABLE)?; // 清掉 UF_ACCOUNTDISABLE 位——顺序保证"先有锁,再开门"        }    }    Ok(()) // 供给完成:账户就绪 + 网络策略就位}

为什么这样设计:这段代码最精彩的是修复模式的顺序控制。卸载/清理沙箱时如果中途崩溃,会留下"账户被禁用但防火墙规则已删"的中间态;下次 setup 检测到这个状态后,先恢复 WFP/防火墙、再解除账户禁用——反过来做就会出现一个时间窗:离线账户能登录了,但它的封网规则还没装回来。这种"先锁门再开门"的顺序约束,是安全系统里典型的 liveness/safety 权衡。

网络封禁分两层:Windows 防火墙(COM API)管粗粒度全量阻断,WFP 管内核级精确规则。先看 WFP 的过滤器清单——src/wfp/filter_specs.rs 第 25-124 行:

📄 codex-rs/windows-sandbox-rs/src/wfp/filter_specs.rs (第 25-124 行,节选)

pub(super) const FILTER_SPECS: &[FilterSpec] = &[ // 静态过滤器清单:每条都是"按沙箱账户封某个协议/端口"    FilterSpec {        key: GUID::from_u128(0x9f5f3812_79f0_4fe9_9615_4c2c92d2f0ff), // 固定 GUID——跨次 setup 幂等:先按 key 删旧再建新的,不会重复累积        name: "codex_wfp_icmp_connect_v4", // 命名带 codex_ 前缀,方便管理员在 wfp 工具里识别归属        description: "Block sandbox-account ICMP connect v4", // 描述写清"只针对沙箱账户"——WFP 规则是全局的,必须靠 User 条件限定范围        layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V4, // ALE AUTH CONNECT 层:在连接建立阶段拦截(比 NETWORK 层早,省得白跑 TCP 握手)        conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMP as u8)], // 双条件 AND:账户=沙箱离线用户 AND 协议=ICMP——其他用户的 ping 完全不受影响    },    FilterSpec { key: GUID::from_u128(0x87498484_45ab_4510_845e_ece8b791b3bc), name: "codex_wfp_icmp_connect_v6", description: "Block sandbox-account ICMP connect v6", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V6, conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMPV6 as u8)], }, // IPv6 版 ICMPv6——双栈都要封,漏一个就能用 ping6 探网    FilterSpec { key: GUID::from_u128(0xaf4751de_f874_4a7b_a34d_f0d0f22d1d9b), name: "codex_wfp_icmp_assign_v4", description: "Block sandbox-account ICMP resource assignment v4", layer_key: FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V4, conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMP as u8)], }, // RESOURCE ASSIGNMENT 层:封 socket 绑定阶段的 ICMP——双保险,防止某些路径绕过 AUTH CONNECT    FilterSpec { key: GUID::from_u128(0xea10db66_a928_4b2e_a82e_a376a54f93ba), name: "codex_wfp_icmp_assign_v6", description: "Block sandbox-account ICMP resource assignment v6", layer_key: FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V6, conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMPV6 as u8)], }, // IPv6 版同上    FilterSpec { key: GUID::from_u128(0x83172805_f6be_4ae1_9dc6_6847aef04e7f), name: "codex_wfp_dns_53_v4", description: "Block sandbox-account DNS TCP or UDP port 53 v4", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V4, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(53)], }, // 封 53 端口:离线账户不能做域名解析——没有 DNS,"先解析再连"的绕过路径直接断掉    FilterSpec { key: GUID::from_u128(0xd23b2efb_1efb_46b2_96f3_b0ccda5690c8), name: "codex_wfp_dns_53_v6", description: "Block sandbox-account DNS TCP or UDP port 53 v6", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V6, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(53)], }, // IPv6 版 DNS    FilterSpec { key: GUID::from_u128(0x420b026f_9dc9_4aea_88f4_0f2b9feab39a), name: "codex_wfp_dns_853_v4", description: "Block sandbox-account DNS-over-TLS port 853 v4", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V4, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(853)], }, // 封 DoT(DNS over TLS):只封 53 的话,攻击者可以用加密 DNS 绕过    FilterSpec { key: GUID::from_u128(0x8d917c81_99cc_45e7_84d6_824df860cfb8), name: "codex_wfp_dns_853_v6", description: "Block sandbox-account DNS-over-TLS port 853 v6", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V6, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(853)], }, // IPv6 版 DoT    FilterSpec { key: GUID::from_u128(0xe1d6e0af_ce5f_471b_b2d3_15ca00e966f3), name: "codex_wfp_smb_445_v4", description: "Block sandbox-account SMB port 445 v4", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V4, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(445)], }, // 封 SMB 445:防沙箱进程横向访问局域网共享/传播    FilterSpec { key: GUID::from_u128(0xc2bceca4_66ef_4a0f_ba80_f4f761b8c6f0), name: "codex_wfp_smb_445_v6", description: "Block sandbox-account SMB port 445 v6", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V6, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(445)], }, // IPv6 版 SMB    FilterSpec { key: GUID::from_u128(0xba10c618_84e7_4b83_8f74_36e22b2fa1ff), name: "codex_wfp_smb_139_v4", description: "Block sandbox-account SMB port 139 v4", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V4, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(139)], }, // 封 NetBIOS-SMB(老协议):双端口都封才完整    FilterSpec { key: GUID::from_u128(0xfe7f22b8_5cf5_4adb_b2aa_71fc0a8f5d44), name: "codex_wfp_smb_139_v6", description: "Block sandbox-account SMB port 139 v6", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V6, conditions: &[ConditionSpec::User, ConditionSpec::RemotePort(139)], }, // IPv6 版 NetBIOS-SMB];

为什么这样设计:注意每条规则都是 User + Protocol/RemotePort 双条件——WFP 是全局内核设施,Codex 的规则绝不能影响用户自己的 ping/DNS/SMB,所以"账户匹配"是每条规则的必备条件。协议选择也有讲究:ICMP(封探网)、DNS 53+853(断域名解析,含加密 DNS 绕过路径)、SMB 445+139(防横向移动)——这是典型的"最小网络面"清单。而 loopback 代理口不在这里封,因为离线沙箱的合法出口就是本地 network-proxy(第 27 讲的主角),放行逻辑在防火墙白名单里按端口精确控制。

WFP 管"特定协议/端口",全量阻断靠 Windows 防火墙。src/bin/setup_main/win/firewall.rs 第 165-214 行:

📄 codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs (第 165-214 行)

pub fn ensure_offline_network_blocks(offline_sid: &str, log: &mut dyn Write) -> Result<()> { // 给离线账户装"全量阻断"防火墙规则(出站+入站)    let local_user_spec = format!("O:LSD:(A;;CC;;;{})", offline_sid); // SDDL 字符串:Owner=登录者, DACL=(允许 CC=连接控制 给该 SID)——Windows 防火墙用它把规则限定到单个用户    let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; // COM 初始化(INetFwPolicy2 是 COM 对象);失败直接报 HelperFirewallComInitFailed    if hr.is_err() { return Err(anyhow::Error::new(SetupFailure::new(SetupErrorCode::HelperFirewallComInitFailed, format!("CoInitializeEx failed: {hr:?}")))); }    let result = unsafe { (|| -> Result<()> { // 闭包包裹:确保无论成功失败都走到 CoUninitialize        let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER).map_err(|err| { /* 包装成 HelperFirewallPolicyAccessFailed */ })?; // 拿全局防火墙策略对象(需要管理员权限——所以这段跑在提权 helper 里)        ensure_local_policy_rules_take_effect(&policy)?; // 确保"本地规则优先于远程策略"的注册表开关打开,否则域策略可能覆盖我们的阻断        let rules = policy.Rules().map_err(|err| { /* 包装错误 */ })?; // 拿规则集合(可枚举/增删)        ensure_block_rule(&rules, &BlockRuleSpec { internal_name: OFFLINE_BLOCK_RULE_NAME, friendly_desc: OFFLINE_BLOCK_RULE_FRIENDLY, direction: NET_FW_RULE_DIR_OUT, protocol: NET_FW_IP_PROTOCOL_ANY.0, local_user_spec: &local_user_spec, offline_sid, remote_addresses: Some(NON_LOOPBACK_REMOTE_ADDRESSES), remote_ports: None }, log)?; // 出站全封:协议=ANY、远端=非 loopback——loopback 被排除,本地代理口才能通        ensure_block_rule(&rules, &BlockRuleSpec { internal_name: OFFLINE_BLOCK_INBOUND_RULE_NAME, friendly_desc: OFFLINE_BLOCK_INBOUND_RULE_FRIENDLY, direction: NET_FW_RULE_DIR_IN, protocol: NET_FW_IP_PROTOCOL_ANY.0, local_user_spec: &local_user_spec, offline_sid, remote_addresses: Some(NON_LOOPBACK_REMOTE_ADDRESSES), remote_ports: None }, log)?; // 入站全封:沙箱账户也不能被局域网主动连进来(防它变成跳板)        Ok(()) // 两条规则就位    })() };    unsafe { CoUninitialize(); } // COM 反初始化,与上面的 CoInitializeEx 配对    result // 返回闭包里的真实结果}

为什么这样设计:remote_addresses: Some(NON_LOOPBACK_REMOTE_ADDRESSES) 是"封网但留活路"的关键——阻断规则只针对非 loopback 远端,所以离线沙箱访问 127.0.0.1 上的 network-proxy 完全不受影响。这正是第 27 讲要拆的代理引擎的入口:所有出站流量必须经过本地代理做域名白名单检查,直连一律被防火墙+WFP 双层拦死。ensure_local_policy_rules_take_effect 则是企业环境的防御——域控下发的远程策略默认优先级更高,不打开这个开关,Codex 的阻断规则可能被静默覆盖。

最后一个小细节:沙箱账户不能出现在登录界面。src/hide_users.rs 第 116-147 行:

📄 codex-rs/windows-sandbox-rs/src/hide_users.rs (第 116-147 行)

fn hide_users_in_winlogon(usernames: &[String], log_base: &Path) -> anyhow::Result<()> { // 把沙箱账户从 Winlogon 登录界面隐藏    let key = create_userlist_key()?; // 打开/创建 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\UserList(需要管理员)    for username in usernames { // 逐个处理两个沙箱账户        let name_w = to_wide(OsStr::new(username)); // UTF-16 转换(Win32 API 要求宽字符)        let value: u32 = 0; // DWORD 值固定为 0——UserList 的语义是"键存在即隐藏",值本身无意义        let status = unsafe { RegSetValueExW(key, name_w.as_ptr(), 0, REG_DWORD, &value as *const u32 as *const u8, std::mem::size_of_val(&value) as u32) }; // 写注册表值:用户名 → DWORD(0),Winlogon 下次枚举登录用户时就会跳过它        if status != 0 { log_note(&format!("hide users: failed to set UserList value for {}: {} ({})", username, status, error = format_last_error(status as i32)), Some(log_base)); } // 失败只记日志不报错——隐藏是体验优化,不是安全边界(账户本身没有交互登录权限)    }    unsafe { RegCloseKey(key); } // 关闭注册表句柄    Ok(()) // 返回成功(即使个别值写入失败也不影响整体)}

为什么这样设计:注意错误处理策略——隐藏用户失败只记日志、不中断 setup。这是刻意的降级:安全边界是"账户没有交互登录权限 + 网络被封",而 UserList 只是防止用户在登录界面看到两个陌生账户产生困惑(或好奇去尝试)。把体验问题和安全问题分开定级,避免一个非关键步骤拖垮整个供给流程。

五、setup 的并发控制:singleflight

elevated setup 很重(UAC + 建账户 + WFP),而 Codex 是多进程的——TUI、app-server、多个 exec 会话可能同时触发。重复跑 setup 不仅慢,还会互相踩账户状态。src/setup.rs 第 106-183 行用 singleflight 模式解决:

📄 codex-rs/windows-sandbox-rs/src/setup.rs (第 106-183 行)

struct SetupFlight { // 一次 setup "航班":leader 执行,followers 等待共享结果    result: Mutex<Option<Result<(), SharedSetupError>>>, // 结果槽位:None=进行中,Some=已完成(成功或失败)    completed: Condvar, // 条件变量:leader 完成后唤醒所有等待者}impl SetupFlight {    fn pending() -> Self { Self { result: Mutex::new(None), completed: Condvar::new() } } // 初始化为"进行中、无结果"    fn complete(&self, result: Result<(), SharedSetupError>) { // leader 调用:写入最终结果        *self.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(result); // 锁中毒时取内层值继续——一个 panic 的等待者不该让所有人卡死        self.completed.notify_all(); // 唤醒所有 follower    }    fn wait(&self) -> Result<()> { // follower 调用:阻塞直到有结果        let mut result = self.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 持锁检查(标准 condvar 用法)        while result.is_none() { // 自旋等待——必须用 while 防虚假唤醒            result = self.completed.wait(result).unwrap_or_else(std::sync::PoisonError::into_inner); // wait 会原子地"放锁+睡眠",被唤醒后重新拿锁        }        match result.clone() { Some(result) => result.map_err(SharedSetupError::into_error), None => Err(anyhow!("setup flight completed without a result")) } // 把共享错误转回 anyhow;理论上不会走到 None 分支(complete 必然写值)    }}static SETUP_FLIGHTS: OnceLock<Mutex<HashMap<String, Arc<SetupFlight>>>> = OnceLock::new(); // 进程级航班表:key=base64(payload),value=进行中的 flight(OnceLock 保证惰性初始化且线程安全)fn run_setup_singleflight(key: String, run: impl FnOnce() -> Result<()>) -> Result<()> { // singleflight 入口:同 key 的并发调用只执行一次    let flights = SETUP_FLIGHTS.get_or_init(|| Mutex::new(HashMap::new())); // 首次访问时初始化表    let (flight, is_leader) = { // 临界区:查/插航班表(作用域块保证锁尽早释放)        let mut flights = flights.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 拿表锁        match flights.get(&key) { // key 相同 = payload 完全相同(base64 序列化),可以共享结果            Some(flight) => (Arc::clone(flight), false), // 已有进行中的 flight → 我是 follower,克隆 Arc 共享它            None => { let flight = Arc::new(SetupFlight::pending()); flights.insert(key.clone(), Arc::clone(&flight)); (flight, true) } // 没有 → 我当 leader:建 pending flight、入表、执行权归我        }    };    if !is_leader { return flight.wait(); } // follower 直接等结果,不重复干活——这是 singleflight 的核心收益    let result = run(); // leader 真正执行 setup(UAC/NetUserAdd/WFP...)    let shared_result = match &result { Ok(()) => Ok(()), Err(error) => Err(SharedSetupError::from_error(error)) }; // 把 anyhow 错误转成可 Clone 的共享形式(anyhow::Error 不可 Clone,而 follower 要各自拿到一份)    flight.complete(shared_result); // 写入结果 + 唤醒所有 follower    let mut flights = flights.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 再拿表锁清理    if flights.get(&key).is_some_and(|current| Arc::ptr_eq(current, &flight)) { flights.remove(&key); } // 只有"表里还是我这个 flight"才删——防止误删别人新插入的同 key flight(极端竞态)    result // leader 返回自己的原始结果}

为什么这样设计:singleflight 的 key 是 payload 的 base64(见第 357 行 run_setup_singleflight(b64.clone(), ...))——不是"setup"这种粗粒度键。这意味着:两个相同权限配置的会话共享一次 setup,而配置不同的会话各跑各的、互不阻塞。SharedSetupError 存在是因为 anyhow::Error 不可 Clone,而一个 leader 的结果要分发给 N 个 follower——必须有个可克隆的错误载体。最后那个 Arc::ptr_eq 检查是教科书级的竞态防御:leader A 完成时,follower B 可能已经带着新 payload 插入了同 key 的 flight C,此时不能把 C 删掉。

六、两个后端对比 + setup payload

维度
legacy(Unelevated)
elevated
隔离机制
受限令牌 + capability SID ACL(当前用户身份)
专属本地账户登录 + ACL + WFP/防火墙按账户封网
提权需求
无(纯用户态 ACL)
首次 UAC(建账户/装 WFP),之后 refresh 不再弹
网络控制
仅环境变量(HTTP_PROXY 等)+ 代理强制开关
WFP 内核规则 + Windows 防火墙按 SID 封禁,离线/在线双账户
deny-read
受限(capability SID 对读操作约束力弱,代码里明确 bail)
完整支持:同步落盘 DENY READ ACE + glob 展开
适用场景
无管理员权限的机器、快速只读沙箱
需要强隔离/离线封网的正式环境(默认推荐)

elevated setup 的完整参数通过 ElevationPayload(src/setup.rs 第 674-695 行)base64 后作为命令行参数传给 helper——用 base64 而不是 JSON 裸传,是为了绕开 Windows 命令行转义地狱:

📄 codex-rs/windows-sandbox-rs/src/setup.rs (第 674-695 行)

#[derive(Serialize)]struct ElevationPayload { // 传给提权 helper 的完整 setup 参数(serde 序列化为 JSON → base64)    version: u32, // SETUP_VERSION=5:helper 按版本决定行为,旧 marker 不匹配就触发重新供给    offline_username: String, // "CodexSandboxOffline"——离线账户名(全网封禁身份)    online_username: String, // "CodexSandboxOnline"——在线账户名(允许正常网络)    codex_home: PathBuf, // CODEX_HOME:.sandbox/.sandbox-secrets 的挂载点    command_cwd: PathBuf, // 命令 cwd:capability SID 派生和 ACL 计算的基准    read_roots: Vec<PathBuf>, // 可读根目录白名单(helper 给沙箱组挂读 ACE)    write_roots: Vec<PathBuf>, // 可写根目录(每个生成专属 capability SID + WRITE ACE)    #[serde(default)] deny_read_paths: Vec<PathBuf>, // deny-read 目标(同步落盘 DENY READ ACE,可为空)    #[serde(default)] deny_write_paths: Vec<PathBuf>, // deny-write carveout(如 ~/.ssh,先物化再挂 DENY WRITE)    proxy_ports: Vec<u16>, // loopback 代理端口白名单——防火墙阻断规则里"排除"的远端端口    #[serde(default)] allow_local_binding: bool, // true=允许本地任意绑定(无固定端口),false=只放行 proxy_ports    otel: Option<codex_otel::StatsigMetricsSettings>, // 遥测配置:helper 里直接上报 setup 指标(elevated 模式才有)    real_user: String, // 真实登录用户名——.sandbox 目录的 ACL 要给它保留访问权    mode: SetupMode, // Full / InteractiveProvision / ProvisionOnly:决定 helper 跑多深    #[serde(default)] refresh_only: bool, // true=只刷新 ACL/防火墙,跳过建账户(每次启动走这条快路径)}

为什么这样设计:refresh_only 是性能关键——首次 setup(建账户+WFP+防火墙)可能要几十秒,但日常启动只需要"ACL 还新鲜吗?防火墙规则还在吗?"的刷新。marker 文件(setup_marker.json)记录 version + proxy_ports + allow_local_binding,任何一项变化就触发重新供给;而 request_mismatch_reason(第 447-469 行)对 local-binding 模式做了特判:该模式下没有端口级规则,改代理监听口不需要动防火墙——避免无谓的 UAC/重配。

七、数据流:elevated 后端一次命令启动

elevated 后端启动路径(run_windows_sandbox_capture_for_permission_profile)

① 解析:PermissionProfile → ResolvedWindowsSandboxPermissions

权限画像按 workspace_roots/cwd 物化成具体路径集合,决定只读还是写能力。

▼

② 账户:require_logon_sandbox_creds → Offline/Online

按网络身份选账户;marker 不匹配则触发 run_elevated_setup(UAC+供给)。

▼

③ 刷新:run_setup_refresh(singleflight)

payload base64 作 key;helper 同步挂 deny-read ACE,读授权走后台 helper。

▼

④ 令牌:capability SID → CreateRestrictedToken

WRITE_RESTRICTED + TokenDefaultDacl + SeChangeNotifyPrivilege。

▼

⑤ 启动:CreateProcessWithLogonW(沙箱账户)

🔹 Job Object 兜住整个进程树,超时/取消时 terminate🔹 WFP+防火墙按账户 SID 封网,loopback 代理口放行

对比 legacy 路径:①②相同 → 跳过账户/网络供给 → 直接 ④(当前用户令牌派生)→ CreateProcessAsUserW。两条路径在"令牌+ACL"层汇合,差异全在身份和网络。

八、小结

Windows 沙箱的本质是"用 Windows 已有的原语拼出其他平台免费给的东西":受限令牌+ACL 替代 Landlock(文件),WFP+防火墙按 SID 匹配替代 seccomp/网络命名空间(网络),专属账户替代 bwrap 的 uid 映射(身份)。三个值得记住的设计决策:

🔹 确定性 capability SID:按根目录派生 UUID,让 ACL 跨次启动可复用——这是"每次启动都刷 ACL"能保持 O(变化量) 而不是 O(全盘) 的前提。

🔹 deny 同步、allow 异步:读拒绝必须在命令启动前落盘(安全边界),读授权可以后台慢慢挂(性能优化)——同一个 helper 里两种时序,对应两种风险等级。

🔹 先锁门再开门:修复模式下"恢复网络保护 → 解除账户禁用"的顺序,是安全系统里 liveness/safety 权衡的教科书案例。

下一讲(第 25 讲)进入 ExecPolicy:Codex 怎么把"哪些 shell 命令允许直接跑、哪些要审批"做成一个可解析、可 amend 的策略引擎。

📚 系列导航

← 第 23 讲:Landlock + Seatbelt 双引擎

→ 第 25 讲:ExecPolicy 命令策略引擎

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料