Optionally back /tmp and /var/tmp with stable host directories

This commit is contained in:
2026-05-15 01:36:58 +02:00
parent 28e68b0fff
commit 3fb0da0577
13 changed files with 839 additions and 52 deletions
Generated
+58
View File
@@ -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"
+1
View File
@@ -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"
+10
View File
@@ -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-<key>` and `/var/tmp/agent-sandbox-<key>`. 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).
`<key>` 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-<key>` 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.
+6
View File
@@ -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
+12
View File
@@ -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<String>,
/// Working directory inside the sandbox (default: current directory)
#[arg(long, value_name = "PATH")]
pub chdir: Option<PathBuf>,
+93 -27
View File
@@ -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<FileConfig>) -> Result<SandboxConfig, SandboxError> {
@@ -13,72 +14,120 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
let profile = c.resolve_profile(args.profile.as_deref())?;
(c.options, profile)
}
None => (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<String> {
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<String, toml::Value>,
}
#[derive(Default)]
pub struct ResolvedProfile {
pub name: Option<String>,
pub options: Options,
}
impl FileConfig {
pub fn load(path: &Path) -> Result<Self, SandboxError> {
Self::load_file(path)?.load_extra()
@@ -364,15 +419,22 @@ impl FileConfig {
Ok(config)
}
fn resolve_profile(&self, selected: Option<&str>) -> Result<Options, SandboxError> {
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<ResolvedProfile, SandboxError> {
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<Options, SandboxError> {
let chain = self.collect_chain(leaf)?;
fn resolve_chain(&self, name: &str) -> Result<Options, SandboxError> {
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<Vec<Options>, SandboxError> {
fn collect_chain(&self, name: &str) -> Result<Vec<Options>, SandboxError> {
let mut visited: Vec<String> = Vec::new();
let mut chain: Vec<Options> = Vec::new();
let mut current = leaf.to_string();
let mut current = name.to_string();
loop {
if visited.iter().any(|seen| seen == &current) {
@@ -424,6 +486,8 @@ pub struct Options {
pub entrypoint: Option<CommandValue>,
pub command: Option<CommandValue>,
pub dry_run: Option<bool>,
pub persistent_tmp: Option<bool>,
pub persistent_key: Option<String>,
pub chdir: Option<PathBuf>,
#[serde(default)]
pub rw: Vec<String>,
@@ -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),
+4
View File
@@ -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())
}
}
}
}
+8 -11
View File
@@ -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<OsString>,
pub chdir: PathBuf,
pub dry_run: bool,
pub persistent_tmp_key: Option<String>,
}
pub fn require_home() -> Result<String, SandboxError> {
@@ -87,19 +91,12 @@ fn resolve_run_user_from_proc() -> Option<String> {
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()))
}
+173
View File
@@ -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/<n>` bind sources valid through `exec()`.
_keep_fds: Vec<OwnedFd>,
}
impl PersistentTmpDirs {
pub fn resolve(config: &SandboxConfig) -> Result<Option<Self>, 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<PathBuf>) -> Self {
Self {
host_path: path.into(),
}
}
pub fn open(&self) -> Result<OwnedFd, SandboxError> {
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)
);
}
}
+55 -9
View File
@@ -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<Command, SandboxError> {
pub struct Bwrap {
command: Command,
_persistent: Option<PersistentTmpDirs>,
}
impl Bwrap {
pub fn build(config: &SandboxConfig) -> Result<Self, SandboxError> {
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<Bwrap, SandboxError> {
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<Command, SandboxError> {
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<Command, SandboxError> {
.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"]);
+68
View File
@@ -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: "<default>",
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);
}
}
+188 -5
View File
@@ -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<String>) -> 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<String> {
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-<key>");
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<String>) {
assert_eq!(bwrap_args[1], "--ro-bind");
assert_eq!(bwrap_args[2], "/");
+163
View File
@@ -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 {