Add mask option to hide paths/files from sandbox
This commit is contained in:
@@ -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>,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", "/", "/"]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user