diff --git a/config-example.toml b/config-example.toml index a59a330..f855e7a 100644 --- a/config-example.toml +++ b/config-example.toml @@ -17,6 +17,7 @@ ro = [ "/etc/alsa", "/run/user/1000/pulse", "/run/user/1000/pipewire-0", + # "/host/path:/sandbox/path", # SRC:DST -> mount host SRC at a different target ] rw = [ "~/.config/claude", diff --git a/src/cli.rs b/src/cli.rs index 61ecee8..7e86d62 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -50,13 +50,13 @@ pub struct Args { #[arg(long, overrides_with = "env_filter")] pub no_env_filter: bool, - /// Bind an extra path read-write (repeatable) - #[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)] - pub extra_rw: Vec, + /// Bind an extra path read-write (repeatable). Use SRC:DST to mount at a different target. + #[arg(long = "rw", value_name = "SRC[:DST]", action = clap::ArgAction::Append)] + pub extra_rw: Vec, - /// Bind an extra path read-only (repeatable) - #[arg(long = "ro", value_name = "PATH", action = clap::ArgAction::Append)] - pub extra_ro: Vec, + /// Bind an extra path read-only (repeatable). Use SRC:DST to mount at a different target. + #[arg(long = "ro", value_name = "SRC[:DST]", action = clap::ArgAction::Append)] + pub extra_ro: Vec, /// Print the bwrap command without executing #[arg(long, overrides_with = "no_dry_run")] diff --git a/src/config.rs b/src/config.rs index 12cb9f6..fa3f598 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::cli::Args; -use crate::{EnvEntry, SandboxConfig, SandboxError, SandboxMode}; +use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode}; pub fn build(args: Args, file_config: Option) -> Result { let (mut globals, mut profile) = match file_config { @@ -64,8 +64,8 @@ pub fn build(args: Args, file_config: Option) -> Result, - profile: &[PathBuf], - globals: &[PathBuf], -) -> Result, SandboxError> { - let config_paths = globals.iter().chain(profile).cloned(); - let cli_paths = cli - .into_iter() - .map(prepare_cli_bind_path) - .collect::, _>>()?; - Ok(config_paths.chain(cli_paths).collect()) +fn merge_bind_specs( + cli: Vec, + profile: &[String], + globals: &[String], +) -> Result, SandboxError> { + let config_specs = globals + .iter() + .chain(profile) + .map(|raw| resolve_config_bind_spec(raw)); + let cli_specs = cli.iter().map(|raw| resolve_cli_bind_spec(raw)); + config_specs.chain(cli_specs).collect() } -fn prepare_cli_bind_path(path: PathBuf) -> Result { +fn resolve_cli_bind_spec(raw: &str) -> Result { + let (src, dst) = split_bind_spec(raw)?; + let source = prepare_cli_bind_path(Path::new(src))?; + let target = match dst { + Some(d) => { + let p = PathBuf::from(d); + require_absolute(&p)?; + p + } + None => source.clone(), + }; + Ok(BindSpec { source, target }) +} + +fn resolve_config_bind_spec(raw: &str) -> Result { + let (src, dst) = split_bind_spec(raw)?; + let source = expand_and_require_existing(Path::new(src))?; + let target = match dst { + Some(d) => expand_and_require_absolute(Path::new(d))?, + None => source.clone(), + }; + Ok(BindSpec { source, target }) +} + +fn split_bind_spec(raw: &str) -> Result<(&str, Option<&str>), SandboxError> { + match raw.split_once(':') { + Some((src, dst)) if !src.is_empty() && !dst.is_empty() => Ok((src, Some(dst))), + Some(_) => Err(SandboxError::InvalidBindSpec(raw.to_string())), + None if raw.is_empty() => Err(SandboxError::InvalidBindSpec(raw.to_string())), + None => Ok((raw, None)), + } +} + +fn prepare_cli_bind_path(path: &Path) -> Result { let absolute = - std::path::absolute(&path).map_err(|_| SandboxError::PathMissing(path.clone()))?; + std::path::absolute(path).map_err(|_| SandboxError::PathMissing(path.to_path_buf()))?; if !absolute.exists() { return Err(SandboxError::PathMissing(absolute)); } @@ -314,9 +347,9 @@ pub struct Options { pub dry_run: Option, pub chdir: Option, #[serde(default)] - pub rw: Vec, + pub rw: Vec, #[serde(default)] - pub ro: Vec, + pub ro: Vec, #[serde(default)] pub mask: Vec, #[serde(default)] @@ -329,12 +362,6 @@ pub struct Options { impl Options { fn validate_paths(&mut self) -> Result<(), SandboxError> { - for p in &mut self.rw { - *p = expand_and_require_existing(p)?; - } - for p in &mut self.ro { - *p = expand_and_require_existing(p)?; - } if let Some(ref chdir) = self.chdir { self.chdir = Some(expand_and_canonicalize(chdir)?); } @@ -722,13 +749,13 @@ mod tests { fn build_rw_paths_accumulate() { let file_config = FileConfig { options: Options { - rw: vec![PathBuf::from("/tmp")], + rw: vec!["/tmp".into()], ..Options::default() }, profile: HashMap::from([( "extra".into(), Options { - rw: vec![PathBuf::from("/usr")], + rw: vec!["/usr".into()], ..Options::default() }, )]), @@ -736,7 +763,7 @@ mod tests { }; let args = Args { profile: Some("extra".into()), - extra_rw: vec![PathBuf::from("/var")], + extra_rw: vec!["/var".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); @@ -885,13 +912,13 @@ mod tests { let home = std::env::var("HOME").unwrap(); let file_config = FileConfig { options: Options { - rw: vec![PathBuf::from("~/")], + rw: vec!["~/".into()], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); - assert_eq!(config.extra_rw, vec![PathBuf::from(&home)]); + assert_eq!(config.extra_rw, vec![same_bind(Path::new(&home))]); } #[test] @@ -902,17 +929,18 @@ mod tests { std::fs::write(&target, "x").unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); + let link_str = link.to_str().unwrap().to_string(); let file_config = FileConfig { options: Options { - ro: vec![link.clone()], - rw: vec![link.clone()], + ro: vec![link_str.clone()], + rw: vec![link_str], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); - assert_eq!(config.extra_ro, vec![link.clone()]); - assert_eq!(config.extra_rw, vec![link]); + assert_eq!(config.extra_ro, vec![same_bind(&link)]); + assert_eq!(config.extra_rw, vec![same_bind(&link)]); } #[test] @@ -923,21 +951,22 @@ mod tests { std::fs::write(&target, "x").unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); + let link_str = link.to_str().unwrap().to_string(); let args = Args { - extra_ro: vec![link.clone()], - extra_rw: vec![link.clone()], + extra_ro: vec![link_str.clone()], + extra_rw: vec![link_str], ..args_with_command() }; let config = build(args, None).unwrap(); - assert_eq!(config.extra_ro, vec![link.clone()]); - assert_eq!(config.extra_rw, vec![link]); + assert_eq!(config.extra_ro, vec![same_bind(&link)]); + assert_eq!(config.extra_rw, vec![same_bind(&link)]); } #[test] fn build_relative_config_path_rejected() { let file_config = FileConfig { options: Options { - rw: vec![PathBuf::from("relative/path")], + rw: vec!["relative/path".into()], ..Options::default() }, ..FileConfig::default() @@ -948,6 +977,120 @@ mod tests { )); } + #[test] + fn build_cli_remap_target() { + let dir = TempDir::new().unwrap(); + let src = dir.path().join("host"); + std::fs::create_dir(&src).unwrap(); + let src_str = src.to_str().unwrap(); + + let args = Args { + extra_ro: vec![format!("{src_str}:/inside/elsewhere")], + ..args_with_command() + }; + let config = build(args, None).unwrap(); + + assert_eq!( + config.extra_ro, + vec![BindSpec { + source: src, + target: PathBuf::from("/inside/elsewhere"), + }] + ); + } + + #[test] + fn build_config_remap_target() { + let dir = TempDir::new().unwrap(); + let src = dir.path().join("host"); + std::fs::create_dir(&src).unwrap(); + let src_str = src.to_str().unwrap(); + + let file_config = FileConfig { + options: Options { + rw: vec![format!("{src_str}:/inside/elsewhere")], + ..Options::default() + }, + ..FileConfig::default() + }; + let config = build(args_with_command(), Some(file_config)).unwrap(); + + assert_eq!( + config.extra_rw, + vec![BindSpec { + source: src, + target: PathBuf::from("/inside/elsewhere"), + }] + ); + } + + #[test] + fn build_config_tilde_expands_in_target() { + let home = std::env::var("HOME").unwrap(); + + let file_config = FileConfig { + options: Options { + rw: vec!["~/:~/mounted".into()], + ..Options::default() + }, + ..FileConfig::default() + }; + let config = build(args_with_command(), Some(file_config)).unwrap(); + + assert_eq!( + config.extra_rw, + vec![BindSpec { + source: PathBuf::from(&home), + target: PathBuf::from(format!("{home}/mounted")), + }] + ); + } + + #[test] + fn build_relative_target_rejected() { + let dir = TempDir::new().unwrap(); + let src_str = dir.path().to_str().unwrap(); + + let args = Args { + extra_ro: vec![format!("{src_str}:relative/target")], + ..args_with_command() + }; + + assert!(matches!( + build(args, None), + Err(SandboxError::ConfigPathNotAbsolute(_)) + )); + } + + #[test] + fn build_empty_source_half_rejected() { + let args = Args { + extra_ro: vec![":/target".into()], + ..args_with_command() + }; + + assert!(matches!( + build(args, None), + Err(SandboxError::InvalidBindSpec(_)) + )); + } + + #[test] + fn build_empty_target_half_rejected() { + let dir = TempDir::new().unwrap(); + let src_str = dir.path().to_str().unwrap(); + + let args = Args { + extra_ro: vec![format!("{src_str}:")], + ..args_with_command() + }; + + assert!(matches!( + build(args, None), + Err(SandboxError::InvalidBindSpec(_)) + )); + } + #[test] fn build_chdir_from_config() { let file_config = FileConfig { @@ -1281,11 +1424,18 @@ mod tests { assert!(matches!(result, Err(SandboxError::NoCommand))); } - fn assert_paths(actual: &[PathBuf], expected: &[&str]) { - let expected: Vec = expected.iter().map(PathBuf::from).collect(); + fn assert_paths(actual: &[String], expected: &[&str]) { + let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); assert_eq!(actual, &expected); } + fn same_bind(path: &Path) -> BindSpec { + BindSpec { + source: path.to_path_buf(), + target: path.to_path_buf(), + } + } + fn assert_command(cmd: &Option, expected: &[&str]) { let actual = cmd.clone().unwrap().into_vec(); let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); diff --git a/src/errors.rs b/src/errors.rs index e28eb44..1ec908f 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -27,6 +27,7 @@ pub enum SandboxError { UnknownConfigKey(String), ConfigPathNotAbsolute(PathBuf), InvalidBwrapArg(String), + InvalidBindSpec(String), NoCommand, Seccomp(String), SeccompUnsupportedArch(String), @@ -80,6 +81,9 @@ impl std::fmt::Display for SandboxError { Self::InvalidBwrapArg(s) => { write!(f, "invalid quoting in --bwrap-arg: {s}") } + Self::InvalidBindSpec(s) => { + write!(f, "invalid bind spec (expected SRC or SRC:DST): {s:?}") + } Self::NoCommand => write!( f, "no command to run; specify a command via config, entrypoint, or pass one after --" diff --git a/src/lib.rs b/src/lib.rs index 8269f6b..2522324 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,14 +35,20 @@ impl EnvEntry { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindSpec { + pub source: PathBuf, + pub target: PathBuf, +} + pub struct SandboxConfig { pub mode: SandboxMode, pub hardened: bool, pub unshare_net: bool, pub seccomp: bool, pub env_filter: bool, - pub extra_rw: Vec, - pub extra_ro: Vec, + pub extra_rw: Vec, + pub extra_ro: Vec, pub mask: Vec, pub env: Vec, pub unsetenv: Vec, diff --git a/src/sandbox.rs b/src/sandbox.rs index 1e889cc..62f1a49 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -5,7 +5,7 @@ use crate::agents; use crate::blacklist; use crate::env; use crate::seccomp; -use crate::{EnvEntry, SandboxConfig, SandboxError, SandboxMode}; +use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode}; pub fn build_command(config: &SandboxConfig) -> Result { let mut cmd = Command::new("bwrap"); @@ -28,12 +28,12 @@ pub fn build_command(config: &SandboxConfig) -> Result { cmd.arg("--bind-try").arg(&path).arg(&path); } - for path in &config.extra_ro { - add_ro_bind(&mut cmd, path)?; + for spec in &config.extra_ro { + add_ro_bind(&mut cmd, spec)?; } - add_rw_bind(&mut cmd, &config.chdir)?; - for path in &config.extra_rw { - add_rw_bind(&mut cmd, path)?; + add_rw_bind_path(&mut cmd, &config.chdir)?; + for spec in &config.extra_rw { + add_rw_bind(&mut cmd, spec)?; } add_env_policy(&mut cmd, config); @@ -220,15 +220,21 @@ fn ro_bind_under_tmpfs(cmd: &mut Command, base: &str, paths: &[&str]) { } } -fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { - let source = resolve_bind_source(path)?; - cmd.arg("--bind").arg(source).arg(path); +fn add_rw_bind(cmd: &mut Command, spec: &BindSpec) -> Result<(), SandboxError> { + let source = resolve_bind_source(&spec.source)?; + cmd.arg("--bind").arg(source).arg(&spec.target); Ok(()) } -fn add_ro_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { +fn add_ro_bind(cmd: &mut Command, spec: &BindSpec) -> Result<(), SandboxError> { + let source = resolve_bind_source(&spec.source)?; + cmd.arg("--ro-bind").arg(source).arg(&spec.target); + Ok(()) +} + +fn add_rw_bind_path(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { let source = resolve_bind_source(path)?; - cmd.arg("--ro-bind").arg(source).arg(path); + cmd.arg("--bind").arg(source).arg(path); Ok(()) } diff --git a/tests/integration.rs b/tests/integration.rs index 6855018..7d5e94b 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -210,6 +210,30 @@ fn extra_rw_mount() { ); } +#[test] +fn ro_mount_with_remapped_target() { + let dir = TempDir::new().expect("failed to create temp dir"); + fs::write(dir.path().join("hello.txt"), "hi").expect("failed to write test file"); + let src_str = dir.path().to_str().unwrap(); + let target = "/tmp/agent-sandbox-remap-test"; + + let output = sandbox(&["--ro", &format!("{src_str}:{target}")]) + .args([ + "--", + "bash", + "-c", + &format!("cat {target}/hello.txt && ls {src_str} 2>&1 | head -1 || echo src_hidden"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hi"), + "expected file content 'hi' at remapped target, got: {stdout}" + ); +} + #[test] fn rw_refines_ro_parent() { let parent = TempDir::new().expect("failed to create temp dir");