sandbox_tests.rs

   1//! Integration tests for terminal sandboxing.
   2//!
   3//! These tests exercise the real kernel sandbox (Seatbelt on macOS, Landlock on
   4//! Linux) by spawning child processes and verifying OS enforcement. They do NOT
   5//! use mocks.
   6//!
   7//! These tests use `std::process::Command::output()` rather than `smol::process`
   8//! because they need `pre_exec` hooks to apply sandboxes before exec.
   9#![allow(clippy::disallowed_methods)]
  10
  11use crate::{ResolvedSystemPaths, SandboxConfig, SandboxExecConfig};
  12use std::collections::HashSet;
  13use std::fs;
  14use std::os::unix::process::CommandExt;
  15use std::path::{Path, PathBuf};
  16use std::process::Command;
  17
  18// ---------------------------------------------------------------------------
  19// Test helpers
  20// ---------------------------------------------------------------------------
  21
  22/// Build a minimal `SandboxConfig` for testing.
  23///
  24/// Uses default executable and read-only system paths so `/bin/sh` and
  25/// commands like `echo`, `cat`, `rm`, `env`, and `curl` are available.
  26///
  27/// Crucially, the read-write system paths are restricted to `/dev` and
  28/// `/private/tmp` only — NOT `/private/var/folders`. This is because the
  29/// test temp directories live under `/private/var/folders`, and granting
  30/// blanket access there would make it impossible to test that the sandbox
  31/// blocks access to sibling directories outside the project.
  32fn test_sandbox_config(project_dir: PathBuf) -> SandboxConfig {
  33    let defaults = ResolvedSystemPaths::with_defaults();
  34    SandboxConfig {
  35        project_dir,
  36        system_paths: ResolvedSystemPaths {
  37            executable: defaults.executable,
  38            read_only: defaults.read_only,
  39            read_write: vec![
  40                PathBuf::from("/dev"),
  41                #[cfg(target_os = "macos")]
  42                PathBuf::from("/private/tmp"),
  43                #[cfg(target_os = "linux")]
  44                PathBuf::from("/tmp"),
  45                #[cfg(target_os = "linux")]
  46                PathBuf::from("/var/tmp"),
  47            ],
  48        },
  49        additional_executable_paths: vec![],
  50        additional_read_only_paths: vec![],
  51        additional_read_write_paths: vec![],
  52        allow_network: true,
  53        allowed_env_vars: SandboxConfig::default_allowed_env_vars(),
  54    }
  55}
  56
  57/// Exercises the full `sandbox_exec_main` production codepath in a child
  58/// process.
  59///
  60/// Returns `(success, stdout, stderr)`.
  61fn run_sandboxed_command(
  62    config: &SandboxConfig,
  63    extra_parent_env: &[(&str, &str)],
  64    shell_command: &str,
  65) -> (bool, String, String) {
  66    let exec_config = SandboxExecConfig::from_sandbox_config(config);
  67    let config_json = exec_config.to_json();
  68    let parsed = SandboxExecConfig::from_json(&config_json)
  69        .expect("SandboxExecConfig JSON roundtrip failed");
  70    let mut sandbox_config = parsed.to_sandbox_config();
  71    sandbox_config.canonicalize_paths();
  72
  73    let zed_vars = [
  74        "ZED_TERM",
  75        "TERM_PROGRAM",
  76        "TERM",
  77        "COLORTERM",
  78        "TERM_PROGRAM_VERSION",
  79    ];
  80    let allowed: HashSet<&str> = parsed.allowed_env_vars.iter().map(|s| s.as_str()).collect();
  81
  82    let mut parent_env: Vec<(String, String)> = std::env::vars().collect();
  83    for &(key, value) in extra_parent_env {
  84        parent_env.push((key.to_string(), value.to_string()));
  85    }
  86    let filtered_env: Vec<(String, String)> = parent_env
  87        .into_iter()
  88        .filter(|(key, _)| allowed.contains(key.as_str()) || zed_vars.contains(&key.as_str()))
  89        .collect();
  90
  91    let mut cmd = Command::new("/bin/sh");
  92    cmd.arg("-c").arg(shell_command);
  93    cmd.current_dir(&sandbox_config.project_dir);
  94    cmd.env_clear();
  95    cmd.envs(filtered_env);
  96
  97    unsafe {
  98        cmd.pre_exec(move || {
  99            #[cfg(target_os = "macos")]
 100            {
 101                crate::sandbox_macos::apply_sandbox(&sandbox_config)?;
 102            }
 103            #[cfg(target_os = "linux")]
 104            {
 105                crate::sandbox_linux::apply_sandbox(&sandbox_config)?;
 106            }
 107            Ok(())
 108        });
 109    }
 110
 111    let output = cmd
 112        .output()
 113        .expect("failed to spawn sandboxed child process");
 114    (
 115        output.status.success(),
 116        String::from_utf8_lossy(&output.stdout).into_owned(),
 117        String::from_utf8_lossy(&output.stderr).into_owned(),
 118    )
 119}
 120
 121/// Run a shell command *without* any sandbox for comparison.
 122fn run_unsandboxed_command(shell_command: &str) -> (bool, String, String) {
 123    let output = Command::new("/bin/sh")
 124        .arg("-c")
 125        .arg(shell_command)
 126        .output()
 127        .expect("failed to spawn unsandboxed child process");
 128    (
 129        output.status.success(),
 130        String::from_utf8_lossy(&output.stdout).into_owned(),
 131        String::from_utf8_lossy(&output.stderr).into_owned(),
 132    )
 133}
 134
 135// ---------------------------------------------------------------------------
 136// Unit tests: SandboxExecConfig serialization roundtrip
 137// ---------------------------------------------------------------------------
 138
 139#[test]
 140fn test_sandbox_exec_config_roundtrip() {
 141    let original = SandboxConfig {
 142        project_dir: PathBuf::from("/tmp/my-project"),
 143        system_paths: ResolvedSystemPaths {
 144            executable: vec![PathBuf::from("/usr/bin"), PathBuf::from("/bin")],
 145            read_only: vec![PathBuf::from("/etc")],
 146            read_write: vec![PathBuf::from("/tmp")],
 147        },
 148        additional_executable_paths: vec![PathBuf::from("/opt/tools/bin")],
 149        additional_read_only_paths: vec![PathBuf::from("/opt/data")],
 150        additional_read_write_paths: vec![PathBuf::from("/opt/cache")],
 151        allow_network: false,
 152        allowed_env_vars: vec!["PATH".into(), "HOME".into()],
 153    };
 154
 155    let exec_config = SandboxExecConfig::from_sandbox_config(&original);
 156    let json = exec_config.to_json();
 157    let deserialized = SandboxExecConfig::from_json(&json).expect("failed to parse JSON");
 158    let roundtripped = deserialized.to_sandbox_config();
 159
 160    assert_eq!(roundtripped.project_dir, original.project_dir);
 161    assert_eq!(
 162        roundtripped.system_paths.executable,
 163        original.system_paths.executable
 164    );
 165    assert_eq!(
 166        roundtripped.system_paths.read_only,
 167        original.system_paths.read_only
 168    );
 169    assert_eq!(
 170        roundtripped.system_paths.read_write,
 171        original.system_paths.read_write
 172    );
 173    assert_eq!(
 174        roundtripped.additional_executable_paths,
 175        original.additional_executable_paths
 176    );
 177    assert_eq!(
 178        roundtripped.additional_read_only_paths,
 179        original.additional_read_only_paths
 180    );
 181    assert_eq!(
 182        roundtripped.additional_read_write_paths,
 183        original.additional_read_write_paths
 184    );
 185    assert_eq!(roundtripped.allow_network, original.allow_network);
 186    assert_eq!(roundtripped.allowed_env_vars, original.allowed_env_vars);
 187}
 188
 189#[test]
 190fn test_sandbox_exec_config_from_json_invalid() {
 191    let result = SandboxExecConfig::from_json("not json");
 192    assert!(result.is_err());
 193}
 194
 195// ---------------------------------------------------------------------------
 196// Unit tests: SandboxConfig::from_settings
 197// ---------------------------------------------------------------------------
 198
 199#[test]
 200fn test_sandbox_config_from_settings_defaults() {
 201    let settings = settings_content::SandboxSettingsContent::default();
 202    let config = SandboxConfig::from_settings(&settings, PathBuf::from("/projects/test"));
 203
 204    assert_eq!(config.project_dir, PathBuf::from("/projects/test"));
 205    assert_eq!(config.allow_network, true);
 206    assert_eq!(
 207        config.allowed_env_vars,
 208        SandboxConfig::default_allowed_env_vars()
 209    );
 210    assert!(config.additional_executable_paths.is_empty());
 211    assert!(config.additional_read_only_paths.is_empty());
 212    assert!(config.additional_read_write_paths.is_empty());
 213
 214    assert!(!config.system_paths.executable.is_empty());
 215    assert!(!config.system_paths.read_only.is_empty());
 216    assert!(!config.system_paths.read_write.is_empty());
 217}
 218
 219#[test]
 220fn test_sandbox_config_tilde_expansion() {
 221    let home = std::env::var("HOME").expect("HOME not set");
 222    let settings = settings_content::SandboxSettingsContent {
 223        additional_read_only_paths: Some(vec!["~/documents".into(), "/absolute/path".into()]),
 224        ..Default::default()
 225    };
 226    let config = SandboxConfig::from_settings(&settings, PathBuf::from("/tmp/test"));
 227
 228    assert_eq!(
 229        config.additional_read_only_paths,
 230        vec![
 231            PathBuf::from(format!("{}/documents", home)),
 232            PathBuf::from("/absolute/path"),
 233        ]
 234    );
 235}
 236
 237#[test]
 238fn test_sandbox_config_custom_allowed_env_vars() {
 239    let settings = settings_content::SandboxSettingsContent {
 240        allowed_env_vars: Some(vec!["CUSTOM_VAR".into()]),
 241        ..Default::default()
 242    };
 243    let config = SandboxConfig::from_settings(&settings, PathBuf::from("/tmp/test"));
 244    assert_eq!(config.allowed_env_vars, vec!["CUSTOM_VAR".to_string()]);
 245}
 246
 247#[test]
 248fn test_sandbox_config_network_disabled() {
 249    let settings = settings_content::SandboxSettingsContent {
 250        allow_network: Some(false),
 251        ..Default::default()
 252    };
 253    let config = SandboxConfig::from_settings(&settings, PathBuf::from("/tmp/test"));
 254    assert!(!config.allow_network);
 255}
 256
 257// ---------------------------------------------------------------------------
 258// Unit tests: SandboxConfig::resolve_if_enabled
 259// ---------------------------------------------------------------------------
 260
 261#[test]
 262fn test_resolve_if_enabled_disabled() {
 263    let settings = settings_content::SandboxSettingsContent {
 264        enabled: Some(false),
 265        ..Default::default()
 266    };
 267    assert!(
 268        SandboxConfig::resolve_if_enabled(
 269            &settings,
 270            settings_content::SandboxApplyTo::Terminal,
 271            PathBuf::from("/tmp/test"),
 272        )
 273        .is_none()
 274    );
 275}
 276
 277#[test]
 278fn test_resolve_if_enabled_terminal_matches_terminal() {
 279    let settings = settings_content::SandboxSettingsContent {
 280        enabled: Some(true),
 281        apply_to: Some(settings_content::SandboxApplyTo::Terminal),
 282        ..Default::default()
 283    };
 284    assert!(
 285        SandboxConfig::resolve_if_enabled(
 286            &settings,
 287            settings_content::SandboxApplyTo::Terminal,
 288            PathBuf::from("/tmp/test"),
 289        )
 290        .is_some()
 291    );
 292}
 293
 294#[test]
 295fn test_resolve_if_enabled_terminal_does_not_match_tool() {
 296    let settings = settings_content::SandboxSettingsContent {
 297        enabled: Some(true),
 298        apply_to: Some(settings_content::SandboxApplyTo::Terminal),
 299        ..Default::default()
 300    };
 301    assert!(
 302        SandboxConfig::resolve_if_enabled(
 303            &settings,
 304            settings_content::SandboxApplyTo::Tool,
 305            PathBuf::from("/tmp/test"),
 306        )
 307        .is_none()
 308    );
 309}
 310
 311#[test]
 312fn test_resolve_if_enabled_both_matches_both_targets() {
 313    let settings = settings_content::SandboxSettingsContent {
 314        enabled: Some(true),
 315        apply_to: Some(settings_content::SandboxApplyTo::Both),
 316        ..Default::default()
 317    };
 318    assert!(
 319        SandboxConfig::resolve_if_enabled(
 320            &settings,
 321            settings_content::SandboxApplyTo::Terminal,
 322            PathBuf::from("/tmp/test"),
 323        )
 324        .is_some()
 325    );
 326    assert!(
 327        SandboxConfig::resolve_if_enabled(
 328            &settings,
 329            settings_content::SandboxApplyTo::Tool,
 330            PathBuf::from("/tmp/test"),
 331        )
 332        .is_some()
 333    );
 334}
 335
 336// ---------------------------------------------------------------------------
 337// Unit tests: macOS SBPL profile generation
 338// ---------------------------------------------------------------------------
 339
 340#[cfg(target_os = "macos")]
 341mod sbpl_tests {
 342    use super::*;
 343    use crate::sandbox_macos::{generate_sbpl_profile, sbpl_escape};
 344
 345    #[test]
 346    fn test_sbpl_escape_plain_path() {
 347        let path = Path::new("/usr/bin");
 348        assert_eq!(sbpl_escape(path), "/usr/bin");
 349    }
 350
 351    #[test]
 352    fn test_sbpl_escape_with_quotes() {
 353        let path = Path::new("/tmp/has\"quote");
 354        assert_eq!(sbpl_escape(path), "/tmp/has\\\"quote");
 355    }
 356
 357    #[test]
 358    fn test_sbpl_escape_with_backslash() {
 359        let path = Path::new("/tmp/has\\backslash");
 360        assert_eq!(sbpl_escape(path), "/tmp/has\\\\backslash");
 361    }
 362
 363    #[test]
 364    fn test_sbpl_escape_with_both() {
 365        let path = Path::new("/tmp/a\"b\\c");
 366        assert_eq!(sbpl_escape(path), "/tmp/a\\\"b\\\\c");
 367    }
 368
 369    #[test]
 370    fn test_sbpl_profile_has_deny_default() {
 371        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
 372        let profile = generate_sbpl_profile(&config, None);
 373        assert!(profile.contains("(deny default)"));
 374    }
 375
 376    #[test]
 377    fn test_sbpl_profile_has_version() {
 378        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
 379        let profile = generate_sbpl_profile(&config, None);
 380        assert!(profile.starts_with("(version 1)\n"));
 381    }
 382
 383    #[test]
 384    fn test_sbpl_profile_includes_project_dir() {
 385        let config = test_sandbox_config(PathBuf::from("/tmp/my-project"));
 386        let profile = generate_sbpl_profile(&config, None);
 387        assert!(
 388            profile.contains("(subpath \"/tmp/my-project\")"),
 389            "Profile should include project dir as a subpath rule. Profile:\n{profile}"
 390        );
 391    }
 392
 393    #[test]
 394    fn test_sbpl_profile_includes_system_paths() {
 395        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
 396        let profile = generate_sbpl_profile(&config, None);
 397        assert!(
 398            profile.contains("(subpath \"/usr/bin\")"),
 399            "Profile should include /usr/bin. Profile:\n{profile}"
 400        );
 401    }
 402
 403    #[test]
 404    fn test_sbpl_profile_network_allowed() {
 405        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
 406        let profile = generate_sbpl_profile(&config, None);
 407        assert!(profile.contains("(allow network-outbound)"));
 408        assert!(profile.contains("(allow network-inbound)"));
 409    }
 410
 411    #[test]
 412    fn test_sbpl_profile_network_denied() {
 413        let mut config = test_sandbox_config(PathBuf::from("/tmp/project"));
 414        config.allow_network = false;
 415        let profile = generate_sbpl_profile(&config, None);
 416        assert!(!profile.contains("(allow network-outbound)"));
 417        assert!(!profile.contains("(allow network-inbound)"));
 418    }
 419
 420    #[test]
 421    fn test_sbpl_profile_no_unrestricted_process_exec() {
 422        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
 423        let profile = generate_sbpl_profile(&config, None);
 424        let lines: Vec<&str> = profile.lines().collect();
 425        for line in &lines {
 426            if line.contains("process-exec") {
 427                assert!(
 428                    line.contains("subpath") || line.contains("literal"),
 429                    "process-exec should be scoped to specific paths, found bare rule: {line}"
 430                );
 431            }
 432        }
 433    }
 434
 435    #[test]
 436    fn test_sbpl_profile_no_unrestricted_mach_lookup() {
 437        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
 438        let profile = generate_sbpl_profile(&config, None);
 439        let lines: Vec<&str> = profile.lines().collect();
 440        for line in &lines {
 441            if line.contains("mach-lookup") {
 442                assert!(
 443                    line.contains("global-name"),
 444                    "mach-lookup should be scoped to specific services, found: {line}"
 445                );
 446            }
 447        }
 448    }
 449
 450    #[test]
 451    fn test_sbpl_profile_additional_paths() {
 452        let mut config = test_sandbox_config(PathBuf::from("/tmp/project"));
 453        config.additional_executable_paths = vec![PathBuf::from("/opt/tools/bin")];
 454        config.additional_read_only_paths = vec![PathBuf::from("/opt/data")];
 455        config.additional_read_write_paths = vec![PathBuf::from("/opt/cache")];
 456
 457        let profile = generate_sbpl_profile(&config, None);
 458
 459        assert!(
 460            profile.contains("(subpath \"/opt/tools/bin\")"),
 461            "Should include additional executable path"
 462        );
 463        assert!(
 464            profile.contains("(subpath \"/opt/data\")"),
 465            "Should include additional read-only path"
 466        );
 467        assert!(
 468            profile.contains("(subpath \"/opt/cache\")"),
 469            "Should include additional read-write path"
 470        );
 471    }
 472
 473    #[test]
 474    fn test_sbpl_profile_signal_scoped_to_children() {
 475        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
 476        let profile = generate_sbpl_profile(&config, None);
 477        assert!(
 478            profile.contains("(allow signal (target children))"),
 479            "Signal should be scoped to children. Profile:\n{profile}"
 480        );
 481        let lines: Vec<&str> = profile.lines().collect();
 482        for line in &lines {
 483            if line.contains("(allow signal") {
 484                assert!(
 485                    line.contains("(target children)"),
 486                    "Found unscoped signal rule: {line}"
 487                );
 488            }
 489        }
 490    }
 491}
 492
 493// ---------------------------------------------------------------------------
 494// Integration tests: filesystem enforcement
 495// ---------------------------------------------------------------------------
 496
 497fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
 498    let dir = tempfile::tempdir().expect("failed to create temp dir");
 499    let canonical = dir.path().canonicalize().expect("failed to canonicalize");
 500    (dir, canonical)
 501}
 502
 503fn create_test_directory(base: &Path, name: &str, content: &str) -> (PathBuf, PathBuf) {
 504    let dir = base.join(name);
 505    fs::create_dir_all(&dir).expect("failed to create test directory");
 506    let file = dir.join("test_file.txt");
 507    fs::write(&file, content).expect("failed to write test file");
 508    (dir, file)
 509}
 510
 511#[test]
 512fn test_sandbox_blocks_rm_rf() {
 513    let (_base_guard, base) = canonical_tempdir();
 514
 515    let (project_dir, _) = create_test_directory(&base, "project", "project content");
 516    let (target_dir, target_file) = create_test_directory(&base, "target", "do not delete me");
 517
 518    let config = test_sandbox_config(project_dir);
 519    let cmd = format!("rm -rf {}", target_dir.display());
 520    let (success, _stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
 521
 522    assert!(
 523        target_dir.exists() && target_file.exists(),
 524        "Sandboxed rm -rf should not be able to delete target directory. \
 525         success={success}, dir_exists={}, file_exists={}",
 526        target_dir.exists(),
 527        target_file.exists(),
 528    );
 529
 530    let (success, _, _) = run_unsandboxed_command(&format!("rm -rf {}", target_dir.display()));
 531    assert!(success, "Unsandboxed rm -rf should succeed");
 532    assert!(
 533        !target_dir.exists(),
 534        "Unsandboxed rm -rf should have deleted the directory"
 535    );
 536}
 537
 538#[test]
 539fn test_sandbox_allows_writes_in_project() {
 540    let (_base_guard, base) = canonical_tempdir();
 541    let project_dir = base.join("project");
 542    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 543
 544    let config = test_sandbox_config(project_dir.clone());
 545    let output_file = project_dir.join("sandbox_output.txt");
 546    #[allow(clippy::redundant_clone)]
 547    let cmd = format!("echo 'hello from sandbox' > {}", output_file.display());
 548    let (success, _stdout, stderr) = run_sandboxed_command(&config, &[], &cmd);
 549
 550    assert!(
 551        success,
 552        "Writing inside the project dir should succeed. stderr: {stderr}"
 553    );
 554    assert!(output_file.exists(), "Output file should have been created");
 555    let content = fs::read_to_string(&output_file).expect("failed to read output file");
 556    assert!(
 557        content.contains("hello from sandbox"),
 558        "File should contain expected content, got: {content}"
 559    );
 560}
 561
 562#[test]
 563fn test_sandbox_blocks_reads_outside_project() {
 564    let (_base_guard, base) = canonical_tempdir();
 565    let project_dir = base.join("project");
 566    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 567
 568    let secret_content = "TOP_SECRET_DATA_12345";
 569    let (_, secret_file) = create_test_directory(&base, "secrets", secret_content);
 570
 571    let config = test_sandbox_config(project_dir);
 572
 573    let cmd = format!("cat {} 2>/dev/null || true", secret_file.display());
 574    let (_success, stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
 575
 576    assert!(
 577        !stdout.contains(secret_content),
 578        "Sandbox should prevent reading files outside the project. stdout: {stdout}"
 579    );
 580}
 581
 582#[test]
 583fn test_additional_read_write_paths_grant_access() {
 584    let (_base_guard, base) = canonical_tempdir();
 585    let project_dir = base.join("project");
 586    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 587
 588    let extra_dir = base.join("extra_rw");
 589    fs::create_dir_all(&extra_dir).expect("failed to create extra dir");
 590
 591    let test_file = extra_dir.join("rw_test.txt");
 592
 593    let config_without = test_sandbox_config(project_dir.clone());
 594    let cmd = format!("echo 'written' > {}", test_file.display());
 595    let (_success, _stdout, _stderr) = run_sandboxed_command(&config_without, &[], &cmd);
 596    let file_written_without = test_file.exists()
 597        && fs::read_to_string(&test_file)
 598            .map(|c| c.contains("written"))
 599            .unwrap_or(false);
 600    assert!(
 601        !file_written_without,
 602        "Write to extra dir should be blocked without additional_read_write_paths"
 603    );
 604
 605    let mut config_with = test_sandbox_config(project_dir);
 606    config_with.additional_read_write_paths = vec![extra_dir];
 607    let (success, _stdout, stderr) = run_sandboxed_command(&config_with, &[], &cmd);
 608    assert!(
 609        success,
 610        "Write to extra dir should succeed with additional_read_write_paths. stderr: {stderr}"
 611    );
 612    assert!(
 613        test_file.exists(),
 614        "File should exist after sandboxed write with additional path"
 615    );
 616}
 617
 618#[test]
 619fn test_additional_read_only_paths_allow_read_block_write() {
 620    let (_base_guard, base) = canonical_tempdir();
 621    let project_dir = base.join("project");
 622    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 623
 624    let known_content = "known_readonly_content";
 625    let (readonly_dir, readonly_file) =
 626        create_test_directory(&base, "readonly_data", known_content);
 627
 628    let mut config = test_sandbox_config(project_dir.clone());
 629    config.additional_read_only_paths = vec![readonly_dir];
 630
 631    let output_file = project_dir.join("read_output.txt");
 632    let cmd = format!(
 633        "cat {} > {}",
 634        readonly_file.display(),
 635        output_file.display()
 636    );
 637    let (success, _stdout, stderr) = run_sandboxed_command(&config, &[], &cmd);
 638    assert!(
 639        success,
 640        "Reading from read-only path should succeed. stderr: {stderr}"
 641    );
 642    let read_content = fs::read_to_string(&output_file).unwrap_or_default();
 643    assert!(
 644        read_content.contains(known_content),
 645        "Should have read the known content. Got: {read_content}"
 646    );
 647
 648    let cmd = format!("echo 'overwritten' > {}", readonly_file.display());
 649    let (_success, _stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
 650    let current_content = fs::read_to_string(&readonly_file).expect("file should still exist");
 651    assert_eq!(
 652        current_content, known_content,
 653        "Read-only file should not have been overwritten"
 654    );
 655}
 656
 657// ---------------------------------------------------------------------------
 658// Integration test: environment variable filtering
 659// ---------------------------------------------------------------------------
 660
 661#[test]
 662fn test_env_var_filtering() {
 663    let (_base_guard, base) = canonical_tempdir();
 664    let project_dir = base.join("project");
 665    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 666
 667    let config = test_sandbox_config(project_dir);
 668
 669    let (success, stdout, stderr) = run_sandboxed_command(
 670        &config,
 671        &[("AWS_SECRET", "super_secret_key_12345")],
 672        "echo HOME=$HOME; echo AWS=$AWS_SECRET",
 673    );
 674    assert!(success, "env command should succeed. stderr: {stderr}");
 675
 676    assert!(
 677        stdout.contains("HOME=/"),
 678        "HOME should be present in filtered env. stdout: {stdout}"
 679    );
 680
 681    assert!(
 682        !stdout.contains("super_secret_key_12345"),
 683        "AWS_SECRET should be filtered out. stdout: {stdout}"
 684    );
 685}
 686
 687// ---------------------------------------------------------------------------
 688// Integration test: network blocking (macOS only)
 689// ---------------------------------------------------------------------------
 690
 691#[cfg(target_os = "macos")]
 692#[test]
 693fn test_network_blocking() {
 694    let (_base_guard, base) = canonical_tempdir();
 695    let project_dir = base.join("project");
 696    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 697
 698    let mut config = test_sandbox_config(project_dir);
 699    config.allow_network = false;
 700
 701    let cmd = "curl -s --max-time 5 https://example.com 2>&1 || true";
 702    let (_success, stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
 703
 704    assert!(
 705        !stdout.contains("Example Domain"),
 706        "Network should be blocked. Got stdout: {stdout}"
 707    );
 708}
 709
 710// ---------------------------------------------------------------------------
 711// Integration test: basic command succeeds under sandbox
 712// ---------------------------------------------------------------------------
 713
 714#[test]
 715fn test_sandbox_basic_echo_succeeds() {
 716    let (_base_guard, base) = canonical_tempdir();
 717    let project_dir = base.join("project");
 718    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 719
 720    let config = test_sandbox_config(project_dir);
 721    let (success, stdout, stderr) = run_sandboxed_command(&config, &[], "echo 'sandbox works'");
 722
 723    assert!(
 724        success,
 725        "Basic echo should succeed under sandbox. stderr: {stderr}"
 726    );
 727    assert!(
 728        stdout.contains("sandbox works"),
 729        "Should see echo output. stdout: {stdout}"
 730    );
 731}
 732
 733// ---------------------------------------------------------------------------
 734// Integration test: additional_executable_paths
 735// ---------------------------------------------------------------------------
 736
 737#[test]
 738fn test_additional_executable_paths_allow_execution() {
 739    let (_base_guard, base) = canonical_tempdir();
 740    let project_dir = base.join("project");
 741    fs::create_dir_all(&project_dir).expect("failed to create project dir");
 742
 743    let tools_dir = base.join("tools");
 744    fs::create_dir_all(&tools_dir).expect("failed to create tools dir");
 745
 746    // Create a simple executable script in the tools directory
 747    let script_path = tools_dir.join("my_tool");
 748    fs::write(&script_path, "#!/bin/sh\necho tool_executed_successfully\n")
 749        .expect("failed to write script");
 750
 751    // Make it executable
 752    use std::os::unix::fs::PermissionsExt;
 753    fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
 754        .expect("failed to set permissions");
 755
 756    // Without additional_executable_paths — execution should fail
 757    let config_without = test_sandbox_config(project_dir.clone());
 758    let cmd = format!("{} 2>&1 || true", script_path.display());
 759    let (_success, stdout_without, _stderr) = run_sandboxed_command(&config_without, &[], &cmd);
 760    assert!(
 761        !stdout_without.contains("tool_executed_successfully"),
 762        "Tool should NOT be executable without additional_executable_paths. stdout: {stdout_without}"
 763    );
 764
 765    // With additional_executable_paths — execution should succeed
 766    let mut config_with = test_sandbox_config(project_dir);
 767    config_with.additional_executable_paths = vec![tools_dir];
 768    let (success, stdout_with, stderr) = run_sandboxed_command(&config_with, &[], &cmd);
 769    assert!(
 770        success && stdout_with.contains("tool_executed_successfully"),
 771        "Tool should be executable with additional_executable_paths. success={success}, stdout: {stdout_with}, stderr: {stderr}"
 772    );
 773}
 774
 775// ---------------------------------------------------------------------------
 776// Integration test: canonicalize_paths with symlinks
 777// ---------------------------------------------------------------------------
 778
 779#[test]
 780fn test_canonicalize_paths_resolves_symlinks() {
 781    let (_base_guard, base) = canonical_tempdir();
 782    let real_project_dir = base.join("real_project");
 783    fs::create_dir_all(&real_project_dir).expect("failed to create project dir");
 784
 785    // Create a test file in the real project directory
 786    let test_file = real_project_dir.join("test.txt");
 787    fs::write(&test_file, "symlink_test_content").expect("failed to write test file");
 788
 789    // Create a symlink to the project directory
 790    let symlink_dir = base.join("symlink_project");
 791    std::os::unix::fs::symlink(&real_project_dir, &symlink_dir)
 792        .expect("failed to create symlink");
 793
 794    // Use the symlinked path as the project dir — canonicalize_paths should resolve it
 795    let config = test_sandbox_config(symlink_dir);
 796
 797    // Writing should work because canonicalize_paths resolves the symlink to the real path
 798    let output_file = real_project_dir.join("output.txt");
 799    let cmd = format!("echo 'from_symlinked_project' > {}", output_file.display());
 800    let (success, _stdout, stderr) = run_sandboxed_command(&config, &[], &cmd);
 801
 802    assert!(
 803        success,
 804        "Writing in symlinked project dir should succeed after canonicalization. stderr: {stderr}"
 805    );
 806    let content = fs::read_to_string(&output_file).unwrap_or_default();
 807    assert!(
 808        content.contains("from_symlinked_project"),
 809        "Should have written through the canonicalized path. content: {content}"
 810    );
 811}
 812
 813// ---------------------------------------------------------------------------
 814// Fingerprint tests (macOS)
 815// ---------------------------------------------------------------------------
 816
 817#[cfg(target_os = "macos")]
 818mod fingerprint_tests {
 819    use super::*;
 820    use crate::sandbox_macos::{
 821        SessionFingerprint, apply_fingerprint_only, apply_sandbox_with_fingerprint,
 822        generate_fingerprint_only_profile,
 823    };
 824
 825    #[test]
 826    fn test_fingerprint_matches_own_process_with_full_sandbox() {
 827        let (_base_guard, base) = canonical_tempdir();
 828        let project_dir = base.join("project");
 829        fs::create_dir_all(&project_dir).expect("failed to create project dir");
 830
 831        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
 832        let config = test_sandbox_config(project_dir);
 833
 834        // Spawn a child process with the fingerprint-embedded sandbox profile
 835        let mut cmd = Command::new("/bin/sh");
 836        cmd.arg("-c").arg("sleep 5");
 837
 838        let sandbox_config = {
 839            let exec_config = SandboxExecConfig::from_sandbox_config(&config);
 840            let parsed = SandboxExecConfig::from_json(&exec_config.to_json()).unwrap();
 841            let mut sc = parsed.to_sandbox_config();
 842            sc.canonicalize_paths();
 843            sc
 844        };
 845
 846        unsafe {
 847            let fp_uuid = fingerprint.uuid_string();
 848            cmd.pre_exec(move || {
 849                let fp = SessionFingerprint::from_uuid_str(&fp_uuid)
 850                    .map_err(|e| std::io::Error::other(e))?;
 851                apply_sandbox_with_fingerprint(&sandbox_config, &fp)?;
 852                Ok(())
 853            });
 854        }
 855
 856        let mut child = cmd.spawn().expect("failed to spawn child");
 857        std::thread::sleep(std::time::Duration::from_millis(100));
 858
 859        // The fingerprint should match the child process
 860        let child_pid = child.id() as libc::pid_t;
 861        assert!(
 862            fingerprint.matches_pid(child_pid),
 863            "Fingerprint should match child process with embedded profile"
 864        );
 865
 866        child.kill().ok();
 867        child.wait().ok();
 868    }
 869
 870    #[test]
 871    fn test_fingerprint_does_not_match_unsandboxed_process() {
 872        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
 873
 874        // Spawn an unsandboxed process
 875        let mut child = Command::new("/bin/sh")
 876            .arg("-c")
 877            .arg("sleep 5")
 878            .spawn()
 879            .expect("failed to spawn child");
 880
 881        std::thread::sleep(std::time::Duration::from_millis(100));
 882
 883        let child_pid = child.id() as libc::pid_t;
 884        assert!(
 885            !fingerprint.matches_pid(child_pid),
 886            "Fingerprint should NOT match unsandboxed process"
 887        );
 888
 889        child.kill().ok();
 890        child.wait().ok();
 891    }
 892
 893    #[test]
 894    fn test_fingerprint_does_not_match_different_session() {
 895        let (_base_guard, base) = canonical_tempdir();
 896        let project_dir = base.join("project");
 897        fs::create_dir_all(&project_dir).expect("failed to create project dir");
 898
 899        let fingerprint_a = SessionFingerprint::new().expect("failed to create fingerprint A");
 900        let fingerprint_b = SessionFingerprint::new().expect("failed to create fingerprint B");
 901
 902        // Spawn a process with fingerprint_b's profile
 903        let mut cmd = Command::new("/bin/sh");
 904        cmd.arg("-c").arg("sleep 5");
 905
 906        unsafe {
 907            let fp_b_uuid = fingerprint_b.uuid_string();
 908            cmd.pre_exec(move || {
 909                let fp = SessionFingerprint::from_uuid_str(&fp_b_uuid)
 910                    .map_err(|e| std::io::Error::other(e))?;
 911                apply_fingerprint_only(&fp)?;
 912                Ok(())
 913            });
 914        }
 915
 916        let mut child = cmd.spawn().expect("failed to spawn child");
 917        std::thread::sleep(std::time::Duration::from_millis(100));
 918
 919        let child_pid = child.id() as libc::pid_t;
 920
 921        // fingerprint_a should NOT match (wrong session)
 922        assert!(
 923            !fingerprint_a.matches_pid(child_pid),
 924            "Fingerprint A should NOT match process from session B"
 925        );
 926
 927        // fingerprint_b SHOULD match
 928        assert!(
 929            fingerprint_b.matches_pid(child_pid),
 930            "Fingerprint B should match its own process"
 931        );
 932
 933        child.kill().ok();
 934        child.wait().ok();
 935    }
 936
 937    #[test]
 938    fn test_fingerprint_only_mode_no_restrictions() {
 939        let (_base_guard, base) = canonical_tempdir();
 940        let project_dir = base.join("project");
 941        fs::create_dir_all(&project_dir).expect("failed to create project dir");
 942
 943        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
 944
 945        // Create a file OUTSIDE the project dir
 946        let external_dir = base.join("external");
 947        fs::create_dir_all(&external_dir).expect("failed to create external dir");
 948        let external_file = external_dir.join("readable.txt");
 949        fs::write(&external_file, "external_content").expect("failed to write");
 950
 951        // Spawn with fingerprint-only mode (should allow everything)
 952        let mut cmd = Command::new("/bin/sh");
 953        cmd.arg("-c")
 954            .arg(format!("cat {}", external_file.display()));
 955
 956        unsafe {
 957            let fp_uuid = fingerprint.uuid_string();
 958            cmd.pre_exec(move || {
 959                let fp = SessionFingerprint::from_uuid_str(&fp_uuid)
 960                    .map_err(|e| std::io::Error::other(e))?;
 961                apply_fingerprint_only(&fp)?;
 962                Ok(())
 963            });
 964        }
 965
 966        let output = cmd.output().expect("failed to spawn");
 967        let stdout = String::from_utf8_lossy(&output.stdout);
 968
 969        assert!(
 970            stdout.contains("external_content"),
 971            "Fingerprint-only mode should NOT restrict file access. stdout: {stdout}"
 972        );
 973    }
 974
 975    #[test]
 976    fn test_fingerprint_only_profile_structure() {
 977        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
 978        let profile = generate_fingerprint_only_profile(&fingerprint);
 979
 980        assert!(profile.contains("(allow default)"), "Should allow everything by default");
 981        assert!(profile.contains("(deny file-read*"), "Should deny the deny-side path");
 982        assert!(profile.contains("(allow file-read*"), "Should allow the allow-side path");
 983        assert!(!profile.contains("(deny default)"), "Should NOT have deny default");
 984    }
 985}
 986
 987// ---------------------------------------------------------------------------
 988// Convergent cleanup tests (macOS)
 989// ---------------------------------------------------------------------------
 990
 991#[cfg(target_os = "macos")]
 992mod cleanup_tests {
 993    use super::*;
 994    use crate::sandbox_macos::SessionFingerprint;
 995
 996    /// Helper: spawn a child process with the fingerprint-only profile.
 997    fn spawn_fingerprinted_process(
 998        fingerprint: &SessionFingerprint,
 999        command: &str,
1000    ) -> std::process::Child {
1001        let mut cmd = Command::new("/bin/sh");
1002        cmd.arg("-c").arg(command);
1003
1004        let fp_uuid = fingerprint.uuid_string();
1005        unsafe {
1006            cmd.pre_exec(move || {
1007                let fp = SessionFingerprint::from_uuid_str(&fp_uuid)
1008                    .map_err(|e| std::io::Error::other(e))?;
1009                crate::sandbox_macos::apply_fingerprint_only(&fp)?;
1010                Ok(())
1011            });
1012        }
1013
1014        cmd.spawn().expect("failed to spawn fingerprinted child")
1015    }
1016
1017    #[test]
1018    fn test_cleanup_kills_simple_child() {
1019        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
1020        let mut child = spawn_fingerprinted_process(&fingerprint, "sleep 60");
1021        std::thread::sleep(std::time::Duration::from_millis(100));
1022
1023        let child_pid = child.id() as libc::pid_t;
1024        assert!(fingerprint.matches_pid(child_pid), "Child should match before cleanup");
1025
1026        fingerprint.kill_all_processes(None);
1027
1028        // The child should be dead now
1029        let status = child.wait().expect("failed to wait");
1030        assert!(!status.success(), "Child should have been killed");
1031    }
1032
1033    #[test]
1034    fn test_cleanup_loop_terminates() {
1035        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
1036        let mut child = spawn_fingerprinted_process(&fingerprint, "sleep 60");
1037        std::thread::sleep(std::time::Duration::from_millis(100));
1038
1039        // kill_all_processes should complete (not hang)
1040        let start = std::time::Instant::now();
1041        fingerprint.kill_all_processes(None);
1042        let elapsed = start.elapsed();
1043
1044        assert!(
1045            elapsed < std::time::Duration::from_secs(5),
1046            "Cleanup should complete quickly, took {elapsed:?}"
1047        );
1048
1049        child.wait().ok();
1050    }
1051}