6e81866226
Flips the default sandbox mode from blacklist to whitelist and replaces the global RUST_TEST_THREADS=1 with a targeted RwLock that only serializes blacklist sandboxes against tests mutating glob-matching host paths. A new Sandbox newtype acquires the guard automatically when --blacklist is in args.
1476 lines
38 KiB
Rust
1476 lines
38 KiB
Rust
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"
|
|
|
|
[profiles.claude]
|
|
rw = ["/home/user/.config/claude"]
|
|
ro = ["/etc/claude", "/etc/shared"]
|
|
entrypoint = ["claude", "--dangerously-skip-permissions"]
|
|
command = ["bash", "-c", "echo hi"]
|
|
|
|
[profiles.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.profiles["claude"].hardened, None);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_multi_element_vecs() {
|
|
assert_paths(
|
|
&CONFIG.profiles["claude"].ro,
|
|
&["/etc/claude", "/etc/shared"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_entrypoint_with_args() {
|
|
assert_command(
|
|
&CONFIG.profiles["claude"].entrypoint,
|
|
&["claude", "--dangerously-skip-permissions"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_command_with_args() {
|
|
assert_command(
|
|
&CONFIG.profiles["claude"].command,
|
|
&["bash", "-c", "echo hi"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_kebab_case_scalars() {
|
|
let codex = &CONFIG.profiles["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()
|
|
},
|
|
profiles: 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 {
|
|
profiles: 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 {
|
|
profiles: 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()
|
|
},
|
|
profiles: 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 {
|
|
profiles: 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 {
|
|
profiles: 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::Whitelist));
|
|
assert!(!config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn build_top_level_profile_used_when_cli_absent() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
profile: Some("auto".into()),
|
|
..Options::default()
|
|
},
|
|
profiles: 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_top_level_profile() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
profile: Some("auto".into()),
|
|
..Options::default()
|
|
},
|
|
profiles: 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_top_level_profile_errors() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
profile: Some("nope".into()),
|
|
..Options::default()
|
|
},
|
|
..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 inheritance_merges_parent_into_child() {
|
|
let file_config = FileConfig {
|
|
profiles: HashMap::from([
|
|
(
|
|
"base".into(),
|
|
Options {
|
|
hardened: Some(true),
|
|
unshare_net: Some(true),
|
|
env: vec!["FROM_PARENT=1".into()],
|
|
..Options::default()
|
|
},
|
|
),
|
|
(
|
|
"child".into(),
|
|
Options {
|
|
profile: Some("base".into()),
|
|
hardened: Some(false),
|
|
seccomp: Some(false),
|
|
env: vec!["FROM_CHILD=1".into()],
|
|
..Options::default()
|
|
},
|
|
),
|
|
]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("child".into()),
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
|
|
assert!(!config.hardened);
|
|
assert!(config.unshare_net);
|
|
assert!(!config.seccomp);
|
|
assert_eq!(
|
|
config.env,
|
|
vec![
|
|
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
|
|
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inheritance_walks_multi_level_chain() {
|
|
let file_config = FileConfig {
|
|
profiles: HashMap::from([
|
|
(
|
|
"grand".into(),
|
|
Options {
|
|
hardened: Some(true),
|
|
env: vec!["FROM_GRAND=1".into()],
|
|
..Options::default()
|
|
},
|
|
),
|
|
(
|
|
"parent".into(),
|
|
Options {
|
|
profile: Some("grand".into()),
|
|
env: vec!["FROM_PARENT=1".into()],
|
|
..Options::default()
|
|
},
|
|
),
|
|
(
|
|
"child".into(),
|
|
Options {
|
|
profile: Some("parent".into()),
|
|
env: vec!["FROM_CHILD=1".into()],
|
|
..Options::default()
|
|
},
|
|
),
|
|
]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("child".into()),
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
|
|
assert!(config.hardened);
|
|
assert_eq!(
|
|
config.env,
|
|
vec![
|
|
EnvEntry::Set("FROM_GRAND".into(), "1".into()),
|
|
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
|
|
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_profile_walks_chain() {
|
|
let file_config = FileConfig {
|
|
options: Options {
|
|
profile: Some("child".into()),
|
|
..Options::default()
|
|
},
|
|
profiles: HashMap::from([
|
|
(
|
|
"base".into(),
|
|
Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
},
|
|
),
|
|
(
|
|
"child".into(),
|
|
Options {
|
|
profile: Some("base".into()),
|
|
..Options::default()
|
|
},
|
|
),
|
|
]),
|
|
..FileConfig::default()
|
|
};
|
|
let config = build(args_with_command(), Some(file_config)).unwrap();
|
|
assert!(config.hardened);
|
|
}
|
|
|
|
#[test]
|
|
fn inheritance_cycle_errors() {
|
|
let file_config = FileConfig {
|
|
profiles: HashMap::from([
|
|
(
|
|
"a".into(),
|
|
Options {
|
|
profile: Some("b".into()),
|
|
..Options::default()
|
|
},
|
|
),
|
|
(
|
|
"b".into(),
|
|
Options {
|
|
profile: Some("a".into()),
|
|
..Options::default()
|
|
},
|
|
),
|
|
]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("a".into()),
|
|
..args_with_command()
|
|
};
|
|
assert!(matches!(
|
|
build(args, Some(file_config)),
|
|
Err(SandboxError::ProfileCycle(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_parent_profile_errors() {
|
|
let file_config = FileConfig {
|
|
profiles: HashMap::from([(
|
|
"child".into(),
|
|
Options {
|
|
profile: Some("ghost".into()),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let args = Args {
|
|
profile: Some("child".into()),
|
|
..args_with_command()
|
|
};
|
|
assert!(matches!(
|
|
build(args, Some(file_config)),
|
|
Err(SandboxError::ProfileNotFound(name)) if name == "ghost"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn inheritance_chain_spans_extra_config() {
|
|
let dir = TempDir::new().unwrap();
|
|
let base_path = dir.path().join("base.toml");
|
|
let extra_path = dir.path().join("extra.toml");
|
|
|
|
std::fs::write(
|
|
&extra_path,
|
|
r#"
|
|
[profiles.child]
|
|
profile = "base"
|
|
env = ["FROM_CHILD=1"]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
&base_path,
|
|
format!(
|
|
r#"
|
|
extra-config = "{}"
|
|
|
|
[profiles.base]
|
|
hardened = true
|
|
env = ["FROM_PARENT=1"]
|
|
"#,
|
|
extra_path.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let file_config = FileConfig::load(&base_path).unwrap();
|
|
let args = Args {
|
|
profile: Some("child".into()),
|
|
..args_with_command()
|
|
};
|
|
let config = build(args, Some(file_config)).unwrap();
|
|
|
|
assert!(config.hardened);
|
|
assert_eq!(
|
|
config.env,
|
|
vec![
|
|
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
|
|
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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()
|
|
},
|
|
profiles: 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()
|
|
},
|
|
profiles: 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()
|
|
},
|
|
profiles: 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()
|
|
},
|
|
profiles: 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#"
|
|
[profiles.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)));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_options_scalar_extra_overrides_base() {
|
|
let base = Options {
|
|
hardened: Some(false),
|
|
..Options::default()
|
|
};
|
|
let extra = Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
};
|
|
assert_eq!(base.merge_with(extra).hardened, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_options_scalar_extra_unset_inherits_base() {
|
|
let base = Options {
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
};
|
|
assert_eq!(base.merge_with(Options::default()).hardened, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_options_extra_mode_replaces_base_mode() {
|
|
let base = Options {
|
|
whitelist: Some(true),
|
|
..Options::default()
|
|
};
|
|
let extra = Options {
|
|
blacklist: Some(true),
|
|
..Options::default()
|
|
};
|
|
let merged = base.merge_with(extra);
|
|
assert_eq!(merged.blacklist, Some(true));
|
|
assert_eq!(merged.whitelist, None);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_options_base_mode_inherited_when_extra_silent() {
|
|
let base = Options {
|
|
whitelist: Some(true),
|
|
..Options::default()
|
|
};
|
|
let merged = base.merge_with(Options::default());
|
|
assert_eq!(merged.whitelist, Some(true));
|
|
assert_eq!(merged.blacklist, None);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_options_lists_append_base_then_extra() {
|
|
let base = Options {
|
|
rw: vec!["/a".into()],
|
|
env: vec!["A=1".into()],
|
|
..Options::default()
|
|
};
|
|
let extra = Options {
|
|
rw: vec!["/b".into()],
|
|
env: vec!["B=2".into()],
|
|
..Options::default()
|
|
};
|
|
let merged = base.merge_with(extra);
|
|
assert_eq!(merged.rw, vec!["/a", "/b"]);
|
|
assert_eq!(merged.env, vec!["A=1", "B=2"]);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_file_config_profile_merged_by_name() {
|
|
let base = FileConfig {
|
|
profiles: HashMap::from([(
|
|
"claude".into(),
|
|
Options {
|
|
rw: vec!["/a".into()],
|
|
hardened: Some(false),
|
|
..Options::default()
|
|
},
|
|
)]),
|
|
..FileConfig::default()
|
|
};
|
|
let extra = FileConfig {
|
|
profiles: HashMap::from([
|
|
(
|
|
"claude".into(),
|
|
Options {
|
|
rw: vec!["/b".into()],
|
|
hardened: Some(true),
|
|
..Options::default()
|
|
},
|
|
),
|
|
(
|
|
"codex".into(),
|
|
Options {
|
|
unshare_net: Some(true),
|
|
..Options::default()
|
|
},
|
|
),
|
|
]),
|
|
..FileConfig::default()
|
|
};
|
|
let merged = base.merge_with(extra);
|
|
assert_eq!(merged.profiles["claude"].hardened, Some(true));
|
|
assert_eq!(merged.profiles["claude"].rw, vec!["/a", "/b"]);
|
|
assert_eq!(merged.profiles["codex"].unshare_net, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_file_config_top_level_profile_extra_overrides() {
|
|
let base = FileConfig {
|
|
options: Options {
|
|
profile: Some("claude".into()),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
let extra = FileConfig {
|
|
options: Options {
|
|
profile: Some("codex".into()),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
assert_eq!(base.merge_with(extra).options.profile, Some("codex".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_file_config_top_level_profile_extra_unset_inherits() {
|
|
let base = FileConfig {
|
|
options: Options {
|
|
profile: Some("claude".into()),
|
|
..Options::default()
|
|
},
|
|
..FileConfig::default()
|
|
};
|
|
assert_eq!(
|
|
base.merge_with(FileConfig::default()).options.profile,
|
|
Some("claude".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_extra_applies_overlay() {
|
|
let dir = TempDir::new().unwrap();
|
|
let base_path = dir.path().join("base.toml");
|
|
let extra_path = dir.path().join("extra.toml");
|
|
std::fs::write(&extra_path, "hardened = true\nrw = [\"/b\"]\n").unwrap();
|
|
std::fs::write(
|
|
&base_path,
|
|
format!(
|
|
"hardened = false\nrw = [\"/a\"]\nextra-config = \"{}\"\n",
|
|
extra_path.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let config = FileConfig::load(&base_path).unwrap();
|
|
assert_eq!(config.options.hardened, Some(true));
|
|
assert_eq!(config.options.rw, vec!["/a", "/b"]);
|
|
assert_eq!(config.extra_config, None);
|
|
}
|
|
|
|
#[test]
|
|
fn load_extra_missing_file_is_skipped() {
|
|
let dir = TempDir::new().unwrap();
|
|
let base_path = dir.path().join("base.toml");
|
|
let extra_path = dir.path().join("does-not-exist.toml");
|
|
std::fs::write(
|
|
&base_path,
|
|
format!(
|
|
"hardened = true\nextra-config = \"{}\"\n",
|
|
extra_path.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let config = FileConfig::load(&base_path).unwrap();
|
|
assert_eq!(config.options.hardened, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn load_extra_nested_rejected() {
|
|
let dir = TempDir::new().unwrap();
|
|
let base_path = dir.path().join("base.toml");
|
|
let extra_path = dir.path().join("extra.toml");
|
|
let deeper_path = dir.path().join("deeper.toml");
|
|
std::fs::write(&deeper_path, "hardened = true\n").unwrap();
|
|
std::fs::write(
|
|
&extra_path,
|
|
format!("extra-config = \"{}\"\n", deeper_path.display()),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
&base_path,
|
|
format!("extra-config = \"{}\"\n", extra_path.display()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(matches!(
|
|
FileConfig::load(&base_path),
|
|
Err(SandboxError::NestedExtraConfig(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn load_extra_relative_path_rejected() {
|
|
let dir = TempDir::new().unwrap();
|
|
let base_path = dir.path().join("base.toml");
|
|
std::fs::write(&base_path, "extra-config = \"extra.toml\"\n").unwrap();
|
|
|
|
assert!(matches!(
|
|
FileConfig::load(&base_path),
|
|
Err(SandboxError::ConfigPathNotAbsolute(_))
|
|
));
|
|
}
|
|
|
|
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);
|
|
}
|