From 7f9b21ef4fb2700d2df190221bf49642837793f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Sat, 25 Apr 2026 15:10:42 +0200 Subject: [PATCH] Organize test code better --- Cargo.toml | 5 + src/config.rs | 1241 +------------------------------- src/env.rs | 41 +- src/seccomp.rs | 80 +-- tests/e2e/cli.rs | 252 +++++++ tests/e2e/common.rs | 40 ++ tests/e2e/env.rs | 359 ++++++++++ tests/e2e/main.rs | 8 + tests/e2e/modes.rs | 200 ++++++ tests/e2e/mounts.rs | 392 +++++++++++ tests/e2e/namespaces.rs | 117 ++++ tests/e2e/seccomp.rs | 124 ++++ tests/integration.rs | 1474 --------------------------------------- tests/unit/config.rs | 1236 ++++++++++++++++++++++++++++++++ tests/unit/env.rs | 37 + tests/unit/seccomp.rs | 76 ++ 16 files changed, 2852 insertions(+), 2830 deletions(-) create mode 100644 tests/e2e/cli.rs create mode 100644 tests/e2e/common.rs create mode 100644 tests/e2e/env.rs create mode 100644 tests/e2e/main.rs create mode 100644 tests/e2e/modes.rs create mode 100644 tests/e2e/mounts.rs create mode 100644 tests/e2e/namespaces.rs create mode 100644 tests/e2e/seccomp.rs delete mode 100644 tests/integration.rs create mode 100644 tests/unit/config.rs create mode 100644 tests/unit/env.rs create mode 100644 tests/unit/seccomp.rs diff --git a/Cargo.toml b/Cargo.toml index 5e65a91..cf1f0da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "agent-sandbox" version = "0.1.0" edition = "2024" +autotests = false [lib] name = "agent_sandbox" @@ -11,6 +12,10 @@ path = "src/lib.rs" name = "agent-sandbox" path = "src/main.rs" +[[test]] +name = "e2e" +path = "tests/e2e/main.rs" + [dependencies] clap = { version = "4", features = ["derive"] } glob = "0.3" diff --git a/src/config.rs b/src/config.rs index 158dfa8..97d632b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -547,1242 +547,5 @@ fn expand_and_canonicalize(path: &Path) -> Result { } #[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 = - 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::>() - ); - } - - #[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::>() - ); - } - - #[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::>() - ); - } - #[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 { - profile: HashMap::from([( - "claude".into(), - Options { - rw: vec!["/a".into()], - hardened: Some(false), - ..Options::default() - }, - )]), - ..FileConfig::default() - }; - let extra = FileConfig { - profile: 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.profile["claude"].hardened, Some(true)); - assert_eq!(merged.profile["claude"].rw, vec!["/a", "/b"]); - assert_eq!(merged.profile["codex"].unshare_net, Some(true)); - } - - #[test] - fn merge_file_config_default_profile_extra_overrides() { - let base = FileConfig { - default_profile: Some("claude".into()), - ..FileConfig::default() - }; - let extra = FileConfig { - default_profile: Some("codex".into()), - ..FileConfig::default() - }; - assert_eq!(base.merge_with(extra).default_profile, Some("codex".into())); - } - - #[test] - fn merge_file_config_default_profile_extra_unset_inherits() { - let base = FileConfig { - default_profile: Some("claude".into()), - ..FileConfig::default() - }; - assert_eq!( - base.merge_with(FileConfig::default()).default_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 = 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, expected: &[&str]) { - let actual = cmd.clone().unwrap().into_vec(); - let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual, expected); - } -} +#[path = "../tests/unit/config.rs"] +mod tests; diff --git a/src/env.rs b/src/env.rs index 6609628..b09af0a 100644 --- a/src/env.rs +++ b/src/env.rs @@ -164,42 +164,5 @@ const BLACKLIST_DROP_SUFFIXES: &[&str] = &[ ]; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn keepenv_emits_setenv_for_present_key() { - let parent = vec![("XDG_RUNTIME_DIR".into(), "/run/user/1000".into())]; - let args = keepenv_args(&["XDG_RUNTIME_DIR".into()], &parent); - assert_eq!(args, vec!["--setenv", "XDG_RUNTIME_DIR", "/run/user/1000"]); - } - - #[test] - fn keepenv_skips_absent_keys() { - let parent = vec![("HOME".into(), "/home/me".into())]; - let args = keepenv_args(&["XDG_RUNTIME_DIR".into()], &parent); - assert!(args.is_empty()); - } - - #[test] - fn keepenv_preserves_caller_key_order() { - let parent = vec![ - ("B".into(), "2".into()), - ("A".into(), "1".into()), - ("C".into(), "3".into()), - ]; - let args = keepenv_args(&["A".into(), "B".into(), "C".into()], &parent); - assert_eq!( - args, - vec![ - "--setenv", "A", "1", "--setenv", "B", "2", "--setenv", "C", "3" - ] - ); - } - - #[test] - fn keepenv_empty_keys_yields_nothing() { - let parent = vec![("A".into(), "1".into())]; - assert!(keepenv_args(&[], &parent).is_empty()); - } -} +#[path = "../tests/unit/env.rs"] +mod tests; diff --git a/src/seccomp.rs b/src/seccomp.rs index b97c7fc..e8a6b63 100644 --- a/src/seccomp.rs +++ b/src/seccomp.rs @@ -164,81 +164,5 @@ fn serialize(program: &[sock_filter]) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_on_supported_arch() { - let bytes = build_program_bytes().expect("seccomp program should build"); - assert!(!bytes.is_empty(), "serialized BPF program is empty"); - assert_eq!(bytes.len() % 8, 0, "BPF byte stream must be 8-byte aligned"); - } - - #[test] - fn allowlist_contains_essential_syscalls() { - for needed in &[ - "read", - "write", - "openat", - "close", - "execve", - "exit_group", - "mmap", - "brk", - "clone", - ] { - assert!( - ALLOWED_SYSCALLS.contains(needed), - "allowlist missing essential syscall: {needed}" - ); - } - } - - #[test] - fn allowlist_excludes_dangerous_syscalls() { - for denied in &[ - "bpf", - "perf_event_open", - "userfaultfd", - "kexec_load", - "kexec_file_load", - "init_module", - "finit_module", - "delete_module", - "mount", - "umount", - "umount2", - "unshare", - "setns", - "pivot_root", - "ptrace", - "process_vm_readv", - "process_vm_writev", - "keyctl", - "personality", - "clone3", - "io_uring_setup", - "io_uring_register", - "io_uring_enter", - "fanotify_init", - "fanotify_mark", - "open_by_handle_at", - "name_to_handle_at", - "fsopen", - "fsconfig", - "fsmount", - "fspick", - "open_tree", - "move_mount", - "mount_setattr", - "reboot", - "swapon", - "swapoff", - ] { - assert!( - !ALLOWED_SYSCALLS.contains(denied), - "allowlist must not contain dangerous syscall: {denied}" - ); - } - } -} +#[path = "../tests/unit/seccomp.rs"] +mod tests; diff --git a/tests/e2e/cli.rs b/tests/e2e/cli.rs new file mode 100644 index 0000000..18d7384 --- /dev/null +++ b/tests/e2e/cli.rs @@ -0,0 +1,252 @@ +use crate::common::*; + +#[test] +fn dry_run_prints_and_exits() { + let output = sandbox(&["--dry-run"]) + .args(["--", "bash", "-c", "exit 42"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("bwrap"), + "expected bwrap command in dry-run output, got: {stdout}" + ); + assert!( + output.status.success(), + "dry-run should exit 0, not 42 from the inner command" + ); +} + +#[test] +fn dry_run_output_is_copy_pasteable_shell() { + let dry = sandbox(&["--dry-run"]) + .args(["--", "bash", "-c", "echo $HOME"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let dry_cmd = String::from_utf8_lossy(&dry.stdout).trim().to_string(); + let args = shlex::split(&dry_cmd).expect("dry-run output is not valid shell"); + assert_eq!(args[0], "bwrap"); + assert_eq!(args[args.len() - 1], "echo $HOME"); + assert_eq!(args[args.len() - 2], "-c"); +} + +#[test] +fn empty_home_rejected() { + let output = sandbox(&[]) + .env("HOME", "") + .args(["--", "true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + !output.status.success(), + "expected failure with empty HOME, but got success" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.to_lowercase().contains("home"), + "expected error mentioning HOME, got: {stderr}" + ); +} + +#[test] +fn config_missing_file_errors() { + let output = sandbox_withconfig(&["--config", "/nonexistent/config.toml"]) + .args(["--", "true"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("/nonexistent/config.toml"), + "expected config path in error, got: {stderr}" + ); +} + +#[test] +fn config_invalid_toml_errors() { + let cfg = ConfigFile::new("not valid {{{{ toml"); + + let output = sandbox_withconfig(&["--config", &cfg]) + .args(["--", "true"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("cannot parse"), + "expected parse error, got: {stderr}" + ); +} + +#[test] +fn config_unknown_key_errors() { + let cfg = ConfigFile::new("hardened = true\nbogus = \"nope\"\n"); + + let output = sandbox_withconfig(&["--config", &cfg]) + .args(["--", "true"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("unknown config key"), + "expected unknown key error, got: {stderr}" + ); +} + +#[test] +fn bwrap_arg_setenv_passes_through() { + let output = sandbox(&["--bwrap-arg", "--setenv MYVAR hello"]) + .args(["--", "bash", "-c", "echo $MYVAR"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "hello", + "expected --bwrap-arg to pass --setenv through to bwrap, got: {stdout}" + ); +} + +#[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 cli_entrypoint_appends_passthrough_args() { + let output = sandbox(&["--entrypoint", "bash"]) + .args(["--", "-c", "echo cli-entrypoint-works"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "cli-entrypoint-works", + "expected --entrypoint to receive trailing args, got: {stdout}" + ); +} + +#[test] +fn cli_entrypoint_overrides_config_entrypoint() { + let cfg = ConfigFile::new( + r#" + entrypoint = ["/bin/false"] + "#, + ); + + let output = sandbox_withconfig(&["--config", &cfg, "--entrypoint", "bash"]) + .args(["--", "-c", "echo override-works"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "override-works", + "expected CLI --entrypoint to override config entrypoint, 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}" + ); +} diff --git a/tests/e2e/common.rs b/tests/e2e/common.rs new file mode 100644 index 0000000..fc8b223 --- /dev/null +++ b/tests/e2e/common.rs @@ -0,0 +1,40 @@ +use std::fs; +use std::process::Command; + +use tempfile::TempDir; + +pub fn sandbox_withconfig(extra_args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_agent-sandbox")); + cmd.args(extra_args); + cmd +} + +pub fn sandbox(extra_args: &[&str]) -> Command { + let mut cmd = sandbox_withconfig(&["--no-config"]); + cmd.args(extra_args); + cmd +} + +pub struct ConfigFile { + _dir: TempDir, + path: String, +} + +impl ConfigFile { + pub 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 + } +} diff --git a/tests/e2e/env.rs b/tests/e2e/env.rs new file mode 100644 index 0000000..8599246 --- /dev/null +++ b/tests/e2e/env.rs @@ -0,0 +1,359 @@ +use crate::common::*; + +fn printenv_inside(args: &[&str], vars: &[(&str, &str)], query: &[&str]) -> String { + let script = query + .iter() + .map(|v| format!("printenv {v} || echo MISSING:{v}")) + .collect::>() + .join("; "); + let mut cmd = sandbox(args); + for (k, v) in vars { + cmd.env(k, v); + } + let output = cmd + .args(["--", "bash", "-c", &script]) + .output() + .expect("agent-sandbox binary failed to execute"); + String::from_utf8_lossy(&output.stdout).into_owned() +} +#[test] +fn whitelist_keeps_identity_and_terminal_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[("TERM", "xterm-test"), ("LANG", "C.UTF-8")], + &["HOME", "PATH", "TERM", "LANG"], + ); + assert!(!stdout.contains("MISSING:HOME"), "HOME stripped: {stdout}"); + assert!(!stdout.contains("MISSING:PATH"), "PATH stripped: {stdout}"); + assert!(stdout.contains("xterm-test"), "TERM stripped: {stdout}"); + assert!(stdout.contains("C.UTF-8"), "LANG stripped: {stdout}"); +} + +#[test] +fn whitelist_strips_arbitrary_host_var() { + let stdout = printenv_inside( + &["--whitelist"], + &[("SOME_RANDOM_NOISE_VAR", "leak")], + &["SOME_RANDOM_NOISE_VAR"], + ); + assert!( + stdout.contains("MISSING:SOME_RANDOM_NOISE_VAR"), + "expected arbitrary host var to be stripped, got: {stdout}" + ); + assert!(!stdout.contains("leak")); +} + +#[test] +fn whitelist_keeps_vendor_prefixes() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("CLAUDE_FOO", "claude-val"), + ("ANTHROPIC_MODEL", "anthropic-val"), + ("OPENAI_API_KEY", "openai-val"), + ("CODEX_FOO", "codex-val"), + ("GEMINI_API_KEY", "gemini-val"), + ("OTEL_SERVICE_NAME", "otel-val"), + ], + &[ + "CLAUDE_FOO", + "ANTHROPIC_MODEL", + "OPENAI_API_KEY", + "CODEX_FOO", + "GEMINI_API_KEY", + "OTEL_SERVICE_NAME", + ], + ); + for expected in [ + "claude-val", + "anthropic-val", + "openai-val", + "codex-val", + "gemini-val", + "otel-val", + ] { + assert!( + stdout.contains(expected), + "expected {expected} in output, got: {stdout}" + ); + } + assert!(!stdout.contains("MISSING:"), "unexpected strip: {stdout}"); +} + +#[test] +fn whitelist_keeps_lc_prefix() { + let stdout = printenv_inside( + &["--whitelist"], + &[("LC_TIME", "en_US.UTF-8")], + &["LC_TIME"], + ); + assert!(stdout.contains("en_US.UTF-8"), "LC_TIME missing: {stdout}"); +} + +#[test] +fn whitelist_keeps_non_gui_xdg_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("XDG_CONFIG_HOME", "/cfg"), + ("XDG_DATA_HOME", "/data"), + ("XDG_CACHE_HOME", "/cache"), + ("XDG_STATE_HOME", "/state"), + ("XDG_CONFIG_DIRS", "/etc/xdg"), + ("XDG_DATA_DIRS", "/usr/share"), + ], + &[ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "XDG_CONFIG_DIRS", + "XDG_DATA_DIRS", + ], + ); + assert!( + !stdout.contains("MISSING:"), + "XDG non-GUI stripped: {stdout}" + ); +} + +#[test] +fn whitelist_strips_gui_xdg_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("XDG_SESSION_ID", "1"), + ("XDG_CURRENT_DESKTOP", "KDE"), + ("XDG_SEAT", "seat0"), + ], + &[ + "XDG_RUNTIME_DIR", + "XDG_SESSION_ID", + "XDG_CURRENT_DESKTOP", + "XDG_SEAT", + ], + ); + for var in [ + "XDG_RUNTIME_DIR", + "XDG_SESSION_ID", + "XDG_CURRENT_DESKTOP", + "XDG_SEAT", + ] { + assert!( + stdout.contains(&format!("MISSING:{var}")), + "expected {var} stripped, got: {stdout}" + ); + } +} + +#[test] +fn whitelist_strips_dbus_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/foo"), + ("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/bar"), + ], + &["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"], + ); + assert!( + stdout.contains("MISSING:DBUS_SESSION_BUS_ADDRESS"), + "expected DBUS_SESSION stripped: {stdout}" + ); + assert!( + stdout.contains("MISSING:DBUS_SYSTEM_BUS_ADDRESS"), + "expected DBUS_SYSTEM stripped: {stdout}" + ); +} + +#[test] +fn whitelist_env_sets_user_var() { + let stdout = printenv_inside( + &["--whitelist", "--env", "USER_INJECTED=forced"], + &[], + &["USER_INJECTED"], + ); + assert!(stdout.contains("forced"), "env not applied: {stdout}"); +} + +#[test] +fn whitelist_env_keep_passes_through_host_var() { + let stdout = printenv_inside( + &["--whitelist", "--env", "PASSED_THROUGH"], + &[("PASSED_THROUGH", "from-host")], + &["PASSED_THROUGH"], + ); + assert!( + stdout.contains("from-host"), + "expected --env KEY to pass host value through: {stdout}" + ); +} + +#[test] +fn whitelist_env_keep_absent_host_var_is_skipped() { + let stdout = printenv_inside( + &["--whitelist", "--env", "NEVER_SET_ON_HOST"], + &[], + &["NEVER_SET_ON_HOST"], + ); + assert!( + stdout.contains("MISSING:NEVER_SET_ON_HOST"), + "expected absent keep-var to remain unset: {stdout}" + ); +} + +#[test] +fn whitelist_unsetenv_overrides_kept_var() { + let stdout = printenv_inside( + &["--whitelist", "--unsetenv", "TERM"], + &[("TERM", "xterm-test")], + &["TERM"], + ); + assert!( + stdout.contains("MISSING:TERM"), + "expected --unsetenv to strip kept var: {stdout}" + ); +} + +#[test] +fn blacklist_drops_token_and_secret_vars() { + let stdout = printenv_inside( + &[], + &[ + ("GH_TOKEN", "gh-secret"), + ("AWS_SECRET_ACCESS_KEY", "aws-secret"), + ("MY_PASSWORD", "pw"), + ("FOO_API_KEY", "fookey"), + ], + &[ + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "MY_PASSWORD", + "FOO_API_KEY", + ], + ); + for var in [ + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "MY_PASSWORD", + "FOO_API_KEY", + ] { + assert!( + stdout.contains(&format!("MISSING:{var}")), + "expected {var} stripped in blacklist mode, got: {stdout}" + ); + } + for leaked in ["gh-secret", "aws-secret", "pw", "fookey"] { + assert!(!stdout.contains(leaked), "{leaked} leaked: {stdout}"); + } +} + +#[test] +fn blacklist_carves_out_vendor_api_keys() { + let stdout = printenv_inside( + &[], + &[ + ("ANTHROPIC_API_KEY", "anthropic-key"), + ("OPENAI_API_KEY", "openai-key"), + ("GEMINI_API_KEY", "gemini-key"), + ], + &["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"], + ); + for expected in ["anthropic-key", "openai-key", "gemini-key"] { + assert!( + stdout.contains(expected), + "expected {expected} to survive carve-out, got: {stdout}" + ); + } + assert!(!stdout.contains("MISSING:"), "carve-out failed: {stdout}"); +} + +#[test] +fn blacklist_suffix_match_does_not_catch_substring() { + let stdout = printenv_inside( + &[], + &[ + ("TOKENIZER_PATH", "/opt/tok"), + ("MY_TOKEN_HOLDER", "holder"), + ], + &["TOKENIZER_PATH", "MY_TOKEN_HOLDER"], + ); + assert!( + stdout.contains("/opt/tok"), + "TOKENIZER_PATH stripped: {stdout}" + ); + assert!( + stdout.contains("holder"), + "MY_TOKEN_HOLDER stripped: {stdout}" + ); +} + +#[test] +fn blacklist_keeps_unrelated_host_var() { + let stdout = printenv_inside(&[], &[("MY_NICE_VAR", "hello")], &["MY_NICE_VAR"]); + assert!(stdout.contains("hello"), "MY_NICE_VAR stripped: {stdout}"); +} + +#[test] +fn blacklist_keeps_dbus_vars() { + let stdout = printenv_inside( + &[], + &[ + ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/fake"), + ("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/tmp/fake-system"), + ], + &["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"], + ); + assert!(stdout.contains("unix:path=/tmp/fake")); + assert!(stdout.contains("unix:path=/tmp/fake-system")); +} + +#[test] +fn no_env_filter_whitelist_keeps_arbitrary_host_var() { + let stdout = printenv_inside( + &["--whitelist", "--no-env-filter"], + &[("SOME_RANDOM_NOISE_VAR", "kept")], + &["SOME_RANDOM_NOISE_VAR"], + ); + assert!( + stdout.contains("kept"), + "expected --no-env-filter to pass host var through, got: {stdout}" + ); +} + +#[test] +fn no_env_filter_blacklist_keeps_secrets() { + let stdout = printenv_inside(&["--no-env-filter"], &[("GH_TOKEN", "kept")], &["GH_TOKEN"]); + assert!( + stdout.contains("kept"), + "expected --no-env-filter to pass secrets through, got: {stdout}" + ); +} + +#[test] +fn no_env_filter_still_honors_user_env() { + let stdout = printenv_inside( + &["--no-env-filter", "--env", "FORCED=yes"], + &[], + &["FORCED"], + ); + assert!( + stdout.contains("yes"), + "expected user --env to still work with --no-env-filter, got: {stdout}" + ); +} + +#[test] +fn blacklist_env_overrides_builtin_deny() { + let stdout = printenv_inside( + &["--env", "GH_TOKEN=overridden"], + &[("GH_TOKEN", "original")], + &["GH_TOKEN"], + ); + assert!( + stdout.contains("overridden"), + "expected --env to override deny, got: {stdout}" + ); + assert!(!stdout.contains("original")); +} diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs new file mode 100644 index 0000000..f6c6006 --- /dev/null +++ b/tests/e2e/main.rs @@ -0,0 +1,8 @@ +mod common; + +mod cli; +mod env; +mod modes; +mod mounts; +mod namespaces; +mod seccomp; diff --git a/tests/e2e/modes.rs b/tests/e2e/modes.rs new file mode 100644 index 0000000..53f8427 --- /dev/null +++ b/tests/e2e/modes.rs @@ -0,0 +1,200 @@ +use crate::common::*; + +#[test] +fn whitelist_hides_home_contents() { + let output = sandbox(&["--whitelist"]) + .args(["--", "bash", "-c", "ls ~/Documents 2>&1 || echo hidden"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hidden"), + "expected ~/Documents to be hidden, got: {stdout}" + ); +} + +#[test] +fn whitelist_sys_is_readable() { + let output = sandbox(&["--whitelist"]) + .args(["--", "bash", "-c", "cat /sys/class/net/lo/address"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "00:00:00:00:00:00", + "expected loopback address from /sys, got: {stdout}" + ); +} + +#[test] +fn blacklist_run_is_tmpfs() { + let output = sandbox(&[]) + .args([ + "--", + "bash", + "-c", + "touch /run/test_canary 2>&1 && echo WRITABLE || echo BLOCKED", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("WRITABLE"), + "expected /run to be a writable tmpfs in blacklist mode, got: {stdout}" + ); +} + +#[test] +fn blacklist_run_dbus_socket_accessible() { + let output = sandbox(&[]) + .args([ + "--", + "bash", + "-c", + "test -e /run/dbus/system_bus_socket && echo EXISTS || echo MISSING", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "EXISTS", + "expected /run/dbus/system_bus_socket to be accessible in blacklist mode" + ); +} + +#[test] +fn blacklist_runuser_is_tmpfs() { + let run_user = agent_sandbox::require_run_user().expect("failed to determine XDG_RUNTIME_DIR"); + let script = format!("ls -A {} | grep -v '^bus$'", run_user); + + let output = sandbox(&[]) + .args(["--", "bash", "-c", &script]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert!( + stdout.is_empty(), + "expected only 'bus' (or empty) in {}, got unexpected entries: {stdout}", + run_user + ); +} + +#[test] +fn blacklist_dev_input_hidden() { + let output = sandbox(&[]) + .args(["--", "bash", "-c", "ls /dev/input/ 2>/dev/null | wc -l"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "0", + "expected /dev/input/ to be empty in blacklist mode, got {stdout} entries" + ); +} + +#[test] +fn blacklist_root_is_readonly() { + let output = sandbox(&[]) + .args([ + "--", + "bash", + "-c", + "touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \ + mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("READONLY"), + "expected root to be read-only in blacklist mode, got: {stdout}" + ); + assert!( + stdout.contains("MKDIR_FAIL"), + "expected mkdir at root to fail in blacklist mode, got: {stdout}" + ); +} + +#[test] +fn whitelist_root_is_readonly() { + let output = sandbox(&["--whitelist"]) + .args([ + "--", + "bash", + "-c", + "touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \ + mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("READONLY"), + "expected root to be read-only in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("MKDIR_FAIL"), + "expected mkdir at root to fail in whitelist mode, got: {stdout}" + ); +} + +#[test] +fn whitelist_mountpoint_parents_are_readonly() { + let output = sandbox(&["--whitelist"]) + .args([ + "--", + "bash", + "-c", + "echo pwned > /home/testfile 2>&1 && echo HOME_WRITABLE || echo HOME_READONLY; \ + touch /etc/newfile 2>&1 && echo ETC_WRITABLE || echo ETC_READONLY; \ + touch /var/newfile 2>&1 && echo VAR_WRITABLE || echo VAR_READONLY", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("HOME_READONLY"), + "expected /home to be read-only in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("ETC_READONLY"), + "expected /etc to be read-only in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("VAR_READONLY"), + "expected /var to be read-only in whitelist mode, got: {stdout}" + ); +} + +#[test] +fn whitelist_tmp_still_writable() { + let output = sandbox(&["--whitelist"]) + .args([ + "--", + "bash", + "-c", + "touch /tmp/ok 2>&1 && echo TMP_WRITABLE || echo TMP_READONLY; \ + touch /var/tmp/ok 2>&1 && echo VARTMP_WRITABLE || echo VARTMP_READONLY", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("TMP_WRITABLE"), + "expected /tmp to remain writable in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("VARTMP_WRITABLE"), + "expected /var/tmp to remain writable in whitelist mode, got: {stdout}" + ); +} diff --git a/tests/e2e/mounts.rs b/tests/e2e/mounts.rs new file mode 100644 index 0000000..7da1f1b --- /dev/null +++ b/tests/e2e/mounts.rs @@ -0,0 +1,392 @@ +use std::fs; +use std::process::Command; + +use tempfile::TempDir; + +use crate::common::*; + +struct CleanupFile(&'static str); + +impl Drop for CleanupFile { + fn drop(&mut self) { + let _ = fs::remove_file(self.0); + } +} +#[test] +fn cwd_is_writable() { + let _cleanup = CleanupFile("./sandbox_canary"); + + let output = sandbox(&[]) + .args(["--", "bash", "-c", "touch ./sandbox_canary && echo ok"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("ok"), + "expected 'ok' in stdout, got: {stdout}" + ); +} + +#[test] +fn host_fs_is_readonly() { + let output = sandbox(&[]) + .args(["--", "bash", "-c", "touch /etc/pwned 2>&1 || echo readonly"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("readonly"), + "expected 'readonly' in stdout, got: {stdout}" + ); + assert!(!std::path::Path::new("/etc/pwned").exists()); +} + +#[test] +fn ssh_dir_is_hidden() { + let output = sandbox(&[]) + .args(["--", "bash", "-c", "ls ~/.ssh 2>/dev/null | wc -l"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!(stdout, "0", "expected empty ~/.ssh, got {stdout} entries"); +} + +#[test] +fn extra_ro_mount() { + let dir = TempDir::new().expect("failed to create temp dir"); + fs::write(dir.path().join("hello.txt"), "hi").expect("failed to write test file"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--ro", dir_str]) + .args([ + "--", + "bash", + "-c", + &format!("cat {dir_str}/hello.txt && touch {dir_str}/new 2>&1 || echo readonly"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hi"), + "expected file content 'hi', got: {stdout}" + ); + assert!( + stdout.contains("readonly"), + "expected ro mount to block writes, got: {stdout}" + ); +} + +#[test] +fn extra_rw_mount() { + let dir = TempDir::new().expect("failed to create temp dir"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--rw", dir_str]) + .args([ + "--", + "bash", + "-c", + &format!("touch {dir_str}/canary && echo ok"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("ok"), "expected 'ok', got: {stdout}"); + assert!( + dir.path().join("canary").exists(), + "canary file should exist on host after rw mount" + ); +} + +#[test] +fn ro_mount_with_remapped_target() { + let dir = TempDir::new().expect("failed to create temp dir"); + fs::write(dir.path().join("hello.txt"), "hi").expect("failed to write test file"); + let src_str = dir.path().to_str().unwrap(); + let target = "/tmp/agent-sandbox-remap-test"; + + let output = sandbox(&["--ro", &format!("{src_str}:{target}")]) + .args([ + "--", + "bash", + "-c", + &format!("cat {target}/hello.txt && ls {src_str} 2>&1 | head -1 || echo src_hidden"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hi"), + "expected file content 'hi' at remapped target, got: {stdout}" + ); +} + +#[test] +fn rw_refines_ro_parent() { + let parent = TempDir::new().expect("failed to create temp dir"); + let child = parent.path().join("sub"); + fs::create_dir(&child).expect("failed to create sub dir"); + fs::write(parent.path().join("top.txt"), "top").expect("write"); + fs::write(child.join("inner.txt"), "inner").expect("write"); + let parent_str = parent.path().to_str().unwrap(); + let child_str = child.to_str().unwrap(); + + let output = sandbox(&["--ro", parent_str, "--rw", child_str]) + .args([ + "--", + "bash", + "-c", + &format!( + "touch {parent_str}/top_new 2>&1 || echo parent_ro; \ + touch {child_str}/child_new && echo child_rw" + ), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("parent_ro"), + "parent should be read-only, got: {stdout}" + ); + assert!( + stdout.contains("child_rw"), + "child should be writable, got: {stdout}" + ); +} + +#[test] +fn blacklist_overlays_survive_tmp_bind() { + fs::write("/tmp/ssh-sandbox-test", "secret").expect("failed to write sentinel"); + let _cleanup = CleanupFile("/tmp/ssh-sandbox-test"); + + let output = sandbox(&[]) + .args([ + "--", + "bash", + "-c", + "cat /tmp/ssh-sandbox-test 2>/dev/null && echo LEAKED || echo HIDDEN", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("HIDDEN"), + "expected /tmp/ssh-* to be hidden in blacklist mode, got: {stdout}" + ); + assert!( + !stdout.contains("LEAKED"), + "/tmp/ssh-sandbox-test was readable inside the sandbox" + ); +} + +#[test] +fn relative_chdir_works() { + let output = sandbox(&["--chdir", "src"]) + .args(["--", "bash", "-c", "pwd"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + output.status.success(), + "relative --chdir should work, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert!( + stdout.ends_with("/src"), + "expected cwd ending in /src, got: {stdout}" + ); +} + +#[test] +fn relative_rw_path_works() { + let output = sandbox(&["--rw", "src"]) + .args(["--", "bash", "-c", "echo ok"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + output.status.success(), + "relative --rw should work, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn relative_ro_path_works() { + let output = sandbox(&["--ro", "src"]) + .args(["--", "bash", "-c", "echo ok"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + output.status.success(), + "relative --ro should work, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn rw_missing_path_errors() { + let output = sandbox(&["--rw", "/nonexistent/xyz"]) + .args(["--", "true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + !output.status.success(), + "expected non-zero exit for missing --rw path" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("/nonexistent/xyz"), + "expected path in error message, got: {stderr}" + ); +} + +#[test] +fn mask_hides_directory() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("secret.txt"), "sensitive").expect("failed to write"); + let dir_str = dir.path().canonicalize().unwrap(); + + let output = sandbox(&["--mask", dir_str.to_str().unwrap()]) + .args([ + "--", + "bash", + "-c", + &format!("ls {} 2>/dev/null | wc -l", dir_str.display()), + ]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "0", + "expected masked directory to be empty, got {stdout} entries" + ); +} + +#[test] +fn mask_hides_file() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("secret.txt"); + fs::write(&file, "sensitive").expect("failed to write"); + let file_str = file.canonicalize().unwrap(); + + let output = sandbox(&["--mask", file_str.to_str().unwrap()]) + .args([ + "--", + "bash", + "-c", + &format!("cat {} 2>/dev/null || echo HIDDEN", file_str.display()), + ]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("sensitive"), + "expected masked file contents to be hidden, got: {stdout}" + ); +} + +#[test] +fn whitelist_ro_symlink_visible_at_link_path() { + let dir = TempDir::new().unwrap(); + let target = dir.path().join("target.txt"); + let link = dir.path().join("link.txt"); + fs::write(&target, "hello from target").expect("failed to write target"); + std::os::unix::fs::symlink(&target, &link).expect("failed to create symlink"); + let link_str = link.to_str().unwrap(); + + let output = sandbox(&["--whitelist", "--ro", link_str]) + .args(["--", "cat", link_str]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hello from target"), + "expected symlink path to be readable inside sandbox, got stdout: {stdout}, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn mask_nonexistent_path_becomes_tmpfs() { + let dir = TempDir::new().unwrap(); + let fake = dir.path().join("does_not_exist"); + let fake_str = fake.to_str().unwrap(); + + let output = sandbox(&["--mask", fake_str]) + .args([ + "--", + "bash", + "-c", + &format!( + "test -d {fake_str} && touch {fake_str}/canary && echo WRITABLE || echo MISSING" + ), + ]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("WRITABLE"), + "expected nonexistent mask to create a writable tmpfs, got: {stdout}" + ); + assert!( + !fake.join("canary").exists(), + "tmpfs writes should not leak to host" + ); +} + +#[test] +fn blacklist_overlays_survive_absolute_var_run_symlink() { + // On Debian/Ubuntu, /var/run -> /run is an absolute symlink; overlays + // like --tmpfs /var/run/dbus trip bwrap's re-rooted symlink resolution. + // Arch ships /var/run -> ../run (relative) so we synthesize the absolute + // layout inside the sandbox to reproduce on any host. + let mut bwrap_args = build_bwrap_command(&["--no-seccomp", "--", "true"]); + inject_absolute_var_run_symlink(&mut bwrap_args); + + let output = Command::new(&bwrap_args[0]) + .args(&bwrap_args[1..]) + .output() + .expect("failed to invoke bwrap directly"); + + assert!( + output.status.success(), + "bwrap failed — an overlay target traverses an absolute /var/run symlink.\n\ + stderr: {}", + String::from_utf8_lossy(&output.stderr), + ); +} +fn build_bwrap_command(sandbox_args: &[&str]) -> Vec { + let output = sandbox(&["--dry-run"]) + .args(sandbox_args) + .output() + .expect("agent-sandbox binary failed to execute"); + let cmd = String::from_utf8_lossy(&output.stdout); + let parsed = shlex::split(cmd.trim()).expect("dry-run output is not valid shell"); + assert_eq!(parsed[0], "bwrap"); + parsed +} + +fn inject_absolute_var_run_symlink(bwrap_args: &mut Vec) { + assert_eq!(bwrap_args[1], "--ro-bind"); + assert_eq!(bwrap_args[2], "/"); + assert_eq!(bwrap_args[3], "/"); + let flags = ["--tmpfs", "/var", "--symlink", "/run", "/var/run"].map(String::from); + bwrap_args.splice(4..4, flags); +} diff --git a/tests/e2e/namespaces.rs b/tests/e2e/namespaces.rs new file mode 100644 index 0000000..a84a30b --- /dev/null +++ b/tests/e2e/namespaces.rs @@ -0,0 +1,117 @@ +use std::fs; + +use tempfile::TempDir; + +use crate::common::*; + +fn read_sid_from_stat(stat: &str) -> u32 { + stat.split_whitespace() + .nth(5) + .expect("missing field 6 in /proc/self/stat") + .parse() + .expect("failed to parse session ID") +} + +fn read_sid_inside_sandbox(extra_args: &[&str]) -> u32 { + let output = sandbox(extra_args) + .args(["--", "bash", "-c", "cat /proc/self/stat"]) + .output() + .expect("agent-sandbox binary failed to execute"); + read_sid_from_stat(&String::from_utf8_lossy(&output.stdout)) +} + +fn read_sid_current_process() -> u32 { + let stat = fs::read_to_string("/proc/self/stat").expect("failed to read /proc/self/stat"); + read_sid_from_stat(&stat) +} +#[test] +fn unshare_net_blocks_network() { + let output = sandbox(&["--unshare-net"]) + .args([ + "--", + "bash", + "-c", + "curl -s --max-time 2 http://1.1.1.1 2>&1; echo $?", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.trim().ends_with("0"), + "expected curl to fail, got: {stdout}" + ); +} + +#[test] +fn hardened_pid_namespace() { + let output = sandbox(&["--hardened"]) + .args(["--", "bash", "-c", "ls /proc | grep -cE '^[0-9]+$'"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let count: u32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(999); + assert!( + count < 10, + "expected isolated PID namespace with few PIDs, got {count}" + ); +} + +#[test] +fn chdir_override() { + let dir = TempDir::new().expect("failed to create temp dir"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--chdir", dir_str]) + .args(["--", "bash", "-c", "pwd"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, dir_str, + "expected cwd to be {dir_str}, got: {stdout}" + ); +} + +#[test] +fn chdir_under_hardened_tmp() { + let dir = TempDir::new().expect("failed to create temp dir"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--hardened", "--chdir", dir_str]) + .args(["--", "bash", "-c", "pwd && touch ./ok && echo done"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("done"), + "expected chdir under /tmp to work with --hardened, got: {stdout}" + ); +} + +#[test] +fn hardened_isolates_sid() { + let inner_sid = read_sid_inside_sandbox(&["--hardened"]); + let outer_sid = read_sid_current_process(); + + assert_ne!( + inner_sid, outer_sid, + "sandboxed process should have a different session ID (got {inner_sid} == {outer_sid})" + ); +} + +#[test] +fn default_mode_shares_session() { + let inner_sid = read_sid_inside_sandbox(&[]); + let outer_sid = read_sid_current_process(); + + assert_eq!( + inner_sid, outer_sid, + "default-mode sandbox should share the session ID (got {inner_sid} != {outer_sid})" + ); +} diff --git a/tests/e2e/seccomp.rs b/tests/e2e/seccomp.rs new file mode 100644 index 0000000..436491a --- /dev/null +++ b/tests/e2e/seccomp.rs @@ -0,0 +1,124 @@ +use crate::common::*; + +#[test] +fn seccomp_on_by_default_blocks_unshare() { + let output = sandbox(&[]) + .args(["--", "unshare", "--user", "--map-root-user", "/bin/true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + !output.status.success(), + "expected unshare(2) to be blocked by default seccomp filter, but it succeeded" + ); +} + +#[test] +fn seccomp_off_allows_blocked_syscall() { + let output = sandbox(&["--no-seccomp"]) + .args(["--", "unshare", "--user", "--map-root-user", "/bin/true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + output.status.success(), + "expected unshare(2) to succeed without seccomp, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn seccomp_dry_run_emits_seccomp_arg() { + let output = sandbox(&["--dry-run"]) + .args(["--", "/bin/true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("--seccomp"), + "expected --seccomp in dry-run output, got: {stdout}" + ); +} + +#[test] +fn seccomp_dry_run_no_seccomp_omits_arg() { + let output = sandbox(&["--dry-run", "--no-seccomp"]) + .args(["--", "/bin/true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("--seccomp"), + "expected no --seccomp in dry-run output with --no-seccomp, got: {stdout}" + ); +} + +#[test] +fn seccomp_normal_workload_succeeds() { + let output = sandbox(&[]) + .args(["--", "bash", "-c", "ls /etc > /dev/null && date"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + output.status.success(), + "expected normal workload to succeed under default seccomp, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} +#[test] +fn seccomp_bash_pthread_fallback_works() { + // Verifies the ENOSYS-not-EPERM choice for clone3 doesn't break libc's + // clone3 -> clone fallback path that bash uses internally. + let output = sandbox(&[]) + .args(["--", "bash", "-c", "true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + output.status.success(), + "expected bash to succeed under default seccomp (clone3 fallback), stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn seccomp_blocks_tiocsti() { + // TIOCSTI (0x5412) injects keystrokes into the terminal input queue. + // Without --new-session, this is the primary defense against CVE-2017-5226. + // + // On kernels >= 6.2 with CONFIG_LEGACY_TIOCSTI=n, the kernel blocks TIOCSTI + // before seccomp sees it. We test with --no-seccomp first to detect that and + // skip, so the test only asserts our filter's behaviour. + let baseline = sandbox(&["--no-seccomp"]) + .args([ + "--", + "python3", + "-c", + "import fcntl; fcntl.ioctl(0, 0x5412, b'x')", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + if !baseline.status.success() { + // Kernel already blocks TIOCSTI; seccomp filter is untestable here. + return; + } + + let output = sandbox(&[]) + .args([ + "--", + "python3", + "-c", + "import fcntl; fcntl.ioctl(0, 0x5412, b'x')", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + !output.status.success(), + "expected TIOCSTI to be blocked by seccomp filter" + ); +} diff --git a/tests/integration.rs b/tests/integration.rs deleted file mode 100644 index e2ad390..0000000 --- a/tests/integration.rs +++ /dev/null @@ -1,1474 +0,0 @@ -use std::fs; -use std::process::Command; - -use tempfile::TempDir; - -fn sandbox_withconfig(extra_args: &[&str]) -> Command { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_agent-sandbox")); - cmd.args(extra_args); - cmd -} - -fn sandbox(extra_args: &[&str]) -> Command { - let mut cmd = sandbox_withconfig(&["--no-config"]); - cmd.args(extra_args); - cmd -} - -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 { - stat.split_whitespace() - .nth(5) - .expect("missing field 6 in /proc/self/stat") - .parse() - .expect("failed to parse session ID") -} - -fn read_sid_inside_sandbox(extra_args: &[&str]) -> u32 { - let output = sandbox(extra_args) - .args(["--", "bash", "-c", "cat /proc/self/stat"]) - .output() - .expect("agent-sandbox binary failed to execute"); - read_sid_from_stat(&String::from_utf8_lossy(&output.stdout)) -} - -fn read_sid_current_process() -> u32 { - let stat = fs::read_to_string("/proc/self/stat").expect("failed to read /proc/self/stat"); - read_sid_from_stat(&stat) -} - -struct CleanupFile(&'static str); - -impl Drop for CleanupFile { - fn drop(&mut self) { - let _ = fs::remove_file(self.0); - } -} - -#[test] -fn cwd_is_writable() { - let _cleanup = CleanupFile("./sandbox_canary"); - - let output = sandbox(&[]) - .args(["--", "bash", "-c", "touch ./sandbox_canary && echo ok"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("ok"), - "expected 'ok' in stdout, got: {stdout}" - ); -} - -#[test] -fn host_fs_is_readonly() { - let output = sandbox(&[]) - .args(["--", "bash", "-c", "touch /etc/pwned 2>&1 || echo readonly"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("readonly"), - "expected 'readonly' in stdout, got: {stdout}" - ); - assert!(!std::path::Path::new("/etc/pwned").exists()); -} - -#[test] -fn ssh_dir_is_hidden() { - let output = sandbox(&[]) - .args(["--", "bash", "-c", "ls ~/.ssh 2>/dev/null | wc -l"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!(stdout, "0", "expected empty ~/.ssh, got {stdout} entries"); -} - -#[test] -fn unshare_net_blocks_network() { - let output = sandbox(&["--unshare-net"]) - .args([ - "--", - "bash", - "-c", - "curl -s --max-time 2 http://1.1.1.1 2>&1; echo $?", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - !stdout.trim().ends_with("0"), - "expected curl to fail, got: {stdout}" - ); -} - -#[test] -fn hardened_pid_namespace() { - let output = sandbox(&["--hardened"]) - .args(["--", "bash", "-c", "ls /proc | grep -cE '^[0-9]+$'"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let count: u32 = String::from_utf8_lossy(&output.stdout) - .trim() - .parse() - .unwrap_or(999); - assert!( - count < 10, - "expected isolated PID namespace with few PIDs, got {count}" - ); -} - -#[test] -fn whitelist_hides_home_contents() { - let output = sandbox(&["--whitelist"]) - .args(["--", "bash", "-c", "ls ~/Documents 2>&1 || echo hidden"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("hidden"), - "expected ~/Documents to be hidden, got: {stdout}" - ); -} - -#[test] -fn extra_ro_mount() { - let dir = TempDir::new().expect("failed to create temp dir"); - fs::write(dir.path().join("hello.txt"), "hi").expect("failed to write test file"); - let dir_str = dir.path().to_str().unwrap(); - - let output = sandbox(&["--ro", dir_str]) - .args([ - "--", - "bash", - "-c", - &format!("cat {dir_str}/hello.txt && touch {dir_str}/new 2>&1 || echo readonly"), - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("hi"), - "expected file content 'hi', got: {stdout}" - ); - assert!( - stdout.contains("readonly"), - "expected ro mount to block writes, got: {stdout}" - ); -} - -#[test] -fn extra_rw_mount() { - let dir = TempDir::new().expect("failed to create temp dir"); - let dir_str = dir.path().to_str().unwrap(); - - let output = sandbox(&["--rw", dir_str]) - .args([ - "--", - "bash", - "-c", - &format!("touch {dir_str}/canary && echo ok"), - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("ok"), "expected 'ok', got: {stdout}"); - assert!( - dir.path().join("canary").exists(), - "canary file should exist on host after rw mount" - ); -} - -#[test] -fn ro_mount_with_remapped_target() { - let dir = TempDir::new().expect("failed to create temp dir"); - fs::write(dir.path().join("hello.txt"), "hi").expect("failed to write test file"); - let src_str = dir.path().to_str().unwrap(); - let target = "/tmp/agent-sandbox-remap-test"; - - let output = sandbox(&["--ro", &format!("{src_str}:{target}")]) - .args([ - "--", - "bash", - "-c", - &format!("cat {target}/hello.txt && ls {src_str} 2>&1 | head -1 || echo src_hidden"), - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("hi"), - "expected file content 'hi' at remapped target, got: {stdout}" - ); -} - -#[test] -fn rw_refines_ro_parent() { - let parent = TempDir::new().expect("failed to create temp dir"); - let child = parent.path().join("sub"); - fs::create_dir(&child).expect("failed to create sub dir"); - fs::write(parent.path().join("top.txt"), "top").expect("write"); - fs::write(child.join("inner.txt"), "inner").expect("write"); - let parent_str = parent.path().to_str().unwrap(); - let child_str = child.to_str().unwrap(); - - let output = sandbox(&["--ro", parent_str, "--rw", child_str]) - .args([ - "--", - "bash", - "-c", - &format!( - "touch {parent_str}/top_new 2>&1 || echo parent_ro; \ - touch {child_str}/child_new && echo child_rw" - ), - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("parent_ro"), - "parent should be read-only, got: {stdout}" - ); - assert!( - stdout.contains("child_rw"), - "child should be writable, got: {stdout}" - ); -} - -#[test] -fn chdir_override() { - let dir = TempDir::new().expect("failed to create temp dir"); - let dir_str = dir.path().to_str().unwrap(); - - let output = sandbox(&["--chdir", dir_str]) - .args(["--", "bash", "-c", "pwd"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, dir_str, - "expected cwd to be {dir_str}, got: {stdout}" - ); -} - -#[test] -fn chdir_under_hardened_tmp() { - let dir = TempDir::new().expect("failed to create temp dir"); - let dir_str = dir.path().to_str().unwrap(); - - let output = sandbox(&["--hardened", "--chdir", dir_str]) - .args(["--", "bash", "-c", "pwd && touch ./ok && echo done"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("done"), - "expected chdir under /tmp to work with --hardened, got: {stdout}" - ); -} - -#[test] -fn dry_run_prints_and_exits() { - let output = sandbox(&["--dry-run"]) - .args(["--", "bash", "-c", "exit 42"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("bwrap"), - "expected bwrap command in dry-run output, got: {stdout}" - ); - assert!( - output.status.success(), - "dry-run should exit 0, not 42 from the inner command" - ); -} - -#[test] -fn dry_run_output_is_copy_pasteable_shell() { - let dry = sandbox(&["--dry-run"]) - .args(["--", "bash", "-c", "echo $HOME"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let dry_cmd = String::from_utf8_lossy(&dry.stdout).trim().to_string(); - let args = shlex::split(&dry_cmd).expect("dry-run output is not valid shell"); - assert_eq!(args[0], "bwrap"); - assert_eq!(args[args.len() - 1], "echo $HOME"); - assert_eq!(args[args.len() - 2], "-c"); -} - -#[test] -fn blacklist_overlays_survive_tmp_bind() { - fs::write("/tmp/ssh-sandbox-test", "secret").expect("failed to write sentinel"); - let _cleanup = CleanupFile("/tmp/ssh-sandbox-test"); - - let output = sandbox(&[]) - .args([ - "--", - "bash", - "-c", - "cat /tmp/ssh-sandbox-test 2>/dev/null && echo LEAKED || echo HIDDEN", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("HIDDEN"), - "expected /tmp/ssh-* to be hidden in blacklist mode, got: {stdout}" - ); - assert!( - !stdout.contains("LEAKED"), - "/tmp/ssh-sandbox-test was readable inside the sandbox" - ); -} - -#[test] -fn relative_chdir_works() { - let output = sandbox(&["--chdir", "src"]) - .args(["--", "bash", "-c", "pwd"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - output.status.success(), - "relative --chdir should work, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert!( - stdout.ends_with("/src"), - "expected cwd ending in /src, got: {stdout}" - ); -} - -#[test] -fn relative_rw_path_works() { - let output = sandbox(&["--rw", "src"]) - .args(["--", "bash", "-c", "echo ok"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - output.status.success(), - "relative --rw should work, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn relative_ro_path_works() { - let output = sandbox(&["--ro", "src"]) - .args(["--", "bash", "-c", "echo ok"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - output.status.success(), - "relative --ro should work, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn empty_home_rejected() { - let output = sandbox(&[]) - .env("HOME", "") - .args(["--", "true"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - !output.status.success(), - "expected failure with empty HOME, but got success" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.to_lowercase().contains("home"), - "expected error mentioning HOME, got: {stderr}" - ); -} - -#[test] -fn whitelist_sys_is_readable() { - let output = sandbox(&["--whitelist"]) - .args(["--", "bash", "-c", "cat /sys/class/net/lo/address"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, "00:00:00:00:00:00", - "expected loopback address from /sys, got: {stdout}" - ); -} - -#[test] -fn hardened_isolates_sid() { - let inner_sid = read_sid_inside_sandbox(&["--hardened"]); - let outer_sid = read_sid_current_process(); - - assert_ne!( - inner_sid, outer_sid, - "sandboxed process should have a different session ID (got {inner_sid} == {outer_sid})" - ); -} - -#[test] -fn default_mode_shares_session() { - let inner_sid = read_sid_inside_sandbox(&[]); - let outer_sid = read_sid_current_process(); - - assert_eq!( - inner_sid, outer_sid, - "default-mode sandbox should share the session ID (got {inner_sid} != {outer_sid})" - ); -} - -#[test] -fn blacklist_run_is_tmpfs() { - let output = sandbox(&[]) - .args([ - "--", - "bash", - "-c", - "touch /run/test_canary 2>&1 && echo WRITABLE || echo BLOCKED", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("WRITABLE"), - "expected /run to be a writable tmpfs in blacklist mode, got: {stdout}" - ); -} - -#[test] -fn blacklist_run_dbus_socket_accessible() { - let output = sandbox(&[]) - .args([ - "--", - "bash", - "-c", - "test -e /run/dbus/system_bus_socket && echo EXISTS || echo MISSING", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, "EXISTS", - "expected /run/dbus/system_bus_socket to be accessible in blacklist mode" - ); -} - -#[test] -fn blacklist_runuser_is_tmpfs() { - let run_user = agent_sandbox::require_run_user().expect("failed to determine XDG_RUNTIME_DIR"); - let script = format!("ls -A {} | grep -v '^bus$'", run_user); - - let output = sandbox(&[]) - .args(["--", "bash", "-c", &script]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert!( - stdout.is_empty(), - "expected only 'bus' (or empty) in {}, got unexpected entries: {stdout}", - run_user - ); -} - -#[test] -fn blacklist_dev_input_hidden() { - let output = sandbox(&[]) - .args(["--", "bash", "-c", "ls /dev/input/ 2>/dev/null | wc -l"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, "0", - "expected /dev/input/ to be empty in blacklist mode, got {stdout} entries" - ); -} - -#[test] -fn blacklist_root_is_readonly() { - let output = sandbox(&[]) - .args([ - "--", - "bash", - "-c", - "touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \ - mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("READONLY"), - "expected root to be read-only in blacklist mode, got: {stdout}" - ); - assert!( - stdout.contains("MKDIR_FAIL"), - "expected mkdir at root to fail in blacklist mode, got: {stdout}" - ); -} - -#[test] -fn whitelist_root_is_readonly() { - let output = sandbox(&["--whitelist"]) - .args([ - "--", - "bash", - "-c", - "touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \ - mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("READONLY"), - "expected root to be read-only in whitelist mode, got: {stdout}" - ); - assert!( - stdout.contains("MKDIR_FAIL"), - "expected mkdir at root to fail in whitelist mode, got: {stdout}" - ); -} - -#[test] -fn whitelist_mountpoint_parents_are_readonly() { - let output = sandbox(&["--whitelist"]) - .args([ - "--", - "bash", - "-c", - "echo pwned > /home/testfile 2>&1 && echo HOME_WRITABLE || echo HOME_READONLY; \ - touch /etc/newfile 2>&1 && echo ETC_WRITABLE || echo ETC_READONLY; \ - touch /var/newfile 2>&1 && echo VAR_WRITABLE || echo VAR_READONLY", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("HOME_READONLY"), - "expected /home to be read-only in whitelist mode, got: {stdout}" - ); - assert!( - stdout.contains("ETC_READONLY"), - "expected /etc to be read-only in whitelist mode, got: {stdout}" - ); - assert!( - stdout.contains("VAR_READONLY"), - "expected /var to be read-only in whitelist mode, got: {stdout}" - ); -} - -#[test] -fn whitelist_tmp_still_writable() { - let output = sandbox(&["--whitelist"]) - .args([ - "--", - "bash", - "-c", - "touch /tmp/ok 2>&1 && echo TMP_WRITABLE || echo TMP_READONLY; \ - touch /var/tmp/ok 2>&1 && echo VARTMP_WRITABLE || echo VARTMP_READONLY", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("TMP_WRITABLE"), - "expected /tmp to remain writable in whitelist mode, got: {stdout}" - ); - assert!( - stdout.contains("VARTMP_WRITABLE"), - "expected /var/tmp to remain writable in whitelist mode, got: {stdout}" - ); -} - -#[test] -fn rw_missing_path_errors() { - let output = sandbox(&["--rw", "/nonexistent/xyz"]) - .args(["--", "true"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - !output.status.success(), - "expected non-zero exit for missing --rw path" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("/nonexistent/xyz"), - "expected path in error message, got: {stderr}" - ); -} - -#[test] -fn config_missing_file_errors() { - let output = sandbox_withconfig(&["--config", "/nonexistent/config.toml"]) - .args(["--", "true"]) - .output() - .expect("failed to execute"); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("/nonexistent/config.toml"), - "expected config path in error, got: {stderr}" - ); -} - -#[test] -fn config_invalid_toml_errors() { - let cfg = ConfigFile::new("not valid {{{{ toml"); - - let output = sandbox_withconfig(&["--config", &cfg]) - .args(["--", "true"]) - .output() - .expect("failed to execute"); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("cannot parse"), - "expected parse error, got: {stderr}" - ); -} - -#[test] -fn config_unknown_key_errors() { - let cfg = ConfigFile::new("hardened = true\nbogus = \"nope\"\n"); - - let output = sandbox_withconfig(&["--config", &cfg]) - .args(["--", "true"]) - .output() - .expect("failed to execute"); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown config key"), - "expected unknown key error, got: {stderr}" - ); -} - -#[test] -fn mask_hides_directory() { - let dir = TempDir::new().unwrap(); - fs::write(dir.path().join("secret.txt"), "sensitive").expect("failed to write"); - let dir_str = dir.path().canonicalize().unwrap(); - - let output = sandbox(&["--mask", dir_str.to_str().unwrap()]) - .args([ - "--", - "bash", - "-c", - &format!("ls {} 2>/dev/null | wc -l", dir_str.display()), - ]) - .output() - .expect("failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, "0", - "expected masked directory to be empty, got {stdout} entries" - ); -} - -#[test] -fn mask_hides_file() { - let dir = TempDir::new().unwrap(); - let file = dir.path().join("secret.txt"); - fs::write(&file, "sensitive").expect("failed to write"); - let file_str = file.canonicalize().unwrap(); - - let output = sandbox(&["--mask", file_str.to_str().unwrap()]) - .args([ - "--", - "bash", - "-c", - &format!("cat {} 2>/dev/null || echo HIDDEN", file_str.display()), - ]) - .output() - .expect("failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - !stdout.contains("sensitive"), - "expected masked file contents to be hidden, got: {stdout}" - ); -} - -#[test] -fn bwrap_arg_setenv_passes_through() { - let output = sandbox(&["--bwrap-arg", "--setenv MYVAR hello"]) - .args(["--", "bash", "-c", "echo $MYVAR"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, "hello", - "expected --bwrap-arg to pass --setenv through to bwrap, got: {stdout}" - ); -} - -#[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 cli_entrypoint_appends_passthrough_args() { - let output = sandbox(&["--entrypoint", "bash"]) - .args(["--", "-c", "echo cli-entrypoint-works"]) - .output() - .expect("failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, "cli-entrypoint-works", - "expected --entrypoint to receive trailing args, got: {stdout}" - ); -} - -#[test] -fn cli_entrypoint_overrides_config_entrypoint() { - let cfg = ConfigFile::new( - r#" - entrypoint = ["/bin/false"] - "#, - ); - - let output = sandbox_withconfig(&["--config", &cfg, "--entrypoint", "bash"]) - .args(["--", "-c", "echo override-works"]) - .output() - .expect("failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!( - stdout, "override-works", - "expected CLI --entrypoint to override config entrypoint, 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 whitelist_ro_symlink_visible_at_link_path() { - let dir = TempDir::new().unwrap(); - let target = dir.path().join("target.txt"); - let link = dir.path().join("link.txt"); - fs::write(&target, "hello from target").expect("failed to write target"); - std::os::unix::fs::symlink(&target, &link).expect("failed to create symlink"); - let link_str = link.to_str().unwrap(); - - let output = sandbox(&["--whitelist", "--ro", link_str]) - .args(["--", "cat", link_str]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("hello from target"), - "expected symlink path to be readable inside sandbox, got stdout: {stdout}, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn mask_nonexistent_path_becomes_tmpfs() { - let dir = TempDir::new().unwrap(); - let fake = dir.path().join("does_not_exist"); - let fake_str = fake.to_str().unwrap(); - - let output = sandbox(&["--mask", fake_str]) - .args([ - "--", - "bash", - "-c", - &format!( - "test -d {fake_str} && touch {fake_str}/canary && echo WRITABLE || echo MISSING" - ), - ]) - .output() - .expect("failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("WRITABLE"), - "expected nonexistent mask to create a writable tmpfs, got: {stdout}" - ); - assert!( - !fake.join("canary").exists(), - "tmpfs writes should not leak to host" - ); -} - -#[test] -fn seccomp_on_by_default_blocks_unshare() { - let output = sandbox(&[]) - .args(["--", "unshare", "--user", "--map-root-user", "/bin/true"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - !output.status.success(), - "expected unshare(2) to be blocked by default seccomp filter, but it succeeded" - ); -} - -#[test] -fn seccomp_off_allows_blocked_syscall() { - let output = sandbox(&["--no-seccomp"]) - .args(["--", "unshare", "--user", "--map-root-user", "/bin/true"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - output.status.success(), - "expected unshare(2) to succeed without seccomp, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn seccomp_dry_run_emits_seccomp_arg() { - let output = sandbox(&["--dry-run"]) - .args(["--", "/bin/true"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("--seccomp"), - "expected --seccomp in dry-run output, got: {stdout}" - ); -} - -#[test] -fn seccomp_dry_run_no_seccomp_omits_arg() { - let output = sandbox(&["--dry-run", "--no-seccomp"]) - .args(["--", "/bin/true"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - !stdout.contains("--seccomp"), - "expected no --seccomp in dry-run output with --no-seccomp, got: {stdout}" - ); -} - -#[test] -fn seccomp_normal_workload_succeeds() { - let output = sandbox(&[]) - .args(["--", "bash", "-c", "ls /etc > /dev/null && date"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - output.status.success(), - "expected normal workload to succeed under default seccomp, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -fn printenv_inside(args: &[&str], vars: &[(&str, &str)], query: &[&str]) -> String { - let script = query - .iter() - .map(|v| format!("printenv {v} || echo MISSING:{v}")) - .collect::>() - .join("; "); - let mut cmd = sandbox(args); - for (k, v) in vars { - cmd.env(k, v); - } - let output = cmd - .args(["--", "bash", "-c", &script]) - .output() - .expect("agent-sandbox binary failed to execute"); - String::from_utf8_lossy(&output.stdout).into_owned() -} - -#[test] -fn whitelist_keeps_identity_and_terminal_vars() { - let stdout = printenv_inside( - &["--whitelist"], - &[("TERM", "xterm-test"), ("LANG", "C.UTF-8")], - &["HOME", "PATH", "TERM", "LANG"], - ); - assert!(!stdout.contains("MISSING:HOME"), "HOME stripped: {stdout}"); - assert!(!stdout.contains("MISSING:PATH"), "PATH stripped: {stdout}"); - assert!(stdout.contains("xterm-test"), "TERM stripped: {stdout}"); - assert!(stdout.contains("C.UTF-8"), "LANG stripped: {stdout}"); -} - -#[test] -fn whitelist_strips_arbitrary_host_var() { - let stdout = printenv_inside( - &["--whitelist"], - &[("SOME_RANDOM_NOISE_VAR", "leak")], - &["SOME_RANDOM_NOISE_VAR"], - ); - assert!( - stdout.contains("MISSING:SOME_RANDOM_NOISE_VAR"), - "expected arbitrary host var to be stripped, got: {stdout}" - ); - assert!(!stdout.contains("leak")); -} - -#[test] -fn whitelist_keeps_vendor_prefixes() { - let stdout = printenv_inside( - &["--whitelist"], - &[ - ("CLAUDE_FOO", "claude-val"), - ("ANTHROPIC_MODEL", "anthropic-val"), - ("OPENAI_API_KEY", "openai-val"), - ("CODEX_FOO", "codex-val"), - ("GEMINI_API_KEY", "gemini-val"), - ("OTEL_SERVICE_NAME", "otel-val"), - ], - &[ - "CLAUDE_FOO", - "ANTHROPIC_MODEL", - "OPENAI_API_KEY", - "CODEX_FOO", - "GEMINI_API_KEY", - "OTEL_SERVICE_NAME", - ], - ); - for expected in [ - "claude-val", - "anthropic-val", - "openai-val", - "codex-val", - "gemini-val", - "otel-val", - ] { - assert!( - stdout.contains(expected), - "expected {expected} in output, got: {stdout}" - ); - } - assert!(!stdout.contains("MISSING:"), "unexpected strip: {stdout}"); -} - -#[test] -fn whitelist_keeps_lc_prefix() { - let stdout = printenv_inside( - &["--whitelist"], - &[("LC_TIME", "en_US.UTF-8")], - &["LC_TIME"], - ); - assert!(stdout.contains("en_US.UTF-8"), "LC_TIME missing: {stdout}"); -} - -#[test] -fn whitelist_keeps_non_gui_xdg_vars() { - let stdout = printenv_inside( - &["--whitelist"], - &[ - ("XDG_CONFIG_HOME", "/cfg"), - ("XDG_DATA_HOME", "/data"), - ("XDG_CACHE_HOME", "/cache"), - ("XDG_STATE_HOME", "/state"), - ("XDG_CONFIG_DIRS", "/etc/xdg"), - ("XDG_DATA_DIRS", "/usr/share"), - ], - &[ - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - "XDG_CACHE_HOME", - "XDG_STATE_HOME", - "XDG_CONFIG_DIRS", - "XDG_DATA_DIRS", - ], - ); - assert!( - !stdout.contains("MISSING:"), - "XDG non-GUI stripped: {stdout}" - ); -} - -#[test] -fn whitelist_strips_gui_xdg_vars() { - let stdout = printenv_inside( - &["--whitelist"], - &[ - ("XDG_RUNTIME_DIR", "/run/user/1000"), - ("XDG_SESSION_ID", "1"), - ("XDG_CURRENT_DESKTOP", "KDE"), - ("XDG_SEAT", "seat0"), - ], - &[ - "XDG_RUNTIME_DIR", - "XDG_SESSION_ID", - "XDG_CURRENT_DESKTOP", - "XDG_SEAT", - ], - ); - for var in [ - "XDG_RUNTIME_DIR", - "XDG_SESSION_ID", - "XDG_CURRENT_DESKTOP", - "XDG_SEAT", - ] { - assert!( - stdout.contains(&format!("MISSING:{var}")), - "expected {var} stripped, got: {stdout}" - ); - } -} - -#[test] -fn whitelist_strips_dbus_vars() { - let stdout = printenv_inside( - &["--whitelist"], - &[ - ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/foo"), - ("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/bar"), - ], - &["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"], - ); - assert!( - stdout.contains("MISSING:DBUS_SESSION_BUS_ADDRESS"), - "expected DBUS_SESSION stripped: {stdout}" - ); - assert!( - stdout.contains("MISSING:DBUS_SYSTEM_BUS_ADDRESS"), - "expected DBUS_SYSTEM stripped: {stdout}" - ); -} - -#[test] -fn whitelist_env_sets_user_var() { - let stdout = printenv_inside( - &["--whitelist", "--env", "USER_INJECTED=forced"], - &[], - &["USER_INJECTED"], - ); - assert!(stdout.contains("forced"), "env not applied: {stdout}"); -} - -#[test] -fn whitelist_env_keep_passes_through_host_var() { - let stdout = printenv_inside( - &["--whitelist", "--env", "PASSED_THROUGH"], - &[("PASSED_THROUGH", "from-host")], - &["PASSED_THROUGH"], - ); - assert!( - stdout.contains("from-host"), - "expected --env KEY to pass host value through: {stdout}" - ); -} - -#[test] -fn whitelist_env_keep_absent_host_var_is_skipped() { - let stdout = printenv_inside( - &["--whitelist", "--env", "NEVER_SET_ON_HOST"], - &[], - &["NEVER_SET_ON_HOST"], - ); - assert!( - stdout.contains("MISSING:NEVER_SET_ON_HOST"), - "expected absent keep-var to remain unset: {stdout}" - ); -} - -#[test] -fn whitelist_unsetenv_overrides_kept_var() { - let stdout = printenv_inside( - &["--whitelist", "--unsetenv", "TERM"], - &[("TERM", "xterm-test")], - &["TERM"], - ); - assert!( - stdout.contains("MISSING:TERM"), - "expected --unsetenv to strip kept var: {stdout}" - ); -} - -#[test] -fn blacklist_drops_token_and_secret_vars() { - let stdout = printenv_inside( - &[], - &[ - ("GH_TOKEN", "gh-secret"), - ("AWS_SECRET_ACCESS_KEY", "aws-secret"), - ("MY_PASSWORD", "pw"), - ("FOO_API_KEY", "fookey"), - ], - &[ - "GH_TOKEN", - "AWS_SECRET_ACCESS_KEY", - "MY_PASSWORD", - "FOO_API_KEY", - ], - ); - for var in [ - "GH_TOKEN", - "AWS_SECRET_ACCESS_KEY", - "MY_PASSWORD", - "FOO_API_KEY", - ] { - assert!( - stdout.contains(&format!("MISSING:{var}")), - "expected {var} stripped in blacklist mode, got: {stdout}" - ); - } - for leaked in ["gh-secret", "aws-secret", "pw", "fookey"] { - assert!(!stdout.contains(leaked), "{leaked} leaked: {stdout}"); - } -} - -#[test] -fn blacklist_carves_out_vendor_api_keys() { - let stdout = printenv_inside( - &[], - &[ - ("ANTHROPIC_API_KEY", "anthropic-key"), - ("OPENAI_API_KEY", "openai-key"), - ("GEMINI_API_KEY", "gemini-key"), - ], - &["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"], - ); - for expected in ["anthropic-key", "openai-key", "gemini-key"] { - assert!( - stdout.contains(expected), - "expected {expected} to survive carve-out, got: {stdout}" - ); - } - assert!(!stdout.contains("MISSING:"), "carve-out failed: {stdout}"); -} - -#[test] -fn blacklist_suffix_match_does_not_catch_substring() { - let stdout = printenv_inside( - &[], - &[ - ("TOKENIZER_PATH", "/opt/tok"), - ("MY_TOKEN_HOLDER", "holder"), - ], - &["TOKENIZER_PATH", "MY_TOKEN_HOLDER"], - ); - assert!( - stdout.contains("/opt/tok"), - "TOKENIZER_PATH stripped: {stdout}" - ); - assert!( - stdout.contains("holder"), - "MY_TOKEN_HOLDER stripped: {stdout}" - ); -} - -#[test] -fn blacklist_keeps_unrelated_host_var() { - let stdout = printenv_inside(&[], &[("MY_NICE_VAR", "hello")], &["MY_NICE_VAR"]); - assert!(stdout.contains("hello"), "MY_NICE_VAR stripped: {stdout}"); -} - -#[test] -fn blacklist_keeps_dbus_vars() { - let stdout = printenv_inside( - &[], - &[ - ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/fake"), - ("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/tmp/fake-system"), - ], - &["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"], - ); - assert!(stdout.contains("unix:path=/tmp/fake")); - assert!(stdout.contains("unix:path=/tmp/fake-system")); -} - -#[test] -fn no_env_filter_whitelist_keeps_arbitrary_host_var() { - let stdout = printenv_inside( - &["--whitelist", "--no-env-filter"], - &[("SOME_RANDOM_NOISE_VAR", "kept")], - &["SOME_RANDOM_NOISE_VAR"], - ); - assert!( - stdout.contains("kept"), - "expected --no-env-filter to pass host var through, got: {stdout}" - ); -} - -#[test] -fn no_env_filter_blacklist_keeps_secrets() { - let stdout = printenv_inside(&["--no-env-filter"], &[("GH_TOKEN", "kept")], &["GH_TOKEN"]); - assert!( - stdout.contains("kept"), - "expected --no-env-filter to pass secrets through, got: {stdout}" - ); -} - -#[test] -fn no_env_filter_still_honors_user_env() { - let stdout = printenv_inside( - &["--no-env-filter", "--env", "FORCED=yes"], - &[], - &["FORCED"], - ); - assert!( - stdout.contains("yes"), - "expected user --env to still work with --no-env-filter, got: {stdout}" - ); -} - -#[test] -fn blacklist_env_overrides_builtin_deny() { - let stdout = printenv_inside( - &["--env", "GH_TOKEN=overridden"], - &[("GH_TOKEN", "original")], - &["GH_TOKEN"], - ); - assert!( - stdout.contains("overridden"), - "expected --env to override deny, got: {stdout}" - ); - assert!(!stdout.contains("original")); -} - -#[test] -fn seccomp_bash_pthread_fallback_works() { - // Verifies the ENOSYS-not-EPERM choice for clone3 doesn't break libc's - // clone3 -> clone fallback path that bash uses internally. - let output = sandbox(&[]) - .args(["--", "bash", "-c", "true"]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - output.status.success(), - "expected bash to succeed under default seccomp (clone3 fallback), stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn blacklist_overlays_survive_absolute_var_run_symlink() { - // On Debian/Ubuntu, /var/run -> /run is an absolute symlink; overlays - // like --tmpfs /var/run/dbus trip bwrap's re-rooted symlink resolution. - // Arch ships /var/run -> ../run (relative) so we synthesize the absolute - // layout inside the sandbox to reproduce on any host. - let mut bwrap_args = build_bwrap_command(&["--no-seccomp", "--", "true"]); - inject_absolute_var_run_symlink(&mut bwrap_args); - - let output = Command::new(&bwrap_args[0]) - .args(&bwrap_args[1..]) - .output() - .expect("failed to invoke bwrap directly"); - - assert!( - output.status.success(), - "bwrap failed — an overlay target traverses an absolute /var/run symlink.\n\ - stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); -} - -fn build_bwrap_command(sandbox_args: &[&str]) -> Vec { - let output = sandbox(&["--dry-run"]) - .args(sandbox_args) - .output() - .expect("agent-sandbox binary failed to execute"); - let cmd = String::from_utf8_lossy(&output.stdout); - let parsed = shlex::split(cmd.trim()).expect("dry-run output is not valid shell"); - assert_eq!(parsed[0], "bwrap"); - parsed -} - -fn inject_absolute_var_run_symlink(bwrap_args: &mut Vec) { - assert_eq!(bwrap_args[1], "--ro-bind"); - assert_eq!(bwrap_args[2], "/"); - assert_eq!(bwrap_args[3], "/"); - let flags = ["--tmpfs", "/var", "--symlink", "/run", "/var/run"].map(String::from); - bwrap_args.splice(4..4, flags); -} - -#[test] -fn seccomp_blocks_tiocsti() { - // TIOCSTI (0x5412) injects keystrokes into the terminal input queue. - // Without --new-session, this is the primary defense against CVE-2017-5226. - // - // On kernels >= 6.2 with CONFIG_LEGACY_TIOCSTI=n, the kernel blocks TIOCSTI - // before seccomp sees it. We test with --no-seccomp first to detect that and - // skip, so the test only asserts our filter's behaviour. - let baseline = sandbox(&["--no-seccomp"]) - .args([ - "--", - "python3", - "-c", - "import fcntl; fcntl.ioctl(0, 0x5412, b'x')", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - if !baseline.status.success() { - // Kernel already blocks TIOCSTI; seccomp filter is untestable here. - return; - } - - let output = sandbox(&[]) - .args([ - "--", - "python3", - "-c", - "import fcntl; fcntl.ioctl(0, 0x5412, b'x')", - ]) - .output() - .expect("agent-sandbox binary failed to execute"); - - assert!( - !output.status.success(), - "expected TIOCSTI to be blocked by seccomp filter" - ); -} diff --git a/tests/unit/config.rs b/tests/unit/config.rs new file mode 100644 index 0000000..689a1e4 --- /dev/null +++ b/tests/unit/config.rs @@ -0,0 +1,1236 @@ +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 = 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::>() + ); +} + +#[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::>() + ); +} + +#[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::>() + ); +} +#[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 { + profile: HashMap::from([( + "claude".into(), + Options { + rw: vec!["/a".into()], + hardened: Some(false), + ..Options::default() + }, + )]), + ..FileConfig::default() + }; + let extra = FileConfig { + profile: 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.profile["claude"].hardened, Some(true)); + assert_eq!(merged.profile["claude"].rw, vec!["/a", "/b"]); + assert_eq!(merged.profile["codex"].unshare_net, Some(true)); +} + +#[test] +fn merge_file_config_default_profile_extra_overrides() { + let base = FileConfig { + default_profile: Some("claude".into()), + ..FileConfig::default() + }; + let extra = FileConfig { + default_profile: Some("codex".into()), + ..FileConfig::default() + }; + assert_eq!(base.merge_with(extra).default_profile, Some("codex".into())); +} + +#[test] +fn merge_file_config_default_profile_extra_unset_inherits() { + let base = FileConfig { + default_profile: Some("claude".into()), + ..FileConfig::default() + }; + assert_eq!( + base.merge_with(FileConfig::default()).default_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 = 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, expected: &[&str]) { + let actual = cmd.clone().unwrap().into_vec(); + let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); + assert_eq!(actual, expected); +} diff --git a/tests/unit/env.rs b/tests/unit/env.rs new file mode 100644 index 0000000..e13bfb1 --- /dev/null +++ b/tests/unit/env.rs @@ -0,0 +1,37 @@ +use super::*; + +#[test] +fn keepenv_emits_setenv_for_present_key() { + let parent = vec![("XDG_RUNTIME_DIR".into(), "/run/user/1000".into())]; + let args = keepenv_args(&["XDG_RUNTIME_DIR".into()], &parent); + assert_eq!(args, vec!["--setenv", "XDG_RUNTIME_DIR", "/run/user/1000"]); +} + +#[test] +fn keepenv_skips_absent_keys() { + let parent = vec![("HOME".into(), "/home/me".into())]; + let args = keepenv_args(&["XDG_RUNTIME_DIR".into()], &parent); + assert!(args.is_empty()); +} + +#[test] +fn keepenv_preserves_caller_key_order() { + let parent = vec![ + ("B".into(), "2".into()), + ("A".into(), "1".into()), + ("C".into(), "3".into()), + ]; + let args = keepenv_args(&["A".into(), "B".into(), "C".into()], &parent); + assert_eq!( + args, + vec![ + "--setenv", "A", "1", "--setenv", "B", "2", "--setenv", "C", "3" + ] + ); +} + +#[test] +fn keepenv_empty_keys_yields_nothing() { + let parent = vec![("A".into(), "1".into())]; + assert!(keepenv_args(&[], &parent).is_empty()); +} diff --git a/tests/unit/seccomp.rs b/tests/unit/seccomp.rs new file mode 100644 index 0000000..69705a6 --- /dev/null +++ b/tests/unit/seccomp.rs @@ -0,0 +1,76 @@ +use super::*; + +#[test] +fn builds_on_supported_arch() { + let bytes = build_program_bytes().expect("seccomp program should build"); + assert!(!bytes.is_empty(), "serialized BPF program is empty"); + assert_eq!(bytes.len() % 8, 0, "BPF byte stream must be 8-byte aligned"); +} + +#[test] +fn allowlist_contains_essential_syscalls() { + for needed in &[ + "read", + "write", + "openat", + "close", + "execve", + "exit_group", + "mmap", + "brk", + "clone", + ] { + assert!( + ALLOWED_SYSCALLS.contains(needed), + "allowlist missing essential syscall: {needed}" + ); + } +} + +#[test] +fn allowlist_excludes_dangerous_syscalls() { + for denied in &[ + "bpf", + "perf_event_open", + "userfaultfd", + "kexec_load", + "kexec_file_load", + "init_module", + "finit_module", + "delete_module", + "mount", + "umount", + "umount2", + "unshare", + "setns", + "pivot_root", + "ptrace", + "process_vm_readv", + "process_vm_writev", + "keyctl", + "personality", + "clone3", + "io_uring_setup", + "io_uring_register", + "io_uring_enter", + "fanotify_init", + "fanotify_mark", + "open_by_handle_at", + "name_to_handle_at", + "fsopen", + "fsconfig", + "fsmount", + "fspick", + "open_tree", + "move_mount", + "mount_setattr", + "reboot", + "swapon", + "swapoff", + ] { + assert!( + !ALLOWED_SYSCALLS.contains(denied), + "allowlist must not contain dangerous syscall: {denied}" + ); + } +}