夜雨聆风学习资料网

ARTICLE · 1199556

Codex 源码-Landlock + Seatbelt 双引擎

Codex 源码-Landlock + Seatbelt 双引擎

Codex 源码解析系列

第 23 讲:Landlock + Seatbelt 双引擎

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

💡 本讲一句话:第 22 讲的 bwrap 只负责"文件系统视图",真正锁死系统调用和文件权限的是两个内核级引擎——Linux 的 Landlock+seccomp、macOS 的 Seatbelt。读完你能看懂:为什么 seccomp 要留一个 recvfrom 例外、为什么 macOS 策略里 deny 必须放最后、以及 rename 是怎么被当成攻击向量防住的。

一、定位:sandboxing crate 里的两个"内核级"引擎

第 21 讲拆过 SandboxManager(决策层),第 22 讲拆过 bwrap(Linux 文件系统视图)。但"视图"只是把目录藏起来,真正在内核层面执行访问控制的是两套机制:Linux 侧的 Landlock + seccomp(linux-sandbox/src/landlock.rs,370 行),macOS 侧的 Seatbelt(sandboxing/src/seatbelt.rs,1075 行 + 4 个 .sbpl 策略文件)。先看 crate 的模块划分——平台差异在编译期就切掉了:

📄 codex-rs/sandboxing/src/lib.rs (第 1-14 行)

#[cfg(target_os = "linux")] // 编译期开关:bwrap 模块只在 Linux 存在,其他平台连这段代码都看不到mod bwrap; // bubblewrap 挂载命名空间沙箱——第 22 讲的主角mod denial; // 从错误输出里识别"这是不是沙箱拒绝",喂给审批流做归因pub mod landlock; // Landlock + seccomp 原语:Linux legacy/备用路径与网络锁(本讲主角之一)mod manager; // SandboxManager:按平台+策略决定用哪个后端——第 21 讲的主角pub mod policy_transforms; // 把权限声明转换成具体沙箱参数#[cfg(target_os = "macos")] // Seatbelt 是 macOS 专属,其他平台编译期直接剔除pub mod seatbelt; // 生成 .sbpl 策略并用 sandbox-exec 包裹命令(本讲主角之二)mod spawn; // 统一的进程启动入口mod terminal_queries; // 终端能力查询mod violation; // 记录沙箱违规事件,反馈给 Guardian/审批流mod windows; // Windows restricted token 沙箱

为什么这样设计:注意 landlock 是 pub mod(跨平台可见),而 seatbelt/bwrap 都挂在 #[cfg] 后面——因为 landlock.rs 里除了 Landlock 文件系统规则,还装着跨平台复用的 seccomp 网络过滤器(seccomp 是 Linux 系统调用,但它的"模式决策逻辑"被 core 层引用)。用编译期 cfg 而不是运行时 if,意味着 macOS 二进制里根本不存在 bwrap 代码——攻击面在构建时就消失了。

二、Linux 侧:限制施加在"当前线程",让子进程继承

apply_permission_profile_to_current_thread 是整个 Linux 沙箱的"收口函数"——第 22 讲里 bwrap 内层重入后调用的就是它。它的核心决策逻辑值得逐行看:

📄 codex-rs/linux-sandbox/src/landlock.rs (第 49-77 行)

let (file_system_sandbox_policy, network_sandbox_policy) = // 把统一权限档案拆成文件系统/网络两份运行时策略  permission_profile.to_runtime_permissions(); // 一次转换,后面所有分支都基于这两份策略let network_seccomp_mode = network_seccomp_mode( // 先按"网络策略+代理模式"决定 seccomp 人格(三选一或 None)  network_sandbox_policy, // 入参:网络沙箱策略(启用/受限)  allow_network_for_proxy, // 入参:是否为本地代理桥放行网络  proxy_routed_network, // 入参:流量是否走代理路由).or_else(|| { // 上面没决定出模式时,兜底检查 VM socket 逃逸面  (!file_system_sandbox_policy.has_full_disk_write_access()) // WSL2 能借 interop socket 拉起 Windows 进程——FS 受限时必须切断这条宿主桥  .then_some(NetworkSeccompMode::VmSocketRestricted) // 命中则进入 VmSocketRestricted 模式,否则保持 None});// PR_SET_NO_NEW_PRIVS 是 seccomp 的前置条件,但它同时会禁止 setuid 提权。// 很多 bwrap 部署依赖 setuid,所以只在必要时才开if network_seccomp_mode.is_some() // 要装网络 seccomp → 必须先关提权通道  || (apply_landlock_fs && !file_system_sandbox_policy.has_full_disk_write_access()) // 或走 legacy Landlock FS 且非全盘可写{  set_no_new_privs()?; // prctl(PR_SET_NO_NEW_PRIVS):先关"提权通道"再装 seccomp——顺序不能反,否则内核直接拒绝}if let Some(mode) = network_seccomp_mode { // 模式已定 → 安装过滤器  install_network_seccomp_filter_on_current_thread(mode)?; // 装在当前线程上:fork 出的子进程自动继承,CLI 主进程不受影响}

为什么这样设计:三个细节都是"少受一点伤"的权衡。① no_new_privs 是条件开启的——它是 seccomp 的硬性前置(内核要求),但副作用是永久禁止 setuid;而不少发行版的 bwrap 恰恰靠 setuid 工作,所以代码只在"确实要装 seccomp / 走 legacy Landlock"时才开它。② 限制装在"当前线程"而非进程——seccomp 过滤器和 no_new_privs 都不可卸载,先 fork 再施加,保证 Codex CLI 本体(还要跑审批、UI)永远干净。③ VmSocketRestricted 兜底分支是 WSL2 专属的逃逸面:即使 /run/WSL 被 mask,interop socket 仍能让沙箱内进程拉起 Windows 侧程序——所以只要文件系统受限就顺手把 AF_VSOCK 掐了。

🔑 关键约束:seccomp 过滤器一旦 apply 就不可卸载。这就是第 22 讲"两阶段执行"的根因——必须先用 bwrap 搭好文件系统视图,再重入本进程装 seccomp;顺序反了,CLI 自己就被锁死在沙箱里。

三、Landlock legacy 路径:全盘可读 + 写白名单

文件系统的 Landlock 规则在 Codex 里是 legacy/备用(主路径已被 bwrap 接管),但这段代码把 Landlock 的"白名单思维"写得最清楚——读全开、写收紧,一条规则一个意图:

📄 codex-rs/linux-sandbox/src/landlock.rs (第 150-169 行)

let abi = ABI::V5; // 目标 Landlock v5 ABI:覆盖文件读写 + socket 权限位let access_rw = AccessFs::from_all(abi); // "可写"根要用的完整文件系统权限位集合let access_ro = AccessFs::from_read(abi); // "只读"根要用的读取权限位集合let mut ruleset = Ruleset::default() // 从空规则集起步:先声明能力,再逐条授权  .set_compatibility(CompatLevel::BestEffort) // 老内核上降级到它支持的最高版本,而不是直接失败——可用性优先  .handle_access(access_rw)? // 声明本规则集将管理哪些权限位(Landlock 要求先声明后授权)  .create()? // 创建空规则集:此时还没施加任何限制  .add_rules(landlock::path_beneath_rules(&["/"], access_ro))? // 全盘可读:"/"及其下所有路径只读放行——编译、装依赖都要读系统库  .add_rules(landlock::path_beneath_rules(&["/dev/null"], access_rw))? // /dev/null 可写:几乎所有程序都要往它里丢输出,不给会大面积报错  .set_no_new_privs(true); // Landlock v3+ 要求 no_new_privs——与 seccomp 路径保持一致的提权封锁if !writable_roots.is_empty() { // 有显式白名单才追加写规则,空列表则跳过  ruleset = ruleset.add_rules(landlock::path_beneath_rules(&writable_roots, access_rw))?; // 写权限只发给白名单根目录——其余路径写入直接 EACCES}let status = ruleset.restrict_self()?; // 施加到当前线程:单向操作,之后无法撤销if status.ruleset == landlock::RulesetStatus::NotEnforced { // 内核没真正执行(如 LSM 被禁)——不能装作成功  return Err(CodexErr::Sandbox(SandboxErr::LandlockRestrict)); // fail loud:宁可报错也不裸奔,绝不让用户以为有保护而其实没有}

为什么这样设计:Landlock 的模型是"默认拒绝 + 逐路径授权",所以 Codex 的策略翻译成两条规则:读 = /(全开)、写 = /dev/null ∪ writable_roots。两个容易忽略的点:① BestEffort 兼容级别——Landlock ABI 从 v1 演进到 v5,老内核没有新权限位,硬要求 V5 会让整套沙箱在旧系统上直接不可用;② NotEnforced 检查是安全底线——某些环境(如未启用 LSM 的容器)里 restrict_self 会"成功返回但实际不生效",代码把它当错误抛出。另外注意文件头注释:这段 FS 规则当前未被主路径使用(bwrap 已接管),保留作 fallback——所以本讲的重心在 seccomp。

四、seccomp 网络锁:一个过滤器,三种人格

install_network_seccomp_filter_on_current_thread(第 178-291 行)是 Linux 侧网络隔离的全部实现。思路很直接:不拦数据,只拦系统调用——socket 家族的系统调用按"模式"选择性拒绝:

📄 codex-rs/linux-sandbox/src/landlock.rs (第 181-290 行,节选)

fn deny_syscall(rules: &mut BTreeMap<i64, Vec<SeccompRule>>, nr: i64) { // 小工具:往规则表里登记一个"无条件拒绝"的 syscall  rules.insert(nr, vec![]); // 空条件列表 = 无条件命中——该 syscall 一律被拒}let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new(); // syscall 号 → 触发条件列表的映射表(BTreeMap 保证规则顺序稳定)if mode != NetworkSeccompMode::VmSocketRestricted { // VmSocket 模式要保留普通网络,ptrace 类拦截不适用  deny_syscall(&mut rules, libc::SYS_ptrace); // 禁 ptrace:防进程注入/调试逃逸  deny_syscall(&mut rules, libc::SYS_process_vm_readv); // 禁跨进程读内存  deny_syscall(&mut rules, libc::SYS_process_vm_writev); // 禁跨进程写内存——三条合起来封死"借宿主进程之手"}// io_uring 能绕过 socket() 直接建 AF_VSOCK socket,所以三种模式一律禁用deny_syscall(&mut rules, libc::SYS_io_uring_setup); // 禁 io_uring 创建——堵第一个旁路入口deny_syscall(&mut rules, libc::SYS_io_uring_enter); // 禁 io_uring 提交——堵第二个旁路入口deny_syscall(&mut rules, libc::SYS_io_uring_register); // 禁 io_uring 注册——三个入口全封,不留任何缝隙match mode { // 按模式选择 socket 家族的放行/拒绝组合  NetworkSeccompMode::Restricted => { // 完全断网模式:只留 AF_UNIX 本地 IPC    deny_syscall(&mut rules, libc::SYS_connect); // 禁 connect:不能主动建连    deny_syscall(&mut rules, libc::SYS_bind); // 禁 bind:不能监听端口    deny_syscall(&mut rules, libc::SYS_listen); // 禁 listen:不能开服务    deny_syscall(&mut rules, libc::SYS_sendto); // 禁 sendto:不能发数据报    deny_syscall(&mut rules, libc::SYS_recvmmsg); // 禁 recvmmsg(注意 recvfrom 被刻意放行,见下方分析)    // socket() 特殊处理:不是一刀切拒绝,而是按第一个参数(domain)判断    let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new( // 构造"domain != AF_UNIX 则命中"的条件规则      0, // 检查第 1 个参数(domain)      SeccompCmpArgLen::Dword, // 按 32 位字比较——domain 是 int      SeccompCmpOp::Ne, // 不等于……      libc::AF_UNIX as u64, // ……AF_UNIX → 命中拒绝规则    )?)?; // 条件编译成 BPF 指令,出错直接上抛    rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]); // socket():只放行 AF_UNIX——本地 IPC(cargo、git 的子进程通信)不受影响    rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); // socketpair():同样限制,防借它建非 Unix 通道}  NetworkSeccompMode::ProxyRouted => { // 代理模式:放行 IP socket,让流量走本地 TCP 桥    let deny_non_ip_socket = SeccompRule::new(vec![ // 构造"domain 既非 IPv4 也非 IPv6 则命中"的规则      SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::AF_INET as u64)?, // domain != AF_INET(条件一)      SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::AF_INET6 as u64)?, // 且 != AF_INET6(条件二)——两个同时满足才拒    ])?; // 编译成 BPF:效果是只放行 IPv4/IPv6 socket    let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( // socketpair 走另一套规则:只留 AF_UNIX      0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::AF_UNIX as u64, // domain != AF_UNIX → 命中拒绝    )?)?; // 编译成 BPF    rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]); // socket():只放行 IPv4/IPv6——目标就是本地代理桥,其余 domain 全拒    rules.insert(libc::SYS_socketpair, vec![deny_non_unix_socketpair]); // socketpair():进程本地 IPC 不能绕过代理桥}  NetworkSeccompMode::VmSocketRestricted => { // WSL2 场景:普通网络保留,只掐 AF_VSOCK 这条宿主桥    let deny_vsock = SeccompRule::new(vec![SeccompCondition::new( // 构造"domain == AF_VSOCK 则命中"的规则(与上面方向相反)      0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, libc::AF_VSOCK as u64, // domain == AF_VSOCK → 命中    )?)?; // 编译成 BPF    rules.insert(libc::SYS_socket, vec![deny_vsock.clone()]); // 掐掉 vsock:防经 interop socket 触达 Windows 宿主服务}}let filter = SeccompFilter::new( // 把规则表 + 动作组装成 seccomp-bpf 过滤器  rules, // 上面收集的 syscall → 条件映射  SeccompAction::Allow, // 默认动作是放行——只限制列出的 syscall,其余照常(白名单的反面:黑名单思维)  SeccompAction::Errno(libc::EPERM as u32), // 命中时返回 EPERM:进程看到"权限拒绝"而不是 SIGSYS 崩溃,行为更可预测  if cfg!(target_arch = "x86_64") { // x86_64 的 syscall 编号表    TargetArch::x86_64 // 该架构下 SYS_connect 等常量的取值基准} else if cfg!(target_arch = "aarch64") { // ARM64 编号表——不同架构 syscall 号完全不同,用错等于全乱    TargetArch::aarch64 // aarch64 的编号基准} else { // 其他架构直接拒绝生成:宁可失败也不装错过滤器    unimplemented!("unsupported architecture for seccomp filter"); // panic 上抛——安全默认是"不沙箱化"而不是"错误地沙箱化"},)?; // 规则编译失败(如条件非法)→ 上抛错误,绝不装半成品过滤器let prog: BpfProgram = filter.try_into()?; // 把过滤器转成 eBPF 程序(seccomp-bpf 格式),供内核加载apply_filter(&prog)?; // prctl(PR_SET_SECCOMP) 装到当前线程——单向操作,不可卸载

为什么这样设计:这段代码里藏着四个"反直觉"决策。① recvfrom 被刻意放行(源码注释明说):cargo clippy 等工具用 socketpair + 子进程做 IPC,如果连收包都禁,整个 Rust 工具链在沙箱里直接瘫痪——所以只禁发、不禁收,攻击者拿到一个只能读不能写的管道也传不出数据。② io_uring 三件套全封:现代内核上 io_uring 可以绕过 socket() 系统调用直接建 AF_VSOCK socket,这是 seccomp-by-syscall 模型的天然盲区,必须显式堵死。③ EPERM 而非 SIGSYS/KILL:返回错误码让程序走正常的"权限不足"分支(很多工具会优雅降级),比直接杀进程体验好、也更不容易触发 panic 级联。④ 架构白名单:seccomp-bpf 规则里的 syscall 号是架构相关的,x86_64 的 SYS_connect 和 aarch64 的根本不是一个数字——所以非 x86/ARM 直接 unimplemented,这是"宁缺毋滥"的安全默认。

三种模式的取舍可以浓缩成一张表:

模式
触发条件与效果
socket() 规则
Restricted
网络禁用,或托管网络强制且无代理路由——完全断网
只放行 AF_UNIX,其余 domain 全拒;connect/bind/listen/sendto 等直接禁
ProxyRouted
allow_network_for_proxy + proxy_routed_network——流量必须走本地 TCP 桥
只放行 AF_INET/AF_INET6(够到代理桥即可);socketpair 限 AF_UNIX
VmSocketRestricted
FS 受限但需保留普通网络(WSL2 兜底)——只断宿主桥
仅拒绝 AF_VSOCK,其余 socket 家族全部放行

五、macOS 侧:先把 sandbox-exec 的路径钉死

Seatbelt 的执行器是系统自带的 sandbox-exec。在生成任何策略之前,seatbelt.rs 先做了一件看似琐碎、实则关键的事——把可执行文件路径写死:

📄 codex-rs/sandboxing/src/seatbelt.rs (第 59-63 行)

/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` // 只用 /usr/bin 下的 sandbox-exec:防 PATH 注入假沙箱/// to defend against an attacker trying to inject a malicious version on the // 攻击者可能在 PATH 里放恶意同名程序冒充执行器/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker // 若 /usr/bin 被篡改,说明攻击者已有 root——那时防御已无意义/// already has root access. // 威胁模型声明:明确告诉读者防御边界在哪,而不是假装能防住一切pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; // 常量而非变量:连"运行时再查一次 PATH"的机会都不给

为什么这样设计:沙箱执行器本身是信任链的根——如果攻击者能在 PATH 里放一个假的 sandbox-exec(比如恶意 npm 包装到 ~/bin),"被沙箱包裹的命令"其实根本没进沙箱。写死绝对路径把这条攻击面直接关掉;注释里那句"If /usr/bin has been tampered with, the attacker already has root"是典型的威胁模型声明:明确告诉读者防御边界在哪,而不是假装能防住一切。

六、策略组装:顺序就是安全语义

create_seatbelt_command_args_with_profile(第 874-1071 行)是 macOS 沙箱的总装车间。先看它如何计算"受保护祖先目录"——这是整篇代码里最精巧的一段防御:

📄 codex-rs/sandboxing/src/seatbelt.rs (第 899-922 行)

// Protect ancestors of read-only paths so renaming a writable directory // 保护只读子路径的祖先目录:防止 rename 把受保护内容搬出白名单// cannot move its descendants outside their policy carveouts. // (rename = unlink+create,操作对象是父目录)let mut protected_ancestors = BTreeSet::new(); // 收集"受保护子路径的祖先目录"集合——BTreeSet 去重且有序for writable_root in &writable_roots { // 遍历每个可写根,找它内部的只读子路径  let root = normalize_path_for_sandbox(writable_root.root.as_path())    .unwrap_or_else(|| writable_root.root.clone()); // 规范化根路径(解析符号链接),失败则用原值——不中断策略生成  for protected_directory in writable_root.read_only_subpaths.iter().filter_map(|path| { // 取每个只读子路径的父目录:rename 作用在父目录上,而不是子节点本身    normalize_path_for_sandbox(path.as_path()) // 子路径同样先规范化——防"别名路径绕过"    .unwrap_or_else(|| path.clone()) // 规范化失败就用原值    .parent() // 取父目录:这是 rename 攻击真正作用的层级}) {    for ancestor in protected_directory.ancestors() { // 从父目录一路向上收集祖先链      if !ancestor.as_path().starts_with(root.as_path()) { break; } // 只收集落在本可写根内的祖先——出了根就不归这条策略管      protected_ancestors.insert(ancestor); // 记录:这些目录必须禁止 unlink(= 禁止 rename)    }  }}let protected_ancestor_params: Vec<(String, PathBuf)> = protected_ancestors  .into_iter() // 转成迭代器准备编号  .enumerate() // 给每个祖先目录编个号——策略文本里用编号引用,路径本体走 -D 传入  .map(|(index, path)| (format!("PROTECTED_ANCESTOR_{index}"), path.into_path_buf())) // 生成 (参数名, 真实路径) 对:解耦策略模板与具体目录  .collect();

为什么这样设计:这是一个针对 rename 攻击的防御。场景:可写根是 ~/project,其中 .codex/ 被设为只读子路径(保护沙箱自身的配置)。Seatbelt 的路径白名单是"按名字匹配"的——如果允许进程 rename ~/project/.codex 到别处,受保护的目录就"搬出"了白名单范围,里面的文件瞬间变成可写。而 rename 在内核层面 = unlink + create,所以防御手段就是禁止对这些祖先目录做 file-write-unlink。注意它收集的是"父目录及所有内部祖先"而不是子路径本身——因为操作对象是父目录。

🔑 Seatbelt 求值规则:.sbpl 策略按书写顺序匹配,后面的 deny 可以覆盖前面的 allow。所以"禁止 rename 祖先目录"的 deny 必须放在所有 allow 之后——源码注释原话:"Keep these denies last so no broader allowance can reopen the unlink operation used by rename."

再看总装:各层策略按固定顺序拼接,最后生成 sandbox-exec 的完整命令行:

📄 codex-rs/sandboxing/src/seatbelt.rs (第 1024-1070 行,节选)

let mut policy_sections = vec![ // 按固定顺序收集各层策略——顺序即安全语义  MACOS_SEATBELT_BASE_POLICY.to_string(), // 基础层:deny default + 进程/PTY/sysctl 白名单(借鉴 Chrome 沙箱策略)  file_read_policy, // 读权限层:按可读根生成,含排除子路径  file_write_policy, // 写权限层:按可写根生成,含受保护元数据名排除  network_policy, // 网络层:全开 / 代理回环白名单 / 完全关闭(见第八节)]; // 四层拼完,后面按需追加平台默认值与 denyif include_platform_defaults { // 策略要求包含 macOS 平台兼容性放行时  policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); // 平台只读默认值:系统目录等 macOS 兼容性放行}policy_sections.push(deny_read_policy); // 不可读 glob 的 deny 规则(如显式屏蔽的密钥目录)——放在 allow 之后才能压住它们// Renaming an allowed ancestor relocates its protected descendants past their pathname carveouts. // rename 允许祖先会把受保护后代搬出路径白名单// Keep these denies last so no broader allowance can reopen the unlink operation used by rename. // deny 必须垫底:前面任何宽泛 allow 都不能重新打开 rename 用的 unlink 通道policy_sections.extend(protected_ancestor_params.iter().map(|(key, _)| { // 把"禁止 rename 祖先目录"的 deny 逐条追加到最末尾——顺序即安全语义  format!("(deny file-write-unlink (require-all (vnode-type DIRECTORY) (literal (param \"{key}\"))))") // 禁 unlink = 禁 rename:rename 在内核里就是 unlink+create,掐住 unlink 就掐住了搬移})); // extend 完成:所有 deny 都排在 allow 之后let full_policy = policy_sections.join("\n"); // 所有层拼成一份完整 .sbpl 文本——内联传递,不落盘(避免策略文件被替换的 TOCTOU)let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy]; // -p:把整份策略作为命令行参数直接传给 sandbox-execseatbelt_args.extend(dir_params.iter().map(|(key, value)| { // 遍历所有 (参数名, 路径) 对,生成 -D 绑定  format!("-D{key}={value}") // -DKEY=value:绑定策略里 (param "KEY") 引用的路径——策略文本与具体路径解耦})); // extend 完成:所有目录参数就位seatbelt_args.push("--".to_string()); // -- 分隔符:后面的一切都是"被沙箱包裹的目标命令"seatbelt_args.extend(command); // 最后接上用户要执行的命令——sandbox-exec -p <策略> -D... -- cmd

为什么这样设计:① 策略内联(-p)而非写临时文件——如果策略先落盘再让 sandbox-exec 读,攻击者有窗口替换该文件;命令行参数没有这个 TOCTOU。② -D 参数化路径是 Seatbelt 的标准做法:.sbpl 文本里只有 (param "WRITABLE_ROOT_0") 这样的占位符,真实路径通过 -D 注入——同一份策略模板可以服务任意工作目录,也避免路径里的特殊字符破坏策略语法。③ deny 永远垫底:Seatbelt 按顺序求值、后写覆盖先写,把最关键的 deny 放最后,确保前面任何宽泛的 allow(比如全盘可写的 fast path)都无法"重新打开"rename 通道。

七、build_seatbelt_access_policy:三种"排除"

build_seatbelt_access_policy(第 498-602 行)负责把"根目录 + 排除项"翻译成 .sbpl。它的核心是三种不同粒度的排除机制,每种都堵一个具体的洞:

📄 codex-rs/sandboxing/src/seatbelt.rs (第 530-589 行,节选)

if access_kind == SeatbeltAccessKind::Write { // 只有写权限需要"根锚点保护"——读不需要  // A sandboxed process must not be able to replace an authority boundary // 沙箱内进程不能替换"权威边界"  // that will be reused to build the next sandbox policy. // ——这个路径是构建下一轮沙箱策略的权威材料,不能被换掉  root_anchor_denies.push(format!( // 生成一条 deny:禁止对可写根目录本身做 unlink(= 删除/重命名该目录)    "(deny file-write-unlink (require-all (literal (param \"{root_param}\")) (vnode-type DIRECTORY)))" // literal 精确命中根目录节点 + vnode-type 限定为目录——只拦目录操作,不误伤文件  )); // push 进 deny 列表:稍后追加到策略末尾(deny 垫底原则)}for (excluded_index, excluded_subpath) in access_root.excluded_subpaths.into_iter().enumerate() { // 遍历每个排除子路径,逐个生成双条件  // Exclude both the exact protected path and anything beneath it. // 要同时排除"目录本身"和"其下所有内容"  // `subpath` alone leaves a gap for first-time creation of the protected directory itself, // 只写 subpath 会漏掉"目录本身的首次创建"// such as `mkdir .codex`. // ——比如 mkdir .codex 就能把受保护目录顶掉  require_parts.push(format!( // 排除条件一:literal 命中"目录节点本身"——subpath 不匹配目录自身,必须补上    "(require-not (literal (param \"{excluded_param}\")))"  )); // push:创建、删除、重命名该目录本身全部被拒  require_parts.push(format!( // 排除条件二:subpath 命中"其下所有内容"——常规的子树保护    "(require-not (subpath (param \"{excluded_param}\")))"  )); // push:两条合起来才没有缝隙}for metadata_name in access_root.protected_metadata_names { // 遍历受保护元数据文件名(.codex/.git 等)  let regex = seatbelt_protected_metadata_name_regex(&root, &metadata_name).replace('"', "\\\\"); // 为每个名字生成锚定正则 ^{root}/\.codex(/.*)?$,并转义引号防注入  require_parts.push(format!(r#"(require-not (regex #"{regex}"))"#)); // 按名字排除:即使路径落在可写根内,这些元数据文件也动不了}policy_components.push(format!("(require-all {} )", require_parts.join(" "))); // 把"允许条件 + 所有排除条件"合成一条 allow 子句——require-all = 全部满足才放行

为什么这样设计:三种排除对应三个不同维度的威胁。① root_anchor_denies(根锚点保护):注释说得很直白——可写根本身是"authority boundary",下一轮沙箱策略还要基于它来构建;如果沙箱内进程能删掉/替换这个目录,等于篡改了信任边界。② literal + subpath 双排除是最容易被忽略的洞:Seatbelt 的 (subpath "/a/b") 只匹配 /a/b 下面的东西,不匹配 /a/b 这个目录节点本身——所以攻击者可以 mkdir .codex(创建动作针对的是父目录里的新条目),把受保护目录"顶掉"。加上 literal 排除后,创建、删除、重命名该目录本身全部被拒。③ 正则按名排除处理的是"路径在可写根内但名字敏感"的情况(比如工作区里的 .git/config)——用锚定正则 ^{root}/\.codex(/.*)?$ 精确命中,不误伤同名文件。

八、网络策略生成:fail-closed 是默认值

dynamic_network_policy_for_network(第 307-369 行)决定 macOS 上的网络放行范围。与 Linux seccomp"按 syscall 拒绝"不同,Seatbelt 是"按端点白名单允许"——而它的默认姿态是关死:

📄 codex-rs/sandboxing/src/seatbelt.rs (第 316-369 行,节选)

let should_use_restricted_network_policy = !proxy.ports.is_empty() // 条件一:推导出代理回环端口 → 受限模式  || proxy.has_proxy_config // 条件二:存在代理配置(即使端点还没解析出来)  || enforce_managed_network // 条件三:托管网络强制生效——必须走受控通道  || (!network_policy.is_enabled() && has_some_unix_socket_access); // 条件四:断网但需要 Unix socket IPC → 也要进受限分支单独放行 socketif should_use_restricted_network_policy { // 命中任一条件 → 生成"白名单式"网络策略  let mut policy = String::new(); // 逐条累加 allow 规则  if proxy.allow_local_binding { // 允许本地绑定时(沙箱内服务要监听回环)    policy.push_str("(allow network-bind (local ip \"*:*\"))\n"); // 允许本地 bind:沙箱内服务可以监听回环地址    policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n"); // 允许访问 localhost:够到本地代理桥即可,外网仍不可达}  if proxy.allow_local_binding && !proxy.ports.is_empty() { // 既要本地绑定又有代理端口 → 需要 DNS    policy.push_str("(allow network-outbound (remote ip \"*:53\"))\n"); // 放行 DNS :53——应用流量走代理,但域名解析必须直连(代理桥不代管 DNS)}  for port in &proxy.ports { // 逐个处理推导出的代理端口    policy.push_str(&format!("(allow network-outbound (remote ip \"localhost:{port}\"))\n")); // 逐端口白名单:只有代理实际监听的回环端口可达——精确到端口号}  return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); // 追加平台网络基础层(AF_SYSTEM socket、mach-lookup 等系统服务)后返回}if proxy.has_proxy_config { // 走到这里 = 有代理配置但上面一个端口都没推出来  // Proxy configuration is present but we could not infer any valid loopback endpoints. // 配置存在却推不出任何有效回环端点// Fail closed to avoid silently widening network access in proxy-enforced sessions. // fail closed:宁可不给网络,也不悄悄放宽——防"半残代理"变成全开  return String::new(); // 返回空策略 = 零网络权限}if enforce_managed_network { // 托管网络强制生效但没有可用代理端点  // Managed network requirements are active but no usable proxy endpoints are available. // 要求走受控通道,但通道不存在// Fail closed for network access. // fail closed:同样全关——不能因为"没代理"就退回全开  return String::new(); // 返回空策略 = 零网络权限}if network_policy.is_enabled() { // 前面都没命中,且策略本身允许网络 → 唯一的全放行分支  // No proxy env is configured: retain the existing full-network behavior. // 无代理配置:保留既有全网络行为(兼容非托管场景)  let mut policy = String::from("(allow network-outbound)\n(allow network-inbound)\n"); // 出/入站全开——这是整个函数里唯一的全放行  return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); // 同样追加平台网络基础层后返回}String::new() // 其余一切情况:零网络权限——默认值就是关死(fail-closed)

为什么这样设计:整个函数的结构是"白名单递进 + 兜底全关":只有明确满足条件才逐级放行(本地 bind → localhost → 具体代理端口),任何中间状态(配置存在但端点推不出来)都直接返回空策略——fail-closed。这与 Linux seccomp 侧的 should_install_network_seccomp 形成镜像:那边是"托管网络会话即使 DangerFullAccess 也要装过滤器",这边是"代理配置残缺就什么都不给"。两个平台、两种机制,同一个安全哲学:不确定时,选拒绝。

九、对比:Landlock+seccomp vs Seatbelt

维度
Linux:Landlock + seccomp
macOS:Seatbelt
执行机制
内核 LSM 规则集 + eBPF 系统调用过滤器(进程内施加)
sandbox-exec 加载 .sbpl 策略文本,包裹子进程
文件系统模型
读全开 + 写白名单(path_beneath_rules),legacy/备用路径
deny default + 按根 allow,literal/subpath/regex 三级排除
网络处理
seccomp 按 syscall + domain 参数拒绝,三模式切换
.sbpl 端点白名单(回环端口/DNS :53),fail-closed 兜底
特殊防御
io_uring 三件套封死、AF_VSOCK 逃逸拦截、recvfrom 例外保工具链
rename 攻击(祖先目录禁 unlink)、mkdir 缝隙(literal+subpath 双排除)
不可逆性
restrict_self / apply_filter 均为单向操作,子进程继承后无法撤销
sandbox-exec profile 随进程树继承,同样无法卸载

两个引擎的哲学高度一致:默认拒绝、白名单放行、不可逆施加、不确定时 fail-closed。差异只在机制——Linux 用"系统调用级拦截 + LSM 路径规则",macOS 用"策略文本 + 内核强制"。Codex 在 core 层把两者抽象成同一套 PermissionProfile → 运行时权限的转换(第 21 讲的 policy_transforms),上层代码完全无感。

十、数据流:从权限档案到沙箱内进程

执行路径(Linux / macOS 双轨)

① 决策:SandboxManager 按平台 + 策略选后端

🔹 Linux → bwrap 外层(第22讲)+ seccomp 内层🔹 macOS → 动态生成 .sbpl + sandbox-exec

▼

② 参数化:权限档案 → 具体规则

writable_roots / unreadable_roots / 代理端口 → path_beneath_rules 或 -DKEY 参数。

▼

③ 施加:限制装到"当前线程/包裹进程"

🔹 Linux:no_new_privs → seccomp apply_filter(bwrap 内层重入后)🔹 macOS:sandbox-exec -p <policy> -D... -- cmd

▼

④ 继承与锁定:子进程树全部受限且不可撤销

restrict_self / apply_filter / seatbelt profile 均为单向操作——沙箱内再 fork 也逃不出去。

本讲拆完了 Codex 的两个内核级沙箱引擎。三个值得记住的设计:① seccomp 的"例外清单"比拒绝清单更见功力——recvfrom 放行保工具链、io_uring 全封堵旁路、AF_VSOCK 拦截 WSL2 逃逸,每一条都对应一个真实攻击面或兼容性需求;② Seatbelt 的顺序即语义——deny 必须垫底才能压住前面的宽泛 allow,rename 被拆成 unlink+create 来防,mkdir 缝隙用 literal+subpath 双排除堵死;③ 两个平台共享同一套 fail-closed 哲学:不确定时选拒绝,宁可报错也不裸奔。下一讲(第 24 讲)进入 Windows 沙箱——restricted token + ACL 的 21K LOC 实现。

📚 系列导航

← 第 22 讲:Linux bwrap 沙箱

→ 第 24 讲:Windows 沙箱

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

相关学习资料