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