mirror of
https://github.com/openai/codex.git
synced 2026-04-26 15:45:02 +00:00
feat(linux-sandbox): add bwrap support (#9938)
## Summary This PR introduces a gated Bubblewrap (bwrap) Linux sandbox path. The curent Linux sandbox path relies on in-process restrictions (including Landlock). Bubblewrap gives us a more uniform filesystem isolation model, especially explicit writable roots with the option to make some directories read-only and granular network controls. This is behind a feature flag so we can validate behavior safely before making it the default. - Added temporary rollout flag: - `features.use_linux_sandbox_bwrap` - Preserved existing default path when the flag is off. - In Bubblewrap mode: - Added internal retry without /proc when /proc mount is not permitted by the host/container.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use codex_core::config::types::ShellEnvironmentPolicy;
|
||||
use codex_core::error::CodexErr;
|
||||
use codex_core::error::Result;
|
||||
use codex_core::error::SandboxErr;
|
||||
use codex_core::exec::ExecParams;
|
||||
use codex_core::exec::process_exec_tool_call;
|
||||
@@ -32,6 +33,8 @@ const NETWORK_TIMEOUT_MS: u64 = 2_000;
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
const NETWORK_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
|
||||
|
||||
fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||
let policy = ShellEnvironmentPolicy::default();
|
||||
create_env(&policy, None)
|
||||
@@ -47,12 +50,24 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used, clippy::unwrap_used)]
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn run_cmd_output(
|
||||
cmd: &[&str],
|
||||
writable_roots: &[PathBuf],
|
||||
timeout_ms: u64,
|
||||
) -> codex_core::exec::ExecToolCallOutput {
|
||||
run_cmd_result_with_writable_roots(cmd, writable_roots, timeout_ms, false)
|
||||
.await
|
||||
.expect("sandboxed command should execute")
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn run_cmd_result_with_writable_roots(
|
||||
cmd: &[&str],
|
||||
writable_roots: &[PathBuf],
|
||||
timeout_ms: u64,
|
||||
use_bwrap_sandbox: bool,
|
||||
) -> Result<codex_core::exec::ExecToolCallOutput> {
|
||||
let cwd = std::env::current_dir().expect("cwd should exist");
|
||||
let sandbox_cwd = cwd.clone();
|
||||
let params = ExecParams {
|
||||
@@ -86,10 +101,48 @@ async fn run_cmd_output(
|
||||
&sandbox_policy,
|
||||
sandbox_cwd.as_path(),
|
||||
&codex_linux_sandbox_exe,
|
||||
use_bwrap_sandbox,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn is_bwrap_unavailable_output(output: &codex_core::exec::ExecToolCallOutput) -> bool {
|
||||
output.stderr.text.contains(BWRAP_UNAVAILABLE_ERR)
|
||||
}
|
||||
|
||||
async fn should_skip_bwrap_tests() -> bool {
|
||||
match run_cmd_result_with_writable_roots(
|
||||
&["bash", "-lc", "true"],
|
||||
&[],
|
||||
NETWORK_TIMEOUT_MS,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => is_bwrap_unavailable_output(&output),
|
||||
Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => {
|
||||
is_bwrap_unavailable_output(&output)
|
||||
}
|
||||
// Probe timeouts are not actionable for the bwrap-specific assertions below;
|
||||
// skip rather than fail the whole suite.
|
||||
Err(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => true,
|
||||
Err(err) => panic!("bwrap availability probe failed unexpectedly: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_denied(
|
||||
result: Result<codex_core::exec::ExecToolCallOutput>,
|
||||
context: &str,
|
||||
) -> codex_core::exec::ExecToolCallOutput {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
assert_ne!(output.exit_code, 0, "{context}: expected nonzero exit code");
|
||||
output
|
||||
}
|
||||
Err(CodexErr::Sandbox(SandboxErr::Denied { output })) => *output,
|
||||
Err(err) => panic!("{context}: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -192,6 +245,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
|
||||
&sandbox_policy,
|
||||
sandbox_cwd.as_path(),
|
||||
&codex_linux_sandbox_exe,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
@@ -242,6 +296,90 @@ async fn sandbox_blocks_nc() {
|
||||
assert_network_blocked(&["nc", "-z", "127.0.0.1", "80"]).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
|
||||
if should_skip_bwrap_tests().await {
|
||||
eprintln!("skipping bwrap test: vendored bwrap was not built in this environment");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
let dot_git = tmpdir.path().join(".git");
|
||||
let dot_codex = tmpdir.path().join(".codex");
|
||||
std::fs::create_dir_all(&dot_git).expect("create .git");
|
||||
std::fs::create_dir_all(&dot_codex).expect("create .codex");
|
||||
|
||||
let git_target = dot_git.join("config");
|
||||
let codex_target = dot_codex.join("config.toml");
|
||||
|
||||
let git_output = expect_denied(
|
||||
run_cmd_result_with_writable_roots(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo denied > {}", git_target.to_string_lossy()),
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
".git write should be denied under bubblewrap",
|
||||
);
|
||||
|
||||
let codex_output = expect_denied(
|
||||
run_cmd_result_with_writable_roots(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo denied > {}", codex_target.to_string_lossy()),
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
".codex write should be denied under bubblewrap",
|
||||
);
|
||||
assert_ne!(git_output.exit_code, 0);
|
||||
assert_ne!(codex_output.exit_code, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_blocks_codex_symlink_replacement_attack() {
|
||||
if should_skip_bwrap_tests().await {
|
||||
eprintln!("skipping bwrap test: vendored bwrap was not built in this environment");
|
||||
return;
|
||||
}
|
||||
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
let decoy = tmpdir.path().join("decoy-codex");
|
||||
std::fs::create_dir_all(&decoy).expect("create decoy dir");
|
||||
|
||||
let dot_codex = tmpdir.path().join(".codex");
|
||||
symlink(&decoy, &dot_codex).expect("create .codex symlink");
|
||||
|
||||
let codex_target = dot_codex.join("config.toml");
|
||||
|
||||
let codex_output = expect_denied(
|
||||
run_cmd_result_with_writable_roots(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo denied > {}", codex_target.to_string_lossy()),
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
".codex symlink replacement should be denied",
|
||||
);
|
||||
assert_ne!(codex_output.exit_code, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_blocks_ssh() {
|
||||
// Force ssh to attempt a real TCP connection but fail quickly. `BatchMode`
|
||||
|
||||
Reference in New Issue
Block a user