mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
380 lines
14 KiB
Rust
380 lines
14 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_exec_server::CreateDirectoryOptions;
|
|
use codex_exec_server::Environment;
|
|
use codex_exec_server::ExecOutputStream;
|
|
use codex_exec_server::ExecParams;
|
|
use codex_exec_server::ExecProcess;
|
|
use codex_exec_server::ExecProcessEvent;
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_exec_server::ProcessId;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_utils_path_uri::PathUri;
|
|
use pretty_assertions::assert_eq;
|
|
use serde::Deserialize;
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
use tokio::time::timeout;
|
|
|
|
use super::BwrapExecServer;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct NamespaceReport {
|
|
user_namespace: String,
|
|
mount_namespace: String,
|
|
pid_namespace: String,
|
|
network_namespace: String,
|
|
ipc_namespace: String,
|
|
uts_namespace: String,
|
|
proc_pid_one_namespace: Option<String>,
|
|
proc_file_system_type: String,
|
|
overflow_uid: String,
|
|
overflow_gid: String,
|
|
effective_uid: u32,
|
|
effective_gid: u32,
|
|
effective_capabilities: String,
|
|
permitted_capabilities: String,
|
|
inheritable_capabilities: String,
|
|
ambient_capabilities: String,
|
|
no_new_privileges: u32,
|
|
seccomp_mode: u32,
|
|
connect_succeeded: Option<bool>,
|
|
allowed_write_succeeded: Option<bool>,
|
|
denied_read_succeeded: Option<bool>,
|
|
denied_write_succeeded: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ReportOutcome {
|
|
report: NamespaceReport,
|
|
stderr: String,
|
|
exit_code: Option<i32>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum FixtureExit {
|
|
Success,
|
|
Code(u8),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct NamespaceIdentity {
|
|
user: String,
|
|
mount: String,
|
|
pid: String,
|
|
network: String,
|
|
ipc: String,
|
|
uts: String,
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn production_sandbox_nests_with_fresh_namespaces_and_proc() -> Result<()> {
|
|
let parent_namespaces = current_namespace_identity()?;
|
|
let fixture = codex_utils_cargo_bin::cargo_bin("bwrap-exec-smoke")?;
|
|
let bwrap = codex_utils_cargo_bin::cargo_bin("bwrap")?;
|
|
let bazel_workspace = std::env::current_dir().context("resolve Bazel test workspace")?;
|
|
let workspace = TempDir::new_in(&bazel_workspace)?;
|
|
let outside_workspace = TempDir::new_in(&bazel_workspace)?;
|
|
let allowed_marker = workspace.path().join("allowed-write-probe");
|
|
let denied_marker = outside_workspace.path().join("denied-write-probe");
|
|
std::fs::write(&allowed_marker, "sentinel")?;
|
|
std::fs::write(&denied_marker, "sentinel")?;
|
|
for protected_name in [".git", ".agents", ".codex"] {
|
|
assert!(!workspace.path().join(protected_name).exists());
|
|
}
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let listener_address = listener.local_addr()?.to_string();
|
|
let host_tmp = TempDir::new_in("/tmp")?;
|
|
let isolated_tmp_dir = host_tmp.path().to_path_buf();
|
|
let isolated_tmp_marker = isolated_tmp_dir.join("same-path-marker");
|
|
std::fs::write(&isolated_tmp_marker, "host")?;
|
|
|
|
BwrapExecServer
|
|
.scope(|exec_server_url| async move {
|
|
let environment = Environment::create_for_tests(Some(exec_server_url))?;
|
|
let file_system = environment.get_filesystem();
|
|
let isolated_tmp_dir_uri = PathUri::from_host_native_path(&isolated_tmp_dir)?;
|
|
let isolated_tmp_marker_uri = PathUri::from_host_native_path(&isolated_tmp_marker)?;
|
|
file_system
|
|
.create_directory(
|
|
&isolated_tmp_dir_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
file_system
|
|
.write_file(
|
|
&isolated_tmp_marker_uri,
|
|
b"remote".to_vec(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
file_system
|
|
.read_file_text(&isolated_tmp_marker_uri, /*sandbox*/ None)
|
|
.await?,
|
|
"remote"
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&isolated_tmp_marker)?, "host");
|
|
|
|
let outer = run_report(
|
|
&environment,
|
|
"bwrap-outer-report",
|
|
&fixture,
|
|
&bwrap,
|
|
workspace.path(),
|
|
&allowed_marker,
|
|
&denied_marker,
|
|
&listener_address,
|
|
/*sandbox*/ None,
|
|
FixtureExit::Success,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(outer.exit_code, Some(0), "{}", outer.stderr);
|
|
let outer = outer.report;
|
|
|
|
let _connection = timeout(Duration::from_secs(2), listener.accept())
|
|
.await
|
|
.context("outer bwrap did not reach the test listener")??;
|
|
assert_eq!(outer.connect_succeeded, Some(true));
|
|
assert_eq!(outer.allowed_write_succeeded, Some(true));
|
|
assert_eq!(outer.denied_read_succeeded, Some(true));
|
|
assert_eq!(outer.denied_write_succeeded, Some(true));
|
|
assert_eq!(std::fs::read_to_string(&allowed_marker)?, "sentinelchanged");
|
|
assert_eq!(std::fs::read_to_string(&denied_marker)?, "sentinelchanged");
|
|
std::fs::write(&allowed_marker, "sentinel")?;
|
|
std::fs::write(&denied_marker, "sentinel")?;
|
|
|
|
assert_ne!(outer.user_namespace, parent_namespaces.user);
|
|
assert_ne!(outer.mount_namespace, parent_namespaces.mount);
|
|
assert_ne!(outer.pid_namespace, parent_namespaces.pid);
|
|
assert_ne!(outer.ipc_namespace, parent_namespaces.ipc);
|
|
assert_ne!(outer.uts_namespace, parent_namespaces.uts);
|
|
assert_eq!(outer.network_namespace, parent_namespaces.network);
|
|
assert_proc_is_available(&outer);
|
|
assert_eq!((outer.effective_uid, outer.effective_gid), (1000, 1000));
|
|
assert_no_capabilities(&outer)?;
|
|
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let mut file_system_policy = FileSystemSandboxPolicy::workspace_write(
|
|
&[],
|
|
/*exclude_tmpdir_env_var*/ true,
|
|
/*exclude_slash_tmp*/ true,
|
|
);
|
|
file_system_policy.entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: denied_marker.as_path().try_into()?,
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
});
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
|
|
PermissionProfile::from_runtime_permissions(
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
),
|
|
cwd,
|
|
);
|
|
let inner = run_report(
|
|
&environment,
|
|
"bwrap-inner-report",
|
|
&fixture,
|
|
&bwrap,
|
|
workspace.path(),
|
|
&allowed_marker,
|
|
&denied_marker,
|
|
&listener_address,
|
|
Some(sandbox),
|
|
FixtureExit::Code(73),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(inner.exit_code, Some(73), "{}", inner.stderr);
|
|
let inner = inner.report;
|
|
|
|
assert_eq!(inner.connect_succeeded, Some(false));
|
|
assert_eq!(inner.allowed_write_succeeded, Some(true));
|
|
assert_eq!(inner.denied_read_succeeded, Some(false));
|
|
assert_eq!(inner.denied_write_succeeded, Some(false));
|
|
assert_eq!(std::fs::read_to_string(&allowed_marker)?, "sentinelchanged");
|
|
assert_eq!(std::fs::read_to_string(&denied_marker)?, "sentinel");
|
|
assert!(
|
|
timeout(Duration::from_millis(200), listener.accept())
|
|
.await
|
|
.is_err(),
|
|
"sandboxed process unexpectedly reached the outer loopback listener"
|
|
);
|
|
|
|
assert_ne!(inner.user_namespace, outer.user_namespace);
|
|
assert_ne!(inner.mount_namespace, outer.mount_namespace);
|
|
assert_ne!(inner.pid_namespace, outer.pid_namespace);
|
|
assert_ne!(inner.network_namespace, outer.network_namespace);
|
|
assert_proc_matches_pid_namespace(&inner);
|
|
assert_eq!((inner.effective_uid, inner.effective_gid), (1000, 1000));
|
|
assert_no_capabilities(&inner)?;
|
|
assert_eq!(inner.no_new_privileges, 1);
|
|
assert_eq!(inner.seccomp_mode, 2);
|
|
for protected_name in [".git", ".agents", ".codex"] {
|
|
assert!(
|
|
!workspace.path().join(protected_name).exists(),
|
|
"sandbox setup leaked synthetic {protected_name} mount target"
|
|
);
|
|
}
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn run_report(
|
|
environment: &Environment,
|
|
process_id: &str,
|
|
fixture: &Path,
|
|
bwrap: &Path,
|
|
cwd: &Path,
|
|
allowed_marker: &Path,
|
|
denied_marker: &Path,
|
|
listener_address: &str,
|
|
sandbox: Option<FileSystemSandboxContext>,
|
|
fixture_exit: FixtureExit,
|
|
) -> Result<ReportOutcome> {
|
|
let bwrap_dir = bwrap.parent().context("bwrap runfile has no parent")?;
|
|
let mut path_entries = vec![bwrap_dir.to_path_buf()];
|
|
if let Some(path) = std::env::var_os("PATH") {
|
|
path_entries.extend(std::env::split_paths(&path));
|
|
}
|
|
let path = std::env::join_paths(path_entries).context("build sandbox smoke PATH")?;
|
|
let mut argv = vec![
|
|
fixture.to_string_lossy().into_owned(),
|
|
"--connect".to_string(),
|
|
listener_address.to_string(),
|
|
"--allowed-write".to_string(),
|
|
allowed_marker.to_string_lossy().into_owned(),
|
|
"--denied-write".to_string(),
|
|
denied_marker.to_string_lossy().into_owned(),
|
|
];
|
|
if let FixtureExit::Code(exit_code) = fixture_exit {
|
|
argv.extend(["--exit-code".to_string(), exit_code.to_string()]);
|
|
}
|
|
let started = environment
|
|
.get_exec_backend()
|
|
.start(ExecParams {
|
|
process_id: ProcessId::from(process_id),
|
|
argv,
|
|
cwd: PathUri::from_host_native_path(cwd)?,
|
|
env_policy: /*env_policy*/ None,
|
|
env: HashMap::from([
|
|
(
|
|
"CARGO_BIN_EXE_bwrap".to_string(),
|
|
bwrap.to_string_lossy().into_owned(),
|
|
),
|
|
("CODEX_HOME".to_string(), cwd.to_string_lossy().into_owned()),
|
|
("HOME".to_string(), cwd.to_string_lossy().into_owned()),
|
|
("PATH".to_string(), path.to_string_lossy().into_owned()),
|
|
("TMPDIR".to_string(), "/tmp".to_string()),
|
|
]),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
})
|
|
.await?;
|
|
let (stdout, stderr, exit_code) = collect_process_output(started.process).await?;
|
|
let report = serde_json::from_str(stdout.trim())
|
|
.with_context(|| format!("parse namespace report; stderr: {stderr}"))?;
|
|
Ok(ReportOutcome {
|
|
report,
|
|
stderr,
|
|
exit_code,
|
|
})
|
|
}
|
|
|
|
async fn collect_process_output(
|
|
process: Arc<dyn ExecProcess>,
|
|
) -> Result<(String, String, Option<i32>)> {
|
|
let mut events = process.subscribe_events();
|
|
let mut stdout = String::new();
|
|
let mut stderr = String::new();
|
|
let mut exit_code = None;
|
|
loop {
|
|
match timeout(Duration::from_secs(10), events.recv())
|
|
.await
|
|
.context("timed out waiting for bwrap smoke process")??
|
|
{
|
|
ExecProcessEvent::Output(chunk) => match chunk.stream {
|
|
ExecOutputStream::Stdout | ExecOutputStream::Pty => {
|
|
stdout.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
|
}
|
|
ExecOutputStream::Stderr => {
|
|
stderr.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
|
}
|
|
},
|
|
ExecProcessEvent::Exited {
|
|
exit_code: code, ..
|
|
} => exit_code = Some(code),
|
|
ExecProcessEvent::Closed { .. } => break,
|
|
ExecProcessEvent::Failed(message) => anyhow::bail!(message),
|
|
}
|
|
}
|
|
Ok((stdout, stderr, exit_code))
|
|
}
|
|
|
|
fn current_namespace_identity() -> Result<NamespaceIdentity> {
|
|
Ok(NamespaceIdentity {
|
|
user: namespace("user")?,
|
|
mount: namespace("mnt")?,
|
|
pid: namespace("pid")?,
|
|
network: namespace("net")?,
|
|
ipc: namespace("ipc")?,
|
|
uts: namespace("uts")?,
|
|
})
|
|
}
|
|
|
|
fn namespace(name: &str) -> std::io::Result<String> {
|
|
std::fs::read_link(format!("/proc/self/ns/{name}"))
|
|
.map(|path| path.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn assert_proc_matches_pid_namespace(report: &NamespaceReport) {
|
|
assert_proc_is_available(report);
|
|
assert_eq!(
|
|
report.proc_pid_one_namespace.as_deref(),
|
|
Some(report.pid_namespace.as_str())
|
|
);
|
|
}
|
|
|
|
fn assert_proc_is_available(report: &NamespaceReport) {
|
|
assert_eq!(report.proc_file_system_type, "proc");
|
|
assert!(report.overflow_uid.parse::<u32>().is_ok());
|
|
assert!(report.overflow_gid.parse::<u32>().is_ok());
|
|
}
|
|
|
|
fn assert_no_capabilities(report: &NamespaceReport) -> Result<()> {
|
|
for (name, capabilities) in [
|
|
("effective", report.effective_capabilities.as_str()),
|
|
("permitted", report.permitted_capabilities.as_str()),
|
|
("inheritable", report.inheritable_capabilities.as_str()),
|
|
("ambient", report.ambient_capabilities.as_str()),
|
|
] {
|
|
assert_eq!(
|
|
u64::from_str_radix(capabilities, 16)?,
|
|
0,
|
|
"process retained {name} capabilities"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|