diff --git a/src/cli.rs b/src/cli.rs index 6688475..52cfb5e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -54,6 +54,10 @@ pub struct Args { #[arg(long)] 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, + /// Command and arguments to run inside the sandbox #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub command_and_args: Vec, diff --git a/src/config.rs b/src/config.rs index 1606fcf..eb292a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,6 +37,7 @@ pub fn build(args: Args, file_config: Option) -> Result, profile: &[PathBuf], globals: &[PathBuf]) -> Vec { + globals.iter().chain(profile).cloned().chain(cli).collect() +} + fn resolve_command( mut positional: Vec, profile_cmd: Option, @@ -180,6 +185,8 @@ pub struct Options { pub rw: Vec, #[serde(default)] pub ro: Vec, + #[serde(default)] + pub mask: Vec, } impl Options { @@ -193,10 +200,19 @@ impl Options { if let Some(ref chdir) = self.chdir { self.chdir = Some(expand_and_canonicalize(chdir)?); } + for p in &mut self.mask { + *p = expand_and_require_absolute(p)?; + } Ok(()) } } +fn expand_and_require_absolute(path: &Path) -> Result { + let expanded = expand_tilde(path)?; + require_absolute(&expanded)?; + Ok(expanded) +} + #[derive(Deserialize, Clone)] #[serde(untagged)] 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]) { let expected: Vec = expected.iter().map(PathBuf::from).collect(); assert_eq!(actual, &expected); diff --git a/src/lib.rs b/src/lib.rs index 4fcfc8b..381bc6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub struct SandboxConfig { pub no_net: bool, pub extra_rw: Vec, pub extra_ro: Vec, + pub mask: Vec, pub command: PathBuf, pub command_args: Vec, pub chdir: PathBuf, diff --git a/src/sandbox.rs b/src/sandbox.rs index 1337a2f..4ad1c37 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::agents; @@ -39,6 +39,8 @@ pub fn build_command(config: &SandboxConfig) -> Result { cmd.arg("--die-with-parent"); cmd.arg("--chdir").arg(&config.chdir); + apply_masks(&mut cmd, &config.mask); + cmd.arg("--") .arg(&config.command) .args(&config.command_args); @@ -46,6 +48,16 @@ pub fn build_command(config: &SandboxConfig) -> Result { 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> { let ctx = blacklist::resolve_path_context()?; cmd.args(["--ro-bind", "/", "/"]); diff --git a/tests/integration.rs b/tests/integration.rs index e5beb30..1290bf1 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -577,3 +577,79 @@ fn config_invalid_toml_errors() { "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" + ); +}