diff --git a/src/blacklist.rs b/src/blacklist.rs index 93a0875..7c406c7 100644 --- a/src/blacklist.rs +++ b/src/blacklist.rs @@ -43,7 +43,7 @@ pub fn resolve_path_context() -> Result { let run_user = std::env::var("XDG_RUNTIME_DIR") .ok() .or_else(resolve_run_user_from_proc) - .unwrap_or_default(); + .ok_or(SandboxError::RunUserNotFound)?; Ok(PathContext { home, run_user }) } diff --git a/src/errors.rs b/src/errors.rs index bf6e7a4..157e8d2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; #[derive(Debug)] pub enum SandboxError { HomeNotSet, + RunUserNotFound, BwrapNotFound, CommandNotFound(PathBuf), CommandNotExecutable(PathBuf), @@ -21,6 +22,10 @@ impl std::fmt::Display for SandboxError { f, "$HOME is not set; cannot determine which paths to protect" ), + Self::RunUserNotFound => write!( + f, + "cannot determine XDG_RUNTIME_DIR; tried $XDG_RUNTIME_DIR and /proc/self/status" + ), Self::BwrapNotFound => write!( f, "bwrap not found; install bubblewrap (e.g. `apt install bubblewrap` or `pacman -S bubblewrap`)" diff --git a/src/lib.rs b/src/lib.rs index 00bf34c..c7e1dae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ mod agents; -mod blacklist; +pub mod blacklist; mod errors; mod preflight; mod sandbox; diff --git a/src/sandbox.rs b/src/sandbox.rs index 6ca0915..1933a09 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -62,7 +62,6 @@ fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> { cmd.args(["--proc", "/proc"]); cmd.args(["--bind", "/tmp", "/tmp"]); cmd.args(["--bind", "/var/tmp", "/var/tmp"]); - cmd.args(["--bind", "/run", "/run"]); let overlays = blacklist::resolve_overlays(&ctx)?; for dir in &overlays.tmpfs_dirs { @@ -72,6 +71,26 @@ fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> { cmd.arg("--ro-bind").arg("/dev/null").arg(file); } + cmd.args(["--tmpfs", "/run"]); + ro_bind_under_tmpfs( + cmd, + "/run", + &[ + "/run/dbus/system_bus_socket", + "/run/systemd/resolve", + "/run/systemd/journal", + "/run/log/journal", + "/run/udev", + "/run/NetworkManager/resolv.conf", + "/run/media", + ], + ); + + ensure_parent_dirs(cmd, "/run", &ctx.run_user); + cmd.arg("--tmpfs").arg(&ctx.run_user); + let run_user_bus = format!("{}/bus", ctx.run_user); + ro_bind_under_tmpfs(cmd, &ctx.run_user, &[&run_user_bus]); + Ok(()) } @@ -127,6 +146,37 @@ fn add_whitelist_mode(cmd: &mut Command) -> Result<(), SandboxError> { Ok(()) } +fn ensure_parent_dirs(cmd: &mut Command, base: &str, path: &str) { + let base = Path::new(base); + let ancestors: Vec<_> = Path::new(path) + .ancestors() + .skip(1) + .take_while(|a| *a != base) + .collect(); + for dir in ancestors.into_iter().rev() { + cmd.arg("--dir").arg(dir); + } +} + +fn ro_bind_under_tmpfs(cmd: &mut Command, base: &str, paths: &[&str]) { + let base = Path::new(base); + let mut dirs_created = std::collections::HashSet::new(); + + for path in paths { + let ancestors: Vec<_> = Path::new(path) + .ancestors() + .skip(1) // skip the path itself + .take_while(|a| *a != base) + .filter(|a| dirs_created.insert(a.to_path_buf())) + .collect(); + + for dir in ancestors.into_iter().rev() { + cmd.arg("--dir").arg(dir); + } + cmd.args(["--ro-bind-try", path, path]); + } +} + fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { if !path.exists() { return Err(SandboxError::RwPathMissing(path.to_path_buf())); diff --git a/tests/integration.rs b/tests/integration.rs index a446301..edcb9da 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,6 +1,7 @@ use std::fs; use std::process::Command; +use agent_sandbox::blacklist; use tempfile::TempDir; fn sandbox(extra_args: &[&str]) -> Command { @@ -350,6 +351,62 @@ fn new_session_isolates_sid() { ); } +#[test] +fn blacklist_run_is_tmpfs() { + let output = sandbox(&[]) + .args([ + "--", + "bash", + "-c", + "touch /run/test_canary 2>&1 && echo WRITABLE || echo BLOCKED", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("WRITABLE"), + "expected /run to be a writable tmpfs in blacklist mode, got: {stdout}" + ); +} + +#[test] +fn blacklist_run_dbus_socket_accessible() { + let output = sandbox(&[]) + .args([ + "--", + "bash", + "-c", + "test -e /run/dbus/system_bus_socket && echo EXISTS || echo MISSING", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "EXISTS", + "expected /run/dbus/system_bus_socket to be accessible in blacklist mode" + ); +} + +#[test] +fn blacklist_runuser_is_tmpfs() { + let ctx = blacklist::resolve_path_context().expect("failed to resolve path context"); + let script = format!("ls -A {} | grep -v '^bus$'", ctx.run_user); + + let output = sandbox(&[]) + .args(["--", "bash", "-c", &script]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert!( + stdout.is_empty(), + "expected only 'bus' (or empty) in {}, got unexpected entries: {stdout}", + ctx.run_user + ); +} + #[test] fn rw_missing_path_errors() { let output = sandbox(&["--rw", "/nonexistent/xyz"])