diff --git a/Cargo.lock b/Cargo.lock index 24ac26e..a1aa812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "agent-sandbox" version = "0.1.0" dependencies = [ + "blake3", "clap", "glob", "libc", @@ -73,12 +74,48 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -131,6 +168,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -153,6 +205,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 237173e..812d92b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ name = "e2e" path = "tests/e2e/main.rs" [dependencies] +blake3 = "1" clap = { version = "4", features = ["derive"] } glob = "0.3" libc = "0.2" diff --git a/README.md b/README.md index a62654f..9b54760 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,16 @@ rw = ["/var/run/docker.sock"] command = ["claude", "--dangerously-skip-permissions"] ``` +## Persistent state + +In whitelist mode, the sandbox's `/tmp` and `/var/tmp` are a fresh tmpfs by default. Pass `--persistent-tmp` (or set `persistent-tmp = true`) to back both with stable host directories at `/tmp/agent-sandbox-` and `/var/tmp/agent-sandbox-`. The host dirs are created on demand (mode `0700`, owned by the invoking user) and as such inherit the host's cleanup policy (typical `systemd-tmpfiles` defaults: `/tmp` cleared on reboot + 10d age-out, `/var/tmp` survives reboot + 30d age-out). + +`` is derived from `profile + canonical cwd`, so different profiles and different working directories each get their own state. Override with `--persistent-key=LABEL` (or `persistent-key` in the config) to share state across profiles or to keep state after moving the project directory. + +`persistent-tmp` is a no-op in blacklist mode — blacklist already binds the host's `/tmp` and `/var/tmp`, so they persist on the host filesystem with no extra opt-in. + +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. + ## 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/config-example.toml b/config-example.toml index ed4b46a..04f2331 100644 --- a/config-example.toml +++ b/config-example.toml @@ -34,6 +34,12 @@ rw = [ ] # mask = ["~/.ssh"] # hide path with tmpfs/over /dev/null +persistent-tmp = true # back /tmp and /var/tmp with host dirs that survive + # across runs (key derived from profile+cwd). + # No-op in blacklist mode (which already shares host /tmp). +# persistent-key = "shared" # override the derived key — e.g. to share state + # across profiles or after moving the project dir + env = [ "XDG_RUNTIME_DIR", # KEY -> pass through from host if set # "DEBUG=", # KEY= -> set to empty string diff --git a/src/cli.rs b/src/cli.rs index bdb49c9..3b5014e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -66,6 +66,18 @@ pub struct Args { #[arg(long, overrides_with = "dry_run")] pub no_dry_run: bool, + /// Back /tmp and /var/tmp with persistent host directories + #[arg(long, overrides_with = "no_persistent_tmp")] + pub persistent_tmp: bool, + + /// Disable persistent /tmp (overrides config-file `persistent-tmp = true`) + #[arg(long, overrides_with = "persistent_tmp")] + pub no_persistent_tmp: bool, + + /// Override the derived session key with an explicit label + #[arg(long = "persistent-key", value_name = "LABEL")] + pub persistent_key: Option, + /// Working directory inside the sandbox (default: current directory) #[arg(long, value_name = "PATH")] pub chdir: Option, diff --git a/src/config.rs b/src/config.rs index 2c50d64..d9f1f25 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::cli::Args; +use crate::session_key::SessionKey; use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode}; pub fn build(args: Args, file_config: Option) -> Result { @@ -13,72 +14,120 @@ pub fn build(args: Args, file_config: Option) -> Result (Options::default(), Options::default()), + None => ( + Options::default(), + ResolvedProfile { + name: args.profile.clone(), + options: Options::default(), + }, + ), }; validate_mode(&globals)?; - validate_mode(&profile)?; + validate_mode(&profile.options)?; globals.validate_paths()?; - profile.validate_paths()?; + profile.options.validate_paths()?; - let (command, command_args) = - resolve_command(args.entrypoint, args.command_and_args, &profile, &globals)?; + let mode = merge_mode(args.blacklist, args.whitelist, &profile.options, &globals); + let chdir = resolve_chdir( + args.chdir.clone(), + profile.options.chdir.clone(), + globals.chdir.clone(), + )?; + let persistent_tmp_key = resolve_persistent_tmp_key(&args, &profile, &globals, &mode, &chdir); + + let (command, command_args) = resolve_command( + args.entrypoint, + args.command_and_args, + &profile.options, + &globals, + )?; let command = resolve_binary(&command) .ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?; let env = dedupe_env_last_wins(parse_env_entries(&merge_vecs( args.env, - &profile.env, + &profile.options.env, &globals.env, ))?); - let unsetenv = merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv); + let unsetenv = merge_vecs(args.unsetenv, &profile.options.unsetenv, &globals.unsetenv); reject_env_key_conflicts(&env, &unsetenv)?; Ok(SandboxConfig { - mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals), + mode, hardened: merge_flag( merge_flag_pair(args.hardened, args.no_hardened), - profile.hardened, + profile.options.hardened, globals.hardened, ), unshare_net: merge_flag( merge_flag_pair(args.unshare_net, args.share_net), - profile.unshare_net, + profile.options.unshare_net, globals.unshare_net, ), seccomp: merge_flag_with_default( merge_flag_pair(args.seccomp, args.no_seccomp), - profile.seccomp, + profile.options.seccomp, globals.seccomp, true, ), env_filter: merge_flag_with_default( merge_flag_pair(args.env_filter, args.no_env_filter), - profile.env_filter, + profile.options.env_filter, globals.env_filter, true, ), dry_run: merge_flag( merge_flag_pair(args.dry_run, args.no_dry_run), - profile.dry_run, + profile.options.dry_run, globals.dry_run, ), - chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?, - extra_rw: merge_bind_specs(args.extra_rw, &profile.rw, &globals.rw)?, - extra_ro: merge_bind_specs(args.extra_ro, &profile.ro, &globals.ro)?, - mask: merge_vecs(args.mask, &profile.mask, &globals.mask), + chdir, + extra_rw: merge_bind_specs(args.extra_rw, &profile.options.rw, &globals.rw)?, + extra_ro: merge_bind_specs(args.extra_ro, &profile.options.ro, &globals.ro)?, + mask: merge_vecs(args.mask, &profile.options.mask, &globals.mask), env, unsetenv, bwrap_args: split_bwrap_args(merge_vecs( args.bwrap_args, - &profile.bwrap_args, + &profile.options.bwrap_args, &globals.bwrap_args, ))?, command, command_args, + persistent_tmp_key, }) } +fn resolve_persistent_tmp_key( + args: &Args, + profile: &ResolvedProfile, + globals: &Options, + mode: &SandboxMode, + chdir: &Path, +) -> Option { + if matches!(mode, SandboxMode::Blacklist) { + return None; + } + + let enabled = merge_flag( + merge_flag_pair(args.persistent_tmp, args.no_persistent_tmp), + profile.options.persistent_tmp, + globals.persistent_tmp, + ); + if !enabled { + return None; + } + let label = args + .persistent_key + .clone() + .or_else(|| profile.options.persistent_key.clone()) + .or_else(|| globals.persistent_key.clone()); + Some( + label.unwrap_or_else(|| SessionKey::default().derive(profile.name.as_deref(), mode, chdir)), + ) +} + fn merge_mode( cli_blacklist: bool, cli_whitelist: bool, @@ -297,6 +346,12 @@ pub struct FileConfig { _unknown: HashMap, } +#[derive(Default)] +pub struct ResolvedProfile { + pub name: Option, + pub options: Options, +} + impl FileConfig { pub fn load(path: &Path) -> Result { Self::load_file(path)?.load_extra() @@ -364,15 +419,22 @@ impl FileConfig { Ok(config) } - fn resolve_profile(&self, selected: Option<&str>) -> Result { - match selected.or(self.options.profile.as_deref()) { - Some(leaf) => self.resolve_chain(leaf), - None => Ok(Options::default()), - } + fn resolve_profile(&self, selected: Option<&str>) -> Result { + let Some(name) = selected + .map(String::from) + .or_else(|| self.options.profile.clone()) + else { + return Ok(ResolvedProfile::default()); + }; + let options = self.resolve_chain(&name)?; + Ok(ResolvedProfile { + name: Some(name), + options, + }) } - fn resolve_chain(&self, leaf: &str) -> Result { - let chain = self.collect_chain(leaf)?; + fn resolve_chain(&self, name: &str) -> Result { + let chain = self.collect_chain(name)?; let merged = chain .into_iter() @@ -382,10 +444,10 @@ impl FileConfig { Ok(merged) } - fn collect_chain(&self, leaf: &str) -> Result, SandboxError> { + fn collect_chain(&self, name: &str) -> Result, SandboxError> { let mut visited: Vec = Vec::new(); let mut chain: Vec = Vec::new(); - let mut current = leaf.to_string(); + let mut current = name.to_string(); loop { if visited.iter().any(|seen| seen == ¤t) { @@ -424,6 +486,8 @@ pub struct Options { pub entrypoint: Option, pub command: Option, pub dry_run: Option, + pub persistent_tmp: Option, + pub persistent_key: Option, pub chdir: Option, #[serde(default)] pub rw: Vec, @@ -453,6 +517,8 @@ impl Options { entrypoint: extra.entrypoint.or(self.entrypoint), command: extra.command.or(self.command), dry_run: extra.dry_run.or(self.dry_run), + persistent_tmp: extra.persistent_tmp.or(self.persistent_tmp), + persistent_key: extra.persistent_key.or(self.persistent_key), chdir: extra.chdir.or(self.chdir), rw: append(self.rw, extra.rw), ro: append(self.ro, extra.ro), diff --git a/src/errors.rs b/src/errors.rs index daddc01..9678879 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -33,6 +33,7 @@ pub enum SandboxError { NoCommand, Seccomp(String), SeccompUnsupportedArch(String), + PersistentTmpDir(PathBuf, String), } impl std::fmt::Display for SandboxError { @@ -107,6 +108,9 @@ impl std::fmt::Display for SandboxError { f, "seccomp filtering is not supported on this architecture: {arch} (use --no-seccomp to disable)" ), + Self::PersistentTmpDir(path, reason) => { + write!(f, "persistent-tmp directory '{}': {reason}", path.display()) + } } } } diff --git a/src/lib.rs b/src/lib.rs index 2522324..986db15 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,17 +4,20 @@ pub mod cli; pub mod config; mod env; mod errors; +mod persistent_tmp; mod preflight; mod sandbox; mod seccomp; +mod session_key; pub use errors::SandboxError; use std::ffi::OsString; use std::fs; -use std::os::unix::process::CommandExt; use std::path::PathBuf; +use crate::sandbox::Bwrap; + pub enum SandboxMode { Blacklist, Whitelist, @@ -57,6 +60,7 @@ pub struct SandboxConfig { pub command_args: Vec, pub chdir: PathBuf, pub dry_run: bool, + pub persistent_tmp_key: Option, } pub fn require_home() -> Result { @@ -87,19 +91,12 @@ fn resolve_run_user_from_proc() -> Option { pub fn run(config: SandboxConfig) -> Result<(), SandboxError> { preflight::check(&config)?; - let mut cmd = sandbox::build_command(&config)?; + let bwrap = Bwrap::build(&config)?; if config.dry_run { - println!("{}", shell_quote_command(&cmd)); + println!("{}", bwrap.shell_quoted()); return Ok(()); } - Err(SandboxError::Io(cmd.exec())) -} - -fn shell_quote_command(cmd: &std::process::Command) -> String { - let prog = cmd.get_program().to_string_lossy(); - let args = cmd.get_args().map(|a| a.to_string_lossy()); - let all: Vec<_> = std::iter::once(prog).chain(args).collect(); - shlex::try_join(all.iter().map(|s| s.as_ref())).unwrap() + Err(SandboxError::Io(bwrap.exec())) } diff --git a/src/persistent_tmp.rs b/src/persistent_tmp.rs new file mode 100644 index 0000000..9968923 --- /dev/null +++ b/src/persistent_tmp.rs @@ -0,0 +1,173 @@ +use std::fs::{DirBuilder, File, OpenOptions}; +use std::io; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; +use std::path::PathBuf; + +use crate::SandboxConfig; +use crate::errors::SandboxError; + +pub struct PersistentTmpDirs { + pub tmp: PathBuf, + pub var_tmp: PathBuf, + // Held to keep `/proc/self/fd/` bind sources valid through `exec()`. + _keep_fds: Vec, +} + +impl PersistentTmpDirs { + pub fn resolve(config: &SandboxConfig) -> Result, SandboxError> { + let Some(key) = &config.persistent_tmp_key else { + return Ok(None); + }; + + let tmp = PersistentTmpDir::new(format!("/tmp/agent-sandbox-{key}")); + let var_tmp = PersistentTmpDir::new(format!("/var/tmp/agent-sandbox-{key}")); + + if config.dry_run { + return Ok(Some(Self { + tmp: tmp.host_path, + var_tmp: var_tmp.host_path, + _keep_fds: Vec::new(), + })); + } + + let tmp_fd = tmp.open()?; + let var_tmp_fd = var_tmp.open()?; + Ok(Some(Self { + tmp: format!("/proc/self/fd/{}", tmp_fd.as_raw_fd()).into(), + var_tmp: format!("/proc/self/fd/{}", var_tmp_fd.as_raw_fd()).into(), + _keep_fds: vec![tmp_fd, var_tmp_fd], + })) + } +} + +pub struct PersistentTmpDir { + pub host_path: PathBuf, +} + +impl PersistentTmpDir { + pub fn new(path: impl Into) -> Self { + Self { + host_path: path.into(), + } + } + + pub fn open(&self) -> Result { + self.create_if_missing()?; + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) + .open(&self.host_path) + .map_err(|e| self.error(format!("open: {e}")))?; + self.verify_owner(&file)?; + let fd: OwnedFd = file.into(); + clear_cloexec(&fd).map_err(|e| self.error(format!("clear FD_CLOEXEC: {e}")))?; + Ok(fd) + } + + fn create_if_missing(&self) -> Result<(), SandboxError> { + match DirBuilder::new().mode(0o700).create(&self.host_path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(self.error(format!("create: {e}"))), + } + } + + fn verify_owner(&self, file: &File) -> Result<(), SandboxError> { + let meta = file + .metadata() + .map_err(|e| self.error(format!("stat: {e}")))?; + let euid = unsafe { libc::geteuid() }; + if meta.uid() != euid { + return Err(self.error(format!("owned by uid {} (expected {euid})", meta.uid()))); + } + Ok(()) + } + + fn error(&self, reason: String) -> SandboxError { + SandboxError::PersistentTmpDir(self.host_path.clone(), reason) + } +} + +fn clear_cloexec(fd: &OwnedFd) -> io::Result<()> { + let raw = fd.as_raw_fd(); + let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(raw, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::process::Command; + use tempfile::TempDir; + + #[test] + fn creates_dir_on_first_open_with_mode_0700() { + let scratch = TempDir::new().unwrap(); + let dir = PersistentTmpDir::new(scratch.path().join("new")); + let _fd = dir.open().unwrap(); + let mode = fs::metadata(&dir.host_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + } + + #[test] + fn second_open_preserves_contents() { + let scratch = TempDir::new().unwrap(); + let dir = PersistentTmpDir::new(scratch.path().join("reuse")); + let _fd1 = dir.open().unwrap(); + let marker = dir.host_path.join("marker"); + fs::write(&marker, "x").unwrap(); + let _fd2 = dir.open().unwrap(); + assert!(marker.exists()); + } + + #[test] + fn rejects_regular_file_at_path() { + let scratch = TempDir::new().unwrap(); + let path = scratch.path().join("plain-file"); + fs::write(&path, "x").unwrap(); + assert!(matches!( + PersistentTmpDir::new(path).open(), + Err(SandboxError::PersistentTmpDir(_, _)) + )); + } + + #[test] + fn rejects_symlink_at_path() { + let scratch = TempDir::new().unwrap(); + let real = scratch.path().join("real"); + fs::create_dir(&real).unwrap(); + let link = scratch.path().join("link"); + symlink(&real, &link).unwrap(); + assert!(matches!( + PersistentTmpDir::new(link).open(), + Err(SandboxError::PersistentTmpDir(_, _)) + )); + } + + #[test] + fn fd_survives_fork_and_exec() { + let scratch = TempDir::new().unwrap(); + let dir = PersistentTmpDir::new(scratch.path().join("fd")); + let fd = dir.open().unwrap(); + let raw = fd.as_raw_fd(); + let output = Command::new("bash") + .arg("-c") + .arg(format!("test -e /proc/self/fd/{raw}")) + .output() + .unwrap(); + assert!( + output.status.success(), + "fd {raw} should be inherited across exec; stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/src/sandbox.rs b/src/sandbox.rs index 63af62f..d52a43c 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -1,13 +1,38 @@ +use std::io; +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; use crate::agents; use crate::blacklist; use crate::env; +use crate::persistent_tmp::PersistentTmpDirs; use crate::seccomp; use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode}; -pub fn build_command(config: &SandboxConfig) -> Result { +pub struct Bwrap { + command: Command, + _persistent: Option, +} + +impl Bwrap { + pub fn build(config: &SandboxConfig) -> Result { + build_command(config) + } + + pub fn exec(mut self) -> io::Error { + self.command.exec() + } + + pub fn shell_quoted(&self) -> String { + let prog = self.command.get_program().to_string_lossy(); + let args = self.command.get_args().map(|a| a.to_string_lossy()); + let all: Vec<_> = std::iter::once(prog).chain(args).collect(); + shlex::try_join(all.iter().map(|s| s.as_ref())).unwrap() + } +} + +fn build_command(config: &SandboxConfig) -> Result { let mut cmd = Command::new("bwrap"); let hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist); @@ -18,10 +43,17 @@ pub fn build_command(config: &SandboxConfig) -> Result { cmd.arg("--unshare-net"); } - match config.mode { - SandboxMode::Blacklist => add_blacklist_mode(&mut cmd)?, - SandboxMode::Whitelist => add_whitelist_mode(&mut cmd)?, - } + let persistent = match config.mode { + SandboxMode::Blacklist => { + add_blacklist_mode(&mut cmd)?; + None + } + SandboxMode::Whitelist => { + let persistent = PersistentTmpDirs::resolve(config)?; + add_whitelist_mode(&mut cmd, persistent.as_ref())?; + persistent + } + }; for path in agents::agent_rw_paths() { cmd.arg("--bind-try").arg(&path).arg(&path); @@ -54,7 +86,10 @@ pub fn build_command(config: &SandboxConfig) -> Result { .arg(&config.command) .args(&config.command_args); - Ok(cmd) + Ok(Bwrap { + command: cmd, + _persistent: persistent, + }) } fn add_env_policy(cmd: &mut Command, config: &SandboxConfig) { @@ -140,7 +175,10 @@ fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> { Ok(()) } -fn add_whitelist_mode(cmd: &mut Command) -> Result<(), SandboxError> { +fn add_whitelist_mode( + cmd: &mut Command, + persistent: Option<&PersistentTmpDirs>, +) -> Result<(), SandboxError> { let home = crate::require_home()?; cmd.args(["--ro-bind", "/usr", "/usr"]); @@ -178,8 +216,16 @@ fn add_whitelist_mode(cmd: &mut Command) -> Result<(), SandboxError> { let cache_dir = format!("{home}/.cache"); cmd.arg("--tmpfs").arg(&cache_dir); - cmd.args(["--tmpfs", "/tmp"]); - cmd.args(["--tmpfs", "/var/tmp"]); + match persistent { + Some(p) => { + cmd.arg("--bind").arg(&p.tmp).arg("/tmp"); + cmd.arg("--bind").arg(&p.var_tmp).arg("/var/tmp"); + } + None => { + cmd.args(["--tmpfs", "/tmp"]); + cmd.args(["--tmpfs", "/var/tmp"]); + } + } cmd.args(["--dev", "/dev"]); cmd.args(["--tmpfs", "/dev/shm"]); cmd.args(["--tmpfs", "/run"]); diff --git a/src/session_key.rs b/src/session_key.rs new file mode 100644 index 0000000..5818d79 --- /dev/null +++ b/src/session_key.rs @@ -0,0 +1,68 @@ +use std::path::Path; + +use crate::SandboxMode; + +pub struct SessionKey { + pub default_profile: &'static str, + pub hex_len: usize, +} + +impl Default for SessionKey { + fn default() -> Self { + Self { + default_profile: "", + hex_len: 12, + } + } +} + +impl SessionKey { + pub fn derive( + &self, + profile: Option<&str>, + mode: &SandboxMode, + canonical_cwd: &Path, + ) -> String { + let profile = profile.unwrap_or(self.default_profile); + let mode = match mode { + SandboxMode::Blacklist => "blacklist", + SandboxMode::Whitelist => "whitelist", + }; + let cwd = canonical_cwd.to_string_lossy(); + + let mut hasher = blake3::Hasher::new(); + hasher.update(profile.as_bytes()); + hasher.update(b"\0"); + hasher.update(mode.as_bytes()); + hasher.update(b"\0"); + hasher.update(cwd.as_bytes()); + + let hex = hasher.finalize().to_hex(); + hex[..self.hex_len].to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_profile_uses_default_label() { + let sk = SessionKey::default(); + let cwd = Path::new("/x"); + assert_eq!( + sk.derive(None, &SandboxMode::Whitelist, cwd), + sk.derive(Some(sk.default_profile), &SandboxMode::Whitelist, cwd), + ); + } + + #[test] + fn key_respects_configured_hex_len() { + let sk = SessionKey { + hex_len: 8, + ..SessionKey::default() + }; + let key = sk.derive(Some("foo"), &SandboxMode::Whitelist, Path::new("/x")); + assert_eq!(key.len(), 8); + } +} diff --git a/tests/e2e/mounts.rs b/tests/e2e/mounts.rs index e74a82d..b3548f2 100644 --- a/tests/e2e/mounts.rs +++ b/tests/e2e/mounts.rs @@ -5,16 +5,29 @@ use tempfile::TempDir; use crate::common::*; -struct CleanupFile(&'static str); +struct CleanupPath(String); -impl Drop for CleanupFile { +impl CleanupPath { + fn new(path: impl Into) -> Self { + Self(path.into()) + } +} + +impl Drop for CleanupPath { fn drop(&mut self) { - let _ = fs::remove_file(self.0); + match std::path::Path::new(&self.0) { + p if p.is_dir() => { + let _ = fs::remove_dir_all(p); + } + p => { + let _ = fs::remove_file(p); + } + } } } #[test] fn cwd_is_writable() { - let _cleanup = CleanupFile("./sandbox_canary"); + let _cleanup = CleanupPath::new("./sandbox_canary"); let output = Sandbox::new(&[]) .args(["--", "bash", "-c", "touch ./sandbox_canary && echo ok"]) @@ -166,7 +179,7 @@ fn rw_refines_ro_parent() { fn blacklist_overlays_survive_tmp_bind() { let mut sandbox = Sandbox::new_for_host_mutation(&["--blacklist"]); fs::write("/tmp/ssh-sandbox-test", "secret").expect("failed to write sentinel"); - let _cleanup = CleanupFile("/tmp/ssh-sandbox-test"); + let _cleanup = CleanupPath::new("/tmp/ssh-sandbox-test"); let output = sandbox .args([ @@ -385,6 +398,176 @@ fn build_bwrap_command(sandbox_args: &[&str]) -> Vec { parsed } +#[test] +fn persistent_tmp_dry_run_uses_session_path_and_creates_no_dirs() { + let label = format!("e2e-dry-{}-{}", std::process::id(), rand_suffix()); + let output = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label, "--dry-run"]) + .args(["--", "true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!("/tmp/agent-sandbox-{label}")), + "dry-run output should embed session /tmp path, got: {stdout}" + ); + assert!( + stdout.contains(&format!("/var/tmp/agent-sandbox-{label}")), + "dry-run output should embed session /var/tmp path, got: {stdout}" + ); + assert!( + !std::path::Path::new(&format!("/tmp/agent-sandbox-{label}")).exists(), + "--dry-run must not create the host directory" + ); +} + +#[test] +fn persistent_tmp_survives_across_invocations() { + let label = format!("e2e-persist-{}-{}", std::process::id(), rand_suffix()); + let _cleanup_tmp = CleanupPath::new(format!("/tmp/agent-sandbox-{label}")); + let _cleanup_var = CleanupPath::new(format!("/var/tmp/agent-sandbox-{label}")); + + let writer = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label]) + .args([ + "--", + "bash", + "-c", + "echo persisted > /tmp/canary && echo also-persisted > /var/tmp/canary", + ]) + .output() + .expect("write run failed"); + assert!( + writer.status.success(), + "stderr: {}", + String::from_utf8_lossy(&writer.stderr) + ); + + let reader = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label]) + .args(["--", "bash", "-c", "cat /tmp/canary /var/tmp/canary"]) + .output() + .expect("read run failed"); + assert!( + reader.status.success(), + "stderr: {}", + String::from_utf8_lossy(&reader.stderr) + ); + let out = String::from_utf8_lossy(&reader.stdout); + assert!( + out.contains("persisted"), + "expected /tmp canary, got: {out}" + ); + assert!( + out.contains("also-persisted"), + "expected /var/tmp canary, got: {out}" + ); +} + +#[test] +fn persistent_tmp_is_noop_in_blacklist_mode() { + let label = format!("e2e-bl-noop-{}-{}", std::process::id(), rand_suffix()); + let canary_name = format!( + "agent-sandbox-bl-canary-{}-{}", + std::process::id(), + rand_suffix() + ); + let canary_host = format!("/tmp/{canary_name}"); + let _cleanup_canary = CleanupPath::new(&canary_host); + + let output = Sandbox::new(&[ + "--blacklist", + "--persistent-tmp", + "--persistent-key", + &label, + ]) + .args([ + "--", + "bash", + "-c", + &format!("echo bl-shared > /tmp/{canary_name}"), + ]) + .output() + .expect("write run failed"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + std::path::Path::new(&canary_host).exists(), + "blacklist /tmp should be host /tmp; expected canary at {canary_host}" + ); + assert!( + !std::path::Path::new(&format!("/tmp/agent-sandbox-{label}")).exists(), + "--persistent-tmp must be a no-op in blacklist mode, but session dir was created" + ); +} + +#[test] +fn persistent_tmp_resumes_with_explicit_key_matching_derived_hash() { + let chdir = TempDir::new().unwrap(); + let chdir_str = chdir.path().to_str().unwrap(); + + let dry = Sandbox::new(&["--persistent-tmp", "--dry-run", "--chdir", chdir_str]) + .args(["--", "true"]) + .output() + .expect("dry-run failed"); + assert!(dry.status.success()); + let key = extract_persistent_key(&String::from_utf8_lossy(&dry.stdout)); + let _cleanup_tmp = CleanupPath::new(format!("/tmp/agent-sandbox-{key}")); + let _cleanup_var = CleanupPath::new(format!("/var/tmp/agent-sandbox-{key}")); + + let writer = Sandbox::new(&["--persistent-tmp", "--chdir", chdir_str]) + .args(["--", "bash", "-c", "echo persisted > /tmp/canary"]) + .output() + .expect("write run failed"); + assert!( + writer.status.success(), + "stderr: {}", + String::from_utf8_lossy(&writer.stderr) + ); + + // Second run: different chdir (would have derived a different hash) plus an + // extra flag, but an explicit key pinning the same host directory. + let other_chdir = TempDir::new().unwrap(); + let reader = Sandbox::new(&[ + "--persistent-tmp", + "--persistent-key", + &key, + "--chdir", + other_chdir.path().to_str().unwrap(), + "--unshare-net", + ]) + .args(["--", "cat", "/tmp/canary"]) + .output() + .expect("read run failed"); + assert!( + reader.status.success(), + "stderr: {}", + String::from_utf8_lossy(&reader.stderr) + ); + assert!(String::from_utf8_lossy(&reader.stdout).contains("persisted")); +} + +fn extract_persistent_key(dryrun_stdout: &str) -> String { + let prefix = "/tmp/agent-sandbox-"; + let idx = dryrun_stdout + .find(prefix) + .expect("dry-run output should mention /tmp/agent-sandbox-"); + dryrun_stdout[idx + prefix.len()..] + .chars() + .take_while(|c| c.is_ascii_hexdigit()) + .collect() +} + +fn rand_suffix() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + format!("{nanos:08x}") +} + 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/unit/config.rs b/tests/unit/config.rs index 493cddb..50a4092 100644 --- a/tests/unit/config.rs +++ b/tests/unit/config.rs @@ -1066,6 +1066,169 @@ fn build_unsetenv_accumulates() { assert_eq!(config.unsetenv, vec!["G", "P", "C"]); } +#[test] +fn persistent_tmp_off_by_default() { + let config = build(args_with_command(), None).unwrap(); + assert!(config.persistent_tmp_key.is_none()); +} + +#[test] +fn persistent_tmp_enabled_via_cli_derives_a_key() { + let args = Args { + persistent_tmp: true, + ..args_with_command() + }; + let config = build(args, None).unwrap(); + let key = config.persistent_tmp_key.expect("expected derived key"); + assert_eq!(key.len(), 12); + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn persistent_tmp_enabled_via_config() { + let file_config = FileConfig { + options: Options { + persistent_tmp: Some(true), + ..Options::default() + }, + ..FileConfig::default() + }; + let config = build(args_with_command(), Some(file_config)).unwrap(); + assert!(config.persistent_tmp_key.is_some()); +} + +#[test] +fn persistent_key_override_replaces_hash() { + let args = Args { + persistent_tmp: true, + persistent_key: Some("my-label".into()), + ..args_with_command() + }; + let config = build(args, None).unwrap(); + assert_eq!(config.persistent_tmp_key.as_deref(), Some("my-label")); +} + +#[test] +fn persistent_key_alone_is_ignored_without_persistent_tmp() { + let args = Args { + persistent_key: Some("my-label".into()), + ..args_with_command() + }; + let config = build(args, None).unwrap(); + assert!(config.persistent_tmp_key.is_none()); +} + +#[test] +fn persistent_tmp_is_noop_in_blacklist_mode() { + let derived = build( + Args { + persistent_tmp: true, + blacklist: true, + ..args_with_command() + }, + None, + ) + .unwrap(); + assert!(derived.persistent_tmp_key.is_none()); + + let explicit = build( + Args { + persistent_tmp: true, + blacklist: true, + persistent_key: Some("explicit".into()), + ..args_with_command() + }, + None, + ) + .unwrap(); + assert!(explicit.persistent_tmp_key.is_none()); +} + +#[test] +fn persistent_tmp_cli_no_overrides_config() { + let file_config = FileConfig { + options: Options { + persistent_tmp: Some(true), + ..Options::default() + }, + ..FileConfig::default() + }; + let args = Args { + no_persistent_tmp: true, + ..args_with_command() + }; + let config = build(args, Some(file_config)).unwrap(); + assert!(config.persistent_tmp_key.is_none()); +} + +#[test] +fn persistent_tmp_uses_leaf_profile_name() { + let file_config = FileConfig { + profiles: HashMap::from([ + ( + "parent".into(), + Options { + ..Options::default() + }, + ), + ( + "leaf".into(), + Options { + profile: Some("parent".into()), + persistent_tmp: Some(true), + ..Options::default() + }, + ), + ]), + ..FileConfig::default() + }; + + let leaf_args = Args { + profile: Some("leaf".into()), + ..args_with_command() + }; + let parent_args = Args { + profile: Some("leaf".into()), + ..args_with_command() + }; + // Same profile selected: same key. + let key1 = build(leaf_args, Some(clone_file_config(&file_config))) + .unwrap() + .persistent_tmp_key + .unwrap(); + let key2 = build(parent_args, Some(clone_file_config(&file_config))) + .unwrap() + .persistent_tmp_key + .unwrap(); + assert_eq!(key1, key2); + + // Selecting the parent directly (with persistent_tmp injected) should + // produce a different key because the leaf profile name differs. + let mut file_config2 = clone_file_config(&file_config); + file_config2 + .profiles + .get_mut("parent") + .unwrap() + .persistent_tmp = Some(true); + let parent_direct = Args { + profile: Some("parent".into()), + ..args_with_command() + }; + let key3 = build(parent_direct, Some(file_config2)) + .unwrap() + .persistent_tmp_key + .unwrap(); + assert_ne!(key1, key3); +} + +fn clone_file_config(src: &FileConfig) -> FileConfig { + FileConfig { + options: src.options.clone(), + profiles: src.profiles.clone(), + ..FileConfig::default() + } +} + #[test] fn build_mask_accumulates() { let file_config = FileConfig {