Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs
T
kdletters 9476ff7648
Project CI / Frontend tests (push) Successful in 4m5s
Project CI / Repository checks (push) Successful in 4m23s
Project CI / Backend tests (push) Successful in 5m1s
Project CI / Native shell tests (push) Failing after 10m54s
迁移仓库工具资源到 Codex 并固化执行边界
将 .hermes 工具资源迁移至 .codex

同步代码、文档与 RAG 路径引用

补充任务范围、验收与时间盒执行规则
2026-08-27 13:55:25 +08:00

1441 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#[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<OsString>,
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<String>, 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<CommandSandboxLaunch, CommandSandboxError> {
#[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<StagedCommandSandboxLaunch, CommandSandboxError> {
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<StagedCommandSandboxLaunch, CommandSandboxError> {
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<Result<(), String>> = 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<String>,
target_environment: Vec<(OsString, OsString)>,
merged_usr_links: Vec<(OsString, PathBuf)>,
protected_read_only: Vec<PathBuf>,
external_read_only: Vec<ReadOnlyMount>,
fixed_system_read_only: Vec<ReadOnlyMount>,
}
#[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<StagedCommandSandboxLaunch, CommandSandboxError> {
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<CommandSandboxLaunch, CommandSandboxError> {
let metadata = CommandSandboxMetadata::enforced_linux();
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 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,
});
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<Vec<PathBuf>, 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<Vec<(OsString, OsString)>, String> {
let mut values = BTreeMap::<OsString, OsString>::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<Vec<ReadOnlyMount>, String> {
let mut mounts = BTreeMap::<PathBuf, PathBuf>::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<PathBuf, PathBuf>,
) -> 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<ReadOnlyMount> {
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::<OsString>::new();
push_namespace_arguments(&mut args);
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<OsString>) {
for argument in [
"--die-with-parent",
"--unshare-all",
"--unshare-user",
"--disable-userns",
"--assert-userns-disabled",
"--cap-drop",
"ALL",
"--clearenv",
] {
args.push(OsString::from(argument));
}
}
fn push_option<'a, I>(args: &mut Vec<OsString>, option: &str, values: I)
where
I: IntoIterator<Item = &'a OsStr>,
{
args.push(OsString::from(option));
args.extend(values.into_iter().map(OsStr::to_owned));
}
fn push_ro_bind(args: &mut Vec<OsString>, source: &Path, destination: &Path) {
push_option(
args,
"--ro-bind",
[source.as_os_str(), destination.as_os_str()],
);
}
fn push_chmod(args: &mut Vec<OsString>, mode: &str, path: &Path) {
push_option(args, "--chmod", [OsStr::new(mode), path.as_os_str()]);
}
fn push_destination_parent_dirs(
args: &mut Vec<OsString>,
destination: &Path,
workspace_root: &Path,
created: &mut BTreeSet<PathBuf>,
) {
let Some(parent) = destination.parent() else {
return;
};
let mut parents = parent
.ancestors()
.take_while(|path| *path != Path::new("/"))
.map(Path::to_path_buf)
.collect::<Vec<_>>();
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<Vec<(OsString, PathBuf)>, 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<PathBuf, String>,
}
fn find_trusted_bwrap() -> Result<PathBuf, String> {
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<I>(inspections: I) -> Result<PathBuf, String>
where
I: IntoIterator<Item = BwrapInspection>,
{
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<PathBuf, String> {
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::<OsString>::new();
push_namespace_arguments(&mut args);
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::<Vec<_>>();
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");
}
}