Add mask option to hide paths/files from sandbox

This commit is contained in:
2026-04-01 23:19:08 +02:00
parent 0119834d5a
commit c7c4c673cb
5 changed files with 176 additions and 1 deletions

View File

@@ -54,6 +54,10 @@ pub struct Args {
#[arg(long)] #[arg(long)]
pub no_config: bool, pub no_config: bool,
/// Hide a path inside the sandbox with a tmpfs overlay (repeatable)
#[arg(long = "mask", value_name = "PATH", action = clap::ArgAction::Append)]
pub mask: Vec<PathBuf>,
/// Command and arguments to run inside the sandbox /// Command and arguments to run inside the sandbox
#[arg(trailing_var_arg = true, allow_hyphen_values = true)] #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub command_and_args: Vec<OsString>, pub command_and_args: Vec<OsString>,

View File

@@ -37,6 +37,7 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?, chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?,
extra_rw: merge_paths(args.extra_rw, &profile.rw, &globals.rw)?, extra_rw: merge_paths(args.extra_rw, &profile.rw, &globals.rw)?,
extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?, extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?,
mask: merge_mask(args.mask, &profile.mask, &globals.mask),
command, command,
command_args, command_args,
}) })
@@ -98,6 +99,10 @@ fn merge_paths(
Ok(config_paths.chain(cli_paths).collect()) Ok(config_paths.chain(cli_paths).collect())
} }
fn merge_mask(cli: Vec<PathBuf>, profile: &[PathBuf], globals: &[PathBuf]) -> Vec<PathBuf> {
globals.iter().chain(profile).cloned().chain(cli).collect()
}
fn resolve_command( fn resolve_command(
mut positional: Vec<OsString>, mut positional: Vec<OsString>,
profile_cmd: Option<CommandValue>, profile_cmd: Option<CommandValue>,
@@ -180,6 +185,8 @@ pub struct Options {
pub rw: Vec<PathBuf>, pub rw: Vec<PathBuf>,
#[serde(default)] #[serde(default)]
pub ro: Vec<PathBuf>, pub ro: Vec<PathBuf>,
#[serde(default)]
pub mask: Vec<PathBuf>,
} }
impl Options { impl Options {
@@ -193,10 +200,19 @@ impl Options {
if let Some(ref chdir) = self.chdir { if let Some(ref chdir) = self.chdir {
self.chdir = Some(expand_and_canonicalize(chdir)?); self.chdir = Some(expand_and_canonicalize(chdir)?);
} }
for p in &mut self.mask {
*p = expand_and_require_absolute(p)?;
}
Ok(()) Ok(())
} }
} }
fn expand_and_require_absolute(path: &Path) -> Result<PathBuf, SandboxError> {
let expanded = expand_tilde(path)?;
require_absolute(&expanded)?;
Ok(expanded)
}
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]
#[serde(untagged)] #[serde(untagged)]
pub enum CommandValue { pub enum CommandValue {
@@ -531,6 +547,72 @@ mod tests {
)); ));
} }
#[test]
fn build_tilde_expansion_in_config_paths() {
let home = std::env::var("HOME").unwrap();
let file_config = FileConfig {
options: Options {
rw: vec![PathBuf::from("~/")],
..Options::default()
},
..FileConfig::default()
};
let config = build(args_with_command(), Some(file_config)).unwrap();
assert_eq!(config.extra_rw, vec![PathBuf::from(&home)]);
}
#[test]
fn build_relative_config_path_rejected() {
let file_config = FileConfig {
options: Options {
rw: vec![PathBuf::from("relative/path")],
..Options::default()
},
..FileConfig::default()
};
assert!(matches!(
build(args_with_command(), Some(file_config)),
Err(SandboxError::ConfigPathNotAbsolute(_))
));
}
#[test]
fn build_chdir_from_config() {
let file_config = FileConfig {
options: Options {
chdir: Some(PathBuf::from("/tmp")),
..Options::default()
},
..FileConfig::default()
};
let config = build(args_with_command(), Some(file_config)).unwrap();
assert_eq!(config.chdir, std::fs::canonicalize("/tmp").unwrap());
}
#[test]
fn build_mask_accumulates() {
let file_config = FileConfig {
options: Options {
mask: vec![PathBuf::from("/tmp/a")],
..Options::default()
},
profile: HashMap::from([(
"extra".into(),
Options {
mask: vec![PathBuf::from("/tmp/b")],
..Options::default()
},
)]),
};
let args = Args {
profile: Some("extra".into()),
mask: vec![PathBuf::from("/tmp/c")],
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert_eq!(config.mask.len(), 3);
}
fn assert_paths(actual: &[PathBuf], expected: &[&str]) { fn assert_paths(actual: &[PathBuf], expected: &[&str]) {
let expected: Vec<PathBuf> = expected.iter().map(PathBuf::from).collect(); let expected: Vec<PathBuf> = expected.iter().map(PathBuf::from).collect();
assert_eq!(actual, &expected); assert_eq!(actual, &expected);

View File

@@ -25,6 +25,7 @@ pub struct SandboxConfig {
pub no_net: bool, pub no_net: bool,
pub extra_rw: Vec<PathBuf>, pub extra_rw: Vec<PathBuf>,
pub extra_ro: Vec<PathBuf>, pub extra_ro: Vec<PathBuf>,
pub mask: Vec<PathBuf>,
pub command: PathBuf, pub command: PathBuf,
pub command_args: Vec<OsString>, pub command_args: Vec<OsString>,
pub chdir: PathBuf, pub chdir: PathBuf,

View File

@@ -1,4 +1,4 @@
use std::path::Path; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::agents; use crate::agents;
@@ -39,6 +39,8 @@ pub fn build_command(config: &SandboxConfig) -> Result<Command, SandboxError> {
cmd.arg("--die-with-parent"); cmd.arg("--die-with-parent");
cmd.arg("--chdir").arg(&config.chdir); cmd.arg("--chdir").arg(&config.chdir);
apply_masks(&mut cmd, &config.mask);
cmd.arg("--") cmd.arg("--")
.arg(&config.command) .arg(&config.command)
.args(&config.command_args); .args(&config.command_args);
@@ -46,6 +48,16 @@ pub fn build_command(config: &SandboxConfig) -> Result<Command, SandboxError> {
Ok(cmd) Ok(cmd)
} }
fn apply_masks(cmd: &mut Command, masks: &[PathBuf]) {
for path in masks {
if path.is_file() {
cmd.arg("--ro-bind").arg("/dev/null").arg(path);
} else {
cmd.arg("--tmpfs").arg(path);
}
}
}
fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> { fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> {
let ctx = blacklist::resolve_path_context()?; let ctx = blacklist::resolve_path_context()?;
cmd.args(["--ro-bind", "/", "/"]); cmd.args(["--ro-bind", "/", "/"]);

View File

@@ -577,3 +577,79 @@ fn config_invalid_toml_errors() {
"expected parse error, got: {stderr}" "expected parse error, got: {stderr}"
); );
} }
#[test]
fn mask_hides_directory() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("secret.txt"), "sensitive").expect("failed to write");
let dir_str = dir.path().canonicalize().unwrap();
let output = sandbox(&["--mask", dir_str.to_str().unwrap()])
.args([
"--",
"bash",
"-c",
&format!("ls {} 2>/dev/null | wc -l", dir_str.display()),
])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "0",
"expected masked directory to be empty, got {stdout} entries"
);
}
#[test]
fn mask_hides_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("secret.txt");
fs::write(&file, "sensitive").expect("failed to write");
let file_str = file.canonicalize().unwrap();
let output = sandbox(&["--mask", file_str.to_str().unwrap()])
.args([
"--",
"bash",
"-c",
&format!("cat {} 2>/dev/null || echo HIDDEN", file_str.display()),
])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("sensitive"),
"expected masked file contents to be hidden, got: {stdout}"
);
}
#[test]
fn mask_nonexistent_path_becomes_tmpfs() {
let dir = TempDir::new().unwrap();
let fake = dir.path().join("does_not_exist");
let fake_str = fake.to_str().unwrap();
let output = sandbox(&["--mask", fake_str])
.args([
"--",
"bash",
"-c",
&format!(
"test -d {fake_str} && touch {fake_str}/canary && echo WRITABLE || echo MISSING"
),
])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("WRITABLE"),
"expected nonexistent mask to create a writable tmpfs, got: {stdout}"
);
assert!(
!fake.join("canary").exists(),
"tmpfs writes should not leak to host"
);
}