1506 lines
44 KiB
Rust
1506 lines
44 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::ffi::OsString;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::cli::Args;
|
|
use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode};
|
|
|
|
pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfig, SandboxError> {
|
|
let (mut globals, mut profile) = match file_config {
|
|
Some(c) => {
|
|
let profile = c.resolve_profile(args.profile.as_deref())?;
|
|
(c.options, profile)
|
|
}
|
|
None => (Options::default(), Options::default()),
|
|
};
|
|
|
|
validate_mode(&globals)?;
|
|
validate_mode(&profile)?;
|
|
globals.validate_paths()?;
|
|
profile.validate_paths()?;
|
|
|
|
let (command, command_args) =
|
|
resolve_command(args.entrypoint, args.command_and_args, &profile, &globals)?;
|
|
let command = resolve_binary(&command)
|
|
.ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?;
|
|
|
|
let env = dedupe_env_last_wins(parse_env_entries(&merge_vecs(
|
|
args.env,
|
|
&profile.env,
|
|
&globals.env,
|
|
))?);
|
|
let unsetenv = merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv);
|
|
reject_env_key_conflicts(&env, &unsetenv)?;
|
|
|
|
Ok(SandboxConfig {
|
|
mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals),
|
|
hardened: merge_flag(
|
|
merge_flag_pair(args.hardened, args.no_hardened),
|
|
profile.hardened,
|
|
globals.hardened,
|
|
),
|
|
unshare_net: merge_flag(
|
|
merge_flag_pair(args.unshare_net, args.share_net),
|
|
profile.unshare_net,
|
|
globals.unshare_net,
|
|
),
|
|
seccomp: merge_flag_with_default(
|
|
merge_flag_pair(args.seccomp, args.no_seccomp),
|
|
profile.seccomp,
|
|
globals.seccomp,
|
|
true,
|
|
),
|
|
env_filter: merge_flag_with_default(
|
|
merge_flag_pair(args.env_filter, args.no_env_filter),
|
|
profile.env_filter,
|
|
globals.env_filter,
|
|
true,
|
|
),
|
|
dry_run: merge_flag(
|
|
merge_flag_pair(args.dry_run, args.no_dry_run),
|
|
profile.dry_run,
|
|
globals.dry_run,
|
|
),
|
|
chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?,
|
|
extra_rw: merge_bind_specs(args.extra_rw, &profile.rw, &globals.rw)?,
|
|
extra_ro: merge_bind_specs(args.extra_ro, &profile.ro, &globals.ro)?,
|
|
mask: merge_vecs(args.mask, &profile.mask, &globals.mask),
|
|
env,
|
|
unsetenv,
|
|
bwrap_args: split_bwrap_args(merge_vecs(
|
|
args.bwrap_args,
|
|
&profile.bwrap_args,
|
|
&globals.bwrap_args,
|
|
))?,
|
|
command,
|
|
command_args,
|
|
})
|
|
}
|
|
|
|
fn merge_mode(
|
|
cli_blacklist: bool,
|
|
cli_whitelist: bool,
|
|
profile: &Options,
|
|
globals: &Options,
|
|
) -> SandboxMode {
|
|
if cli_whitelist {
|
|
return SandboxMode::Whitelist;
|
|
}
|
|
if cli_blacklist {
|
|
return SandboxMode::Blacklist;
|
|
}
|
|
resolve_mode(profile)
|
|
.or_else(|| resolve_mode(globals))
|
|
.unwrap_or(SandboxMode::Blacklist)
|
|
}
|
|
|
|
fn resolve_mode(opts: &Options) -> Option<SandboxMode> {
|
|
match (opts.blacklist, opts.whitelist) {
|
|
(_, Some(true)) => Some(SandboxMode::Whitelist),
|
|
(Some(true), _) => Some(SandboxMode::Blacklist),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn merge_flag(cli: Option<bool>, profile: Option<bool>, globals: Option<bool>) -> bool {
|
|
cli.or(profile).or(globals).unwrap_or(false)
|
|
}
|
|
|
|
fn merge_flag_with_default(
|
|
cli: Option<bool>,
|
|
profile: Option<bool>,
|
|
globals: Option<bool>,
|
|
default: bool,
|
|
) -> bool {
|
|
cli.or(profile).or(globals).unwrap_or(default)
|
|
}
|
|
|
|
fn merge_flag_pair(enable: bool, disable: bool) -> Option<bool> {
|
|
if enable {
|
|
Some(true)
|
|
} else if disable {
|
|
Some(false)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn resolve_chdir(
|
|
cli: Option<PathBuf>,
|
|
profile: Option<PathBuf>,
|
|
globals: Option<PathBuf>,
|
|
) -> Result<PathBuf, SandboxError> {
|
|
match cli.or(profile).or(globals) {
|
|
Some(p) => std::fs::canonicalize(&p).map_err(|_| SandboxError::ChdirMissing(p)),
|
|
None => std::env::current_dir().map_err(SandboxError::CurrentDirUnavailable),
|
|
}
|
|
}
|
|
|
|
fn merge_bind_specs(
|
|
cli: Vec<String>,
|
|
profile: &[String],
|
|
globals: &[String],
|
|
) -> Result<Vec<BindSpec>, 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 resolve_cli_bind_spec(raw: &str) -> Result<BindSpec, SandboxError> {
|
|
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<BindSpec, SandboxError> {
|
|
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<PathBuf, SandboxError> {
|
|
let absolute =
|
|
std::path::absolute(path).map_err(|_| SandboxError::PathMissing(path.to_path_buf()))?;
|
|
if !absolute.exists() {
|
|
return Err(SandboxError::PathMissing(absolute));
|
|
}
|
|
Ok(absolute)
|
|
}
|
|
|
|
fn split_bwrap_args(raw: Vec<String>) -> Result<Vec<String>, 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<T: Clone>(cli: Vec<T>, profile: &[T], globals: &[T]) -> Vec<T> {
|
|
globals.iter().chain(profile).cloned().chain(cli).collect()
|
|
}
|
|
|
|
fn parse_env_entries(raw: &[String]) -> Result<Vec<EnvEntry>, SandboxError> {
|
|
raw.iter()
|
|
.map(|s| match s.split_once('=') {
|
|
None if s.is_empty() => Err(SandboxError::InvalidEnvEntry(s.clone())),
|
|
None => Ok(EnvEntry::Keep(s.clone())),
|
|
Some(("", _)) => Err(SandboxError::InvalidEnvEntry(s.clone())),
|
|
Some((key, value)) => Ok(EnvEntry::Set(key.to_string(), value.to_string())),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn dedupe_env_last_wins(entries: Vec<EnvEntry>) -> Vec<EnvEntry> {
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut result: Vec<EnvEntry> = Vec::new();
|
|
for entry in entries.into_iter().rev() {
|
|
if seen.insert(entry.key().to_string()) {
|
|
result.push(entry);
|
|
}
|
|
}
|
|
result.reverse();
|
|
result
|
|
}
|
|
|
|
fn reject_env_key_conflicts(env: &[EnvEntry], unsetenv: &[String]) -> Result<(), SandboxError> {
|
|
let env_keys: HashSet<&str> = env.iter().map(EnvEntry::key).collect();
|
|
let unsetenv_keys: HashSet<&str> = unsetenv.iter().map(String::as_str).collect();
|
|
match env_keys.intersection(&unsetenv_keys).next() {
|
|
Some(&key) => Err(SandboxError::ConflictingEnvKey(key.to_string())),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn resolve_command(
|
|
cli_entrypoint: Option<String>,
|
|
mut passthrough_args: Vec<OsString>,
|
|
profile: &Options,
|
|
globals: &Options,
|
|
) -> Result<(OsString, Vec<OsString>), SandboxError> {
|
|
let entrypoint = cli_entrypoint
|
|
.map(CommandValue::Simple)
|
|
.or_else(|| profile.entrypoint.clone())
|
|
.or_else(|| 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 Ok((cmd, args));
|
|
}
|
|
|
|
if !passthrough_args.is_empty() {
|
|
return Ok((passthrough_args.remove(0), passthrough_args));
|
|
}
|
|
if let Some(config_cmd) = command {
|
|
return Ok(config_cmd.into_binary_and_args());
|
|
}
|
|
Err(SandboxError::NoCommand)
|
|
}
|
|
|
|
fn resolve_binary(name: &OsString) -> Option<PathBuf> {
|
|
let path = PathBuf::from(name);
|
|
if path.is_absolute() || path.components().count() > 1 {
|
|
return path.is_file().then_some(path);
|
|
}
|
|
std::env::var_os("PATH").and_then(|path_var| {
|
|
std::env::split_paths(&path_var)
|
|
.map(|dir| dir.join(name))
|
|
.find(|p| p.is_file())
|
|
})
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct FileConfig {
|
|
#[serde(flatten)]
|
|
pub options: Options,
|
|
#[serde(default)]
|
|
pub profile: HashMap<String, Options>,
|
|
#[serde(rename = "default-profile", default)]
|
|
pub default_profile: Option<String>,
|
|
// Collects unrecognized keys; deny_unknown_fields is incompatible with flatten.
|
|
#[serde(flatten)]
|
|
_unknown: HashMap<String, toml::Value>,
|
|
}
|
|
|
|
impl FileConfig {
|
|
pub fn load(path: &Path) -> Result<Self, SandboxError> {
|
|
let contents = std::fs::read_to_string(path).map_err(|e| SandboxError::ConfigRead {
|
|
path: path.to_path_buf(),
|
|
source: e,
|
|
})?;
|
|
Self::parse(&contents).map_err(|e| match e {
|
|
SandboxError::ConfigParse { source, .. } => SandboxError::ConfigParse {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
},
|
|
other => other,
|
|
})
|
|
}
|
|
|
|
fn parse(contents: &str) -> Result<Self, SandboxError> {
|
|
let config: Self = toml::from_str(contents).map_err(|e| SandboxError::ConfigParse {
|
|
path: PathBuf::new(),
|
|
source: e,
|
|
})?;
|
|
if let Some(key) = config._unknown.keys().next() {
|
|
return Err(SandboxError::UnknownConfigKey(key.clone()));
|
|
}
|
|
Ok(config)
|
|
}
|
|
|
|
fn resolve_profile(&self, name: Option<&str>) -> Result<Options, SandboxError> {
|
|
match name.or(self.default_profile.as_deref()) {
|
|
Some(n) => self
|
|
.profile
|
|
.get(n)
|
|
.cloned()
|
|
.ok_or_else(|| SandboxError::ProfileNotFound(n.to_string())),
|
|
None => Ok(Options::default()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default, Clone)]
|
|
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
|
pub struct Options {
|
|
pub blacklist: Option<bool>,
|
|
pub whitelist: Option<bool>,
|
|
pub hardened: Option<bool>,
|
|
pub unshare_net: Option<bool>,
|
|
pub seccomp: Option<bool>,
|
|
pub env_filter: Option<bool>,
|
|
pub entrypoint: Option<CommandValue>,
|
|
pub command: Option<CommandValue>,
|
|
pub dry_run: Option<bool>,
|
|
pub chdir: Option<PathBuf>,
|
|
#[serde(default)]
|
|
pub rw: Vec<String>,
|
|
#[serde(default)]
|
|
pub ro: Vec<String>,
|
|
#[serde(default)]
|
|
pub mask: Vec<PathBuf>,
|
|
#[serde(default)]
|
|
pub env: Vec<String>,
|
|
#[serde(default)]
|
|
pub unsetenv: Vec<String>,
|
|
#[serde(default)]
|
|
pub bwrap_args: Vec<String>,
|
|
}
|
|
|
|
impl Options {
|
|
fn validate_paths(&mut self) -> Result<(), SandboxError> {
|
|
if let Some(ref chdir) = self.chdir {
|
|
self.chdir = Some(expand_and_canonicalize(chdir)?);
|
|
}
|
|
for p in &mut self.mask {
|
|
*p = expand_and_require_absolute(p)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn expand_and_require_existing(path: &Path) -> Result<PathBuf, SandboxError> {
|
|
let expanded = expand_and_require_absolute(path)?;
|
|
if !expanded.exists() {
|
|
return Err(SandboxError::PathMissing(expanded));
|
|
}
|
|
Ok(expanded)
|
|
}
|
|
|
|
fn expand_and_require_absolute(path: &Path) -> Result<PathBuf, SandboxError> {
|
|
let expanded = expand_tilde(path)?;
|
|
require_absolute(&expanded)?;
|
|
Ok(expanded)
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
#[serde(untagged)]
|
|
pub enum CommandValue {
|
|
Simple(String),
|
|
WithArgs(Vec<String>),
|
|
}
|
|
|
|
impl CommandValue {
|
|
pub fn into_vec(self) -> Vec<String> {
|
|
match self {
|
|
Self::Simple(s) => vec![s],
|
|
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> {
|
|
if let Some(p) = explicit {
|
|
return Some(p.to_path_buf());
|
|
}
|
|
let config_dir = std::env::var("XDG_CONFIG_HOME")
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.map(PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::var("HOME")
|
|
.ok()
|
|
.filter(|s| !s.is_empty())
|
|
.map(|h| PathBuf::from(h).join(".config"))
|
|
})?;
|
|
let path = config_dir.join("agent-sandbox/config.toml");
|
|
path.exists().then_some(path)
|
|
}
|
|
|
|
fn validate_mode(opts: &Options) -> Result<(), SandboxError> {
|
|
if opts.blacklist == Some(true) && opts.whitelist == Some(true) {
|
|
return Err(SandboxError::ConflictingMode);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn expand_tilde(path: &Path) -> Result<PathBuf, SandboxError> {
|
|
let s = path.to_string_lossy();
|
|
if !s.starts_with('~') {
|
|
return Ok(path.to_path_buf());
|
|
}
|
|
if s.len() > 1 && !s.starts_with("~/") {
|
|
return Err(SandboxError::ConfigPathNotAbsolute(path.to_path_buf()));
|
|
}
|
|
let home = std::env::var("HOME")
|
|
.ok()
|
|
.filter(|h| !h.is_empty())
|
|
.ok_or(SandboxError::HomeNotSet)?;
|
|
Ok(PathBuf::from(home).join(s.strip_prefix("~/").unwrap_or("")))
|
|
}
|
|
|
|
fn require_absolute(path: &Path) -> Result<(), SandboxError> {
|
|
if !path.is_absolute() {
|
|
return Err(SandboxError::ConfigPathNotAbsolute(path.to_path_buf()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn expand_and_canonicalize(path: &Path) -> Result<PathBuf, SandboxError> {
|
|
let expanded = expand_tilde(path)?;
|
|
require_absolute(&expanded)?;
|
|
std::fs::canonicalize(&expanded).map_err(|e| SandboxError::ConfigRead {
|
|
path: expanded,
|
|
source: e,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::LazyLock;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
|
|
const FULL_CONFIG_TOML: &str = r#"
|
|
hardened = true
|
|
unshare-net = true
|
|
seccomp = false
|
|
rw = ["/tmp/a", "/tmp/b"]
|
|
command = "zsh"
|
|
|
|
[profile.claude]
|
|
rw = ["/home/user/.config/claude"]
|
|
ro = ["/etc/claude", "/etc/shared"]
|
|
entrypoint = ["claude", "--dangerously-skip-permissions"]
|
|
command = ["bash", "-c", "echo hi"]
|
|
|
|
[profile.codex]
|
|
whitelist = true
|
|
dry-run = true
|
|
chdir = "/home/user/project"
|
|
rw = ["/home/user/.codex"]
|
|
"#;
|
|
|
|
static CONFIG: LazyLock<FileConfig> =
|
|
LazyLock::new(|| toml::from_str(FULL_CONFIG_TOML).unwrap());
|
|
|
|
#[test]
|
|
fn globals_scalars() {
|
|
assert_eq!(CONFIG.options.hardened, Some(true));
|
|
assert_eq!(CONFIG.options.unshare_net, Some(true));
|
|
assert_eq!(CONFIG.options.seccomp, Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn globals_multi_element_vecs() {
|
|
assert_paths(&CONFIG.options.rw, &["/tmp/a", "/tmp/b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn globals_simple_command() {
|
|
assert_command(&CONFIG.options.command, &["zsh"]);
|
|
}
|
|
|
|
#[test]
|
|
fn unset_vecs_default_empty() {
|
|
assert!(CONFIG.options.ro.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unset_scalars_are_none() {
|
|
assert_eq!(CONFIG.profile["claude"].hardened, None);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_multi_element_vecs() {
|
|
assert_paths(
|
|
&CONFIG.profile["claude"].ro,
|
|
&["/etc/claude", "/etc/shared"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_entrypoint_with_args() {
|
|
assert_command(
|
|
&CONFIG.profile["claude"].entrypoint,
|
|
&["claude", "--dangerously-skip-permissions"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_command_with_args() {
|
|
assert_command(
|
|
&CONFIG.profile["claude"].command,
|
|
&["bash", "-c", "echo hi"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_kebab_case_scalars() {
|
|
let codex = &CONFIG.profile["codex"];
|
|
assert_eq!(codex.whitelist, Some(true));
|
|
assert_eq!(codex.dry_run, Some(true));
|
|
assert_eq!(codex.chdir, Some(PathBuf::from("/home/user/project")));
|
|
}
|
|
|
|
fn args_with_command() -> Args {
|
|
Args {
|
|
command_and_args: vec!["/usr/bin/true".into()],
|
|
..Args::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_hardened_from_globals() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let config = build(args_with_command(), Some(file_config)).unwrap();
|
|
assert!(config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn build_profile_overrides_globals() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
},
|
|
profile: HashMap::from([(
|
|
"relaxed".into(),
|
|
Options {
|
|
hardened: Some(false),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("relaxed".into()),
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(!config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_flag_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
profile: HashMap::from([(
|
|
"nonet".into(),
|
|
Options {
|
|
unshare_net: Some(false),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("nonet".into()),
|
|
unshare_net: true,
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(config.unshare_net);
|
|
}
|
|
|
|
#[test]
|
|
fn build_seccomp_default_is_true() {
|
|
let config = build(args_with_command(), None).unwrap();
|
|
assert!(config.seccomp);
|
|
}
|
|
|
|
#[test]
|
|
fn build_seccomp_disabled_via_config() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
seccomp: Some(false),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let config = build(args_with_command(), Some(file_config)).unwrap();
|
|
assert!(!config.seccomp);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_seccomp_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
seccomp: Some(false),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
seccomp: true,
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(config.seccomp);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_no_seccomp_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
seccomp: Some(true),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
no_seccomp: true,
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(!config.seccomp);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_no_hardened_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
no_hardened: true,
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(!config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_share_net_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
unshare_net: Some(true),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
share_net: true,
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(!config.unshare_net);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_no_dry_run_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
dry_run: Some(true),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
no_dry_run: true,
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(!config.dry_run);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_mode_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
profile: HashMap::from([(
|
|
"wl".into(),
|
|
Options {
|
|
whitelist: Some(true),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("wl".into()),
|
|
blacklist: true,
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(matches!(config.mode, SandboxMode::Blacklist));
|
|
}
|
|
|
|
#[test]
|
|
fn build_rw_paths_accumulate() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
rw: vec!["/tmp".into()],
|
|
..Options::default()
|
|
},
|
|
profile: HashMap::from([(
|
|
"extra".into(),
|
|
Options {
|
|
rw: vec!["/usr".into()],
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("extra".into()),
|
|
extra_rw: vec!["/var".into()],
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(config.extra_rw.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn build_command_from_profile() {
|
|
let file_config = FileConfig {
|
|
profile: HashMap::from([(
|
|
"test".into(),
|
|
Options {
|
|
command: Some(CommandValue::WithArgs(vec![
|
|
"/usr/bin/true".into(),
|
|
"--flag".into(),
|
|
])),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("test".into()),
|
|
..Args::default()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(config.command, PathBuf::from("/usr/bin/true"));
|
|
assert_eq!(config.command_args, vec![OsString::from("--flag")]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_entrypoint_overrides_profile() {
|
|
let file_config = FileConfig {
|
|
profile: HashMap::from([(
|
|
"test".into(),
|
|
Options {
|
|
entrypoint: Some(CommandValue::Simple("/usr/bin/false".into())),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("test".into()),
|
|
entrypoint: Some("/usr/bin/true".into()),
|
|
..Args::default()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(config.command, PathBuf::from("/usr/bin/true"));
|
|
assert!(config.command_args.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_entrypoint_combines_with_passthrough_args() {
|
|
let args = Args {
|
|
entrypoint: Some("/usr/bin/true".into()),
|
|
command_and_args: vec!["--flag".into(), "value".into()],
|
|
..Args::default()
|
|
};
|
|
let config = build(args, None).unwrap();
|
|
assert_eq!(config.command, PathBuf::from("/usr/bin/true"));
|
|
assert_eq!(
|
|
config.command_args,
|
|
vec![OsString::from("--flag"), OsString::from("value")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_entrypoint_falls_back_to_config_command_defaults() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
command: Some(CommandValue::WithArgs(vec![
|
|
"--default".into(),
|
|
"args".into(),
|
|
])),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
entrypoint: Some("/usr/bin/true".into()),
|
|
..Args::default()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(config.command, PathBuf::from("/usr/bin/true"));
|
|
assert_eq!(
|
|
config.command_args,
|
|
vec![OsString::from("--default"), OsString::from("args")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_command_overrides_config() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
command: Some(CommandValue::Simple("/usr/bin/false".into())),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
command_and_args: vec!["/usr/bin/true".into()],
|
|
..Args::default()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(config.command, PathBuf::from("/usr/bin/true"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_no_file_config() {
|
|
let config = build(args_with_command(), None).unwrap();
|
|
assert!(matches!(config.mode, SandboxMode::Blacklist));
|
|
assert!(!config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn build_default_profile_used_when_cli_absent() {
|
|
let file_config = FileConfig {
|
|
default_profile: Some("auto".into()),
|
|
profile: HashMap::from([(
|
|
"auto".into(),
|
|
Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let config = build(args_with_command(), Some(file_config)).unwrap();
|
|
assert!(config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn build_cli_profile_overrides_default_profile() {
|
|
let file_config = FileConfig {
|
|
default_profile: Some("auto".into()),
|
|
profile: HashMap::from([
|
|
(
|
|
"auto".into(),
|
|
Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
},
|
|
),
|
|
(
|
|
"other".into(),
|
|
Options {
|
|
hardened: Some(false),
|
|
..Options::default()
|
|
},
|
|
),
|
|
]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("other".into()),
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert!(!config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn build_missing_default_profile_errors() {
|
|
let file_config = FileConfig {
|
|
default_profile: Some("nope".into()),
|
|
..FileConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
build(args_with_command(), Some(file_config)),
|
|
Err(SandboxError::ProfileNotFound(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_missing_profile_errors() {
|
|
let args = Args {
|
|
profile: Some("nope".into()),
|
|
..args_with_command()
|
|
};
|
|
assert!(matches!(
|
|
build(args, Some(FileConfig::default())),
|
|
Err(SandboxError::ProfileNotFound(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_conflicting_mode_errors() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
blacklist: Some(true),
|
|
whitelist: Some(true),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
build(args_with_command(), Some(file_config)),
|
|
Err(SandboxError::ConflictingMode)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_tilde_expansion_in_config_paths() {
|
|
let home = std::env::var("HOME").unwrap();
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
rw: vec!["~/".into()],
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let config = build(args_with_command(), Some(file_config)).unwrap();
|
|
assert_eq!(config.extra_rw, vec![same_bind(Path::new(&home))]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preserves_symlinks_in_config_paths() {
|
|
let dir = TempDir::new().unwrap();
|
|
let target = dir.path().join("target");
|
|
let link = dir.path().join("link");
|
|
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_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![same_bind(&link)]);
|
|
assert_eq!(config.extra_rw, vec![same_bind(&link)]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preserves_symlinks_in_cli_paths() {
|
|
let dir = TempDir::new().unwrap();
|
|
let target = dir.path().join("target");
|
|
let link = dir.path().join("link");
|
|
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_str.clone()],
|
|
extra_rw: vec![link_str],
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, None).unwrap();
|
|
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!["relative/path".into()],
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
build(args_with_command(), Some(file_config)),
|
|
Err(SandboxError::ConfigPathNotAbsolute(_))
|
|
));
|
|
}
|
|
|
|
#[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 {
|
|
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_env_accumulates_disjoint_keys() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
env: vec!["A=global".into(), "B".into()],
|
|
..Options::default()
|
|
},
|
|
profile: HashMap::from([(
|
|
"p".into(),
|
|
Options {
|
|
env: vec!["C=profile".into()],
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("p".into()),
|
|
env: vec!["D".into()],
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(
|
|
config.env,
|
|
vec![
|
|
EnvEntry::Set("A".into(), "global".into()),
|
|
EnvEntry::Keep("B".into()),
|
|
EnvEntry::Set("C".into(), "profile".into()),
|
|
EnvEntry::Keep("D".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_later_tier_overrides_earlier() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
env: vec!["A=global".into()],
|
|
..Options::default()
|
|
},
|
|
profile: HashMap::from([(
|
|
"p".into(),
|
|
Options {
|
|
env: vec!["A=profile".into()],
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("p".into()),
|
|
env: vec!["A=cli".into()],
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(config.env, vec![EnvEntry::Set("A".into(), "cli".into())]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_conflicts_with_unsetenv() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
env: vec!["X=1".into()],
|
|
unsetenv: vec!["X".into()],
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
build(args_with_command(), Some(file_config)),
|
|
Err(SandboxError::ConflictingEnvKey(k)) if k == "X"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_keep_conflicts_with_unsetenv() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
env: vec!["X".into()],
|
|
unsetenv: vec!["X".into()],
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
build(args_with_command(), Some(file_config)),
|
|
Err(SandboxError::ConflictingEnvKey(k)) if k == "X"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_rejects_empty_key() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
env: vec!["=foo".into()],
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
assert!(matches!(
|
|
build(args_with_command(), Some(file_config)),
|
|
Err(SandboxError::InvalidEnvEntry(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_set_to_empty_string_is_allowed() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
env: vec!["DEBUG=".into()],
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let config = build(args_with_command(), Some(file_config)).unwrap();
|
|
assert_eq!(config.env, vec![EnvEntry::Set("DEBUG".into(), "".into())]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_unsetenv_accumulates() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
unsetenv: vec!["G".into()],
|
|
..Options::default()
|
|
},
|
|
profile: HashMap::from([(
|
|
"p".into(),
|
|
Options {
|
|
unsetenv: vec!["P".into()],
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("p".into()),
|
|
unsetenv: vec!["C".into()],
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
assert_eq!(config.unsetenv, vec!["G", "P", "C"]);
|
|
}
|
|
|
|
#[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()
|
|
},
|
|
)]),
|
|
..FileConfig::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);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_option_rejected() {
|
|
let toml = r#"
|
|
hardened = true
|
|
bogus = "nope"
|
|
"#;
|
|
assert!(matches!(
|
|
FileConfig::parse(toml),
|
|
Err(SandboxError::UnknownConfigKey(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_profile_option_rejected() {
|
|
let toml = r#"
|
|
[profile.test]
|
|
hardened = true
|
|
frobnicate = 42
|
|
"#;
|
|
assert!(matches!(
|
|
FileConfig::parse(toml),
|
|
Err(SandboxError::ConfigParse { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn entrypoint_with_passthrough_args() {
|
|
let (cmd, args) = resolve_command(
|
|
None,
|
|
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(),
|
|
)
|
|
.unwrap();
|
|
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(
|
|
None,
|
|
vec![],
|
|
&Options {
|
|
entrypoint: Some(CommandValue::Simple("claude".into())),
|
|
command: Some(CommandValue::WithArgs(vec![
|
|
"--dangerously-skip-permissions".into(),
|
|
"--verbose".into(),
|
|
])),
|
|
..Options::default()
|
|
},
|
|
&Options::default(),
|
|
)
|
|
.unwrap();
|
|
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(
|
|
None,
|
|
vec![],
|
|
&Options {
|
|
entrypoint: Some(CommandValue::WithArgs(vec![
|
|
"claude".into(),
|
|
"--dangerously-skip-permissions".into(),
|
|
])),
|
|
..Options::default()
|
|
},
|
|
&Options::default(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(cmd, "claude");
|
|
assert_eq!(args, vec![OsString::from("--dangerously-skip-permissions")]);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_entrypoint_overrides_global() {
|
|
let (cmd, _) = resolve_command(
|
|
None,
|
|
vec![],
|
|
&Options {
|
|
entrypoint: Some(CommandValue::Simple("claude".into())),
|
|
..Options::default()
|
|
},
|
|
&Options {
|
|
entrypoint: Some(CommandValue::Simple("codex".into())),
|
|
..Options::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(cmd, "claude");
|
|
}
|
|
|
|
#[test]
|
|
fn global_entrypoint_with_profile_command() {
|
|
let (cmd, args) = resolve_command(
|
|
None,
|
|
vec![],
|
|
&Options {
|
|
command: Some(CommandValue::WithArgs(vec![
|
|
"--dangerously-skip-permissions".into(),
|
|
"--verbose".into(),
|
|
])),
|
|
..Options::default()
|
|
},
|
|
&Options {
|
|
entrypoint: Some(CommandValue::Simple("claude".into())),
|
|
..Options::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(cmd, "claude");
|
|
assert_eq!(
|
|
args,
|
|
vec!["--dangerously-skip-permissions", "--verbose"]
|
|
.into_iter()
|
|
.map(OsString::from)
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
#[test]
|
|
fn no_command_errors() {
|
|
let result = resolve_command(None, vec![], &Options::default(), &Options::default());
|
|
assert!(matches!(result, Err(SandboxError::NoCommand)));
|
|
}
|
|
|
|
fn assert_paths(actual: &[String], expected: &[&str]) {
|
|
let expected: Vec<String> = 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<CommandValue>, expected: &[&str]) {
|
|
let actual = cmd.clone().unwrap().into_vec();
|
|
let expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
|
assert_eq!(actual, expected);
|
|
}
|
|
}
|