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}