From c8f2d62b64cab9067924b2ae9a2875cc6ed56193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 2 Sep 2026 16:09:46 +0200 Subject: [PATCH] Expose the bwrap command line at /run/agent-sandbox inside every sandbox --- README.md | 6 +++ src/lib.rs | 2 + src/memfd.rs | 15 +++++++ src/sandbox.rs | 13 +++++-- src/sandbox_info.rs | 65 +++++++++++++++++++++++++++++++ src/seccomp.rs | 15 ++----- tests/e2e/main.rs | 1 + tests/e2e/mounts.rs | 10 +++++ tests/e2e/sandbox_info.rs | 82 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 src/memfd.rs create mode 100644 src/sandbox_info.rs create mode 100644 tests/e2e/sandbox_info.rs diff --git a/README.md b/README.md index 9b54760..fdaa41d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ In whitelist mode, the sandbox's `/tmp` and `/var/tmp` are a fresh tmpfs by defa Stale `/tmp/agent-sandbox-*` directories are not auto-cleaned — remove them by hand when you no longer need them. If `/tmp/agent-sandbox-` already exists owned by a different user, the sandbox refuses to start rather than risk hijacked writes. +## Detecting the sandbox from inside + +Every sandbox contains a read-only `/run/agent-sandbox/bwrap-args` holding the exact shell-quoted `bwrap` invocation that spawned it (identical to `--dry-run` output). Agents can test for the directory to learn they are sandboxed and read the file to see which paths are bound, which are masked, and whether the network is shared. + +A user `--rw`/`--ro` targeting `/run/agent-sandbox` overrides the directory, like any other built-in path. + ## Escape hatches When the agent needs access to something the sandbox blocks, use `--rw` or `--ro` for paths and `--setenv`/`--unsetenv` for env vars. User overrides always win over the built-in policies. diff --git a/src/lib.rs b/src/lib.rs index 986db15..043e72e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,9 +4,11 @@ pub mod cli; pub mod config; mod env; mod errors; +mod memfd; mod persistent_tmp; mod preflight; mod sandbox; +mod sandbox_info; mod seccomp; mod session_key; diff --git a/src/memfd.rs b/src/memfd.rs new file mode 100644 index 0000000..5b4def1 --- /dev/null +++ b/src/memfd.rs @@ -0,0 +1,15 @@ +use std::ffi::CStr; +use std::fs::File; +use std::os::fd::FromRawFd; + +use crate::SandboxError; + +pub fn create_inheritable(name: &CStr) -> Result { + // flags=0 omits MFD_CLOEXEC so the fd survives exec into bwrap + let raw_fd = unsafe { libc::memfd_create(name.as_ptr(), 0) }; + if raw_fd < 0 { + return Err(SandboxError::Io(std::io::Error::last_os_error())); + } + + Ok(unsafe { File::from_raw_fd(raw_fd) }) +} diff --git a/src/sandbox.rs b/src/sandbox.rs index 1a0ecac..aa8a3b3 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -7,6 +7,7 @@ use crate::agents; use crate::blacklist; use crate::env; use crate::persistent_tmp::PersistentTmpDirs; +use crate::sandbox_info::BwrapCommandLine; use crate::seccomp; use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode}; @@ -17,7 +18,9 @@ pub struct Bwrap { impl Bwrap { pub fn build(config: &SandboxConfig) -> Result { - build_command(config) + let (bwrap, command_line) = build_command(config)?; + command_line.fill(&bwrap.shell_quoted())?; + Ok(bwrap) } pub fn exec(mut self) -> io::Error { @@ -32,7 +35,7 @@ impl Bwrap { } } -fn build_command(config: &SandboxConfig) -> Result { +fn build_command(config: &SandboxConfig) -> Result<(Bwrap, BwrapCommandLine), SandboxError> { let mut cmd = Command::new("bwrap"); let hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist); @@ -54,6 +57,7 @@ fn build_command(config: &SandboxConfig) -> Result { persistent } }; + let command_line = BwrapCommandLine::add_to(&mut cmd)?; for path in agents::agent_rw_paths() { cmd.arg("--bind-try").arg(&path).arg(&path); @@ -86,10 +90,11 @@ fn build_command(config: &SandboxConfig) -> Result { .arg(&config.command) .args(&config.command_args); - Ok(Bwrap { + let bwrap = Bwrap { command: cmd, _persistent: persistent, - }) + }; + Ok((bwrap, command_line)) } fn add_env_policy(cmd: &mut Command, config: &SandboxConfig) { diff --git a/src/sandbox_info.rs b/src/sandbox_info.rs new file mode 100644 index 0000000..df27c6e --- /dev/null +++ b/src/sandbox_info.rs @@ -0,0 +1,65 @@ +use std::fs::File; +use std::io::{Seek, SeekFrom, Write}; +use std::os::fd::{AsRawFd, IntoRawFd}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::SandboxError; +use crate::memfd; + +pub const DIR: &str = "/run/agent-sandbox"; + +pub fn bwrap_command_line_path() -> PathBuf { + Path::new(DIR).join("bwrap-args") +} + +/// Created empty so its fd number can appear in the invocation it later holds. +pub struct BwrapCommandLine { + file: File, +} + +impl BwrapCommandLine { + pub fn add_to(cmd: &mut Command) -> Result { + let file = memfd::create_inheritable(c"agent-sandbox-bwrap-args")?; + + cmd.arg("--dir").arg(DIR); + cmd.arg("--ro-bind-data") + .arg(file.as_raw_fd().to_string()) + .arg(bwrap_command_line_path()); + + Ok(Self { file }) + } + + pub fn fill(mut self, invocation: &str) -> Result<(), SandboxError> { + self.file.write_all(invocation.as_bytes())?; + self.file.write_all(b"\n")?; + self.file.seek(SeekFrom::Start(0))?; + let _inherited_by_bwrap = self.file.into_raw_fd(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binds_memfd_read_only_and_fills_it_with_invocation() { + let mut cmd = Command::new("bwrap"); + let command_line = BwrapCommandLine::add_to(&mut cmd).unwrap(); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + + assert_eq!(args[0..2], ["--dir", DIR]); + assert_eq!(args[2], "--ro-bind-data"); + assert_eq!(args[4], "/run/agent-sandbox/bwrap-args"); + let fd: i32 = args[3].parse().unwrap(); + + command_line.fill("bwrap --dir /run/agent-sandbox").unwrap(); + + let content = std::fs::read_to_string(format!("/proc/self/fd/{fd}")).unwrap(); + assert_eq!(content, "bwrap --dir /run/agent-sandbox\n"); + } +} diff --git a/src/seccomp.rs b/src/seccomp.rs index e8a6b63..53a7a41 100644 --- a/src/seccomp.rs +++ b/src/seccomp.rs @@ -32,7 +32,7 @@ use std::collections::BTreeMap; use std::io::{Seek, SeekFrom, Write}; -use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; +use std::os::fd::{IntoRawFd, RawFd}; use std::str::FromStr; use seccompiler::{ @@ -42,6 +42,7 @@ use seccompiler::{ use syscalls::Sysno; use crate::SandboxError; +use crate::memfd; /// Syscall allowlist. Includes Podman's unconditional allow set (minus syscalls /// we deny on top, see module docs) plus arch-specific syscalls for the targets @@ -55,17 +56,7 @@ const ALLOWED_SYSCALLS: &[&str] = &include!("seccomp_allowlist.in"); pub fn write_program_to_memfd() -> Result { let bytes = build_program_bytes()?; - // Safety: memfd_create is a normal Linux syscall. We pass a valid C string - // and flags=0, so the fd is created without MFD_CLOEXEC and survives exec - // into bwrap. The name is only a debugging label (shows up as the symlink - // target in /proc//fd/). - let raw_fd = unsafe { libc::memfd_create(c"agent-sandbox-seccomp".as_ptr(), 0) }; - if raw_fd < 0 { - return Err(SandboxError::Io(std::io::Error::last_os_error())); - } - - // Safety: raw_fd is owned by us and currently uniquely held. - let mut file = unsafe { std::fs::File::from_raw_fd(raw_fd) }; + let mut file = memfd::create_inheritable(c"agent-sandbox-seccomp")?; file.write_all(&bytes)?; file.seek(SeekFrom::Start(0))?; // into_raw_fd consumes the File without closing the underlying fd. diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index f6c6006..4bfd3e8 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -5,4 +5,5 @@ mod env; mod modes; mod mounts; mod namespaces; +mod sandbox_info; mod seccomp; diff --git a/tests/e2e/mounts.rs b/tests/e2e/mounts.rs index b3548f2..f70aee8 100644 --- a/tests/e2e/mounts.rs +++ b/tests/e2e/mounts.rs @@ -373,6 +373,7 @@ fn blacklist_overlays_survive_absolute_var_run_symlink() { // layout inside the sandbox to reproduce on any host. let _guard = HostGlobsLock::for_scan(); let mut bwrap_args = build_bwrap_command(&["--blacklist", "--no-seccomp", "--", "true"]); + strip_command_line_bind(&mut bwrap_args); inject_absolute_var_run_symlink(&mut bwrap_args); let output = Command::new(&bwrap_args[0]) @@ -568,6 +569,15 @@ fn rand_suffix() -> String { format!("{nanos:08x}") } +// The bind reads from an fd that only exists in the agent-sandbox process. +fn strip_command_line_bind(bwrap_args: &mut Vec) { + let start = bwrap_args + .iter() + .position(|a| a == "--ro-bind-data") + .expect("dry-run output should bind the bwrap command line"); + bwrap_args.drain(start..start + 3); +} + fn inject_absolute_var_run_symlink(bwrap_args: &mut Vec) { assert_eq!(bwrap_args[1], "--ro-bind"); assert_eq!(bwrap_args[2], "/"); diff --git a/tests/e2e/sandbox_info.rs b/tests/e2e/sandbox_info.rs new file mode 100644 index 0000000..91521db --- /dev/null +++ b/tests/e2e/sandbox_info.rs @@ -0,0 +1,82 @@ +use crate::common::*; + +const BWRAP_COMMAND_LINE_PATH: &str = "/run/agent-sandbox/bwrap-args"; + +#[test] +fn whitelist_exposes_exact_bwrap_invocation() { + assert_exposes_exact_invocation(&[]); +} + +#[test] +fn blacklist_exposes_exact_bwrap_invocation() { + assert_exposes_exact_invocation(&["--blacklist"]); +} + +fn assert_exposes_exact_invocation(mode_args: &[&str]) { + let output = Sandbox::new(mode_args) + .args(["--", "cat", BWRAP_COMMAND_LINE_PATH]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "reading {BWRAP_COMMAND_LINE_PATH} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let argv = shlex::split(stdout.trim()).expect("file content is not valid shell"); + assert_eq!(argv[0], "bwrap"); + assert!( + argv.windows(3) + .any(|w| w[0] == "--ro-bind-data" && w[2] == BWRAP_COMMAND_LINE_PATH), + "invocation should include the bind of itself, got: {stdout}" + ); + let [separator, command, argument] = &argv[argv.len() - 3..] else { + unreachable!() + }; + assert_eq!(separator, "--"); + assert!( + command.ends_with("/cat"), + "resolved inner command, got: {command}" + ); + assert_eq!(argument, BWRAP_COMMAND_LINE_PATH); +} + +#[test] +fn bwrap_command_line_is_read_only() { + let output = Sandbox::new(&[]) + .args([ + "--", + "bash", + "-c", + &format!("echo x >> {BWRAP_COMMAND_LINE_PATH} && echo WRITABLE || echo READ_ONLY"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "READ_ONLY"); +} + +#[test] +fn user_rw_bind_can_override_run_agent_sandbox() { + let dir = tempfile::TempDir::new().unwrap(); + let dir_str = dir.path().to_str().unwrap(); + + let output = Sandbox::new(&["--rw", &format!("{dir_str}:/run/agent-sandbox")]) + .args([ + "--", + "bash", + "-c", + &format!("test -e {BWRAP_COMMAND_LINE_PATH} || echo GONE"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("GONE"), + "user --rw must win over the info directory, got: {stdout}" + ); +}