Files
codex/prs/bolinfest/PR-2309.md
2025-09-02 15:17:45 -07:00

9.8 KiB
Raw Blame History

PR #2309: Fix AF_UNIX, sockpair, recvfrom in linux sandbox

Description

When using codex-tui on a linux system I was unable to run cargo clippy inside of codex due to:

[pid 3548377] socketpair(AF_UNIX, SOCK_SEQPACKET|SOCK_CLOEXEC, 0,  <unfinished ...>
[pid 3548370] close(8 <unfinished ...>
[pid 3548377] <... socketpair resumed>0x7ffb97f4ed60) = -1 EPERM (Operation not permitted)

And

3611300 <... recvfrom resumed>0x708b8b5cffe0, 8, 0, NULL, NULL) = -1 EPERM (Operation not permitted)

This PR:

  • Fixes a bug that disallowed AF_UNIX to allow it on socket()
  • Adds recvfrom() to the syscall allow list, this should be fine since we disable opening new sockets. But we should validate there is not a open socket inheritance issue.
  • Allow socketpair to be called for AF_UNIX
  • Adds tests for AF_UNIX components
  • All of which allows running cargo clippy within the sandbox on linux, and possibly other tooling using a fork server model + AF_UNIX comms.

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 41392633be..feec0b04b8 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -733,6 +733,7 @@ dependencies = [
  "codex-common",
  "codex-core",
  "codex-ollama",
+ "libc",
  "owo-colors",
  "predicates",
  "serde_json",
diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml
index aee480d7b4..b7c20df321 100644
--- a/codex-rs/exec/Cargo.toml
+++ b/codex-rs/exec/Cargo.toml
@@ -41,5 +41,6 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
 
 [dev-dependencies]
 assert_cmd = "2"
+libc = "0.2"
 predicates = "3"
 tempfile = "3.13.0"
diff --git a/codex-rs/exec/tests/sandbox.rs b/codex-rs/exec/tests/sandbox.rs
index 8cc31bba58..22caf8abc6 100644
--- a/codex-rs/exec/tests/sandbox.rs
+++ b/codex-rs/exec/tests/sandbox.rs
@@ -4,7 +4,10 @@
 use codex_core::protocol::SandboxPolicy;
 use codex_core::spawn::StdioPolicy;
 use std::collections::HashMap;
+use std::future::Future;
+use std::io;
 use std::path::PathBuf;
+use std::process::ExitStatus;
 use tokio::process::Child;
 
 #[cfg(target_os = "macos")]
@@ -90,3 +93,128 @@ if __name__ == '__main__':
     let status = child.wait().await.expect("should wait for child process");
     assert!(status.success(), "python exited with {status:?}");
 }
+
+fn unix_sock_body() {
+    unsafe {
+        let mut fds = [0i32; 2];
+        let r = libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr());
+        assert_eq!(
+            r,
+            0,
+            "socketpair(AF_UNIX, SOCK_DGRAM) failed: {}",
+            io::Error::last_os_error()
+        );
+
+        let msg = b"hello_unix";
+        // write() from one end (generic write is allowed)
+        let sent = libc::write(fds[0], msg.as_ptr() as *const libc::c_void, msg.len());
+        assert!(sent >= 0, "write() failed: {}", io::Error::last_os_error());
+
+        // recvfrom() on the other end. We dont need the address for socketpair,
+        // so we pass null pointers for src address.
+        let mut buf = [0u8; 64];
+        let recvd = libc::recvfrom(
+            fds[1],
+            buf.as_mut_ptr() as *mut libc::c_void,
+            buf.len(),
+            0,
+            std::ptr::null_mut(),
+            std::ptr::null_mut(),
+        );
+        assert!(
+            recvd >= 0,
+            "recvfrom() failed: {}",
+            io::Error::last_os_error()
+        );
+
+        let recvd_slice = &buf[..(recvd as usize)];
+        assert_eq!(
+            recvd_slice,
+            &msg[..],
+            "payload mismatch: sent {} bytes, got {} bytes",
+            msg.len(),
+            recvd
+        );
+
+        // Also exercise AF_UNIX stream socketpair quickly to ensure AF_UNIX in general works.
+        let mut sfds = [0i32; 2];
+        let sr = libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sfds.as_mut_ptr());
+        assert_eq!(
+            sr,
+            0,
+            "socketpair(AF_UNIX, SOCK_STREAM) failed: {}",
+            io::Error::last_os_error()
+        );
+        let snt2 = libc::write(sfds[0], msg.as_ptr() as *const libc::c_void, msg.len());
+        assert!(
+            snt2 >= 0,
+            "write(stream) failed: {}",
+            io::Error::last_os_error()
+        );
+        let mut b2 = [0u8; 64];
+        let rcv2 = libc::recv(sfds[1], b2.as_mut_ptr() as *mut libc::c_void, b2.len(), 0);
+        assert!(
+            rcv2 >= 0,
+            "recv(stream) failed: {}",
+            io::Error::last_os_error()
+        );
+
+        // Clean up
+        let _ = libc::close(sfds[0]);
+        let _ = libc::close(sfds[1]);
+        let _ = libc::close(fds[0]);
+        let _ = libc::close(fds[1]);
+    }
+}
+
+#[tokio::test]
+async fn allow_unix_socketpair_recvfrom() {
+    run_code_under_sandbox(
+        "allow_unix_socketpair_recvfrom",
+        &SandboxPolicy::ReadOnly,
+        || async { unix_sock_body() },
+    )
+    .await
+    .expect("should be able to reexec");
+}
+
+const IN_SANDBOX_ENV_VAR: &str = "IN_SANDBOX";
+
+pub async fn run_code_under_sandbox<F, Fut>(
+    test_selector: &str,
+    policy: &SandboxPolicy,
+    child_body: F,
+) -> io::Result<Option<ExitStatus>>
+where
+    F: FnOnce() -> Fut + Send + 'static,
+    Fut: Future<Output = ()> + Send + 'static,
+{
+    if std::env::var(IN_SANDBOX_ENV_VAR).is_err() {
+        let exe = std::env::current_exe()?;
+        let mut cmds = vec![exe.to_string_lossy().into_owned(), "--exact".into()];
+        let mut stdio_policy = StdioPolicy::RedirectForShellTool;
+        // Allow for us to pass forward --nocapture / use the right stdio policy.
+        if std::env::args().any(|a| a == "--nocapture") {
+            cmds.push("--nocapture".into());
+            stdio_policy = StdioPolicy::Inherit;
+        }
+        cmds.push(test_selector.into());
+
+        // Your existing launcher:
+        let mut child = spawn_command_under_sandbox(
+            cmds,
+            policy,
+            std::env::current_dir().expect("should be able to get current dir"),
+            stdio_policy,
+            HashMap::from([("IN_SANDBOX".into(), "1".into())]),
+        )
+        .await?;
+
+        let status = child.wait().await?;
+        Ok(Some(status))
+    } else {
+        // Child branch: run the provided body.
+        child_body().await;
+        Ok(None)
+    }
+}
diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs
index e13e3c8b75..5bc96130dd 100644
--- a/codex-rs/linux-sandbox/src/landlock.rs
+++ b/codex-rs/linux-sandbox/src/landlock.rs
@@ -104,7 +104,9 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(),
     deny_syscall(libc::SYS_sendto);
     deny_syscall(libc::SYS_sendmsg);
     deny_syscall(libc::SYS_sendmmsg);
-    deny_syscall(libc::SYS_recvfrom);
+    // NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run
+    // with their socketpair + child processes for sub-proc management
+    // deny_syscall(libc::SYS_recvfrom);
     deny_syscall(libc::SYS_recvmsg);
     deny_syscall(libc::SYS_recvmmsg);
     deny_syscall(libc::SYS_getsockopt);
@@ -115,12 +117,12 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(),
     let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new(
         0, // first argument (domain)
         SeccompCmpArgLen::Dword,
-        SeccompCmpOp::Eq,
+        SeccompCmpOp::Ne,
         libc::AF_UNIX as u64,
     )?])?;
 
-    rules.insert(libc::SYS_socket, vec![unix_only_rule]);
-    rules.insert(libc::SYS_socketpair, vec![]); // always deny (Unix can use socketpair but fine, keep open?)
+    rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
+    rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); // always deny (Unix can use socketpair but fine, keep open?)
 
     let filter = SeccompFilter::new(
         rules,

Review Comments

codex-rs/exec/Cargo.toml

@@ -43,3 +43,4 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
 assert_cmd = "2"
 predicates = "3"
 tempfile = "3.13.0"
+libc = "0.2.172"

alpha?

Still trying to find a way to enforce this in CI...

We do have https://github.com/openai/codex/blob/main/.vscode/settings.json if you edit the file in VS Code, but cargo add skips that...

codex-rs/exec/tests/sandbox.rs

@@ -4,9 +4,53 @@
 use codex_core::protocol::SandboxPolicy;
 use codex_core::spawn::StdioPolicy;
 use std::collections::HashMap;
+use std::future::Future;
+use std::io;
 use std::path::PathBuf;
+use std::process::ExitStatus;
 use tokio::process::Child;
 
+const IN_SANDBOX_ENV_VAR: &str = "IN_SANDBOX";
+
+pub async fn run_code_under_sandbox<F, Fut>(

Can we put this at the end of the file? I try to have us keep the most important stuff at the top (module small-ish struct defs and consts). Arguably the spawn_command_under_sandbox() defs should go below the tests, too...

@@ -90,3 +134,85 @@ if __name__ == '__main__':
     let status = child.wait().await.expect("should wait for child process");
     assert!(status.success(), "python exited with {status:?}");
 }
+
+#[tokio::test]
+async fn allow_unix_socketpair_recvfrom() {
+    run_code_under_sandbox(
+        "allow_unix_socketpair_recvfrom",
+        &SandboxPolicy::ReadOnly,
+        || async {

The one thing that's not great about this approach is the 2-3 extra levels of indenting...

Do you want to put everything inside unsafe in its own function?