Linux sandbox regression: default backend denies /dev/null and /dev/zero; legacy Landlock works

Open 💬 6 comments Opened Apr 1, 2026 by wwitzke-pdw
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

On Linux with codex-cli 0.118.0, the default sandbox backend denies access to /dev/null and /dev/zero, which breaks normal shell redirections and device-file reads. Enabling use_legacy_landlock=true in the same environment fixes the problem immediately.

This looks like a regression in the default bubblewrap-backed Linux sandbox path rather than a general Ubuntu or host configuration issue.

Environment

  • Codex CLI: 0.118.0
  • OS: Ubuntu 24.04
  • Kernel: 6.17.0-19-generic
  • bubblewrap: 0.9.0
  • Sandbox mode: workspace-write
  • Approval policy: never

Reproduction

With the default Linux sandbox backend:

cat /dev/null
printf test >/dev/null
head -c 8 /dev/zero | od -An -tx1

Observed results:

cat: /dev/null: Permission denied
/bin/bash: line 1: /dev/null: Permission denied
head: cannot open /dev/zero for reading: Permission denied

This also caused shell startup noise in some cases because startup scripts that touched /dev/null would fail.

Control / Comparison

In the same environment, after enabling legacy Landlock:

codex --enable use_legacy_landlock

The same tests succeed:

cat /dev/null
printf test >/dev/null
head -c 8 /dev/zero | od -An -tx1

Observed results:

<success>
<success>
00 00 00 00 00 00 00 00

codex features list reports:

use_legacy_landlock              stable             true

Why this matters

Blocking /dev/null breaks common shell patterns like:

>/dev/null
2>/dev/null
</dev/null

That has a wide blast radius across wrappers, probes, installers, test harnesses, and scripts that rely on standard Unix device semantics.

Related context

I found a related earlier issue/PR in the repo about Linux sandbox device handling:

  • #12056
  • #12081

This report may be the same class of bug, or a regression / alternate code path where the /dev fix is not taking effect.

Expected behavior

The default Linux sandbox should allow standard access to /dev/null and /dev/zero.

Actual behavior

The default backend denies access, while use_legacy_landlock=true restores expected behavior in the same environment.

View original on GitHub ↗

6 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #16402
  • #15434
  • #16334

Powered by Codex Action

wwitzke-pdw · 3 months ago

Adding explicit repro steps for maintainers.

Repro steps

Default backend path:

codex -s workspace-write -a never

Then, from inside the Codex session, run:

cat /dev/null
printf test >/dev/null
head -c 8 /dev/zero | od -An -tx1

Observed in my environment:

cat: /dev/null: Permission denied
/bin/bash: line 1: /dev/null: Permission denied
head: cannot open /dev/zero for reading: Permission denied

Comparison case

Launch Codex with legacy Landlock enabled:

codex --enable use_legacy_landlock -s workspace-write -a never

Then run the same commands inside the Codex session:

cat /dev/null
printf test >/dev/null
head -c 8 /dev/zero | od -An -tx1

Observed in the same environment:

<success>
<success>
00 00 00 00 00 00 00 00

That comparison is what makes me think this is specifically the default Linux sandbox backend path rather than a general host or Ubuntu issue.

wwitzke-pdw · 2 months ago

Quick version update: this issue is still present in codex-cli 0.122.0.

wwitzke-pdw · 2 months ago

Proposed fix for this issue:

Root cause:

  • bubblewrap's synthetic /dev works correctly when mounted with --dev /dev
  • Codex was then rebinding host /dev over that synthetic mount when /dev itself was treated as a writable root
  • raw bwrap confirms the difference:
  • bwrap --ro-bind / / --proc /proc --dev /dev ... works
  • bwrap --ro-bind / / --proc /proc --dev /dev --bind /dev /dev ... fails for /dev/null and /dev/zero

Proposed fix:

  • when the writable root resolves to /dev, keep bubblewrap's synthetic /dev in place instead of rebinding host /dev
  • this preserves working device nodes like /dev/null and /dev/zero under the rootless bwrap path

Validation I ran against the patched tree:

  • cargo test -p codex-linux-sandbox
  • cargo test -p codex-cli --test sandbox_linux_devices -- --nocapture
  • direct validation against the patched binary for the non-legacy workspace-write + /dev writable-root path:
  • /dev/null write succeeds
  • /dev/zero read succeeds

Patch (issue-16451-proposed-fix.patch):

<details>
<summary>Show patch</summary>

diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md
index 5745f4816..b7d1bd73a 100644
--- a/codex-rs/linux-sandbox/README.md
+++ b/codex-rs/linux-sandbox/README.md
@@ -50,6 +50,9 @@ commands that would enter the bubblewrap path.
   seccomp network filter in-process.
 - When bubblewrap is active, the filesystem is read-only by default via `--ro-bind / /`.
 - When bubblewrap is active, writable roots are layered with `--bind <root> <root>`.
+- When `/dev` itself is a writable root, the helper keeps bubblewrap's
+  synthetic `/dev` mount instead of rebinding host `/dev`, so standard device
+  nodes like `/dev/null` and `/dev/zero` remain usable.
 - When bubblewrap is active, protected subpaths under writable roots (for
   example `.git`,
   resolved `gitdir:`, and `.codex`) are re-applied as read-only via `--ro-bind`.
diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs
index 64d1342be..4d58cb6a7 100644
--- a/codex-rs/linux-sandbox/src/bwrap.rs
+++ b/codex-rs/linux-sandbox/src/bwrap.rs
@@ -401,9 +401,15 @@ fn create_filesystem_args(
         }
 
         let mount_root = symlink_target.as_deref().unwrap_or(root);
-        args.push("--bind".to_string());
-        args.push(path_to_string(mount_root));
-        args.push(path_to_string(mount_root));
+        if mount_root != Path::new("/dev") {
+            args.push("--bind".to_string());
+            args.push(path_to_string(mount_root));
+            args.push(path_to_string(mount_root));
+        } else {
+            // Keep bubblewrap's synthetic /dev in place. Rebinding the host
+            // /dev over it makes standard device nodes like /dev/null and
+            // /dev/zero fail with EPERM under the rootless bwrap path.
+        }
 
         let mut read_only_subpaths: Vec<PathBuf> = writable_root
             .read_only_subpaths
@@ -1434,11 +1440,6 @@ mod tests {
                 "--ro-bind".to_string(),
                 "/dev/null".to_string(),
                 "/.codex".to_string(),
-                // Rebind /dev after the root bind so device nodes remain
-                // writable/usable inside the writable root.
-                "--bind".to_string(),
-                "/dev".to_string(),
-                "/dev".to_string(),
             ]
         );
     }
diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs
index 3795719f0..84fd78193 100644
--- a/codex-rs/linux-sandbox/tests/suite/landlock.rs
+++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs
@@ -234,6 +234,56 @@ async fn test_dev_null_write() {
     assert_eq!(output.exit_code, 0);
 }
 
+#[tokio::test]
+async fn bwrap_allows_dev_null_and_zero_io() {
+    if should_skip_bwrap_tests().await {
+        eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
+        return;
+    }
+
+    let output = run_cmd_result_with_writable_roots(
+        &[
+            "sh",
+            "-c",
+            "exec 3<>/dev/null; dd if=/dev/zero bs=1 count=4 status=none | od -An -t x1",
+        ],
+        &[],
+        LONG_TIMEOUT_MS,
+        /*use_legacy_landlock*/ false,
+        /*network_access*/ true,
+    )
+    .await
+    .expect("sandboxed command should execute");
+
+    assert_eq!(output.exit_code, 0);
+    assert_eq!(output.stdout.text.trim(), "00 00 00 00");
+}
+
+#[tokio::test]
+async fn bwrap_allows_dev_null_and_zero_io_with_dev_writable_root() {
+    if should_skip_bwrap_tests().await {
+        eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
+        return;
+    }
+
+    let output = run_cmd_result_with_writable_roots(
+        &[
+            "sh",
+            "-c",
+            "exec 3<>/dev/null; dd if=/dev/zero bs=1 count=4 status=none | od -An -t x1",
+        ],
+        &[PathBuf::from("/dev")],
+        LONG_TIMEOUT_MS,
+        /*use_legacy_landlock*/ false,
+        /*network_access*/ true,
+    )
+    .await
+    .expect("sandboxed command should execute");
+
+    assert_eq!(output.exit_code, 0);
+    assert_eq!(output.stdout.text.trim(), "00 00 00 00");
+}
+
 #[tokio::test]
 async fn bwrap_populates_minimal_dev_nodes() {
     if should_skip_bwrap_tests().await {
diff --git a/codex-rs/cli/tests/sandbox_linux_devices.rs b/codex-rs/cli/tests/sandbox_linux_devices.rs
new file mode 100644
index 000000000..73b2fc795
--- /dev/null
+++ b/codex-rs/cli/tests/sandbox_linux_devices.rs
@@ -0,0 +1,101 @@
+#![cfg(target_os = "linux")]
+
+use std::process::Command;
+use std::process::Stdio;
+
+use pretty_assertions::assert_eq;
+use tempfile::TempDir;
+
+fn run_sandbox_command(
+    codex_home: &TempDir,
+    extra_args: &[&str],
+    shell_command: &str,
+) -> Result<std::process::Output, Box<dyn std::error::Error>> {
+    let mut cmd = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
+    cmd.env("CODEX_HOME", codex_home.path())
+        .args(["sandbox", "linux"]);
+    cmd.args(extra_args)
+        .args(["sh", "-c", shell_command])
+        .stdin(Stdio::null())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped());
+    let output = cmd.output()?;
+    Ok(output)
+}
+
+#[test]
+fn sandbox_linux_supports_dev_null_and_zero_with_redirected_stdio()
+-> Result<(), Box<dyn std::error::Error>> {
+    let codex_home = TempDir::new()?;
+    let dev_null_cmd = "exec 3<>/dev/null; printf devnull-ok";
+    let dev_zero_cmd = concat!(
+        "dd if=/dev/zero bs=1 count=4 status=none ",
+        "| od -An -t x1",
+    );
+
+    let dev_null_output = run_sandbox_command(&codex_home, &[], dev_null_cmd)?;
+    assert!(
+        dev_null_output.status.success(),
+        "expected /dev/null command to succeed, stderr: {}",
+        String::from_utf8_lossy(&dev_null_output.stderr),
+    );
+    assert_eq!(
+        String::from_utf8(dev_null_output.stdout)?.trim(),
+        "devnull-ok",
+    );
+
+    let dev_zero_output = run_sandbox_command(&codex_home, &[], dev_zero_cmd)?;
+    assert!(
+        dev_zero_output.status.success(),
+        "expected /dev/zero command to succeed, stderr: {}",
+        String::from_utf8_lossy(&dev_zero_output.stderr),
+    );
+    assert_eq!(
+        String::from_utf8(dev_zero_output.stdout)?.trim(),
+        "00 00 00 00",
+    );
+
+    Ok(())
+}
+
+#[test]
+fn sandbox_linux_workspace_write_dev_root_supports_dev_nodes_without_legacy_landlock()
+-> Result<(), Box<dyn std::error::Error>> {
+    let codex_home = TempDir::new()?;
+    let extra_args = [
+        "-c",
+        "features.use_legacy_landlock=false",
+        "-c",
+        "sandbox_workspace_write.writable_roots=[\"/dev\"]",
+        "--full-auto",
+    ];
+    let dev_null_cmd = "exec 3<>/dev/null; printf devnull-ok";
+    let dev_zero_cmd = concat!(
+        "dd if=/dev/zero bs=1 count=4 status=none ",
+        "| od -An -t x1",
+    );
+
+    let dev_null_output = run_sandbox_command(&codex_home, &extra_args, dev_null_cmd)?;
+    assert!(
+        dev_null_output.status.success(),
+        "expected /dev/null command to succeed, stderr: {}",
+        String::from_utf8_lossy(&dev_null_output.stderr),
+    );
+    assert_eq!(
+        String::from_utf8(dev_null_output.stdout)?.trim(),
+        "devnull-ok",
+    );
+
+    let dev_zero_output = run_sandbox_command(&codex_home, &extra_args, dev_zero_cmd)?;
+    assert!(
+        dev_zero_output.status.success(),
+        "expected /dev/zero command to succeed, stderr: {}",
+        String::from_utf8_lossy(&dev_zero_output.stderr),
+    );
+    assert_eq!(
+        String::from_utf8(dev_zero_output.stdout)?.trim(),
+        "00 00 00 00",
+    );
+
+    Ok(())
+}

</details>

wwitzke-pdw · 2 months ago

This is still an issue on 0.125.0.

ctkm-aelf · 18 days ago

Reproduction notes and proposed fix path

I took a pass at reducing this to an actionable code path before suggesting a fix.

Breakdown:

  • I treated the report as reproducible enough to trace against the current code instead of asking for more information.
  • Root cause: codex-rs/linux-sandbox/src/bwrap.rs:567
  • Impact: calibrated priority score 80.4

Proposed approach:

  • Keep the patch scoped to the code path around codex-rs/linux-sandbox/src/bwrap.rs:567, so the behavior changes where the regression is introduced rather than broadening the surface area.
  • Validation plan: add or update focused regression coverage and run the affected package test target before proposing the branch
  • I will not open an unsolicited PR; if this direction matches maintainer expectations, I can open the prepared branch for review.

Precedent: #29189 Codex Desktop 26.616.41845 node_repl fails: codex/sandbox-state-meta missing sandboxPolicy

How I shaped this comment from prior successful threads:

  • Nearest successful examples considered: 3.
  • The shared pattern was to show the repro/root-cause evidence first, state the smallest viable fix, defer to maintainers on direction, and disclose AI assistance.

This analysis was prepared with AI assistance.

<!-- fkst:codex-saga:engage:codex-triage:dup:openai/codex#16451 -->