1use super::agent::AgentInvocation;
5use sandbox_jail::{Access, Backend, Policy, Sandbox, known};
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9const HOME_READ_ONLY: &[&str] = &[
12 ".config", ".claude", ".ssh", ".gnupg", "bin", "Library", ".local",
13];
14
15const HOME_WRITABLE: &[&str] = &[
17 "Library/Caches",
18 "Library/Logs",
19 ".local/share",
20 ".local/state",
21];
22
23const AGENT_CONFIG: &[&str] = &[".codex/config.toml", ".codex/AGENTS.md", ".codex/rules"];
25
26const HOME_EXEC_CONFIG: &[&str] = &[
29 ".cargo/config",
30 ".cargo/config.toml",
31 ".gradle/init.d",
32 ".gradle/init.gradle",
33 ".gradle/gradle.properties",
34 ".m2/settings.xml",
35 ".oh-my-zsh",
36 ".zprezto",
37];
38
39const SCRATCH: &[&str] = &["/tmp", "/run", "/var/tmp"];
41
42const KEYCHAIN: &str = "com.apple.SecurityServer";
45
46pub fn run_temp_dir(attempt_dir: &Path) -> PathBuf {
48 attempt_dir.join("tmp")
49}
50
51pub fn enabled(project_dir: &Path) -> bool {
54 crate::config::get_for_project(project_dir, "workflow.sandbox").is_ok_and(|v| v.trim() == "os")
55}
56
57pub fn enabled_globally() -> bool {
59 crate::config::get_global("workflow.sandbox").is_ok_and(|v| v.trim() == "os")
60}
61
62pub async fn argv(inv: &AgentInvocation, temp: &Path) -> anyhow::Result<Vec<OsString>> {
68 build(inv, temp)
69 .await
70 .map_err(|e| anyhow::Error::new(SandboxFailed(format!("{e:#}"))))
71}
72
73#[derive(Debug)]
75pub struct SandboxFailed(pub String);
76
77impl std::fmt::Display for SandboxFailed {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 write!(f, "the OS sandbox cannot start: {}", self.0)
80 }
81}
82
83impl std::error::Error for SandboxFailed {}
84
85async fn build(inv: &AgentInvocation, temp: &Path) -> anyhow::Result<Vec<OsString>> {
86 let backend = Backend::detect()?;
87 let common_dir =
88 super::agent::resolve_git_dir_arg(&inv.main_project_dir, "--git-common-dir").await?;
89 let git_dir = super::agent::resolve_git_dir_arg(&inv.work_dir, "--git-dir").await?;
90
91 check_git_dirs(
92 &inv.work_dir,
93 &inv.main_project_dir,
94 git_dir.as_deref(),
95 common_dir.as_deref(),
96 )?;
97
98 let home = std::env::var_os("HOME")
99 .map(PathBuf::from)
100 .ok_or_else(|| anyhow::anyhow!("HOME is not set, so the sandbox cannot hide its keys"))?;
101 let mut projects: Vec<PathBuf> = crate::config::get_projects_with_paths()
102 .unwrap_or_default()
103 .into_iter()
104 .map(|(_, dir)| dir)
105 .collect();
106 projects.push(inv.main_project_dir.clone());
107 let path_dirs = std::env::var_os("PATH")
108 .map(|p| std::env::split_paths(&p).collect())
109 .unwrap_or_default();
110
111 let run = Run {
112 work_dir: inv.work_dir.clone(),
113 git_dir,
114 common_dir,
115 own_ref: (!inv.branch.is_empty()).then(|| format!("refs/heads/{}", inv.branch)),
116 home,
117 orch_home: crate::home::orch_home()?,
118 projects,
119 path_dirs,
120 temp: temp.to_path_buf(),
121 internal: inv.task_id.starts_with("internal:"),
122 seatbelt: backend == Backend::Seatbelt,
123 };
124 let sandbox = Sandbox::new(backend, policy(&run))?;
125 Ok(sandbox.argv().to_vec())
126}
127
128fn check_git_dirs(
134 work_dir: &Path,
135 main_project_dir: &Path,
136 git_dir: Option<&Path>,
137 common_dir: Option<&Path>,
138) -> anyhow::Result<()> {
139 let real = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
140
141 match (git_dir, common_dir) {
142 (Some(git), Some(common)) if git == common => anyhow::ensure!(
143 real(work_dir) == real(main_project_dir),
144 "the worktree {} uses the repository's own git dir {}",
145 work_dir.display(),
146 common.display()
147 ),
148 (Some(git), Some(common)) => {
149 anyhow::ensure!(
150 git.starts_with(common.join("worktrees")),
151 "the worktree's git dir {} is outside its repository {}",
152 git.display(),
153 common.display()
154 );
155 let back = std::fs::read_to_string(git.join("gitdir")).unwrap_or_default();
156 anyhow::ensure!(
157 real(Path::new(back.trim())) == real(&work_dir.join(".git")),
158 "the git dir {} belongs to another worktree",
159 git.display()
160 );
161 }
162 (None, Some(common)) => anyhow::bail!(
163 "the worktree {} has no git dir in {}",
164 work_dir.display(),
165 common.display()
166 ),
167 _ => {}
168 }
169
170 Ok(())
171}
172
173struct Run {
175 work_dir: PathBuf,
176 git_dir: Option<PathBuf>,
177 common_dir: Option<PathBuf>,
178 own_ref: Option<String>,
180 home: PathBuf,
181 orch_home: PathBuf,
182 projects: Vec<PathBuf>,
183 path_dirs: Vec<PathBuf>,
184 temp: PathBuf,
186 internal: bool,
187 seatbelt: bool,
188}
189
190impl Run {
191 fn git_dirs(&self) -> impl Iterator<Item = &PathBuf> {
192 self.git_dir.iter().chain(&self.common_dir)
193 }
194}
195
196fn policy(run: &Run) -> Policy {
197 let mut p = Policy::default();
198 p.writable.push(run.work_dir.clone());
199 p.writable.extend(run.git_dirs().cloned());
200 p.writable.extend(home_writable(&run.home));
201 p.writable.push(run.temp.clone());
202
203 if run.seatbelt {
204 p.writable.push("/private/tmp".into());
205 p.mach_services = Access::Only(
206 known::BASIC_MACH_SERVICES
207 .iter()
208 .chain(&[KEYCHAIN])
209 .map(std::string::ToString::to_string)
210 .collect(),
211 );
212 } else {
213 p.scratch = SCRATCH
215 .iter()
216 .map(PathBuf::from)
217 .filter(|d| d.is_dir())
218 .collect();
219 }
220
221 p.unix_sockets = Access::Only(vec![run.work_dir.clone(), run.temp.clone()]);
223
224 if let Some(common) = &run.common_dir {
225 git_rules(
226 common,
227 run.git_dir.as_ref(),
228 run.own_ref.as_deref(),
229 &mut p.read_only,
230 );
231
232 if run.git_dir.as_ref().is_some_and(|g| g != common) {
233 p.read_only.push(run.work_dir.join(".git"));
234 }
235 }
236
237 for dir in home_writable(&run.home) {
238 find_git_dirs(&dir, GIT_SCAN_DEPTH, &mut p.read_only);
239 }
240
241 let hidden = p.scratch.clone();
244 p.read_only.extend(
245 socket_dirs(&run.temp)
246 .into_iter()
247 .filter(|d| !hidden.iter().any(|s| d.starts_with(s))),
248 );
249
250 p.read_only.push(run.work_dir.join(".orch.yml"));
251 p.read_only.extend(
252 AGENT_CONFIG
253 .iter()
254 .chain(HOME_EXEC_CONFIG)
255 .map(|path| run.home.join(path)),
256 );
257 p.read_only.extend(
259 run.path_dirs
260 .iter()
261 .filter(|d| d.starts_with(&run.home))
262 .cloned(),
263 );
264
265 let mut keep: Vec<PathBuf> = vec![run.work_dir.clone(), run.temp.clone()];
266 keep.extend(run.git_dirs().cloned());
267
268 if run.internal {
269 keep.extend(["orch.db", "orch.db-wal", "orch.db-shm"].map(|f| run.orch_home.join(f)));
270 }
271 let write_token = run.orch_home.join("state/cluster.port");
273
274 read_only_around(&run.orch_home, &keep, &mut p.read_only);
275
276 for project in &run.projects {
277 read_only_around(project, &keep, &mut p.read_only);
278 }
279
280 p.hidden = known::credentials(&run.home)
282 .into_iter()
283 .filter(|path| {
284 ![".cargo/config", ".cargo/config.toml"]
285 .map(|c| run.home.join(c))
286 .contains(path)
287 })
288 .collect();
289 p.hidden.push(run.home.join(".gnupg"));
290
291 if !run.internal {
292 p.hidden.push(write_token);
293 }
294
295 p.own_signals = true;
296
297 if run.seatbelt {
299 p.apple_events = false;
300 p.no_exec.push(known::LAUNCHCTL.into());
301 p.no_exec.extend(known::setuid_binaries().iter().cloned());
302 }
303
304 p
305}
306
307const GIT_SCAN_DEPTH: usize = 4;
309
310fn find_git_dirs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
312 let git = dir.join(".git");
313
314 if git.is_dir() {
315 out.extend(["hooks", "config", "info", "modules"].map(|f| git.join(f)));
316 }
317
318 if depth == 0 {
319 return;
320 }
321
322 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
323 let name = entry.file_name();
324
325 if entry.file_type().is_ok_and(|t| t.is_dir()) && name != ".git" && name != "node_modules" {
326 find_git_dirs(&entry.path(), depth - 1, out);
327 }
328 }
329}
330
331fn socket_dirs(temp: &Path) -> Vec<PathBuf> {
334 let mut roots = vec![PathBuf::from("/private/tmp"), PathBuf::from("/tmp")];
335 roots.extend(std::env::var_os("TMUX_TMPDIR").map(PathBuf::from));
336 roots.push(temp.to_path_buf());
337
338 roots
339 .iter()
340 .filter_map(|r| std::fs::read_dir(r).ok())
341 .flatten()
342 .flatten()
343 .filter(|e| {
344 let name = e.file_name();
345 let name = name.to_string_lossy();
346 name.starts_with("tmux-") || name.starts_with("com.apple.launchd.")
347 })
348 .map(|e| e.path())
349 .collect()
350}
351
352fn home_writable(home: &Path) -> Vec<PathBuf> {
355 let mut dirs: Vec<PathBuf> = std::fs::read_dir(home)
356 .into_iter()
357 .flatten()
358 .flatten()
359 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
360 .filter(|e| !HOME_READ_ONLY.contains(&e.file_name().to_string_lossy().as_ref()))
361 .map(|e| e.path())
362 .collect();
363 dirs.extend(
364 HOME_WRITABLE
365 .iter()
366 .map(|d| home.join(d))
367 .filter(|d| d.is_dir()),
368 );
369 dirs
370}
371
372fn git_rules(
380 common: &Path,
381 git_dir: Option<&PathBuf>,
382 own_ref: Option<&str>,
383 out: &mut Vec<PathBuf>,
384) {
385 out.extend(
386 [
387 "hooks",
388 "config",
389 "info",
390 "modules",
391 "packed-refs",
392 "refs/tags",
393 "commondir",
394 "gitdir",
395 "config.worktree",
396 ]
397 .map(|d| common.join(d)),
398 );
399 let own: Vec<PathBuf> = own_ref
400 .map(|r| vec![common.join(r), common.join("logs").join(r)])
401 .unwrap_or_default();
402
403 match git_dir {
404 Some(dir) if dir != common => {
405 let mut keep = vec![
406 common.join("objects"),
407 common.join("refs/remotes"),
408 common.join("logs/refs/remotes"),
409 dir.clone(),
410 ];
411 keep.extend(own.iter().cloned());
412 read_only_around(common, &keep, out);
413 out.extend(["HEAD", "commondir", "gitdir", "config.worktree"].map(|f| dir.join(f)));
414 }
415 _ => read_only_around(&common.join("refs/heads"), &own, out),
417 }
418
419 let packed = std::fs::read_to_string(common.join("packed-refs")).unwrap_or_default();
420 for name in packed
421 .lines()
422 .filter_map(|l| l.split_once(' ').map(|(_, r)| r))
423 {
424 let path = common.join(name);
425
426 if name.starts_with("refs/heads/") && !own.contains(&path) && !path.exists() {
427 out.push(path);
428 }
429 }
430}
431
432fn read_only_around(dir: &Path, keep: &[PathBuf], out: &mut Vec<PathBuf>) {
438 let Ok(entries) = std::fs::read_dir(dir) else {
439 return;
440 };
441
442 for entry in entries.flatten() {
443 let path = entry.path();
444
445 if keep.iter().any(|k| path.starts_with(k)) {
446 continue;
447 }
448
449 let holds_kept = keep.iter().any(|k| k.starts_with(&path));
450 let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
451
452 if holds_kept && is_dir {
453 read_only_around(&path, keep, out);
454 } else if !holds_kept {
455 out.push(path);
456 }
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use std::fs;
464
465 #[tokio::test]
466 async fn argv_error_is_tagged_so_spawn_blocks_the_task() {
467 let inv = AgentInvocation {
468 agent: "claude".to_string(),
469 model: None,
470 work_dir: PathBuf::from("/nonexistent/work"),
471 system_prompt: String::new(),
472 agent_message: String::new(),
473 task_id: "t".to_string(),
474 disallowed_tools: vec![],
475 git_author_name: String::new(),
476 git_author_email: String::new(),
477 output_file: PathBuf::from("/nonexistent/out.json"),
478 timeout_seconds: 0,
479 repo: "o/r".to_string(),
480 attempt: 1,
481 sandbox: true,
482 main_project_dir: PathBuf::from("/nonexistent/main"),
483 branch: String::new(),
484 };
485 let err = argv(&inv, Path::new("/tmp/orch-test-run"))
486 .await
487 .expect_err("no checkout, so no sandbox");
488 assert!(err.downcast_ref::<SandboxFailed>().is_some());
489 assert!(err.to_string().starts_with("the OS sandbox cannot start: "));
490 }
491
492 fn fixture(internal: bool) -> (tempfile::TempDir, Run) {
494 let root = tempfile::tempdir().unwrap();
495 let r = root.path().canonicalize().unwrap();
496 let (home, orch) = (r.join("home"), r.join("home/.orch"));
497 for dir in [
498 "home/.orch/worktrees/proj/mine",
499 "home/.orch/worktrees/proj/other",
500 "home/.orch/state",
501 "home/proj/.git/worktrees/mine",
502 "home/proj/.git/refs/heads/feat",
503 "home/proj/src",
504 "home/proj/.git/objects",
505 "home/proj/.git/worktrees/other",
506 "home/.config/nvim",
507 "home/.claude",
508 "home/.cargo/bin",
509 "home/Library/Caches",
510 "home/Library/LaunchAgents",
511 ] {
512 fs::create_dir_all(r.join(dir)).unwrap();
513 }
514 for file in [
515 "home/.orch/config.yml",
516 "home/.orch/orch.db",
517 "home/proj/README.md",
518 "home/proj/.git/refs/heads/main",
519 "home/proj/.git/refs/heads/feat/mine",
520 "home/proj/.git/worktrees/mine/HEAD",
521 "home/.zshrc",
522 "home/.claude.json",
523 ] {
524 fs::write(r.join(file), "").unwrap();
525 }
526 fs::write(
527 r.join("home/proj/.git/packed-refs"),
528 "# pack-refs with: peeled\nabc refs/heads/release\n",
529 )
530 .unwrap();
531
532 let run = Run {
533 work_dir: orch.join("worktrees/proj/mine"),
534 git_dir: Some(home.join("proj/.git/worktrees/mine")),
535 common_dir: Some(home.join("proj/.git")),
536 own_ref: Some("refs/heads/feat/mine".into()),
537 home: home.clone(),
538 orch_home: orch,
539 projects: vec![home.join("proj")],
540 path_dirs: vec![home.join(".cargo/bin"), "/usr/bin".into()],
541 temp: r.join("tmp"),
542 internal,
543 seatbelt: false,
544 };
545 (root, run)
546 }
547
548 #[test]
549 fn orch_home_is_read_only_around_the_task_worktree() {
550 let (_root, run) = fixture(false);
551 let p = policy(&run);
552 let o = &run.orch_home;
553 for path in [
554 o.join("config.yml"),
555 o.join("state"),
556 o.join("orch.db"),
557 o.join("worktrees/proj/other"),
558 ] {
559 assert!(
560 p.read_only.contains(&path),
561 "{} not read-only",
562 path.display()
563 );
564 }
565 for path in [
566 o.join("worktrees"),
567 o.join("worktrees/proj"),
568 run.work_dir.clone(),
569 ] {
570 assert!(!p.read_only.contains(&path), "{} read-only", path.display());
571 }
572 assert!(p.writable.contains(&run.work_dir));
573 }
574
575 #[test]
576 fn the_main_checkout_is_read_only_but_its_git_dir() {
577 let (_root, run) = fixture(false);
578 let p = policy(&run);
579 let proj = run.home.join("proj");
580 assert!(p.read_only.contains(&proj.join("src")));
581 assert!(p.read_only.contains(&proj.join("README.md")));
582 assert!(!p.read_only.contains(&proj.join(".git")));
583 assert!(p.read_only.contains(&proj.join(".git/hooks")));
584 assert!(p.read_only.contains(&proj.join(".git/config")));
585 }
586
587 #[test]
588 fn only_the_task_branch_is_writable_in_the_git_dir() {
589 let (_root, run) = fixture(false);
590 let p = policy(&run);
591 let git = run.home.join("proj/.git");
592 for path in [
593 "refs/heads/main",
594 "refs/heads/release",
595 "packed-refs",
596 "hooks",
597 "config",
598 "commondir",
599 "gitdir",
600 "config.worktree",
601 ] {
602 assert!(
603 p.read_only.contains(&git.join(path)),
604 "{path} not read-only"
605 );
606 }
607 assert!(!p.read_only.contains(&git.join("refs/heads/feat/mine")));
608 assert!(!p.read_only.contains(&git.join("refs/heads/feat")));
609 assert!(!p.read_only.contains(&git.join("objects")));
610 assert!(p.read_only.contains(&git.join("worktrees/other")));
611 assert!(p.read_only.contains(&git.join("worktrees/mine/HEAD")));
612 assert!(p.read_only.contains(&run.work_dir.join(".git")));
613 assert!(p.writable.contains(&git));
614 }
615
616 #[test]
617 fn hooks_and_config_of_an_unknown_repository_are_read_only() {
618 let (_root, run) = fixture(false);
619 let git = run.home.join("code/deep/other/.git");
620 fs::create_dir_all(&git).unwrap();
621 let p = policy(&run);
622 assert!(p.read_only.contains(&git.join("hooks")));
623 assert!(p.read_only.contains(&git.join("config")));
624 assert!(!p.read_only.contains(&git));
625 }
626
627 #[test]
628 fn only_the_home_s_cache_dirs_are_writable() {
629 let (_root, run) = fixture(false);
630 let p = policy(&run);
631 let h = &run.home;
632 for path in [".cargo", ".orch", "Library/Caches"] {
633 assert!(p.writable.contains(&h.join(path)), "{path} not writable");
634 }
635 for path in ["", ".config", ".claude", "Library"] {
636 assert!(!p.writable.contains(&h.join(path)), "{path:?} writable");
637 }
638 assert!(p.read_only.contains(&h.join(".cargo/bin")));
639 assert!(!p.read_only.contains(&PathBuf::from("/usr/bin")));
640 }
641
642 #[test]
643 fn no_socket_outside_the_worktree_and_no_launch_services() {
644 let (_root, mut run) = fixture(false);
645 run.seatbelt = true;
646 let p = policy(&run);
647 let Access::Only(sockets) = &p.unix_sockets else {
648 panic!("every unix socket is reachable");
649 };
650 assert_eq!(sockets, &vec![run.work_dir, run.temp]);
651 assert!(
652 !sockets
653 .iter()
654 .any(|s| s.starts_with("/tmp") || s.starts_with("/private/tmp"))
655 );
656 let Access::Only(mach) = &p.mach_services else {
657 panic!("every Mach service is reachable");
658 };
659 assert!(mach.iter().any(|s| s == KEYCHAIN));
660 assert!(!mach.iter().any(|s| s.contains("launchservices")));
661 }
662
663 #[test]
664 fn code_running_config_in_a_writable_home_dir_is_read_only() {
665 let (_root, run) = fixture(false);
666 let p = policy(&run);
667 assert!(p.writable.contains(&run.home.join(".cargo")));
668 assert!(p.read_only.contains(&run.home.join(".cargo/config.toml")));
669 }
670
671 #[test]
672 fn a_git_dir_the_agent_could_have_pointed_elsewhere_is_refused() {
673 let (_root, run) = fixture(false);
674 let (work, main) = (&run.work_dir, run.home.join("proj"));
675 let (git, common) = (
676 run.git_dir.clone().unwrap(),
677 run.common_dir.clone().unwrap(),
678 );
679
680 fs::write(
681 git.join("gitdir"),
682 format!("{}\n", work.join(".git").display()),
683 )
684 .unwrap();
685 assert!(check_git_dirs(work, &main, Some(&git), Some(&common)).is_ok());
686
687 assert!(check_git_dirs(work, &main, Some(&common), Some(&common)).is_err());
689 assert!(check_git_dirs(&main, &main, Some(&common), Some(&common)).is_ok());
690 let other = common.join("worktrees/other");
692 fs::write(other.join("gitdir"), "/elsewhere/.git\n").unwrap();
693 assert!(check_git_dirs(work, &main, Some(&other), Some(&common)).is_err());
694 assert!(check_git_dirs(work, &main, Some(&run.home), Some(&common)).is_err());
695 assert!(check_git_dirs(work, &main, None, Some(&common)).is_err());
696 }
697
698 #[test]
699 fn a_job_task_may_write_the_store() {
700 let (_root, run) = fixture(true);
701 assert!(
702 !policy(&run)
703 .read_only
704 .contains(&run.orch_home.join("orch.db"))
705 );
706 }
707
708 #[test]
709 fn only_a_job_task_sees_the_write_endpoint_token() {
710 let (_root, run) = fixture(false);
711 let token = run.orch_home.join("state/cluster.port");
712 assert!(policy(&run).hidden.contains(&token));
713 let (_root, run) = fixture(true);
714 assert!(!policy(&run).hidden.contains(&token));
715 }
716
717 #[test]
718 fn keys_are_hidden_and_startup_files_read_only() {
719 let (_root, run) = fixture(false);
720 let p = policy(&run);
721 assert!(p.hidden.contains(&run.home.join(".ssh")));
722 assert!(p.hidden.contains(&run.home.join(".gnupg")));
723 assert!(!p.hidden.iter().any(|h| h.ends_with(".cargo/config.toml")));
724 assert!(p.read_only.contains(&run.work_dir.join(".orch.yml")));
725 }
726
727 #[cfg(target_os = "macos")]
729 #[test]
730 fn the_kernel_enforces_the_policy() {
731 let Ok(backend) = Backend::detect() else {
732 eprintln!("skipped: no OS sandbox starts here");
733 return;
734 };
735 let (_root, mut run) = fixture(false);
736 run.seatbelt = backend == Backend::Seatbelt;
737 let other = run.orch_home.join("worktrees/proj/other");
738 fs::create_dir_all(run.home.join(".ssh")).unwrap();
739 fs::write(run.home.join(".ssh/key"), "k").unwrap();
740 let script = format!(
741 "echo ok > {w}/f && ! echo x > {o}/config.yml && ! echo x > {other}/f \
742 && ! echo x > {w}/.orch.yml && ! echo x >> {h}/.zshrc && ! cat {h}/.ssh/key \
743 && ! echo x > {h}/.new-dotfile && ! echo x > {h}/.claude.json \
744 && echo cache > {h}/.cargo/probe",
745 w = run.work_dir.display(),
746 o = run.orch_home.display(),
747 other = other.display(),
748 h = run.home.display(),
749 );
750 let out = Sandbox::new(backend, policy(&run))
751 .unwrap()
752 .command("/bin/sh")
753 .args(["-c", &script])
754 .output()
755 .unwrap();
756 assert!(
757 out.status.success(),
758 "{}",
759 String::from_utf8_lossy(&out.stderr)
760 );
761 assert!(run.work_dir.join("f").exists());
762 assert!(!other.join("f").exists());
763 }
764
765 #[cfg(target_os = "macos")]
767 #[test]
768 fn the_kernel_keeps_sockets_and_other_branches_out_of_reach() {
769 let Ok(backend) = Backend::detect() else {
770 eprintln!("skipped: no OS sandbox starts here");
771 return;
772 };
773 let (_root, mut run) = fixture(false);
774 run.seatbelt = backend == Backend::Seatbelt;
775 for dir in [&run.work_dir, &run.home] {
776 let listener = std::os::unix::net::UnixListener::bind(dir.join("s")).unwrap();
777 std::thread::spawn(move || listener.incoming().for_each(drop));
778 }
779 let git = run.home.join("proj/.git");
780 let script = format!(
781 "/usr/bin/nc -U {w}/s </dev/null && ! /usr/bin/nc -U {h}/s </dev/null \
782 && echo sha > {g}/refs/heads/feat/mine && ! echo sha > {g}/refs/heads/main",
783 w = run.work_dir.display(),
784 h = run.home.display(),
785 g = git.display(),
786 );
787 let out = Sandbox::new(backend, policy(&run))
788 .unwrap()
789 .command("/bin/sh")
790 .args(["-c", &script])
791 .output()
792 .unwrap();
793 assert!(
794 out.status.success(),
795 "{}",
796 String::from_utf8_lossy(&out.stderr)
797 );
798 assert_eq!(fs::read_to_string(git.join("refs/heads/main")).unwrap(), "");
799 }
800
801 #[cfg(target_os = "macos")]
803 #[test]
804 fn the_kernel_refuses_a_new_commondir_in_the_common_dir() {
805 let Ok(backend) = Backend::detect() else {
806 eprintln!("skipped: no OS sandbox starts here");
807 return;
808 };
809 let (_root, mut run) = fixture(false);
810 run.seatbelt = backend == Backend::Seatbelt;
811 let git = run.home.join("proj/.git");
812 assert!(!git.join("commondir").exists());
813 let script = format!(
814 "! echo {w} > {g}/commondir",
815 w = run.work_dir.display(),
816 g = git.display()
817 );
818 let out = Sandbox::new(backend, policy(&run))
819 .unwrap()
820 .command("/bin/sh")
821 .args(["-c", &script])
822 .output()
823 .unwrap();
824 assert!(
825 out.status.success(),
826 "{}",
827 String::from_utf8_lossy(&out.stderr)
828 );
829 assert!(!git.join("commondir").exists());
830 }
831
832 #[cfg(target_os = "macos")]
834 #[test]
835 fn the_kernel_keeps_the_tmux_socket_in_the_temp_dir_in_place() {
836 let Ok(backend) = Backend::detect() else {
837 eprintln!("skipped: no OS sandbox starts here");
838 return;
839 };
840 let (_root, mut run) = fixture(false);
841 run.seatbelt = backend == Backend::Seatbelt;
842 let dir = tempfile::Builder::new()
843 .prefix("tmux-")
844 .tempdir_in(&run.home)
845 .unwrap();
846 run.temp = run.home.clone();
847 let sock = dir.path().join("default");
848 fs::write(&sock, "").unwrap();
849 let script = format!(
850 "! rm {s} && ! mv {s} {s}2 && ! echo x > {d}/new",
851 s = sock.display(),
852 d = dir.path().display(),
853 );
854 let out = Sandbox::new(backend, policy(&run))
855 .unwrap()
856 .command("/bin/sh")
857 .args(["-c", &script])
858 .output()
859 .unwrap();
860 assert!(
861 out.status.success(),
862 "{}",
863 String::from_utf8_lossy(&out.stderr)
864 );
865 assert!(sock.exists());
866 }
867
868 #[cfg(target_os = "macos")]
870 #[test]
871 #[ignore = "needs a logged-in claude on the host"]
872 fn claude_answers_inside_the_sandbox_with_the_real_home() {
873 let root = tempfile::tempdir().unwrap();
874 let work = root.path().canonicalize().unwrap();
875 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
876 let run = Run {
877 work_dir: work.clone(),
878 git_dir: None,
879 common_dir: None,
880 own_ref: None,
881 home: home.clone(),
882 orch_home: home.join(".orch"),
883 projects: vec![],
884 path_dirs: std::env::split_paths(&std::env::var_os("PATH").unwrap()).collect(),
885 temp: std::env::temp_dir(),
886 internal: false,
887 seatbelt: true,
888 };
889 let out = Sandbox::new(Backend::Seatbelt, policy(&run))
890 .unwrap()
891 .command("/bin/sh")
892 .args(["-c", "claude -p --model haiku 'Reply with the word ok'"])
893 .current_dir(&work)
894 .output()
895 .unwrap();
896 let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
897 assert!(
898 text.contains("ok"),
899 "{text}{}",
900 String::from_utf8_lossy(&out.stderr)
901 );
902 }
903
904 #[test]
905 fn a_worktree_that_is_the_main_checkout_stays_writable() {
906 let (_root, mut run) = fixture(false);
907 run.work_dir = run.home.join("proj");
908 let p = policy(&run);
909 assert!(!p.read_only.contains(&run.work_dir));
910 assert!(!p.read_only.contains(&run.work_dir.join("src")));
911 }
912}