#[cfg(target_os = "linux")] use std::ffi::OsStr; use std::ffi::OsString; use std::fmt; use std::path::{Path, PathBuf}; #[cfg(target_os = "linux")] use crate::command_sandbox_trampoline::LaunchGate; #[cfg(target_os = "linux")] const COMMAND_SANDBOX_PROFILE_VERSION: &str = "workspace-v1"; #[cfg(target_os = "linux")] const COMMAND_SANDBOX_PRIVATE_ROOT: &str = "/tmp/genarrative-command"; #[cfg(target_os = "linux")] const COMMAND_SANDBOX_PRIVATE_HOME: &str = "/tmp/genarrative-command/home"; #[cfg(target_os = "linux")] const COMMAND_SANDBOX_PRIVATE_CACHE: &str = "/tmp/genarrative-command/cache"; #[cfg(target_os = "linux")] const COMMAND_SANDBOX_PRIVATE_CONFIG: &str = "/tmp/genarrative-command/config"; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CommandSandboxMetadata { pub(crate) backend: &'static str, pub(crate) mode: &'static str, pub(crate) network: &'static str, pub(crate) profile_version: &'static str, } impl CommandSandboxMetadata { #[cfg(target_os = "linux")] fn enforced_linux() -> Self { Self { backend: "bubblewrap", mode: "workspace-write", network: "disabled", profile_version: COMMAND_SANDBOX_PROFILE_VERSION, } } #[cfg(not(target_os = "linux"))] fn unsupported_legacy() -> Self { Self { backend: "legacy-host-restricted", mode: "fixed-command", network: "proxy-only", profile_version: "legacy-v1", } } } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CommandSandboxLaunch { pub(crate) executable: PathBuf, pub(crate) arguments: Vec, pub(crate) cwd: PathBuf, /// The caller must still use `env_clear`; target variables are encoded as /// bubblewrap `--setenv` arguments so the launcher inherits nothing. pub(crate) environment: Vec<(OsString, OsString)>, pub(crate) metadata: CommandSandboxMetadata, } #[cfg(target_os = "linux")] #[derive(Debug)] pub(crate) struct StagedCommandSandboxLaunch { pub(crate) launch: CommandSandboxLaunch, pub(crate) gate: LaunchGate, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CommandSandboxError { message: String, metadata: CommandSandboxMetadata, } impl CommandSandboxError { fn new(message: impl Into, metadata: CommandSandboxMetadata) -> Self { Self { message: message.into(), metadata, } } #[cfg(not(target_os = "linux"))] pub(crate) fn metadata(&self) -> &CommandSandboxMetadata { &self.metadata } } impl fmt::Display for CommandSandboxError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.message) } } impl std::error::Error for CommandSandboxError {} pub(crate) fn command_sandbox_platform_metadata() -> CommandSandboxMetadata { #[cfg(target_os = "linux")] { CommandSandboxMetadata::enforced_linux() } #[cfg(not(target_os = "linux"))] { CommandSandboxMetadata::unsupported_legacy() } } /// Builds a fail-closed launcher for a direct executable plus structured argv. /// It never falls back to launching the original command on the host. pub(crate) fn prepare_command_sandbox_launch( root: &Path, executable: &Path, arguments: &[String], cwd: &Path, environment: &[(OsString, OsString)], ) -> Result { #[cfg(target_os = "linux")] { prepare_linux_command_sandbox_launch(root, executable, arguments, cwd, environment) } #[cfg(not(target_os = "linux"))] { let _ = (root, executable, arguments, cwd, environment); let metadata = CommandSandboxMetadata::unsupported_legacy(); Err(CommandSandboxError::new( "当前平台没有可用的 OS-enforced workspace sandbox;拒绝宿主直通执行", metadata, )) } } #[cfg(target_os = "linux")] pub(crate) fn stage_command_sandbox_launch( launch: CommandSandboxLaunch, target_executable: &Path, target_arguments: &[OsString], ) -> Result { let metadata = launch.metadata.clone(); let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments) .map_err(|error| CommandSandboxError::new(error, metadata))?; linux::stage_linux_command_sandbox_launch( launch, gate, linux::StagedSandboxTrampoline::SandboxStdin, ) } #[cfg(target_os = "linux")] pub(crate) fn stage_command_sandbox_launch_for_process_session( launch: CommandSandboxLaunch, target_executable: &Path, target_arguments: &[OsString], ) -> Result { let metadata = launch.metadata.clone(); let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments) .map_err(|error| CommandSandboxError::new(error, metadata))?; linux::stage_linux_command_sandbox_launch( launch, gate, linux::StagedSandboxTrampoline::ProcessSession, ) } #[cfg(target_os = "linux")] mod linux { use super::*; use crate::command_sandbox_trampoline::{ process_session_trampoline_arguments, sandbox_trampoline_arguments, LaunchGate, BWRAP_BLOCK_FD, BWRAP_STATUS_FD, TRAMPOLINE_PATH, TRAMPOLINE_SOURCE_FD, }; #[cfg(test)] use crate::command_sandbox_trampoline::{ process_session_trampoline_test_environment, sandbox_trampoline_test_environment, }; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::process::{Command, Stdio}; use std::sync::OnceLock; use std::time::{Duration, Instant}; const TRUSTED_BWRAP_PATHS: [&str; 2] = ["/usr/bin/bwrap", "/bin/bwrap"]; const PROTECTED_READ_ONLY_NAMES: [&str; 3] = [".git", ".agents", ".codex"]; const TOOLCHAIN_ENVIRONMENT_ROOTS: [&str; 4] = ["RUSTUP_HOME", "JAVA_HOME", "GOROOT", "DOTNET_ROOT"]; const FIXED_SYSTEM_READ_ONLY_PATHS: [&str; 8] = [ "/etc/alternatives", "/etc/group", "/etc/hosts", "/etc/ld.so.cache", "/etc/localtime", "/etc/nsswitch.conf", "/etc/passwd", "/etc/resolv.conf", ]; static BWRAP_PREFLIGHT: OnceLock> = OnceLock::new(); #[derive(Clone, Debug, Eq, PartialEq)] struct ReadOnlyMount { source: PathBuf, destination: PathBuf, } #[derive(Clone, Debug, Eq, PartialEq)] struct LinuxSandboxPlan { bwrap: PathBuf, root: PathBuf, cwd: PathBuf, executable: PathBuf, arguments: Vec, target_environment: Vec<(OsString, OsString)>, merged_usr_links: Vec<(OsString, PathBuf)>, protected_read_only: Vec, external_read_only: Vec, fixed_system_read_only: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum StagedSandboxTrampoline { SandboxStdin, ProcessSession, } pub(super) fn stage_linux_command_sandbox_launch( mut launch: CommandSandboxLaunch, gate: LaunchGate, trampoline: StagedSandboxTrampoline, ) -> Result { let metadata = launch.metadata.clone(); let separator = launch .arguments .iter() .position(|argument| argument == OsStr::new("--")) .ok_or_else(|| { CommandSandboxError::new( "command sandbox staged launcher 缺少 argv 分隔符", metadata.clone(), ) })?; launch.arguments.truncate(separator); push_option( &mut launch.arguments, "--dir", [OsStr::new("/run/genarrative-launch")], ); push_chmod( &mut launch.arguments, "0700", Path::new("/run/genarrative-launch"), ); let bwrap_status_fd = OsString::from(BWRAP_STATUS_FD.to_string()); push_option( &mut launch.arguments, "--json-status-fd", [bwrap_status_fd.as_os_str()], ); let bwrap_block_fd = OsString::from(BWRAP_BLOCK_FD.to_string()); push_option( &mut launch.arguments, "--block-fd", [bwrap_block_fd.as_os_str()], ); let trampoline_source_fd = OsString::from(TRAMPOLINE_SOURCE_FD.to_string()); push_option( &mut launch.arguments, "--ro-bind-fd", [ trampoline_source_fd.as_os_str(), OsStr::new(TRAMPOLINE_PATH), ], ); #[cfg(test)] { let (name, value) = match trampoline { StagedSandboxTrampoline::SandboxStdin => sandbox_trampoline_test_environment(), StagedSandboxTrampoline::ProcessSession => { process_session_trampoline_test_environment() } }; push_option( &mut launch.arguments, "--setenv", [OsStr::new(name), OsStr::new(value)], ); } launch.arguments.push(OsString::from("--")); launch.arguments.push(OsString::from(TRAMPOLINE_PATH)); launch.arguments.extend(match trampoline { StagedSandboxTrampoline::SandboxStdin => sandbox_trampoline_arguments(), StagedSandboxTrampoline::ProcessSession => process_session_trampoline_arguments(), }); Ok(StagedCommandSandboxLaunch { launch, gate }) } pub(super) fn prepare_linux_command_sandbox_launch( root: &Path, executable: &Path, arguments: &[String], cwd: &Path, environment: &[(OsString, OsString)], ) -> Result { let mut metadata = CommandSandboxMetadata::enforced_linux(); let network_enabled = executable .file_name() .and_then(OsStr::to_str) .is_some_and(|name| { name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") }) && arguments .first() .is_some_and(|argument| argument == "install"); if network_enabled { metadata.network = "enabled"; } let bwrap = find_trusted_bwrap().map_err(|error| { CommandSandboxError::new( format!("command sandbox unavailable: {error}"), metadata.clone(), ) })?; let merged_usr_links = inspect_merged_usr_layout().map_err(|error| { CommandSandboxError::new( format!("command sandbox merged-usr preflight 失败:{error}"), metadata.clone(), ) })?; ensure_bwrap_preflight(&bwrap, &merged_usr_links).map_err(|error| { CommandSandboxError::new( format!("command sandbox namespace preflight 失败:{error}"), metadata.clone(), ) })?; let root = canonical_workspace_root(root).map_err(|error| { CommandSandboxError::new( format!("command sandbox workspace 无效:{error}"), metadata.clone(), ) })?; let cwd = canonical_workspace_cwd(&root, cwd).map_err(|error| { CommandSandboxError::new( format!("command sandbox cwd 无效:{error}"), metadata.clone(), ) })?; let executable = validate_executable(executable).map_err(|error| { CommandSandboxError::new( format!("command sandbox executable 无效:{error}"), metadata.clone(), ) })?; let protected_read_only = inspect_workspace_control_paths(&root).map_err(|error| { CommandSandboxError::new( format!("command sandbox 控制目录无效:{error}"), metadata.clone(), ) })?; let target_environment = normalize_target_environment(environment).map_err(|error| { CommandSandboxError::new( format!("command sandbox environment 无效:{error}"), metadata.clone(), ) })?; let external_read_only = collect_external_toolchain_mounts(&root, &executable, &target_environment).map_err( |error| { CommandSandboxError::new( format!("command sandbox toolchain mount 无效:{error}"), metadata.clone(), ) }, )?; let fixed_system_read_only = collect_fixed_system_mounts(); let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan { bwrap, root, cwd, executable, arguments: arguments.to_vec(), target_environment, merged_usr_links, protected_read_only, external_read_only, fixed_system_read_only, }); launch.metadata = metadata.clone(); run_project_mount_preflight(&launch).map_err(|error| { CommandSandboxError::new( format!("command sandbox project mount preflight 失败:{error}"), metadata, ) })?; Ok(launch) } fn canonical_workspace_root(root: &Path) -> Result { let root = fs::canonicalize(root).map_err(|error| format!("无法 canonicalize 项目根:{error}"))?; if !root.is_absolute() || !root.is_dir() || root.parent().is_none() { return Err("项目根必须是非根目录的绝对普通目录".to_string()); } for system_root in ["/usr", "/etc", "/proc", "/dev", "/run"] { if root == Path::new(system_root) || root.starts_with(Path::new(system_root)) { return Err(format!("项目根不能位于系统目录 {system_root}")); } } Ok(root) } fn canonical_workspace_cwd(root: &Path, cwd: &Path) -> Result { let cwd = fs::canonicalize(cwd).map_err(|error| format!("无法 canonicalize cwd:{error}"))?; if !cwd.is_dir() || !cwd.starts_with(root) { return Err("cwd 必须是项目根内的普通目录".to_string()); } if cwd.starts_with(root.join(".agent")) { return Err("cwd 不能位于隐藏的 .agent 控制目录".to_string()); } Ok(cwd) } fn validate_executable(executable: &Path) -> Result { if !executable.is_absolute() { return Err("executable 必须是绝对路径".to_string()); } let canonical = fs::canonicalize(executable) .map_err(|error| format!("无法 canonicalize executable:{error}"))?; let metadata = fs::metadata(&canonical).map_err(|error| format!("无法读取 executable:{error}"))?; if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { return Err("executable 必须是可执行普通文件".to_string()); } // Keep the original absolute entry so argv[0]-sensitive proxies such as // cargo -> rustup retain their selected tool identity inside the sandbox. Ok(executable.to_path_buf()) } fn inspect_workspace_control_paths(root: &Path) -> Result, String> { let agent = root.join(".agent"); let metadata = fs::symlink_metadata(&agent) .map_err(|error| format!(".agent 必须在 sandbox 准备前存在:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(".agent 必须是项目根内普通目录".to_string()); } let mut protected = Vec::new(); for name in PROTECTED_READ_ONLY_NAMES { let path = root.join(name); let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return Err(format!("读取 {name} 失败:{error}")), }; if metadata.file_type().is_symlink() { return Err(format!("{name} 不能是符号链接")); } let canonical = fs::canonicalize(&path) .map_err(|error| format!("canonicalize {name} 失败:{error}"))?; if !canonical.starts_with(root) { return Err(format!("{name} 逃逸项目根")); } protected.push(canonical); } Ok(protected) } fn normalize_target_environment( environment: &[(OsString, OsString)], ) -> Result, String> { let mut values = BTreeMap::::new(); for (name, value) in environment { validate_environment_name(name)?; let replacement = match name.to_str().unwrap_or_default() { "HOME" | "USERPROFILE" => Some(COMMAND_SANDBOX_PRIVATE_HOME), "TMPDIR" | "TEMP" | "TMP" => Some("/tmp"), "CARGO_HOME" => Some("/tmp/genarrative-command/cache/cargo"), "XDG_CACHE_HOME" | "npm_config_cache" => Some("/tmp/genarrative-command/cache/npm"), "XDG_CONFIG_HOME" => Some(COMMAND_SANDBOX_PRIVATE_CONFIG), "npm_config_userconfig" => Some("/dev/null"), "GIT_CONFIG_GLOBAL" => Some("/dev/null"), _ => None, }; values.insert( name.clone(), replacement .map(OsString::from) .unwrap_or_else(|| value.clone()), ); } for (name, value) in [ ("HOME", COMMAND_SANDBOX_PRIVATE_HOME), ("USERPROFILE", COMMAND_SANDBOX_PRIVATE_HOME), ("TMPDIR", "/tmp"), ("TEMP", "/tmp"), ("TMP", "/tmp"), ("XDG_CACHE_HOME", COMMAND_SANDBOX_PRIVATE_CACHE), ("XDG_CONFIG_HOME", COMMAND_SANDBOX_PRIVATE_CONFIG), ("GENARRATIVE_COMMAND_SANDBOX", "1"), ("GENARRATIVE_COMMAND_SANDBOX_BACKEND", "bubblewrap"), ] { values.insert(OsString::from(name), OsString::from(value)); } Ok(values.into_iter().collect()) } fn validate_environment_name(name: &OsStr) -> Result<(), String> { let bytes = name.as_bytes(); if bytes.is_empty() || bytes.contains(&0) || bytes.contains(&b'=') { return Err("环境变量名为空、包含 NUL 或 '='".to_string()); } Ok(()) } fn collect_external_toolchain_mounts( root: &Path, executable: &Path, environment: &[(OsString, OsString)], ) -> Result, String> { let mut mounts = BTreeMap::::new(); add_external_mount(root, executable, executable, &mut mounts)?; for name in TOOLCHAIN_ENVIRONMENT_ROOTS { let Some(value) = environment .iter() .find(|(candidate, _)| candidate == OsStr::new(name)) .map(|(_, value)| value) else { continue; }; let path = PathBuf::from(value); if !path.is_absolute() { return Err(format!("{name} 必须是绝对路径")); } let canonical = fs::canonicalize(&path) .map_err(|error| format!("canonicalize {name} 失败:{error}"))?; if !canonical.is_dir() { return Err(format!("{name} 必须指向普通目录")); } validate_external_toolchain_root(name, &canonical)?; add_external_mount(root, &canonical, &canonical, &mut mounts)?; } Ok(mounts .into_iter() .map(|(destination, source)| ReadOnlyMount { source, destination, }) .collect()) } fn validate_external_toolchain_root(name: &str, root: &Path) -> Result<(), String> { if root.parent() == Some(Path::new("/home")) { return Err(format!( "拒绝把用户主目录作为 {name} 挂载:{}", root.display() )); } let leaf = root .file_name() .and_then(OsStr::to_str) .map(str::to_ascii_lowercase) .ok_or_else(|| format!("{name} 缺少可识别的工具链目录名"))?; let expected_leaf = match name { "RUSTUP_HOME" => leaf == ".rustup" || leaf.contains("rustup"), "JAVA_HOME" => leaf.contains("java") || leaf.contains("jdk") || leaf.contains("jre"), "GOROOT" => leaf == "go" || leaf.contains("golang"), "DOTNET_ROOT" => leaf.contains("dotnet"), _ => false, }; if !expected_leaf { return Err(format!( "拒绝把非 {name} 工具链目录挂载进沙箱:{}", root.display() )); } Ok(()) } fn add_external_mount( root: &Path, source: &Path, destination: &Path, mounts: &mut BTreeMap, ) -> Result<(), String> { if source.starts_with("/usr") || source.starts_with(root) || matches!(source.to_str(), Some("/bin" | "/sbin" | "/lib" | "/lib64")) { return Ok(()); } if !source.is_absolute() || !destination.is_absolute() { return Err("外部工具链 mount 必须是绝对路径".to_string()); } if matches!(source.to_str(), Some("/" | "/home" | "/root" | "/tmp")) { return Err(format!("拒绝挂载宽泛宿主用户路径:{}", source.display())); } mounts.insert(destination.to_path_buf(), source.to_path_buf()); Ok(()) } fn collect_fixed_system_mounts() -> Vec { FIXED_SYSTEM_READ_ONLY_PATHS .iter() .filter_map(|path| { let path = PathBuf::from(path); path.exists().then(|| ReadOnlyMount { source: path.clone(), destination: path, }) }) .collect() } fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { let mut args = Vec::::new(); push_namespace_arguments( &mut args, plan.executable .file_name() .and_then(OsStr::to_str) .is_some_and(|name| { name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") }) && plan .arguments .first() .is_some_and(|argument| argument == "install"), ); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { push_option( &mut args, "--symlink", [target.as_os_str(), destination.as_os_str()], ); } push_option(&mut args, "--proc", [OsStr::new("/proc")]); push_option(&mut args, "--dev", [OsStr::new("/dev")]); push_option(&mut args, "--tmpfs", [OsStr::new("/tmp")]); push_chmod(&mut args, "1777", Path::new("/tmp")); push_option(&mut args, "--tmpfs", [OsStr::new("/run")]); push_chmod(&mut args, "0755", Path::new("/run")); for directory in [ COMMAND_SANDBOX_PRIVATE_ROOT, COMMAND_SANDBOX_PRIVATE_HOME, COMMAND_SANDBOX_PRIVATE_CACHE, "/tmp/genarrative-command/cache/cargo", "/tmp/genarrative-command/cache/npm", COMMAND_SANDBOX_PRIVATE_CONFIG, ] { push_option(&mut args, "--dir", [OsStr::new(directory)]); } push_chmod(&mut args, "0700", Path::new(COMMAND_SANDBOX_PRIVATE_ROOT)); let mut created_directories = BTreeSet::new(); for mount in plan .fixed_system_read_only .iter() .chain(plan.external_read_only.iter()) { push_destination_parent_dirs( &mut args, &mount.destination, &plan.root, &mut created_directories, ); push_ro_bind(&mut args, &mount.source, &mount.destination); } push_option( &mut args, "--bind", [plan.root.as_os_str(), plan.root.as_os_str()], ); for protected in &plan.protected_read_only { push_ro_bind(&mut args, protected, protected); } let agent_path = plan.root.join(".agent"); push_option(&mut args, "--tmpfs", [agent_path.as_os_str()]); push_chmod(&mut args, "0000", &agent_path); for (name, value) in &plan.target_environment { push_option(&mut args, "--setenv", [name.as_os_str(), value.as_os_str()]); } push_option(&mut args, "--chdir", [plan.cwd.as_os_str()]); args.push(OsString::from("--")); args.push(plan.executable.as_os_str().to_owned()); args.extend(plan.arguments.iter().map(OsString::from)); CommandSandboxLaunch { executable: plan.bwrap, arguments: args, cwd: plan.root, environment: Vec::new(), metadata: CommandSandboxMetadata::enforced_linux(), } } fn push_namespace_arguments(args: &mut Vec, share_network: bool) { for argument in [ "--die-with-parent", "--unshare-all", "--unshare-user", "--disable-userns", "--assert-userns-disabled", "--cap-drop", "ALL", "--clearenv", ] { args.push(OsString::from(argument)); } if share_network { args.push(OsString::from("--share-net")); } } fn push_option<'a, I>(args: &mut Vec, option: &str, values: I) where I: IntoIterator, { args.push(OsString::from(option)); args.extend(values.into_iter().map(OsStr::to_owned)); } fn push_ro_bind(args: &mut Vec, source: &Path, destination: &Path) { push_option( args, "--ro-bind", [source.as_os_str(), destination.as_os_str()], ); } fn push_chmod(args: &mut Vec, mode: &str, path: &Path) { push_option(args, "--chmod", [OsStr::new(mode), path.as_os_str()]); } fn push_destination_parent_dirs( args: &mut Vec, destination: &Path, workspace_root: &Path, created: &mut BTreeSet, ) { let Some(parent) = destination.parent() else { return; }; let mut parents = parent .ancestors() .take_while(|path| *path != Path::new("/")) .map(Path::to_path_buf) .collect::>(); parents.reverse(); for path in parents { if path.starts_with("/usr") || path.starts_with(workspace_root) || matches!(path.to_str(), Some("/tmp" | "/run")) || !created.insert(path.clone()) { continue; } push_option(args, "--dir", [path.as_os_str()]); } } fn inspect_merged_usr_layout() -> Result, String> { let candidates = [ ("/bin", "usr/bin"), ("/sbin", "usr/sbin"), ("/lib", "usr/lib"), ("/lib64", "usr/lib64"), ]; let mut links = Vec::new(); for (destination, expected_target) in candidates { if !Path::new(&format!("/{expected_target}")).exists() { continue; } let target = fs::read_link(destination) .map_err(|error| format!("读取 merged-usr link {destination} 失败:{error}"))?; if target != Path::new(expected_target) { return Err(format!( "{destination} 必须指向 {expected_target},当前为 {}", target.display() )); } links.push((OsString::from(expected_target), PathBuf::from(destination))); } Ok(links) } #[derive(Debug)] struct BwrapInspection { requested: PathBuf, result: Result, } fn find_trusted_bwrap() -> Result { choose_trusted_bwrap(TRUSTED_BWRAP_PATHS.into_iter().map(|path| BwrapInspection { requested: PathBuf::from(path), result: inspect_trusted_bwrap(Path::new(path)), })) } fn choose_trusted_bwrap(inspections: I) -> Result where I: IntoIterator, { let mut failures = Vec::new(); for inspection in inspections { match inspection.result { Ok(path) => return Ok(path), Err(error) => failures.push(format!("{}: {error}", inspection.requested.display())), } } Err(format!( "未找到固定受信任系统 bwrap({})", failures.join(";") )) } fn inspect_trusted_bwrap(path: &Path) -> Result { let metadata = fs::metadata(path).map_err(|error| format!("不可用:{error}"))?; if !metadata.is_file() || metadata.uid() != 0 || metadata.permissions().mode() & 0o111 == 0 || metadata.permissions().mode() & 0o022 != 0 { return Err("必须是 root-owned、可执行且不可被 group/other 写入的普通文件".to_string()); } let canonical = fs::canonicalize(path).map_err(|error| format!("canonicalize 失败:{error}"))?; if canonical != Path::new("/usr/bin/bwrap") && canonical != Path::new("/bin/bwrap") { return Err(format!("canonical path 不受信任:{}", canonical.display())); } let parent = canonical .parent() .ok_or_else(|| "缺少 parent".to_string())?; let parent_metadata = fs::metadata(parent).map_err(|error| format!("读取 parent 失败:{error}"))?; if parent_metadata.uid() != 0 || parent_metadata.permissions().mode() & 0o022 != 0 { return Err("bwrap parent 必须 root-owned 且不可被 group/other 写入".to_string()); } Ok(canonical) } fn ensure_bwrap_preflight( bwrap: &Path, merged_usr_links: &[(OsString, PathBuf)], ) -> Result<(), String> { BWRAP_PREFLIGHT .get_or_init(|| run_bwrap_preflight(bwrap, merged_usr_links)) .clone() } fn run_bwrap_preflight( bwrap: &Path, merged_usr_links: &[(OsString, PathBuf)], ) -> Result<(), String> { let mut args = Vec::::new(); push_namespace_arguments(&mut args, false); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in merged_usr_links { push_option( &mut args, "--symlink", [target.as_os_str(), destination.as_os_str()], ); } push_option(&mut args, "--proc", [OsStr::new("/proc")]); push_option(&mut args, "--dev", [OsStr::new("/dev")]); push_option(&mut args, "--tmpfs", [OsStr::new("/tmp")]); args.push(OsString::from("--")); args.push(OsString::from("/usr/bin/true")); let mut child = Command::new(bwrap) .args(&args) .current_dir("/") .env_clear() .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .map_err(|error| format!("启动 bwrap preflight 失败:{error}"))?; let deadline = Instant::now() + Duration::from_secs(3); loop { match child.try_wait() { Ok(Some(status)) if status.success() => return Ok(()), Ok(Some(status)) => { let output = child .wait_with_output() .map_err(|error| format!("读取 bwrap preflight 输出失败:{error}"))?; return Err(format!( "bwrap preflight 退出 {}:{}", status, String::from_utf8_lossy(&output.stderr).trim() )); } Ok(None) if Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(20)); } Ok(None) => { let _ = child.kill(); let _ = child.wait(); return Err("bwrap preflight 超时".to_string()); } Err(error) => return Err(format!("等待 bwrap preflight 失败:{error}")), } } } fn run_project_mount_preflight(launch: &CommandSandboxLaunch) -> Result<(), String> { let separator = launch .arguments .iter() .position(|argument| argument == OsStr::new("--")) .ok_or_else(|| "sandbox launcher 缺少 argv 分隔符".to_string())?; let mut arguments = launch.arguments[..=separator].to_vec(); arguments.push(OsString::from("/usr/bin/true")); let mut child = Command::new(&launch.executable) .args(arguments) .current_dir(&launch.cwd) .env_clear() .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .map_err(|error| format!("启动 project mount preflight 失败:{error}"))?; let deadline = Instant::now() + Duration::from_secs(3); loop { match child.try_wait() { Ok(Some(status)) if status.success() => return Ok(()), Ok(Some(status)) => { let output = child.wait_with_output().map_err(|error| { format!("读取 project mount preflight 输出失败:{error}") })?; return Err(format!( "project mount preflight 退出 {status}:{}", String::from_utf8_lossy(&output.stderr).trim() )); } Ok(None) if Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(20)); } Ok(None) => { let _ = child.kill(); let _ = child.wait(); return Err("project mount preflight 超时".to_string()); } Err(error) => return Err(format!("等待 project mount preflight 失败:{error}")), } } } #[cfg(test)] mod tests { use super::*; use crate::command_sandbox_trampoline::{TargetExecState, TargetTerminalState}; use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; fn has_sequence(arguments: &[OsString], values: &[&str]) -> bool { arguments.windows(values.len()).any(|window| { window .iter() .zip(values) .all(|(actual, expected)| actual == OsStr::new(expected)) }) } #[test] fn pure_builder_constructs_empty_workspace_namespace_and_private_environment() { let launch = build_linux_bwrap_launch(LinuxSandboxPlan { bwrap: PathBuf::from("/usr/bin/bwrap"), root: PathBuf::from("/workspace/project"), cwd: PathBuf::from("/workspace/project/game"), executable: PathBuf::from("/opt/toolchain/bin/tool"), arguments: vec!["check".to_string(), "--flag".to_string()], target_environment: vec![ ( OsString::from("HOME"), OsString::from(COMMAND_SANDBOX_PRIVATE_HOME), ), (OsString::from("PATH"), OsString::from("/usr/bin")), ], merged_usr_links: vec![ (OsString::from("usr/bin"), PathBuf::from("/bin")), (OsString::from("usr/lib"), PathBuf::from("/lib")), ], protected_read_only: vec![ PathBuf::from("/workspace/project/.git"), PathBuf::from("/workspace/project/.agents"), PathBuf::from("/workspace/project/.codex"), ], external_read_only: vec![ReadOnlyMount { source: PathBuf::from("/opt/toolchain/bin/tool"), destination: PathBuf::from("/opt/toolchain/bin/tool"), }], fixed_system_read_only: Vec::new(), }); assert_eq!(launch.executable, Path::new("/usr/bin/bwrap")); assert!(launch.environment.is_empty()); assert_eq!(launch.metadata.backend, "bubblewrap"); assert_eq!(launch.metadata.mode, "workspace-write"); assert_eq!(launch.metadata.network, "disabled"); assert!(has_sequence( &launch.arguments, &["--unshare-all", "--unshare-user"] )); assert!(launch .arguments .contains(&OsString::from("--disable-userns"))); assert!(launch .arguments .contains(&OsString::from("--assert-userns-disabled"))); assert!(!launch.arguments.contains(&OsString::from("--share-net"))); assert!(has_sequence( &launch.arguments, &["--ro-bind", "/usr", "/usr"] )); assert!(has_sequence( &launch.arguments, &["--bind", "/workspace/project", "/workspace/project"] )); for path in [".git", ".agents", ".codex"] { let path = format!("/workspace/project/{path}"); assert!(has_sequence( &launch.arguments, &["--ro-bind", &path, &path] )); } assert!(has_sequence( &launch.arguments, &[ "--tmpfs", "/workspace/project/.agent", "--chmod", "0000", "/workspace/project/.agent" ] )); assert!(has_sequence( &launch.arguments, &[ "--ro-bind", "/opt/toolchain/bin/tool", "/opt/toolchain/bin/tool" ] )); assert!(has_sequence( &launch.arguments, &["--setenv", "HOME", COMMAND_SANDBOX_PRIVATE_HOME] )); assert!(launch.arguments.ends_with(&[ OsString::from("--"), OsString::from("/opt/toolchain/bin/tool"), OsString::from("check"), OsString::from("--flag"), ])); } #[test] fn unavailable_parser_reports_fixed_candidates_without_host_fallback() { let error = choose_trusted_bwrap([ BwrapInspection { requested: PathBuf::from("/usr/bin/bwrap"), result: Err("missing".to_string()), }, BwrapInspection { requested: PathBuf::from("/bin/bwrap"), result: Err("group-writable".to_string()), }, ]) .expect_err("unavailable bwrap must fail closed"); assert!(error.contains("/usr/bin/bwrap: missing")); assert!(error.contains("/bin/bwrap: group-writable")); assert!(!error.contains("直接执行")); } #[test] fn invalid_control_mount_fails_before_target_execution() { use std::os::unix::fs::symlink; let tree = unique_temp_tree(); let root = tree.0.join("workspace-preflight"); let outside = tree.0.join("outside-control"); std::fs::create_dir_all(root.join(".agent")).expect("create runtime control"); std::fs::create_dir_all(&outside).expect("create outside control"); symlink(&outside, root.join(".codex")).expect("create invalid control symlink"); let marker = root.join("target-ran"); let script = format!( "from pathlib import Path; Path({:?}).write_text('ran')", marker.to_string_lossy() ); let error = prepare_command_sandbox_launch( &root, Path::new("/usr/bin/python3"), &["-c".to_string(), script], &root, &[(OsString::from("PATH"), OsString::from("/usr/bin"))], ) .expect_err("invalid protected path must fail closed"); assert!(error.to_string().contains(".codex")); assert!( !marker.exists(), "target program must not execute on preflight failure" ); } #[test] fn external_toolchain_roots_reject_home_and_symlinked_home() { use std::os::unix::fs::symlink; let tree = unique_temp_tree(); let root = tree.0.join("workspace-toolchain-root"); let home = tree.0.join("developer-home"); std::fs::create_dir_all(&root).expect("create workspace"); std::fs::create_dir_all(&home).expect("create fake home"); let direct = collect_external_toolchain_mounts( &root, Path::new("/usr/bin/true"), &[(OsString::from("RUSTUP_HOME"), home.as_os_str().to_owned())], ) .expect_err("whole home must never be mounted as rustup root"); assert!(direct.contains("非 RUSTUP_HOME 工具链目录"), "{direct}"); let rustup_link = tree.0.join(".rustup"); symlink(&home, &rustup_link).expect("create rustup-to-home symlink"); let linked = collect_external_toolchain_mounts( &root, Path::new("/usr/bin/true"), &[(OsString::from("RUSTUP_HOME"), rustup_link.into_os_string())], ) .expect_err("canonicalized home target must never be mounted"); assert!(linked.contains("非 RUSTUP_HOME 工具链目录"), "{linked}"); } #[test] fn external_toolchain_root_accepts_narrow_rustup_directory() { let tree = unique_temp_tree(); let root = tree.0.join("workspace-narrow-toolchain"); let rustup_home = tree.0.join(".rustup"); std::fs::create_dir_all(&root).expect("create workspace"); std::fs::create_dir_all(&rustup_home).expect("create rustup home"); let mounts = collect_external_toolchain_mounts( &root, Path::new("/usr/bin/true"), &[( OsString::from("RUSTUP_HOME"), rustup_home.as_os_str().to_owned(), )], ) .expect("narrow rustup root should be mountable"); assert_eq!(mounts.len(), 1); assert_eq!(mounts[0].source, rustup_home); } struct TempTree(PathBuf); impl Drop for TempTree { fn drop(&mut self) { std::fs::remove_dir_all(&self.0).ok(); } } fn unique_temp_tree() -> TempTree { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time") .as_nanos(); let path = std::env::temp_dir().join(format!( "genarrative-command-sandbox-{}-{nanos}", std::process::id() )); std::fs::create_dir_all(&path).expect("create temp tree"); TempTree(path) } #[test] fn command_sandbox_real_linux_opt_in_enforces_workspace() { if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { return; } let tree = unique_temp_tree(); let root = tree.0.join("workspace"); let outside = tree.0.join("outside-secret.txt"); std::fs::create_dir_all(&root).expect("create workspace"); std::fs::write(&outside, "OUTSIDE_SECRET").expect("write outside secret"); for name in [".agent", ".git", ".agents", ".codex"] { std::fs::create_dir_all(root.join(name)).expect("create control directory"); std::fs::write(root.join(name).join("marker"), name).expect("write control marker"); } let script = format!( r#" from pathlib import Path import socket import subprocess Path("workspace-write.txt").write_text("WORKSPACE_OK") assert Path("workspace-write.txt").read_text() == "WORKSPACE_OK" for control in [".git", ".agents", ".codex"]: assert Path(control, "marker").read_text() == control try: Path(control, "blocked-write").write_text("NO") raise AssertionError(control + " was writable") except OSError: pass try: Path(".agent/marker").read_text() raise AssertionError(".agent was visible") except OSError: pass outside = Path({outside:?}) try: outside.read_text() raise AssertionError("outside host file was visible") except OSError: pass child = subprocess.run([ "/usr/bin/python3", "-c", "from pathlib import Path; p=Path(" + repr(str(outside)) + "); " "assert not p.exists(); Path('child-write.txt').write_text('CHILD_OK')" ], check=False) assert child.returncode == 0 assert Path("child-write.txt").read_text() == "CHILD_OK" s = socket.socket() s.settimeout(0.2) try: s.connect(("1.1.1.1", 53)) raise AssertionError("network namespace was shared") except OSError: pass finally: s.close() print("SANDBOX_OK") "#, outside = outside.to_string_lossy() ); let environment = vec![ (OsString::from("PATH"), OsString::from("/usr/bin")), (OsString::from("HOME"), OsString::from("/host/home")), ]; let launch = prepare_command_sandbox_launch( &root, Path::new("/usr/bin/python3"), &["-c".to_string(), script], &root, &environment, ) .expect("prepare real Linux sandbox"); let output = Command::new(&launch.executable) .args(&launch.arguments) .current_dir(&launch.cwd) .env_clear() .envs(launch.environment.iter().cloned()) .output() .expect("run real Linux sandbox"); if !output.status.success() { let mut diagnostics = std::io::stderr().lock(); let _ = diagnostics.write_all(&output.stdout); let _ = diagnostics.write_all(&output.stderr); } assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("SANDBOX_OK")); assert_eq!( std::fs::read_to_string(root.join("workspace-write.txt")) .expect("workspace write persisted"), "WORKSPACE_OK" ); assert_eq!( std::fs::read_to_string(root.join("child-write.txt")) .expect("child write persisted"), "CHILD_OK" ); assert_eq!( std::fs::read_to_string(&outside).expect("outside secret unchanged"), "OUTSIDE_SECRET" ); for name in [".git", ".agents", ".codex"] { assert!(!root.join(name).join("blocked-write").exists()); } } #[test] fn command_sandbox_staged_gate_real_linux_opt_in_blocks_until_commit() { if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { return; } let tree = unique_temp_tree(); let root = tree.0.join("workspace-staged-gate"); std::fs::create_dir_all(&root).expect("create workspace"); for name in [".agent", ".git", ".agents", ".codex"] { std::fs::create_dir_all(root.join(name)).expect("create control directory"); } let marker = root.join("committed-target-ran"); let script = format!( "from pathlib import Path; import os; assert os.readlink('/proc/self/fd/0') == '/dev/null'; assert all(not Path(f'/proc/self/fd/{{fd}}').exists() for fd in (3, 4, 5, 6)); Path({:?}).write_text('COMMITTED')", marker.to_string_lossy() ); let arguments = vec!["-c".to_string(), script, "--".to_string()]; let environment = vec![(OsString::from("PATH"), OsString::from("/usr/bin"))]; let launch = prepare_linux_command_sandbox_launch( &root, Path::new("/usr/bin/python3"), &arguments, &root, &environment, ) .expect("prepare real Linux sandbox"); let target_arguments = arguments.iter().map(OsString::from).collect::>(); let gate = LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/python3"), &target_arguments) .expect("create real Linux sandbox gate"); let staged = stage_linux_command_sandbox_launch( launch, gate, StagedSandboxTrampoline::SandboxStdin, ) .expect("stage real Linux sandbox"); assert!(!staged .launch .arguments .iter() .any(|argument| argument == OsStr::new(&arguments[1]))); let mut gate = staged.gate; let mut command = Command::new(&staged.launch.executable); command .args(&staged.launch.arguments) .current_dir(&staged.launch.cwd) .env_clear() .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); gate.install_on_command(&mut command) .expect("install private control channel"); let child = command.spawn().expect("spawn staged sandbox"); gate.child_created().expect("mark child-created"); if let Err(error) = gate.wait_sandbox_ready(Duration::from_secs(3)) { let output = child .wait_with_output() .expect("collect failed staged sandbox output"); panic!( "sandbox ready failed: {error}; stdout={}; stderr={}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } std::thread::sleep(Duration::from_millis(100)); assert!(!marker.exists(), "target ran before durable commit"); gate.commit_exec().expect("commit target exec"); assert_eq!( gate.wait_target_exec(Duration::from_secs(3)) .expect("target exec-established"), TargetExecState::Established ); assert_eq!( gate.wait_terminal(Duration::from_secs(3)) .expect("target terminal"), TargetTerminalState::Exited { code: 0 } ); let output = child.wait_with_output().expect("collect sandbox output"); if !output.status.success() { let mut diagnostics = std::io::stderr().lock(); let _ = diagnostics.write_all(&output.stdout); let _ = diagnostics.write_all(&output.stderr); } assert!(output.status.success()); assert_eq!( std::fs::read_to_string(marker).expect("committed marker"), "COMMITTED" ); } #[test] fn staged_gate_variants_share_bwrap_control_mounts() { let launch = CommandSandboxLaunch { executable: PathBuf::from("/usr/bin/bwrap"), arguments: vec![ OsString::from("--unshare-all"), OsString::from("--"), OsString::from("/usr/bin/true"), ], cwd: PathBuf::from("/workspace/project"), environment: Vec::new(), metadata: CommandSandboxMetadata::enforced_linux(), }; let target_arguments = [OsString::from("--version")]; let stdin = stage_command_sandbox_launch( launch.clone(), Path::new("/usr/bin/true"), &target_arguments, ) .expect("stage stdin sandbox launch"); let process_session = stage_command_sandbox_launch_for_process_session( launch, Path::new("/usr/bin/true"), &target_arguments, ) .expect("stage process-session sandbox launch"); for staged in [&stdin, &process_session] { assert_eq!(staged.launch.executable, Path::new("/usr/bin/bwrap")); assert_eq!(staged.launch.cwd, Path::new("/workspace/project")); assert_eq!(staged.launch.metadata, stdin.launch.metadata); assert!(has_sequence( &staged.launch.arguments, &["--json-status-fd", "4", "--block-fd", "5"] )); assert!(has_sequence( &staged.launch.arguments, &["--ro-bind-fd", "6", TRAMPOLINE_PATH] )); } assert!(has_sequence( &stdin.launch.arguments, &[ "--setenv", "GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE", "stdin" ] )); assert!(has_sequence( &process_session.launch.arguments, &[ "--setenv", "GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE", "process-session" ] )); } } } #[cfg(target_os = "linux")] use linux::prepare_linux_command_sandbox_launch; #[cfg(all(test, not(target_os = "linux")))] mod unsupported_tests { use super::*; #[test] fn unsupported_platform_returns_legacy_metadata_without_launcher() { let error = prepare_command_sandbox_launch( Path::new("."), Path::new("tool"), &[], Path::new("."), &[], ) .expect_err("unsupported platform must fail closed"); assert_eq!(error.metadata().backend, "legacy-host-restricted"); assert_eq!(error.metadata().mode, "fixed-command"); assert_eq!(error.metadata().network, "proxy-only"); } }