fix(linux-sandbox): mount /dev in bwrap sandbox (#12081)

## Summary
- Updates the Linux bubblewrap sandbox args to mount a minimal `/dev`
using `--dev /dev` instead of only binding `/dev/null`. tools needing
entropy (git, crypto libs, etc.) can fail.

- Changed mount order so `--dev /dev` is added before writable-root
`--bind` mounts, preserving writable `/dev/*` submounts like `/dev/shm`

## Why
Fixes sandboxed command failures when reading `/dev/urandom` (and
similar standard device-node access).


Fixes https://github.com/openai/codex/issues/12056
This commit is contained in:
viyatb-oai
2026-02-18 23:27:32 -08:00
committed by GitHub
parent 18eb640a47
commit 4fe99b086f
4 changed files with 145 additions and 28 deletions

View File

@@ -56,7 +56,7 @@ async fn run_cmd_output(
writable_roots: &[PathBuf],
timeout_ms: u64,
) -> codex_core::exec::ExecToolCallOutput {
run_cmd_result_with_writable_roots(cmd, writable_roots, timeout_ms, false)
run_cmd_result_with_writable_roots(cmd, writable_roots, timeout_ms, false, false)
.await
.expect("sandboxed command should execute")
}
@@ -67,6 +67,7 @@ async fn run_cmd_result_with_writable_roots(
writable_roots: &[PathBuf],
timeout_ms: u64,
use_bwrap_sandbox: bool,
network_access: bool,
) -> Result<codex_core::exec::ExecToolCallOutput> {
let cwd = std::env::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
@@ -89,7 +90,7 @@ async fn run_cmd_result_with_writable_roots(
.map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap())
.collect(),
read_only_access: Default::default(),
network_access: false,
network_access,
// Exclude tmp-related folders from writable roots because we need a
// folder that is writable by tests but that we intentionally disallow
// writing to in the sandbox.
@@ -112,6 +113,13 @@ async fn run_cmd_result_with_writable_roots(
fn is_bwrap_unavailable_output(output: &codex_core::exec::ExecToolCallOutput) -> bool {
output.stderr.text.contains(BWRAP_UNAVAILABLE_ERR)
|| (output
.stderr
.text
.contains("Can't mount proc on /newroot/proc")
&& (output.stderr.text.contains("Operation not permitted")
|| output.stderr.text.contains("Permission denied")
|| output.stderr.text.contains("Invalid argument")))
}
async fn should_skip_bwrap_tests() -> bool {
@@ -120,6 +128,7 @@ async fn should_skip_bwrap_tests() -> bool {
&[],
NETWORK_TIMEOUT_MS,
true,
true,
)
.await
{
@@ -168,14 +177,90 @@ async fn test_root_write() {
#[tokio::test]
async fn test_dev_null_write() {
run_cmd(
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let output = run_cmd_result_with_writable_roots(
&["bash", "-lc", "echo blah > /dev/null"],
&[],
// We have seen timeouts when running this test in CI on GitHub,
// so we are using a generous timeout until we can diagnose further.
LONG_TIMEOUT_MS,
true,
true,
)
.await;
.await
.expect("sandboxed command should execute");
assert_eq!(output.exit_code, 0);
}
#[tokio::test]
async fn bwrap_populates_minimal_dev_nodes() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
let output = run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
"for node in null zero full random urandom tty; do [ -c \"/dev/$node\" ] || { echo \"missing /dev/$node\" >&2; exit 1; }; done",
],
&[],
LONG_TIMEOUT_MS,
true,
true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(output.exit_code, 0);
}
#[tokio::test]
async fn bwrap_preserves_writable_dev_shm_bind_mount() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
if !std::path::Path::new("/dev/shm").exists() {
eprintln!("skipping bwrap test: /dev/shm is unavailable in this environment");
return;
}
let target_file = match NamedTempFile::new_in("/dev/shm") {
Ok(file) => file,
Err(err) => {
eprintln!("skipping bwrap test: failed to create /dev/shm temp file: {err}");
return;
}
};
let target_path = target_file.path().to_path_buf();
std::fs::write(&target_path, "host-before").expect("seed /dev/shm file");
let output = run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
&format!("printf sandbox-after > {}", target_path.to_string_lossy()),
],
&[PathBuf::from("/dev/shm")],
LONG_TIMEOUT_MS,
true,
true,
)
.await
.expect("sandboxed command should execute");
assert_eq!(output.exit_code, 0);
assert_eq!(
std::fs::read_to_string(&target_path).expect("read /dev/shm file"),
"sandbox-after"
);
}
#[tokio::test]
@@ -306,7 +391,7 @@ async fn sandbox_blocks_nc() {
#[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");
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
@@ -329,6 +414,7 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
true,
true,
)
.await,
".git write should be denied under bubblewrap",
@@ -344,6 +430,7 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
true,
true,
)
.await,
".codex write should be denied under bubblewrap",
@@ -355,7 +442,7 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
#[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");
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
@@ -380,6 +467,7 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() {
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
true,
true,
)
.await,
".codex symlink replacement should be denied",