Expose the bwrap command line at /run/agent-sandbox inside every sandbox
This commit is contained in:
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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<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 {
|
||||
@@ -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 hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist);
|
||||
|
||||
@@ -54,6 +57,7 @@ fn build_command(config: &SandboxConfig) -> Result<Bwrap, SandboxError> {
|
||||
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<Bwrap, SandboxError> {
|
||||
.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) {
|
||||
|
||||
@@ -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
@@ -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<RawFd, SandboxError> {
|
||||
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/<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) };
|
||||
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.
|
||||
|
||||
@@ -5,4 +5,5 @@ mod env;
|
||||
mod modes;
|
||||
mod mounts;
|
||||
mod namespaces;
|
||||
mod sandbox_info;
|
||||
mod seccomp;
|
||||
|
||||
@@ -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<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>) {
|
||||
assert_eq!(bwrap_args[1], "--ro-bind");
|
||||
assert_eq!(bwrap_args[2], "/");
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user