diff --git a/Cargo.lock b/Cargo.lock index e7d8fb3..b2ed16d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ dependencies = [ "clap", "glob", "serde", + "shlex", "tempfile", "toml", ] @@ -372,6 +373,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index d430cff..5305929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,9 @@ path = "src/main.rs" clap = { version = "4", features = ["derive"] } glob = "0.3" serde = { version = "1", features = ["derive"] } +shlex = "1.3.0" toml = "1" [dev-dependencies] +shlex = "1.3.0" tempfile = "3" diff --git a/src/cli.rs b/src/cli.rs index a99ab7e..ec71640 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -58,6 +58,10 @@ pub struct Args { #[arg(long = "mask", value_name = "PATH", action = clap::ArgAction::Append)] pub mask: Vec, + /// Pass an arbitrary argument directly to bwrap (repeatable) + #[arg(long = "bwrap-arg", value_name = "ARG", action = clap::ArgAction::Append)] + pub bwrap_args: 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 3d4c1c7..9937a35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,7 +37,12 @@ pub fn build(args: Args, file_config: Option) -> Result, profile: &[PathBuf], globals: &[PathBuf]) -> Vec { +fn split_bwrap_args(raw: Vec) -> Result, SandboxError> { + let mut out = Vec::new(); + for value in &raw { + let words = + shlex::split(value).ok_or_else(|| SandboxError::InvalidBwrapArg(value.clone()))?; + out.extend(words); + } + Ok(out) +} + +fn merge_vecs(cli: Vec, profile: &[T], globals: &[T]) -> Vec { globals.iter().chain(profile).cloned().chain(cli).collect() } @@ -204,6 +219,8 @@ pub struct Options { pub ro: Vec, #[serde(default)] pub mask: Vec, + #[serde(default)] + pub bwrap_args: Vec, } impl Options { diff --git a/src/errors.rs b/src/errors.rs index 2709e2a..19ed1bb 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -24,6 +24,7 @@ pub enum SandboxError { ConflictingMode, UnknownConfigKey(String), ConfigPathNotAbsolute(PathBuf), + InvalidBwrapArg(String), } impl std::fmt::Display for SandboxError { @@ -65,6 +66,9 @@ impl std::fmt::Display for SandboxError { Self::ConfigPathNotAbsolute(p) => { write!(f, "config path is not absolute: {}", p.display()) } + Self::InvalidBwrapArg(s) => { + write!(f, "invalid quoting in --bwrap-arg: {s}") + } } } } diff --git a/src/lib.rs b/src/lib.rs index 381bc6c..4459e62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub struct SandboxConfig { pub extra_rw: Vec, pub extra_ro: Vec, pub mask: Vec, + pub bwrap_args: Vec, pub command: PathBuf, pub command_args: Vec, pub chdir: PathBuf, @@ -63,9 +64,16 @@ pub fn run(config: SandboxConfig) -> Result<(), SandboxError> { let mut cmd = sandbox::build_command(&config)?; if config.dry_run { - println!("{:?}", cmd); + println!("{}", shell_quote_command(&cmd)); 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() +} diff --git a/src/sandbox.rs b/src/sandbox.rs index 4ad1c37..aa053cb 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -41,6 +41,8 @@ pub fn build_command(config: &SandboxConfig) -> Result { apply_masks(&mut cmd, &config.mask); + cmd.args(&config.bwrap_args); + cmd.arg("--") .arg(&config.command) .args(&config.command_args); diff --git a/tests/integration.rs b/tests/integration.rs index 4f96099..e18eb40 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -245,6 +245,20 @@ fn dry_run_prints_and_exits() { ); } +#[test] +fn dry_run_output_is_copy_pasteable_shell() { + let dry = sandbox(&["--dry-run"]) + .args(["--", "bash", "-c", "echo $HOME"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let dry_cmd = String::from_utf8_lossy(&dry.stdout).trim().to_string(); + let args = shlex::split(&dry_cmd).expect("dry-run output is not valid shell"); + assert_eq!(args[0], "bwrap"); + assert_eq!(args[args.len() - 1], "echo $HOME"); + assert_eq!(args[args.len() - 2], "-c"); +} + #[test] fn blacklist_overlays_survive_tmp_bind() { fs::write("/tmp/ssh-sandbox-test", "secret").expect("failed to write sentinel"); @@ -649,6 +663,20 @@ fn mask_hides_file() { ); } +#[test] +fn bwrap_arg_setenv_passes_through() { + let output = sandbox(&["--bwrap-arg", "--setenv MYVAR hello"]) + .args(["--", "bash", "-c", "echo $MYVAR"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "hello", + "expected --bwrap-arg to pass --setenv through to bwrap, got: {stdout}" + ); +} + #[test] fn mask_nonexistent_path_becomes_tmpfs() { let dir = TempDir::new().unwrap();