Cargo.lock 🔗
@@ -17418,6 +17418,7 @@ dependencies = [
"smol",
"sysinfo 0.37.2",
"task",
+ "tempfile",
"theme",
"thiserror 2.0.17",
"url",
Richard Feldman created
Adds crates/terminal/src/sandbox_tests.rs with:
- Unit tests for SandboxExecConfig serialization roundtrip
- Unit tests for SandboxConfig::from_settings defaults and tilde expansion
- macOS SBPL profile generation tests (escaping, structure, path scoping)
- Integration tests using real kernel sandbox enforcement:
- rm -rf blocked outside project
- Writes succeed inside project directory
- Reads blocked outside project
- additional_read_write_paths grants access
- additional_read_only_paths allows read, blocks write
- Environment variable filtering
- Network blocking (macOS)
- Basic echo succeeds under sandbox
Some integration tests currently fail due to sandbox being too
restrictive for child process execution - to be debugged next.
Cargo.lock | 1
crates/terminal/Cargo.toml | 2
crates/terminal/src/sandbox_macos.rs | 4
crates/terminal/src/sandbox_tests.rs | 653 ++++++++++++++++++++++++++++++
crates/terminal/src/terminal.rs | 2
5 files changed, 660 insertions(+), 2 deletions(-)
@@ -17418,6 +17418,7 @@ dependencies = [
"smol",
"sysinfo 0.37.2",
"task",
+ "tempfile",
"theme",
"thiserror 2.0.17",
"url",
@@ -53,5 +53,7 @@ landlock = "0.4"
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
rand.workspace = true
+serde_json.workspace = true
settings = { workspace = true, features = ["test-support"] }
+tempfile.workspace = true
util_macros.workspace = true
@@ -47,7 +47,7 @@ pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
}
/// Generate an SBPL (Sandbox Profile Language) profile from the sandbox config.
-fn generate_sbpl_profile(config: &SandboxConfig) -> String {
+pub(crate) fn generate_sbpl_profile(config: &SandboxConfig) -> String {
let mut p = String::from("(version 1)\n(deny default)\n");
// Process lifecycle
@@ -207,7 +207,7 @@ fn generate_sbpl_profile(config: &SandboxConfig) -> String {
p
}
-fn sbpl_escape(path: &Path) -> String {
+pub(crate) fn sbpl_escape(path: &Path) -> String {
path.display()
.to_string()
.replace('\\', "\\\\")
@@ -0,0 +1,653 @@
+//! Integration tests for terminal sandboxing.
+//!
+//! These tests exercise the real kernel sandbox (Seatbelt on macOS, Landlock on
+//! Linux) by spawning child processes and verifying OS enforcement. They do NOT
+//! use mocks.
+
+use crate::sandbox_exec::SandboxExecConfig;
+use crate::terminal_settings::{ResolvedSystemPaths, SandboxConfig};
+use std::collections::HashSet;
+use std::fs;
+use std::os::unix::process::CommandExt;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+// ---------------------------------------------------------------------------
+// Test helpers
+// ---------------------------------------------------------------------------
+
+/// Build a minimal `SandboxConfig` for testing.
+/// Uses the OS-specific default system paths so that `/bin/sh` and basic
+/// commands like `echo`, `cat`, `rm`, `env`, and `curl` are available.
+fn test_sandbox_config(project_dir: PathBuf) -> SandboxConfig {
+ SandboxConfig {
+ project_dir,
+ system_paths: ResolvedSystemPaths::with_defaults(),
+ additional_executable_paths: vec![],
+ additional_read_only_paths: vec![],
+ additional_read_write_paths: vec![],
+ allow_network: true,
+ allowed_env_vars: SandboxConfig::default_allowed_env_vars(),
+ }
+}
+
+/// Spawn `/bin/sh -c <shell_command>` in a child process that has the OS-level
+/// sandbox applied (Seatbelt on macOS, Landlock on Linux).
+///
+/// Returns `(success, stdout, stderr)`.
+fn run_sandboxed_command(config: &SandboxConfig, shell_command: &str) -> (bool, String, String) {
+ let mut config = config.clone();
+ config.canonicalize_paths();
+
+ let mut cmd = Command::new("/bin/sh");
+ cmd.arg("-c").arg(shell_command);
+
+ unsafe {
+ cmd.pre_exec(move || {
+ #[cfg(target_os = "macos")]
+ {
+ crate::sandbox_macos::apply_sandbox(&config)?;
+ }
+ #[cfg(target_os = "linux")]
+ {
+ crate::sandbox_linux::apply_sandbox(&config)?;
+ }
+ Ok(())
+ });
+ }
+
+ let output = cmd
+ .output()
+ .expect("failed to spawn sandboxed child process");
+ (
+ output.status.success(),
+ String::from_utf8_lossy(&output.stdout).into_owned(),
+ String::from_utf8_lossy(&output.stderr).into_owned(),
+ )
+}
+
+/// Like `run_sandboxed_command`, but also filters environment variables
+/// the way `sandbox_exec_main` does: only allowed vars + Zed-specific
+/// vars are passed through. Extra env vars can be injected.
+fn run_sandboxed_with_env(
+ config: &SandboxConfig,
+ extra_env: &[(&str, &str)],
+ shell_command: &str,
+) -> (bool, String, String) {
+ let mut config = config.clone();
+ config.canonicalize_paths();
+
+ let allowed: HashSet<&str> = config.allowed_env_vars.iter().map(|s| s.as_str()).collect();
+ let zed_vars = [
+ "ZED_TERM",
+ "TERM_PROGRAM",
+ "TERM",
+ "COLORTERM",
+ "TERM_PROGRAM_VERSION",
+ ];
+
+ let mut cmd = Command::new("/bin/sh");
+ cmd.arg("-c").arg(shell_command);
+
+ // Filter env: start clean, then add only allowed vars
+ cmd.env_clear();
+ for (key, value) in std::env::vars() {
+ if allowed.contains(key.as_str()) || zed_vars.contains(&key.as_str()) {
+ cmd.env(&key, &value);
+ }
+ }
+ for &(key, value) in extra_env {
+ cmd.env(key, value);
+ }
+
+ unsafe {
+ cmd.pre_exec(move || {
+ #[cfg(target_os = "macos")]
+ {
+ crate::sandbox_macos::apply_sandbox(&config)?;
+ }
+ #[cfg(target_os = "linux")]
+ {
+ crate::sandbox_linux::apply_sandbox(&config)?;
+ }
+ Ok(())
+ });
+ }
+
+ let output = cmd
+ .output()
+ .expect("failed to spawn sandboxed child process");
+ (
+ output.status.success(),
+ String::from_utf8_lossy(&output.stdout).into_owned(),
+ String::from_utf8_lossy(&output.stderr).into_owned(),
+ )
+}
+
+/// Run a shell command *without* any sandbox for comparison.
+fn run_unsandboxed_command(shell_command: &str) -> (bool, String, String) {
+ let output = Command::new("/bin/sh")
+ .arg("-c")
+ .arg(shell_command)
+ .output()
+ .expect("failed to spawn unsandboxed child process");
+ (
+ output.status.success(),
+ String::from_utf8_lossy(&output.stdout).into_owned(),
+ String::from_utf8_lossy(&output.stderr).into_owned(),
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests: SandboxExecConfig serialization roundtrip
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_sandbox_exec_config_roundtrip() {
+ let original = SandboxConfig {
+ project_dir: PathBuf::from("/tmp/my-project"),
+ system_paths: ResolvedSystemPaths {
+ executable: vec![PathBuf::from("/usr/bin"), PathBuf::from("/bin")],
+ read_only: vec![PathBuf::from("/etc")],
+ read_write: vec![PathBuf::from("/tmp")],
+ },
+ additional_executable_paths: vec![PathBuf::from("/opt/tools/bin")],
+ additional_read_only_paths: vec![PathBuf::from("/opt/data")],
+ additional_read_write_paths: vec![PathBuf::from("/opt/cache")],
+ allow_network: false,
+ allowed_env_vars: vec!["PATH".into(), "HOME".into()],
+ };
+
+ let exec_config = SandboxExecConfig::from_sandbox_config(&original);
+ let json = exec_config.to_json();
+ let deserialized = SandboxExecConfig::from_json(&json).expect("failed to parse JSON");
+ let roundtripped = deserialized.to_sandbox_config();
+
+ assert_eq!(roundtripped.project_dir, original.project_dir);
+ assert_eq!(
+ roundtripped.system_paths.executable,
+ original.system_paths.executable
+ );
+ assert_eq!(
+ roundtripped.system_paths.read_only,
+ original.system_paths.read_only
+ );
+ assert_eq!(
+ roundtripped.system_paths.read_write,
+ original.system_paths.read_write
+ );
+ assert_eq!(
+ roundtripped.additional_executable_paths,
+ original.additional_executable_paths
+ );
+ assert_eq!(
+ roundtripped.additional_read_only_paths,
+ original.additional_read_only_paths
+ );
+ assert_eq!(
+ roundtripped.additional_read_write_paths,
+ original.additional_read_write_paths
+ );
+ assert_eq!(roundtripped.allow_network, original.allow_network);
+ assert_eq!(roundtripped.allowed_env_vars, original.allowed_env_vars);
+}
+
+#[test]
+fn test_sandbox_exec_config_from_json_invalid() {
+ let result = SandboxExecConfig::from_json("not json");
+ assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests: SandboxConfig::from_settings
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_sandbox_config_from_settings_defaults() {
+ let settings = settings::SandboxSettingsContent::default();
+ let config = SandboxConfig::from_settings(&settings, PathBuf::from("/projects/test"));
+
+ assert_eq!(config.project_dir, PathBuf::from("/projects/test"));
+ assert_eq!(config.allow_network, true);
+ assert_eq!(
+ config.allowed_env_vars,
+ SandboxConfig::default_allowed_env_vars()
+ );
+ assert!(config.additional_executable_paths.is_empty());
+ assert!(config.additional_read_only_paths.is_empty());
+ assert!(config.additional_read_write_paths.is_empty());
+
+ // System paths should use OS-specific defaults
+ assert!(!config.system_paths.executable.is_empty());
+ assert!(!config.system_paths.read_only.is_empty());
+ assert!(!config.system_paths.read_write.is_empty());
+}
+
+#[test]
+fn test_sandbox_config_tilde_expansion() {
+ let home = std::env::var("HOME").expect("HOME not set");
+ let settings = settings::SandboxSettingsContent {
+ additional_read_only_paths: Some(vec!["~/documents".into(), "/absolute/path".into()]),
+ ..Default::default()
+ };
+ let config = SandboxConfig::from_settings(&settings, PathBuf::from("/tmp/test"));
+
+ assert_eq!(
+ config.additional_read_only_paths,
+ vec![
+ PathBuf::from(format!("{}/documents", home)),
+ PathBuf::from("/absolute/path"),
+ ]
+ );
+}
+
+#[test]
+fn test_sandbox_config_custom_allowed_env_vars() {
+ let settings = settings::SandboxSettingsContent {
+ allowed_env_vars: Some(vec!["CUSTOM_VAR".into()]),
+ ..Default::default()
+ };
+ let config = SandboxConfig::from_settings(&settings, PathBuf::from("/tmp/test"));
+ assert_eq!(config.allowed_env_vars, vec!["CUSTOM_VAR".to_string()]);
+}
+
+#[test]
+fn test_sandbox_config_network_disabled() {
+ let settings = settings::SandboxSettingsContent {
+ allow_network: Some(false),
+ ..Default::default()
+ };
+ let config = SandboxConfig::from_settings(&settings, PathBuf::from("/tmp/test"));
+ assert!(!config.allow_network);
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests: macOS SBPL profile generation
+// ---------------------------------------------------------------------------
+
+#[cfg(target_os = "macos")]
+mod sbpl_tests {
+ use super::*;
+ use crate::sandbox_macos::{generate_sbpl_profile, sbpl_escape};
+
+ #[test]
+ fn test_sbpl_escape_plain_path() {
+ let path = Path::new("/usr/bin");
+ assert_eq!(sbpl_escape(path), "/usr/bin");
+ }
+
+ #[test]
+ fn test_sbpl_escape_with_quotes() {
+ let path = Path::new("/tmp/has\"quote");
+ assert_eq!(sbpl_escape(path), "/tmp/has\\\"quote");
+ }
+
+ #[test]
+ fn test_sbpl_escape_with_backslash() {
+ let path = Path::new("/tmp/has\\backslash");
+ assert_eq!(sbpl_escape(path), "/tmp/has\\\\backslash");
+ }
+
+ #[test]
+ fn test_sbpl_escape_with_both() {
+ let path = Path::new("/tmp/a\"b\\c");
+ assert_eq!(sbpl_escape(path), "/tmp/a\\\"b\\\\c");
+ }
+
+ #[test]
+ fn test_sbpl_profile_has_deny_default() {
+ let config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ let profile = generate_sbpl_profile(&config);
+ assert!(profile.contains("(deny default)"));
+ }
+
+ #[test]
+ fn test_sbpl_profile_has_version() {
+ let config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ let profile = generate_sbpl_profile(&config);
+ assert!(profile.starts_with("(version 1)\n"));
+ }
+
+ #[test]
+ fn test_sbpl_profile_includes_project_dir() {
+ let config = test_sandbox_config(PathBuf::from("/tmp/my-project"));
+ let profile = generate_sbpl_profile(&config);
+ assert!(
+ profile.contains("(subpath \"/tmp/my-project\")"),
+ "Profile should include project dir as a subpath rule. Profile:\n{profile}"
+ );
+ }
+
+ #[test]
+ fn test_sbpl_profile_includes_system_paths() {
+ let config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ let profile = generate_sbpl_profile(&config);
+ // At minimum, /usr/bin should be in the executable paths
+ assert!(
+ profile.contains("(subpath \"/usr/bin\")"),
+ "Profile should include /usr/bin. Profile:\n{profile}"
+ );
+ }
+
+ #[test]
+ fn test_sbpl_profile_network_allowed() {
+ let config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ let profile = generate_sbpl_profile(&config);
+ assert!(profile.contains("(allow network-outbound)"));
+ assert!(profile.contains("(allow network-inbound)"));
+ }
+
+ #[test]
+ fn test_sbpl_profile_network_denied() {
+ let mut config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ config.allow_network = false;
+ let profile = generate_sbpl_profile(&config);
+ assert!(!profile.contains("(allow network-outbound)"));
+ assert!(!profile.contains("(allow network-inbound)"));
+ }
+
+ #[test]
+ fn test_sbpl_profile_no_unrestricted_process_exec() {
+ let config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ let profile = generate_sbpl_profile(&config);
+ // Should NOT have a bare "(allow process-exec)" without a filter
+ let lines: Vec<&str> = profile.lines().collect();
+ for line in &lines {
+ if line.contains("process-exec") {
+ assert!(
+ line.contains("subpath") || line.contains("literal"),
+ "process-exec should be scoped to specific paths, found bare rule: {line}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn test_sbpl_profile_no_unrestricted_mach_lookup() {
+ let config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ let profile = generate_sbpl_profile(&config);
+ // Should NOT have a bare "(allow mach-lookup)" without a filter
+ let lines: Vec<&str> = profile.lines().collect();
+ for line in &lines {
+ if line.contains("mach-lookup") {
+ assert!(
+ line.contains("global-name"),
+ "mach-lookup should be scoped to specific services, found: {line}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn test_sbpl_profile_additional_paths() {
+ let mut config = test_sandbox_config(PathBuf::from("/tmp/project"));
+ config.additional_executable_paths = vec![PathBuf::from("/opt/tools/bin")];
+ config.additional_read_only_paths = vec![PathBuf::from("/opt/data")];
+ config.additional_read_write_paths = vec![PathBuf::from("/opt/cache")];
+
+ let profile = generate_sbpl_profile(&config);
+
+ assert!(
+ profile.contains("(subpath \"/opt/tools/bin\")"),
+ "Should include additional executable path"
+ );
+ assert!(
+ profile.contains("(subpath \"/opt/data\")"),
+ "Should include additional read-only path"
+ );
+ assert!(
+ profile.contains("(subpath \"/opt/cache\")"),
+ "Should include additional read-write path"
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Integration tests: filesystem enforcement
+// ---------------------------------------------------------------------------
+
+/// Creates a directory with a known file for testing.
+/// Returns (dir_path, file_path).
+fn create_test_directory(base: &Path, name: &str, content: &str) -> (PathBuf, PathBuf) {
+ let dir = base.join(name);
+ fs::create_dir_all(&dir).expect("failed to create test directory");
+ let file = dir.join("test_file.txt");
+ fs::write(&file, content).expect("failed to write test file");
+ (dir, file)
+}
+
+#[test]
+fn test_sandbox_blocks_rm_rf() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+
+ let (project_dir, _) = create_test_directory(base.path(), "project", "project content");
+ let (target_dir, target_file) =
+ create_test_directory(base.path(), "target", "do not delete me");
+
+ // Sandboxed: rm -rf should be blocked
+ let config = test_sandbox_config(project_dir.clone());
+ let cmd = format!("rm -rf {}", target_dir.display());
+ let (success, _stdout, _stderr) = run_sandboxed_command(&config, &cmd);
+
+ // The rm might "succeed" (exit 0) on some platforms even if individual
+ // deletes fail, or it might fail. Either way, the files should still exist.
+ assert!(
+ target_dir.exists() && target_file.exists(),
+ "Sandboxed rm -rf should not be able to delete target directory. \
+ success={success}, dir_exists={}, file_exists={}",
+ target_dir.exists(),
+ target_file.exists(),
+ );
+
+ // Unsandboxed: rm -rf should succeed
+ let (success, _, _) = run_unsandboxed_command(&format!("rm -rf {}", target_dir.display()));
+ assert!(success, "Unsandboxed rm -rf should succeed");
+ assert!(
+ !target_dir.exists(),
+ "Unsandboxed rm -rf should have deleted the directory"
+ );
+}
+
+#[test]
+fn test_sandbox_allows_writes_in_project() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+ let project_dir = base.path().join("project");
+ fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+ let config = test_sandbox_config(project_dir.clone());
+ let output_file = project_dir.join("sandbox_output.txt");
+ let cmd = format!("echo 'hello from sandbox' > {}", output_file.display());
+ let (success, _stdout, stderr) = run_sandboxed_command(&config, &cmd);
+
+ assert!(
+ success,
+ "Writing inside the project dir should succeed. stderr: {stderr}"
+ );
+ assert!(output_file.exists(), "Output file should have been created");
+ let content = fs::read_to_string(&output_file).expect("failed to read output file");
+ assert!(
+ content.contains("hello from sandbox"),
+ "File should contain expected content, got: {content}"
+ );
+}
+
+#[test]
+fn test_sandbox_blocks_reads_outside_project() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+ let project_dir = base.path().join("project");
+ fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+ let secret_content = "TOP_SECRET_DATA_12345";
+ let (_, secret_file) = create_test_directory(base.path(), "secrets", secret_content);
+
+ let config = test_sandbox_config(project_dir.clone());
+
+ // Try to cat the secret file and capture stdout
+ let cmd = format!("cat {} 2>/dev/null || true", secret_file.display());
+ let (_success, stdout, _stderr) = run_sandboxed_command(&config, &cmd);
+
+ assert!(
+ !stdout.contains(secret_content),
+ "Sandbox should prevent reading files outside the project. stdout: {stdout}"
+ );
+}
+
+#[test]
+fn test_additional_read_write_paths_grant_access() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+ let project_dir = base.path().join("project");
+ fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+ let extra_dir = base.path().join("extra_rw");
+ fs::create_dir_all(&extra_dir).expect("failed to create extra dir");
+
+ let test_file = extra_dir.join("rw_test.txt");
+
+ // First, WITHOUT the extra path — write should fail
+ let config_without = test_sandbox_config(project_dir.clone());
+ let cmd = format!("echo 'written' > {}", test_file.display());
+ let (_success, _stdout, _stderr) = run_sandboxed_command(&config_without, &cmd);
+ let file_written_without = test_file.exists()
+ && fs::read_to_string(&test_file)
+ .map(|c| c.contains("written"))
+ .unwrap_or(false);
+ assert!(
+ !file_written_without,
+ "Write to extra dir should be blocked without additional_read_write_paths"
+ );
+
+ // Now, WITH the extra path — write should succeed
+ let mut config_with = test_sandbox_config(project_dir);
+ config_with.additional_read_write_paths = vec![extra_dir.clone()];
+ let (success, _stdout, stderr) = run_sandboxed_command(&config_with, &cmd);
+ assert!(
+ success,
+ "Write to extra dir should succeed with additional_read_write_paths. stderr: {stderr}"
+ );
+ assert!(
+ test_file.exists(),
+ "File should exist after sandboxed write with additional path"
+ );
+}
+
+#[test]
+fn test_additional_read_only_paths_allow_read_block_write() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+ let project_dir = base.path().join("project");
+ fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+ let known_content = "known_readonly_content";
+ let (readonly_dir, readonly_file) =
+ create_test_directory(base.path(), "readonly_data", known_content);
+
+ let mut config = test_sandbox_config(project_dir.clone());
+ config.additional_read_only_paths = vec![readonly_dir.clone()];
+
+ // Read the file into the project dir — should succeed
+ let output_file = project_dir.join("read_output.txt");
+ let cmd = format!(
+ "cat {} > {}",
+ readonly_file.display(),
+ output_file.display()
+ );
+ let (success, _stdout, stderr) = run_sandboxed_command(&config, &cmd);
+ assert!(
+ success,
+ "Reading from read-only path should succeed. stderr: {stderr}"
+ );
+ let read_content = fs::read_to_string(&output_file).unwrap_or_default();
+ assert!(
+ read_content.contains(known_content),
+ "Should have read the known content. Got: {read_content}"
+ );
+
+ // Try to overwrite the read-only file — should fail
+ let cmd = format!("echo 'overwritten' > {}", readonly_file.display());
+ let (_success, _stdout, _stderr) = run_sandboxed_command(&config, &cmd);
+ let current_content = fs::read_to_string(&readonly_file).expect("file should still exist");
+ assert_eq!(
+ current_content, known_content,
+ "Read-only file should not have been overwritten"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Integration test: environment variable filtering
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_env_var_filtering() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+ let project_dir = base.path().join("project");
+ fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+ let config = test_sandbox_config(project_dir);
+
+ // HOME is in the default allowlist; AWS_SECRET is not
+ let (success, stdout, stderr) = run_sandboxed_with_env(
+ &config,
+ &[("AWS_SECRET", "super_secret_key_12345")],
+ "echo HOME=$HOME; echo AWS=$AWS_SECRET",
+ );
+ assert!(success, "env command should succeed. stderr: {stderr}");
+
+ // HOME should be present (it's in the default allowed list)
+ assert!(
+ stdout.contains("HOME=/"),
+ "HOME should be present in filtered env. stdout: {stdout}"
+ );
+
+ // AWS_SECRET should be absent (not in the allowed list)
+ assert!(
+ !stdout.contains("super_secret_key_12345"),
+ "AWS_SECRET should be filtered out. stdout: {stdout}"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Integration test: network blocking (macOS only)
+// ---------------------------------------------------------------------------
+
+#[cfg(target_os = "macos")]
+#[test]
+fn test_network_blocking() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+ let project_dir = base.path().join("project");
+ fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+ let mut config = test_sandbox_config(project_dir);
+ config.allow_network = false;
+
+ // Try to fetch a URL — should fail due to network being blocked
+ let cmd = "curl -s --max-time 5 https://example.com 2>&1 || true";
+ let (_success, stdout, _stderr) = run_sandboxed_command(&config, &cmd);
+
+ // The response should NOT contain the expected HTML from example.com
+ assert!(
+ !stdout.contains("Example Domain"),
+ "Network should be blocked. Got stdout: {stdout}"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Integration test: basic command succeeds under sandbox
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_sandbox_basic_echo_succeeds() {
+ let base = tempfile::tempdir().expect("failed to create temp dir");
+ let project_dir = base.path().join("project");
+ fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+ let config = test_sandbox_config(project_dir);
+ let (success, stdout, stderr) = run_sandboxed_command(&config, "echo 'sandbox works'");
+
+ assert!(
+ success,
+ "Basic echo should succeed under sandbox. stderr: {stderr}"
+ );
+ assert!(
+ stdout.contains("sandbox works"),
+ "Should see echo output. stdout: {stdout}"
+ );
+}
@@ -9,6 +9,8 @@ pub mod sandbox_exec;
pub mod sandbox_linux;
#[cfg(target_os = "macos")]
pub mod sandbox_macos;
+#[cfg(all(test, unix))]
+mod sandbox_tests;
mod terminal_hyperlinks;
pub mod terminal_settings;