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
+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);
}
}