Add entrypoint option

This commit is contained in:
2026-04-04 10:16:57 +02:00
parent 8ecba5d6dc
commit 062ddab5f8
2 changed files with 278 additions and 24 deletions

View File

@@ -21,11 +21,7 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
globals.validate_paths()?;
profile.validate_paths()?;
let (command, command_args) = resolve_command(
args.command_and_args,
profile.command.clone(),
globals.command.clone(),
);
let (command, command_args) = resolve_command(args.command_and_args, &profile, &globals);
let command = resolve_binary(&command)
.ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?;
@@ -119,20 +115,31 @@ fn merge_vecs<T: Clone>(cli: Vec<T>, profile: &[T], globals: &[T]) -> Vec<T> {
}
fn resolve_command(
mut positional: Vec<OsString>,
profile_cmd: Option<CommandValue>,
globals_cmd: Option<CommandValue>,
mut passthrough_args: Vec<OsString>,
profile: &Options,
globals: &Options,
) -> (OsString, Vec<OsString>) {
if !positional.is_empty() {
let cmd = positional.remove(0);
return (cmd, positional);
}
if let Some(config_cmd) = profile_cmd.or(globals_cmd) {
let parts = config_cmd.into_vec();
let cmd = OsString::from(&parts[0]);
let args = parts[1..].iter().map(OsString::from).collect();
let entrypoint = profile.entrypoint.clone().or(globals.entrypoint.clone());
let command = profile.command.clone().or(globals.command.clone());
if let Some(ep) = entrypoint {
let (cmd, mut args) = ep.into_binary_and_args();
if !passthrough_args.is_empty() {
args.extend(passthrough_args);
} else if let Some(default_cmd) = command {
args.extend(default_cmd.into_vec().into_iter().map(OsString::from));
}
return (cmd, args);
}
if !passthrough_args.is_empty() {
return (passthrough_args.remove(0), passthrough_args);
}
if let Some(config_cmd) = command {
return config_cmd.into_binary_and_args();
}
if let Ok(cmd) = std::env::var("SANDBOX_CMD") {
return (OsString::from(cmd), vec![]);
}
@@ -210,6 +217,7 @@ pub struct Options {
pub whitelist: Option<bool>,
pub hardened: Option<bool>,
pub no_net: Option<bool>,
pub entrypoint: Option<CommandValue>,
pub command: Option<CommandValue>,
pub dry_run: Option<bool>,
pub chdir: Option<PathBuf>,
@@ -261,6 +269,13 @@ impl CommandValue {
Self::WithArgs(v) => v,
}
}
fn into_binary_and_args(self) -> (OsString, Vec<OsString>) {
let mut parts = self.into_vec();
let binary = OsString::from(parts.remove(0));
let args = parts.into_iter().map(OsString::from).collect();
(binary, args)
}
}
pub fn find_config_path(explicit: Option<&Path>) -> Option<PathBuf> {
@@ -334,6 +349,7 @@ mod tests {
[profile.claude]
rw = ["/home/user/.config/claude"]
ro = ["/etc/claude", "/etc/shared"]
entrypoint = ["claude", "--dangerously-skip-permissions"]
command = ["bash", "-c", "echo hi"]
[profile.codex]
@@ -380,6 +396,14 @@ mod tests {
);
}
#[test]
fn profile_entrypoint_with_args() {
assert_command(
&CONFIG.profile["claude"].entrypoint,
&["claude", "--dangerously-skip-permissions"],
);
}
#[test]
fn profile_command_with_args() {
assert_command(
@@ -675,6 +699,117 @@ mod tests {
));
}
#[test]
fn entrypoint_with_passthrough_args() {
let (cmd, args) = resolve_command(
vec!["--verbose".into(), "--model".into(), "opus".into()],
&Options {
entrypoint: Some(CommandValue::WithArgs(vec![
"claude".into(),
"--dangerously-skip-permissions".into(),
])),
command: Some(CommandValue::Simple("--default-flag".into())),
..Options::default()
},
&Options::default(),
);
assert_eq!(cmd, "claude");
assert_eq!(
args,
vec![
"--dangerously-skip-permissions",
"--verbose",
"--model",
"opus"
]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn entrypoint_without_passthrough_uses_command_defaults() {
let (cmd, args) = resolve_command(
vec![],
&Options {
entrypoint: Some(CommandValue::Simple("claude".into())),
command: Some(CommandValue::WithArgs(vec![
"--dangerously-skip-permissions".into(),
"--verbose".into(),
])),
..Options::default()
},
&Options::default(),
);
assert_eq!(cmd, "claude");
assert_eq!(
args,
vec!["--dangerously-skip-permissions", "--verbose"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn entrypoint_without_passthrough_or_command() {
let (cmd, args) = resolve_command(
vec![],
&Options {
entrypoint: Some(CommandValue::WithArgs(vec![
"claude".into(),
"--dangerously-skip-permissions".into(),
])),
..Options::default()
},
&Options::default(),
);
assert_eq!(cmd, "claude");
assert_eq!(args, vec![OsString::from("--dangerously-skip-permissions")]);
}
#[test]
fn profile_entrypoint_overrides_global() {
let (cmd, _) = resolve_command(
vec![],
&Options {
entrypoint: Some(CommandValue::Simple("claude".into())),
..Options::default()
},
&Options {
entrypoint: Some(CommandValue::Simple("codex".into())),
..Options::default()
},
);
assert_eq!(cmd, "claude");
}
#[test]
fn global_entrypoint_with_profile_command() {
let (cmd, args) = resolve_command(
vec![],
&Options {
command: Some(CommandValue::WithArgs(vec![
"--dangerously-skip-permissions".into(),
"--verbose".into(),
])),
..Options::default()
},
&Options {
entrypoint: Some(CommandValue::Simple("claude".into())),
..Options::default()
},
);
assert_eq!(cmd, "claude");
assert_eq!(
args,
vec!["--dangerously-skip-permissions", "--verbose"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
fn assert_paths(actual: &[PathBuf], expected: &[&str]) {
let expected: Vec<PathBuf> = expected.iter().map(PathBuf::from).collect();
assert_eq!(actual, &expected);

View File

@@ -15,10 +15,28 @@ fn sandbox(extra_args: &[&str]) -> Command {
cmd
}
fn write_config(dir: &TempDir, content: &str) -> String {
let path = dir.path().join("config.toml");
fs::write(&path, content).expect("failed to write config");
path.to_str().unwrap().to_string()
struct ConfigFile {
_dir: TempDir,
path: String,
}
impl ConfigFile {
fn new(content: &str) -> Self {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, content).expect("failed to write config");
Self {
_dir: dir,
path: path.to_str().unwrap().to_string(),
}
}
}
impl std::ops::Deref for ConfigFile {
type Target = str;
fn deref(&self) -> &str {
&self.path
}
}
fn read_sid_from_stat(stat: &str) -> u32 {
@@ -582,8 +600,7 @@ fn config_missing_file_errors() {
#[test]
fn config_invalid_toml_errors() {
let dir = TempDir::new().unwrap();
let cfg = write_config(&dir, "not valid {{{{ toml");
let cfg = ConfigFile::new("not valid {{{{ toml");
let output = sandbox_withconfig(&["--config", &cfg])
.args(["--", "true"])
@@ -600,8 +617,7 @@ fn config_invalid_toml_errors() {
#[test]
fn config_unknown_key_errors() {
let dir = TempDir::new().unwrap();
let cfg = write_config(&dir, "hardened = true\nbogus = \"nope\"\n");
let cfg = ConfigFile::new("hardened = true\nbogus = \"nope\"\n");
let output = sandbox_withconfig(&["--config", &cfg])
.args(["--", "true"])
@@ -677,6 +693,109 @@ fn bwrap_arg_setenv_passes_through() {
);
}
#[test]
fn config_entrypoint_appends_passthrough_args() {
let cfg = ConfigFile::new(
r#"
[profile.test]
entrypoint = ["bash", "-c"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.args(["--", "echo entrypoint-works"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "entrypoint-works",
"expected passthrough args appended to entrypoint, got: {stdout}"
);
}
#[test]
fn config_entrypoint_falls_back_to_command_defaults() {
let cfg = ConfigFile::new(
r#"
[profile.test]
entrypoint = ["bash", "-c"]
command = ["echo default-args"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "default-args",
"expected command defaults when no passthrough args, got: {stdout}"
);
}
#[test]
fn config_entrypoint_alone_without_command_or_passthrough() {
let cfg = ConfigFile::new(
r#"
[profile.test]
entrypoint = ["bash", "-c", "echo entrypoint-only"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "entrypoint-only",
"expected entrypoint to run on its own, got: {stdout}"
);
}
#[test]
fn config_command_alone_without_passthrough() {
let cfg = ConfigFile::new(
r#"
[profile.test]
command = ["bash", "-c", "echo command-only"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "command-only",
"expected config command to run on its own, got: {stdout}"
);
}
#[test]
fn config_command_replaced_by_passthrough() {
let cfg = ConfigFile::new(
r#"
[profile.test]
command = ["bash", "-c", "echo should-not-see-this"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.args(["--", "bash", "-c", "echo replaced"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "replaced",
"expected passthrough to replace config command, got: {stdout}"
);
}
#[test]
fn mask_nonexistent_path_becomes_tmpfs() {
let dir = TempDir::new().unwrap();