Expose the bwrap command line at /run/agent-sandbox inside every sandbox

This commit is contained in:
2026-09-02 16:09:46 +02:00
parent 9185d1fb26
commit c8f2d62b64
9 changed files with 193 additions and 16 deletions
+6
View File
@@ -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-<key>` already exists owned by a different user, the sandbox refuses to start rather than risk hijacked writes. Stale `/tmp/agent-sandbox-*` directories are not auto-cleaned — remove them by hand when you no longer need them. If `/tmp/agent-sandbox-<key>` 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 ## 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. 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.
+2
View File
@@ -4,9 +4,11 @@ pub mod cli;
pub mod config; pub mod config;
mod env; mod env;
mod errors; mod errors;
mod memfd;
mod persistent_tmp; mod persistent_tmp;
mod preflight; mod preflight;
mod sandbox; mod sandbox;
mod sandbox_info;
mod seccomp; mod seccomp;
mod session_key; mod session_key;
+15
View File
@@ -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<File, SandboxError> {
// 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) })
}
+9 -4
View File
@@ -7,6 +7,7 @@ use crate::agents;
use crate::blacklist; use crate::blacklist;
use crate::env; use crate::env;
use crate::persistent_tmp::PersistentTmpDirs; use crate::persistent_tmp::PersistentTmpDirs;
use crate::sandbox_info::BwrapCommandLine;
use crate::seccomp; use crate::seccomp;
use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode}; use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode};
@@ -17,7 +18,9 @@ pub struct Bwrap {
impl Bwrap { impl Bwrap {
pub fn build(config: &SandboxConfig) -> Result<Self, SandboxError> { pub fn build(config: &SandboxConfig) -> Result<Self, SandboxError> {
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 { pub fn exec(mut self) -> io::Error {
@@ -32,7 +35,7 @@ impl Bwrap {
} }
} }
fn build_command(config: &SandboxConfig) -> Result<Bwrap, SandboxError> { fn build_command(config: &SandboxConfig) -> Result<(Bwrap, BwrapCommandLine), SandboxError> {
let mut cmd = Command::new("bwrap"); let mut cmd = Command::new("bwrap");
let hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist); let hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist);
@@ -54,6 +57,7 @@ fn build_command(config: &SandboxConfig) -> Result<Bwrap, SandboxError> {
persistent persistent
} }
}; };
let command_line = BwrapCommandLine::add_to(&mut cmd)?;
for path in agents::agent_rw_paths() { for path in agents::agent_rw_paths() {
cmd.arg("--bind-try").arg(&path).arg(&path); cmd.arg("--bind-try").arg(&path).arg(&path);
@@ -86,10 +90,11 @@ fn build_command(config: &SandboxConfig) -> Result<Bwrap, SandboxError> {
.arg(&config.command) .arg(&config.command)
.args(&config.command_args); .args(&config.command_args);
Ok(Bwrap { let bwrap = Bwrap {
command: cmd, command: cmd,
_persistent: persistent, _persistent: persistent,
}) };
Ok((bwrap, command_line))
} }
fn add_env_policy(cmd: &mut Command, config: &SandboxConfig) { fn add_env_policy(cmd: &mut Command, config: &SandboxConfig) {
+65
View File
@@ -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<Self, SandboxError> {
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<String> = 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");
}
}
+3 -12
View File
@@ -32,7 +32,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io::{Seek, SeekFrom, Write}; use std::io::{Seek, SeekFrom, Write};
use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; use std::os::fd::{IntoRawFd, RawFd};
use std::str::FromStr; use std::str::FromStr;
use seccompiler::{ use seccompiler::{
@@ -42,6 +42,7 @@ use seccompiler::{
use syscalls::Sysno; use syscalls::Sysno;
use crate::SandboxError; use crate::SandboxError;
use crate::memfd;
/// Syscall allowlist. Includes Podman's unconditional allow set (minus syscalls /// Syscall allowlist. Includes Podman's unconditional allow set (minus syscalls
/// we deny on top, see module docs) plus arch-specific syscalls for the targets /// 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<RawFd, SandboxError> { pub fn write_program_to_memfd() -> Result<RawFd, SandboxError> {
let bytes = build_program_bytes()?; let bytes = build_program_bytes()?;
// Safety: memfd_create is a normal Linux syscall. We pass a valid C string let mut file = memfd::create_inheritable(c"agent-sandbox-seccomp")?;
// 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/<pid>/fd/<n>).
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) };
file.write_all(&bytes)?; file.write_all(&bytes)?;
file.seek(SeekFrom::Start(0))?; file.seek(SeekFrom::Start(0))?;
// into_raw_fd consumes the File without closing the underlying fd. // into_raw_fd consumes the File without closing the underlying fd.
+1
View File
@@ -5,4 +5,5 @@ mod env;
mod modes; mod modes;
mod mounts; mod mounts;
mod namespaces; mod namespaces;
mod sandbox_info;
mod seccomp; mod seccomp;
+10
View File
@@ -373,6 +373,7 @@ fn blacklist_overlays_survive_absolute_var_run_symlink() {
// layout inside the sandbox to reproduce on any host. // layout inside the sandbox to reproduce on any host.
let _guard = HostGlobsLock::for_scan(); let _guard = HostGlobsLock::for_scan();
let mut bwrap_args = build_bwrap_command(&["--blacklist", "--no-seccomp", "--", "true"]); 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); inject_absolute_var_run_symlink(&mut bwrap_args);
let output = Command::new(&bwrap_args[0]) let output = Command::new(&bwrap_args[0])
@@ -568,6 +569,15 @@ fn rand_suffix() -> String {
format!("{nanos:08x}") 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<String>) {
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<String>) { fn inject_absolute_var_run_symlink(bwrap_args: &mut Vec<String>) {
assert_eq!(bwrap_args[1], "--ro-bind"); assert_eq!(bwrap_args[1], "--ro-bind");
assert_eq!(bwrap_args[2], "/"); assert_eq!(bwrap_args[2], "/");
+82
View File
@@ -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}"
);
}