Skip to content

Bubblewrap命令构造

深入 Bubblewrap 根视图、重叠权限、glob mask、symlink、missing path、metadata保护与launcher兼容算法。

基于rust-v0.150.0
CodexRustSecurityLinuxBubblewrap

Bubblewrap命令构造 ​

Bubblewrap argv 不是把 readable roots 变成 --ro-bind、writable roots 变成 --bind 就结束。真正困难的是重叠权限:一个 writable root 内可能有 read-only metadata,denied parent 内又可能有更窄的 writable child,unreadable glob 只能在启动前展开成已有路径,missing protected path 还必须阻止首次创建。所有这些约束都依赖 mount 顺序。

rust-v0.150.0 的构造器还处理两个运行时竞态。第一,writable path 中的 symlink 可以在 policy 生成后被进程替换,所以 read-only/deny path 穿过 writable symlink 时必须 fail closed。第二,Bubblewrap mount target 必须存在,构造器可能临时创建 empty file/directory;多个并发 sandbox 共享这些 target 时,需要 registry、inode identity 和清理协议,不能由某个进程随意删除。

本文承接Linux Seccomp与Namespace和Linux Landlock策略。前文解释 namespace 与 inner Seccomp,本篇只聚焦 filesystem mount argv、launcher compatibility 和清理 sidecar;不重复 proxy bridge 或 syscall filter。

1. 返回模型 ​

1.1 BwrapArgs ​

构造结果不仅是 argv。preserved_files 保存需要跨 exec 的 FD;synthetic_mount_targets 记录为了满足 mount target 存在性而创建或接管的空文件/目录;protected_create_targets 记录 policy 要求“不应出现”的 metadata path,outer supervisor 会监视并清理。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: BwrapArgs, SyntheticMountTarget

rust
#[derive(Debug)]
pub(crate) struct BwrapArgs {
    pub args: Vec<String>,
    pub preserved_files: Vec<File>,
    pub synthetic_mount_targets: Vec<SyntheticMountTarget>,
    pub protected_create_targets: Vec<ProtectedCreateTarget>,
}

#[derive(Debug, Clone)]
pub(crate) struct SyntheticMountTarget {
    path: PathBuf,
    kind: SyntheticMountTargetKind,
    pre_existing_path: Option<FileIdentity>,
}

FileIdentity 保存 device/inode。清理时不仅检查“文件为空”,还检查是不是构造器自己创建或替换的那个 inode,避免删除原来就存在的真实空文件。

1.2 快路径 ​

full disk write 且没有 unreadable glob 时,FullAccess network 可以直接返回原 command;网络需要 Isolated/ProxyOnly 时仍必须包装,以创建 network namespace。只要存在 unreadable glob,即使 full disk write 也要构造具体 mask。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: create_bwrap_command_args

rust
let unreadable_globs =
    file_system_sandbox_policy.get_unreadable_globs_with_cwd(sandbox_policy_cwd);
// Full disk write normally skips bwrap, but unreadable glob patterns still
// need concrete bwrap masks for the matches expanded below.
if file_system_sandbox_policy.has_full_disk_write_access() && unreadable_globs.is_empty() {
    return if options.network_mode == BwrapNetworkMode::FullAccess {
        Ok(BwrapArgs {
            args: command,
            preserved_files: Vec::new(),
            synthetic_mount_targets: Vec::new(),
            protected_create_targets: Vec::new(),
        })
    } else {
        Ok(create_bwrap_flags_full_filesystem(command, options))
    };
}

create_bwrap_flags(
    command,
    file_system_sandbox_policy,
    sandbox_policy_cwd,
    command_cwd,
    options,
)

2. 根视图 ​

2.1 Full read ​

full disk read 从 --ro-bind / / 开始,再用 --dev /dev 建立 minimal device tree。/dev 必须在 writable /dev/* bind 之前建立,否则后来的 device mount 会覆盖显式 writable subpath。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: create_filesystem_args

rust
let args = if file_system_sandbox_policy.has_full_disk_read_access() {
    // Read-only root, then mount a minimal device tree.
    // `/dev` must be mounted before writable roots so explicit `/dev/*`
    // writable binds remain visible.
    vec![
        "--ro-bind".to_string(),
        "/".to_string(),
        "/".to_string(),
        "--dev".to_string(),
        "/dev".to_string(),
    ]

2.2 Restricted read ​

restricted read 默认从 --tmpfs / 空根开始,只挂载 approved readable roots 和 minimal /dev。如果 readable roots 显式包含 /,则切回 read-only root baseline,再在后面重施 deny carveout。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: create_filesystem_args

rust
} else {
    // Start from an empty filesystem and add only the approved readable
    // roots plus a minimal `/dev`.
    let mut args = vec![
        "--tmpfs".to_string(),
        "/".to_string(),
        "--dev".to_string(),
        "/dev".to_string(),
    ];

    let mut readable_roots: BTreeSet<PathBuf> = file_system_sandbox_policy
        .get_readable_roots_with_cwd(cwd)
        .into_iter()
        .map(PathBuf::from)
        .collect();
    if file_system_sandbox_policy.include_platform_defaults() {
        readable_roots.extend(
            LINUX_PLATFORM_DEFAULT_READ_ROOTS
                .iter()
                .map(|path| PathBuf::from(*path))
                .filter(|path| path.exists()),
        );
    }

    if readable_roots.iter().any(|root| root == Path::new("/")) {
        args = vec![
            "--ro-bind".to_string(),
            "/".to_string(),
            "/".to_string(),
            "--dev".to_string(),
            "/dev".to_string(),
        ];
    } else {
        for root in readable_roots {
            if !root.exists() {
                continue;
            }
            args.push("--ro-bind".to_string());
            args.push(path_to_string(&root));
            args.push(path_to_string(&root));
        }
    }

missing readable root 被跳过;missing protected path 则在 writable root 处理中使用 synthetic mask,二者语义不同。

3. Glob预展开 ​

3.1 Search root ​

Bubblewrap 不理解 glob,只能 mask concrete paths。构造器把每个 pattern 按首个 glob metacharacter 拆成 static search root 和相对 glob;root-level pattern(如 /**/*.env)会失败关闭,因为启动时扫描整个 / 过宽。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: expand_unreadable_globs_with_ripgrep, split_pattern_for_ripgrep

rust
let mut patterns_by_search_root: BTreeMap<AbsolutePathBuf, Vec<String>> = BTreeMap::new();
for pattern in patterns {
    let Some((search_root, glob)) = split_pattern_for_ripgrep(pattern, cwd) else {
        return Err(CodexErr::Fatal(format!(
            "unreadable glob `{pattern}` cannot be safely expanded; use a pattern with a non-root directory prefix"
        )));
    };
    if search_root.as_path().is_dir() {
        patterns_by_search_root
            .entry(search_root)
            .or_default()
            .push(glob);
    }
}

同一 search root 下的多个 pattern 合并为一次扫描,减少启动成本。每个 logical match 还会加入 canonical symlink target,防止通过可读 symlink 指向被 mask 文件的真实位置。

3.2 Glob扫描 ​

优先使用 rg --files --hidden --no-ignore --null,可选 --max-depth。exit 1 且 stderr 为空表示无匹配;rg 不存在时使用内部 globset walker;其它失败保持 fatal,不能因为扫描工具异常而静默减弱 deny-read。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: ripgrep_files, glob_files

rust
let mut command = Command::new("rg");
command
    .arg("--files")
    .arg("--hidden")
    .arg("--no-ignore")
    .arg("--null");
if let Some(max_depth) = max_depth {
    command.arg("--max-depth").arg(max_depth.to_string());
}
for glob in globs {
    command.arg("--glob").arg(glob);
}
command.arg("--").arg(search_root);

let output = match command.output() {
    Ok(output) => output,
    Err(err) if err.kind() == io::ErrorKind::NotFound => {
        return glob_files(search_root, globs, max_depth);
    }
    Err(err) => return Err(err.into()),
};
if !output.status.success() {
    if output.status.code() == Some(1) && output.stderr.is_empty() {
        return Ok(Vec::new());
    }
    return Err(CodexErr::Fatal(format!(
        "ripgrep unreadable glob scan failed for {}: {}",
        search_root.display(),
        String::from_utf8_lossy(&output.stderr)
    )));
}

expanded path 总数上限为 8192;超过上限同样失败关闭。glob_scan_max_depth=0 则显式不展开现有 match,这一配置会改变 Bubblewrap mask 覆盖面,因此应与 direct filesystem tools 的运行时 glob enforcement 区分。

4. 重叠权限排序 ​

4.1 六阶段顺序 ​

源码注释给出 mount precedence 的真实算法:

  1. 建立 read-only root 或空根;
  2. 建 minimal /dev;
  3. 先 mask 位于所有 writable root 外部的 denied ancestor;
  4. 按路径深度 bind writable roots;
  5. 在每个 writable root 中重新施加 read-only metadata;
  6. 最后 mask nested 与 unrelated deny paths。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: create_filesystem_args

rust
/// The mount order is important:
/// 1. Full-read policies, and restricted policies that explicitly read `/`,
///    use `--ro-bind / /`; other restricted-read policies start from
///    `--tmpfs /` and layer scoped `--ro-bind` mounts.
/// 2. `--dev /dev` mounts a minimal writable `/dev` with standard device nodes.
/// 3. Unreadable ancestors of writable roots are masked before their child
///    mounts are rebound so nested writable carveouts can be reopened safely.
/// 4. `--bind <root> <root>` re-enables writes for allowed roots.
/// 5. `--ro-bind <subpath> <subpath>` re-applies read-only protections under
///    those writable roots so protected subpaths win.
/// 6. Nested unreadable carveouts under a writable root are masked after that
///    root is bound, and unrelated unreadable roots are masked afterward.

4.2 Deny父与Write子 ​

若 /blocked denied,但 /blocked/allowed writable,构造器先用 execute-only tmpfs mask /blocked,再创建 child mount target、remount parent read-only,最后 bind writable child。111 允许遍历到显式重开的 descendant,但不能列出 denied parent 内容。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: append_existing_unreadable_path_args

rust
if unreadable_root.is_dir() {
    let mut writable_descendants: Vec<&Path> = allowed_write_paths
        .iter()
        .map(PathBuf::as_path)
        .filter(|path| *path != unreadable_root && path.starts_with(unreadable_root))
        .collect();
    bwrap_args.args.push("--perms".to_string());
    bwrap_args.args.push(if writable_descendants.is_empty() {
        "000".to_string()
    } else {
        "111".to_string()
    });
    bwrap_args.args.push("--tmpfs".to_string());
    bwrap_args.args.push(path_to_string(unreadable_root));
    writable_descendants.sort_by_key(|path| path_depth(path));
    for writable_descendant in writable_descendants {
        append_mount_target_parent_dir_args(
            &mut bwrap_args.args,
            writable_descendant,
            unreadable_root,
        );
    }
    bwrap_args.args.push("--remount-ro".to_string());
    bwrap_args.args.push(path_to_string(unreadable_root));
    return Ok(());
}

测试 split_policy_reenables_writable_subpaths_after_unreadable_parent 精确比较四个 index:deny mask < child dir创建 < parent remount-ro < child bind。

4.3 Read父与Write子 ​

同理,/docs read-only 而 /docs/public writable 时,writable roots 按 path depth 排序;parent 的 --ro-bind 必须早于 nested child 的 --bind,否则 parent remount 会把 child 再次封闭。

测试 split_policy_reopens_writable_child_after_read_only_parent 断言 docs_ro_index < docs_public_rw_index,这是 mount sequence 而非数据结构排序的验证。

5. Metadata保护 ​

5.1 已存在路径 ​

writable root 的 protected metadata names 会加入 read_only_subpaths。已存在 .git、.codex 或 .agents 通过 --ro-bind 重新覆盖 writable root;如果 writable root 本身是 symlink,subpath 会映射到 canonical target。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: append_metadata_path_masks_for_writable_root, append_protected_create_targets_for_writable_root

rust
fn append_metadata_path_masks_for_writable_root(
    read_only_subpaths: &mut Vec<PathBuf>,
    root: &Path,
    protected_metadata_names: &[String],
) {
    for name in protected_metadata_names {
        let path = root.join(name);
        if !read_only_subpaths.iter().any(|subpath| subpath == &path) {
            read_only_subpaths.push(path);
        }
    }
}

fn append_protected_create_targets_for_writable_root(
    bwrap_args: &mut BwrapArgs,
    protected_metadata_names: &[String],
    root: &Path,
    symlink_target: Option<&Path>,
    read_only_subpaths: &[PathBuf],
) {
    for name in protected_metadata_names {
        let mut path = root.join(name);
        if let Some(target) = symlink_target
            && let Ok(relative_path) = path.strip_prefix(root)
        {
            path = target.join(relative_path);
        }
        if read_only_subpaths.iter().any(|subpath| subpath == &path) || path.exists() {
            continue;
        }
        bwrap_args
            .protected_create_targets
            .push(ProtectedCreateTarget::missing(&path));
    }
}

5.2 Missing path ​

Bubblewrap mount target 必须存在。missing protected metadata directory 使用 mode 555 tmpfs + remount-ro;其它 missing path 使用 /dev/null FD 的 --ro-bind-data 创建空文件 mask。构造器同时记录 synthetic target,outer supervisor 后续按 inode 与并发 owner清理。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: append_missing_read_only_subpath_args, append_missing_empty_file_bind_data_args

rust
fn append_missing_read_only_subpath_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> {
    if path.file_name().is_some_and(is_protected_metadata_name) {
        append_empty_directory_args(bwrap_args, path);
        bwrap_args
            .synthetic_mount_targets
            .push(SyntheticMountTarget::missing_empty_directory(path));
        return Ok(());
    }

    append_missing_empty_file_bind_data_args(bwrap_args, path)
}

fn append_missing_empty_file_bind_data_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> {
    append_empty_file_bind_data_args(bwrap_args, path)?;
    bwrap_args
        .synthetic_mount_targets
        .push(SyntheticMountTarget::missing(path));
    Ok(())
}

第一 missing component 会被 mask,而不是只 mask 最终 leaf。这样 missing/.codex/config.toml 不能通过先创建 ancestor 绕过。

6. Symlink边界 ​

read-only 或 deny path 如果穿过 sandbox 内可写的 symlink,启动时 canonicalize 只会保护旧 target。进程可以替换 symlink 后访问新 target,因此构造器扫描 logical path,发现 writable root 下的 symlink component 就返回 Fatal error。

源码位置:codex-rs/linux-sandbox/src/bwrap.rs :: first_writable_symlink_component_in_path, append_read_only_subpath_args

rust
fn first_writable_symlink_component_in_path(
    target_path: &Path,
    allowed_write_paths: &[PathBuf],
) -> Option<PathBuf> {
    let mut current = PathBuf::new();

    for component in target_path.components() {
        match component {
            Component::RootDir => {
                current.push(Path::new("/"));
                continue;
            }
            Component::CurDir => continue,
            Component::ParentDir => {
                current.pop();
                continue;
            }
            Component::Normal(part) => current.push(part),
            Component::Prefix(_) => continue,
        }

        let metadata = match std::fs::symlink_metadata(&current) {
            Ok(metadata) => metadata,
            Err(_) => break,
        };
        if metadata.file_type().is_symlink()
            && is_within_allowed_write_paths(&current, allowed_write_paths)
        {
            return Some(current);
        }
    }
    None
}

append_unreadable_root_args 使用同一检查,因此 read-only 与 deny 两类约束都不会接受可变 symlink snapshot。

6.2 Symlinked root ​

writable root 本身若是 symlink,构造器可以 bind canonical target,并将 logical root 与 target 都加入 allowed_write_paths。这与“约束 path 穿过可写 symlink”不同:前者由 policy 显式授予整个 root,后者是 root 内部可被进程改写的中间跳转。

7. Launcher兼容 ​

7.1 Capability probe ​

launcher 优先使用 PATH 中可信 system bwrap,并检查 --as-pid-1、--perms、--argv0 和 --ro-bind-fd capability。缺少必要 --perms 时拒绝该 system binary;找不到合格 system bwrap 才尝试 bundled binary。

源码位置:codex-rs/linux-sandbox/src/launcher.rs :: SystemBwrapCapabilities, preferred_bwrap_launcher

rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SystemBwrapCapabilities {
    supports_argv0: bool,
    supports_perms: bool,
    supports_ro_bind_fd: bool,
}

fn preferred_bwrap_launcher() -> BubblewrapLauncher {
    static LAUNCHER: OnceLock<BubblewrapLauncher> = OnceLock::new();
    LAUNCHER
        .get_or_init(|| {
            if let Some(path) = find_system_bwrap_in_path()
                && let Some(launcher) = system_bwrap_launcher_for_path(&path)
            {
                return BubblewrapLauncher::System(launcher);
            }

            match bundled_bwrap::launcher() {
                Some(launcher) => BubblewrapLauncher::Bundled(launcher),
                None => BubblewrapLauncher::Unavailable,
            }
        })
        .clone()
}

7.2 argv0兼容 ​

bwrap 支持 --argv0 时,在 command separator 前插入 --argv0 codex-linux-sandbox;旧版不支持时,只替换第一个 inner helper command path,不修改用户 command 中后续出现的 current executable。

源码位置:codex-rs/linux-sandbox/src/linux_run_main.rs :: apply_inner_command_argv0_for_launcher

rust
let command_separator_index = argv
    .iter()
    .position(|arg| arg == "--")
    .unwrap_or_else(|| panic!("bubblewrap argv is missing command separator '--'"));

if supports_argv0 {
    argv.splice(
        command_separator_index..command_separator_index,
        ["--argv0".to_string(), CODEX_LINUX_SANDBOX_ARG0.to_string()],
    );
    return;
}

let command_index = command_separator_index + 1;
let Some(command) = argv.get_mut(command_index) else {
    panic!("bubblewrap argv is missing inner command after '--'");
};
*command = argv0_fallback_command;

7.3 FD mount兼容 ​

旧 system bwrap 不支持 --ro-bind-fd 时,launcher 把它翻译成 /proc/self/fd/N 的普通 --ro-bind,并向 trusted inner stage 注入 --verify-fd-mount N:DEST。如果 argv 没有 --apply-seccomp-then-exec,则拒绝 translation,避免未认证 descriptor mount 直接进入用户 command。

源码位置:codex-rs/linux-sandbox/src/launcher.rs :: translate_legacy_bwrap_fd_mounts

rust
verification_args.push("--verify-fd-mount".to_string());
verification_args.push(format!("{fd}:{destination}"));
verified_fds.push(fd);
argv[argument_index] = "--ro-bind".to_string();
argv[argument_index + 1] = format!("/proc/self/fd/{fd}");

if !argv[inner_command + 1..]
    .iter()
    .take_while(|argument| argument.as_str() != "--")
    .any(|argument| argument == "--apply-seccomp-then-exec")
{
    return Err("descriptor-backed mounts require the trusted inner sandbox stage".to_string());
}
argv.splice(inner_command + 1..inner_command + 1, verification_args);

8. 测试边界 ​

bwrap.rs 当前包含 31 项 unit test,覆盖 default glob depth、root view、platform defaults、writable/read-only/deny overlap、missing metadata、symlink fail-closed、canonical cwd、glob depth/canonical target 和 root-prefix glob rejection。Linux integration suite 另有 27 项 async test,实际运行 Bubblewrap helper验证 read/write、metadata、symlink和glob行为。

这些测试由 Linux target 条件编译。当前非 Linux 主机只能执行共享 helper argv 测试,不能把 0 runnable Linux tests计为 mount namespace通过。Linux target建议执行:

bash
cargo test -p codex-linux-sandbox --lib -- --nocapture --test-threads=1
cargo test -p codex-linux-sandbox --test all -- --nocapture --test-threads=1
cargo test -p codex-sandboxing --lib landlock::tests:: -- --nocapture --test-threads=1

关键断言包括:

  • denied parent先 mask,writable child target随后创建并 bind;
  • read-only parent remount index 小于 nested writable child bind index;
  • missing protected metadata生成 synthetic target和空目录/文件 mask;
  • writable symlink穿越使 sandbox construction失败;
  • unreadable glob按 max depth展开,并同时 mask symlink canonical target;
  • root-prefix glob拒绝启动,而不是扫描整个 filesystem;
  • old bwrap FD translation必须注入 inner verification。

这些断言证明 argv算法与Linux fixture行为;边界在于 mount能否执行仍依赖 user namespace、Bubblewrap capability和container policy。启动前 glob expansion只覆盖当时已有路径,后续新建文件仍需要其它 runtime read enforcement配合,不能把 concrete mask当成动态 glob watcher。

9. 继续阅读 ​

阅读源码时可以从 create_bwrap_command_args 进入 create_filesystem_args,先标出 root baseline,再追踪 unreadable_ancestors_of_writable_roots、sorted_writable_roots、nested/rootless unreadable三个集合的执行顺序。之后阅读 missing path、symlink和launcher translation,才能解释一条最终 argv为什么以特定顺序出现。

下一篇Linux沙箱降级与诊断将分析 bwrap缺失、user namespace不可用、WSL1、proc mount失败、helper digest和sandbox denial的诊断边界。