From f9ac595d636d9b0066130adf717ef955e76a4a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Nov 2026 18:12:04 +0100 Subject: [PATCH] Mark inherited descriptors close-on-exec so none reach the sandbox --- src/errors.rs | 6 ++++++ src/inherited_fds.rs | 20 ++++++++++++++++++++ src/lib.rs | 2 ++ tests/e2e/mounts.rs | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 src/inherited_fds.rs diff --git a/src/errors.rs b/src/errors.rs index 9bf553e..b297c33 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -35,6 +35,7 @@ pub enum SandboxError { SeccompUnsupportedArch(String), PersistentTmpDir(PathBuf, String), ProcChildrenMissing, + InheritedFdsCloexec(std::io::Error), } impl std::fmt::Display for SandboxError { @@ -116,6 +117,10 @@ impl std::fmt::Display for SandboxError { f, "the kernel does not provide /proc//task//children (CONFIG_PROC_CHILDREN); agent-sandbox needs it to find the processes it must clean up" ), + Self::InheritedFdsCloexec(e) => write!( + f, + "cannot keep inherited file descriptors out of the sandbox (close_range with CLOSE_RANGE_CLOEXEC needs Linux 5.11+): {e}" + ), } } } @@ -128,6 +133,7 @@ impl std::error::Error for SandboxError { Self::Io(e) => Some(e), Self::ConfigRead { source, .. } => Some(source), Self::ConfigParse { source, .. } => Some(source), + Self::InheritedFdsCloexec(e) => Some(e), _ => None, } } diff --git a/src/inherited_fds.rs b/src/inherited_fds.rs new file mode 100644 index 0000000..6900de5 --- /dev/null +++ b/src/inherited_fds.rs @@ -0,0 +1,20 @@ +use std::io; + +use crate::SandboxError; + +const FIRST_FD_AFTER_STDIO: libc::c_uint = 3; + +pub fn keep_out_of_sandbox() -> Result<(), SandboxError> { + let result = unsafe { + libc::syscall( + libc::SYS_close_range, + FIRST_FD_AFTER_STDIO, + libc::c_uint::MAX, + libc::CLOSE_RANGE_CLOEXEC, + ) + }; + if result < 0 { + return Err(SandboxError::InheritedFdsCloexec(io::Error::last_os_error())); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 1e5f208..9e9f8c2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod config; mod env; mod errors; +mod inherited_fds; mod memfd; mod persistent_tmp; mod preflight; @@ -99,6 +100,7 @@ fn resolve_run_user_from_proc() -> Option { } pub fn run(config: SandboxConfig) -> Result { + inherited_fds::keep_out_of_sandbox()?; preflight::check(&config)?; let forwarded_signals = ForwardedSignals::detect(); diff --git a/tests/e2e/mounts.rs b/tests/e2e/mounts.rs index c8f4d10..0f7029a 100644 --- a/tests/e2e/mounts.rs +++ b/tests/e2e/mounts.rs @@ -1,4 +1,6 @@ use std::fs; +use std::os::fd::AsRawFd; +use std::os::unix::process::CommandExt; use std::process::Command; use tempfile::TempDir; @@ -496,6 +498,42 @@ fn persistent_tmp_leaks_no_descriptor_into_host_tmp() { ); } +#[test] +fn inherited_host_descriptor_does_not_reach_sandbox() { + const PASSED_FD: i32 = 9; + let host_dir = TempDir::new().unwrap(); + fs::write(host_dir.path().join("canary"), "host-secret").unwrap(); + let host_dir_handle = fs::File::open(host_dir.path()).unwrap(); + let host_dir_fd = host_dir_handle.as_raw_fd(); + + let mut sandbox = Sandbox::new(&[]); + sandbox.args([ + "--", + "bash", + "-c", + &format!("cat /proc/self/fd/{PASSED_FD}/canary 2>/dev/null; true"), + ]); + unsafe { + sandbox.pre_exec(move || match libc::dup2(host_dir_fd, PASSED_FD) { + -1 => Err(std::io::Error::last_os_error()), + _ => Ok(()), + }); + } + let output = sandbox + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !String::from_utf8_lossy(&output.stdout).contains("host-secret"), + "a descriptor inherited by agent-sandbox reaches the sandboxed command" + ); +} + #[test] fn persistent_tmp_is_noop_in_blacklist_mode() { let label = format!("e2e-bl-noop-{}-{}", std::process::id(), rand_suffix());