Add entrypoint option
This commit is contained in:
165
src/config.rs
165
src/config.rs
@@ -21,11 +21,7 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
|
|||||||
globals.validate_paths()?;
|
globals.validate_paths()?;
|
||||||
profile.validate_paths()?;
|
profile.validate_paths()?;
|
||||||
|
|
||||||
let (command, command_args) = resolve_command(
|
let (command, command_args) = resolve_command(args.command_and_args, &profile, &globals);
|
||||||
args.command_and_args,
|
|
||||||
profile.command.clone(),
|
|
||||||
globals.command.clone(),
|
|
||||||
);
|
|
||||||
let command = resolve_binary(&command)
|
let command = resolve_binary(&command)
|
||||||
.ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&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(
|
fn resolve_command(
|
||||||
mut positional: Vec<OsString>,
|
mut passthrough_args: Vec<OsString>,
|
||||||
profile_cmd: Option<CommandValue>,
|
profile: &Options,
|
||||||
globals_cmd: Option<CommandValue>,
|
globals: &Options,
|
||||||
) -> (OsString, Vec<OsString>) {
|
) -> (OsString, Vec<OsString>) {
|
||||||
if !positional.is_empty() {
|
let entrypoint = profile.entrypoint.clone().or(globals.entrypoint.clone());
|
||||||
let cmd = positional.remove(0);
|
let command = profile.command.clone().or(globals.command.clone());
|
||||||
return (cmd, positional);
|
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
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();
|
|
||||||
return (cmd, args);
|
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") {
|
if let Ok(cmd) = std::env::var("SANDBOX_CMD") {
|
||||||
return (OsString::from(cmd), vec![]);
|
return (OsString::from(cmd), vec![]);
|
||||||
}
|
}
|
||||||
@@ -210,6 +217,7 @@ pub struct Options {
|
|||||||
pub whitelist: Option<bool>,
|
pub whitelist: Option<bool>,
|
||||||
pub hardened: Option<bool>,
|
pub hardened: Option<bool>,
|
||||||
pub no_net: Option<bool>,
|
pub no_net: Option<bool>,
|
||||||
|
pub entrypoint: Option<CommandValue>,
|
||||||
pub command: Option<CommandValue>,
|
pub command: Option<CommandValue>,
|
||||||
pub dry_run: Option<bool>,
|
pub dry_run: Option<bool>,
|
||||||
pub chdir: Option<PathBuf>,
|
pub chdir: Option<PathBuf>,
|
||||||
@@ -261,6 +269,13 @@ impl CommandValue {
|
|||||||
Self::WithArgs(v) => v,
|
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> {
|
pub fn find_config_path(explicit: Option<&Path>) -> Option<PathBuf> {
|
||||||
@@ -334,6 +349,7 @@ mod tests {
|
|||||||
[profile.claude]
|
[profile.claude]
|
||||||
rw = ["/home/user/.config/claude"]
|
rw = ["/home/user/.config/claude"]
|
||||||
ro = ["/etc/claude", "/etc/shared"]
|
ro = ["/etc/claude", "/etc/shared"]
|
||||||
|
entrypoint = ["claude", "--dangerously-skip-permissions"]
|
||||||
command = ["bash", "-c", "echo hi"]
|
command = ["bash", "-c", "echo hi"]
|
||||||
|
|
||||||
[profile.codex]
|
[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]
|
#[test]
|
||||||
fn profile_command_with_args() {
|
fn profile_command_with_args() {
|
||||||
assert_command(
|
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]) {
|
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);
|
||||||
|
|||||||
@@ -15,10 +15,28 @@ fn sandbox(extra_args: &[&str]) -> Command {
|
|||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_config(dir: &TempDir, content: &str) -> 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");
|
let path = dir.path().join("config.toml");
|
||||||
fs::write(&path, content).expect("failed to write config");
|
fs::write(&path, content).expect("failed to write config");
|
||||||
path.to_str().unwrap().to_string()
|
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 {
|
fn read_sid_from_stat(stat: &str) -> u32 {
|
||||||
@@ -582,8 +600,7 @@ fn config_missing_file_errors() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_invalid_toml_errors() {
|
fn config_invalid_toml_errors() {
|
||||||
let dir = TempDir::new().unwrap();
|
let cfg = ConfigFile::new("not valid {{{{ toml");
|
||||||
let cfg = write_config(&dir, "not valid {{{{ toml");
|
|
||||||
|
|
||||||
let output = sandbox_withconfig(&["--config", &cfg])
|
let output = sandbox_withconfig(&["--config", &cfg])
|
||||||
.args(["--", "true"])
|
.args(["--", "true"])
|
||||||
@@ -600,8 +617,7 @@ fn config_invalid_toml_errors() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_unknown_key_errors() {
|
fn config_unknown_key_errors() {
|
||||||
let dir = TempDir::new().unwrap();
|
let cfg = ConfigFile::new("hardened = true\nbogus = \"nope\"\n");
|
||||||
let cfg = write_config(&dir, "hardened = true\nbogus = \"nope\"\n");
|
|
||||||
|
|
||||||
let output = sandbox_withconfig(&["--config", &cfg])
|
let output = sandbox_withconfig(&["--config", &cfg])
|
||||||
.args(["--", "true"])
|
.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]
|
#[test]
|
||||||
fn mask_nonexistent_path_becomes_tmpfs() {
|
fn mask_nonexistent_path_becomes_tmpfs() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user