689082901f
新增 PTY 外私有 bridge 与 sandbox ready/commit/exec 协议 升级 process record v3 并收紧恢复、幂等与 final/idle 门禁 完善 target 前台进程组、graceful 后代收束和审计失败处理 补齐跨平台回归测试及 Runtime 文档
1442 lines
54 KiB
Rust
1442 lines
54 KiB
Rust
#[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; 4] = [".git", ".agents", ".codex", ".hermes"];
|
||
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"),
|
||
PathBuf::from("/workspace/project/.hermes"),
|
||
],
|
||
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", ".hermes"] {
|
||
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", ".hermes"] {
|
||
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", ".hermes"]:
|
||
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", ".hermes"] {
|
||
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", ".hermes"] {
|
||
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");
|
||
}
|
||
}
|