1use super::agent::AgentInvocation;
5use sandbox_jail::{known, Access, Backend, Policy, Sandbox};
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 enabled(project_dir: &Path) -> bool {
49 crate::config::get_for_project(project_dir, "workflow.sandbox").is_ok_and(|v| v.trim() == "os")
50}
51
52pub fn enabled_globally() -> bool {
54 crate::config::get_global("workflow.sandbox").is_ok_and(|v| v.trim() == "os")
55}
56
57pub async fn argv(inv: &AgentInvocation) -> anyhow::Result<Vec<OsString>> {
63 let backend = Backend::detect()?;
64 let common_dir =
65 super::agent::resolve_git_dir_arg(&inv.main_project_dir, "--git-common-dir").await?;
66 let git_dir = super::agent::resolve_git_dir_arg(&inv.work_dir, "--git-dir").await?;
67
68 check_git_dirs(
69 &inv.work_dir,
70 &inv.main_project_dir,
71 git_dir.as_deref(),
72 common_dir.as_deref(),
73 )?;
74
75 let home = std::env::var_os("HOME")
76 .map(PathBuf::from)
77 .ok_or_else(|| anyhow::anyhow!("HOME is not set, so the sandbox cannot hide its keys"))?;
78 let mut projects: Vec<PathBuf> = crate::config::get_projects_with_paths()
79 .unwrap_or_default()
80 .into_iter()
81 .map(|(_, dir)| dir)
82 .collect();
83 projects.push(inv.main_project_dir.clone());
84 let path_dirs = std::env::var_os("PATH")
85 .map(|p| std::env::split_paths(&p).collect())
86 .unwrap_or_default();
87
88 let run = Run {
89 work_dir: inv.work_dir.clone(),
90 git_dir,
91 common_dir,
92 own_ref: (!inv.branch.is_empty()).then(|| format!("refs/heads/{}", inv.branch)),
93 home,
94 orch_home: crate::home::orch_home()?,
95 projects,
96 path_dirs,
97 temp: std::env::temp_dir(),
98 internal: inv.task_id.starts_with("internal:"),
99 seatbelt: backend == Backend::Seatbelt,
100 };
101 let sandbox = Sandbox::new(backend, policy(&run))?;
102 Ok(sandbox.argv().to_vec())
103}
104
105fn check_git_dirs(
111 work_dir: &Path,
112 main_project_dir: &Path,
113 git_dir: Option<&Path>,
114 common_dir: Option<&Path>,
115) -> anyhow::Result<()> {
116 let real = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
117
118 match (git_dir, common_dir) {
119 (Some(git), Some(common)) if git == common => anyhow::ensure!(
120 real(work_dir) == real(main_project_dir),
121 "the worktree {} uses the repository's own git dir {}",
122 work_dir.display(),
123 common.display()
124 ),
125 (Some(git), Some(common)) => {
126 anyhow::ensure!(
127 git.starts_with(common.join("worktrees")),
128 "the worktree's git dir {} is outside its repository {}",
129 git.display(),
130 common.display()
131 );
132 let back = std::fs::read_to_string(git.join("gitdir")).unwrap_or_default();
133 anyhow::ensure!(
134 real(Path::new(back.trim())) == real(&work_dir.join(".git")),
135 "the git dir {} belongs to another worktree",
136 git.display()
137 );
138 }
139 (None, Some(common)) => anyhow::bail!(
140 "the worktree {} has no git dir in {}",
141 work_dir.display(),
142 common.display()
143 ),
144 _ => {}
145 }
146
147 Ok(())
148}
149
150struct Run {
152 work_dir: PathBuf,
153 git_dir: Option<PathBuf>,
154 common_dir: Option<PathBuf>,
155 own_ref: Option<String>,
157 home: PathBuf,
158 orch_home: PathBuf,
159 projects: Vec<PathBuf>,
160 path_dirs: Vec<PathBuf>,
161 temp: PathBuf,
163 internal: bool,
164 seatbelt: bool,
165}
166
167impl Run {
168 fn git_dirs(&self) -> impl Iterator<Item = &PathBuf> {
169 self.git_dir.iter().chain(&self.common_dir)
170 }
171}
172
173fn policy(run: &Run) -> Policy {
174 let mut p = Policy::default();
175 p.writable.push(run.work_dir.clone());
176 p.writable.extend(run.git_dirs().cloned());
177 p.writable.extend(home_writable(&run.home));
178 let temp = run.temp.clone();
179
180 if run.seatbelt {
181 p.writable.extend([temp.clone(), "/private/tmp".into()]);
182 p.mach_services = Access::Only(
183 known::BASIC_MACH_SERVICES
184 .iter()
185 .chain(&[KEYCHAIN])
186 .map(|s| s.to_string())
187 .collect(),
188 );
189 } else {
190 p.scratch = SCRATCH
192 .iter()
193 .map(PathBuf::from)
194 .filter(|d| d.is_dir())
195 .collect();
196
197 if !p.scratch.iter().any(|s| temp.starts_with(s)) {
198 p.writable.push(temp.clone());
199 }
200 }
201
202 let mut sockets = vec![run.work_dir.clone()];
204 if !["/tmp", "/private/tmp"].iter().any(|t| temp.starts_with(t)) {
205 sockets.push(temp);
206 }
207 p.unix_sockets = Access::Only(sockets);
208
209 if let Some(common) = &run.common_dir {
210 git_rules(
211 common,
212 run.git_dir.as_ref(),
213 run.own_ref.as_deref(),
214 &mut p.read_only,
215 );
216
217 if run.git_dir.as_ref().is_some_and(|g| g != common) {
218 p.read_only.push(run.work_dir.join(".git"));
219 }
220 }
221
222 p.read_only.push(run.work_dir.join(".orch.yml"));
223 p.read_only.extend(
224 AGENT_CONFIG
225 .iter()
226 .chain(HOME_EXEC_CONFIG)
227 .map(|path| run.home.join(path)),
228 );
229 p.read_only.extend(
231 run.path_dirs
232 .iter()
233 .filter(|d| d.starts_with(&run.home))
234 .cloned(),
235 );
236
237 let mut keep: Vec<PathBuf> = vec![run.work_dir.clone()];
238 keep.extend(run.git_dirs().cloned());
239
240 if run.internal {
241 keep.extend(["orch.db", "orch.db-wal", "orch.db-shm"].map(|f| run.orch_home.join(f)));
242 }
243
244 read_only_around(&run.orch_home, &keep, &mut p.read_only);
245
246 for project in &run.projects {
247 read_only_around(project, &keep, &mut p.read_only);
248 }
249
250 p.hidden = known::credentials(&run.home)
252 .into_iter()
253 .filter(|path| {
254 ![".cargo/config", ".cargo/config.toml"]
255 .map(|c| run.home.join(c))
256 .contains(path)
257 })
258 .collect();
259 p.hidden.push(run.home.join(".gnupg"));
260 p.own_signals = true;
261
262 if run.seatbelt {
264 p.apple_events = false;
265 p.no_exec.push(known::LAUNCHCTL.into());
266 p.no_exec.extend(known::setuid_binaries().iter().cloned());
267 }
268
269 p
270}
271
272fn home_writable(home: &Path) -> Vec<PathBuf> {
275 let mut dirs: Vec<PathBuf> = std::fs::read_dir(home)
276 .into_iter()
277 .flatten()
278 .flatten()
279 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
280 .filter(|e| !HOME_READ_ONLY.contains(&e.file_name().to_string_lossy().as_ref()))
281 .map(|e| e.path())
282 .collect();
283 dirs.extend(
284 HOME_WRITABLE
285 .iter()
286 .map(|d| home.join(d))
287 .filter(|d| d.is_dir()),
288 );
289 dirs
290}
291
292fn git_rules(
300 common: &Path,
301 git_dir: Option<&PathBuf>,
302 own_ref: Option<&str>,
303 out: &mut Vec<PathBuf>,
304) {
305 out.extend(
306 [
307 "hooks",
308 "config",
309 "info",
310 "modules",
311 "packed-refs",
312 "refs/tags",
313 ]
314 .map(|d| common.join(d)),
315 );
316 let own: Vec<PathBuf> = own_ref
317 .map(|r| vec![common.join(r), common.join("logs").join(r)])
318 .unwrap_or_default();
319
320 match git_dir {
321 Some(dir) if dir != common => {
322 let mut keep = vec![
323 common.join("objects"),
324 common.join("refs/remotes"),
325 common.join("logs/refs/remotes"),
326 dir.clone(),
327 ];
328 keep.extend(own.iter().cloned());
329 read_only_around(common, &keep, out);
330 out.extend(["HEAD", "commondir", "gitdir", "config.worktree"].map(|f| dir.join(f)));
331 }
332 _ => read_only_around(&common.join("refs/heads"), &own, out),
334 }
335
336 let packed = std::fs::read_to_string(common.join("packed-refs")).unwrap_or_default();
337 for name in packed
338 .lines()
339 .filter_map(|l| l.split_once(' ').map(|(_, r)| r))
340 {
341 let path = common.join(name);
342
343 if name.starts_with("refs/heads/") && !own.contains(&path) && !path.exists() {
344 out.push(path);
345 }
346 }
347}
348
349fn read_only_around(dir: &Path, keep: &[PathBuf], out: &mut Vec<PathBuf>) {
355 let Ok(entries) = std::fs::read_dir(dir) else {
356 return;
357 };
358
359 for entry in entries.flatten() {
360 let path = entry.path();
361
362 if keep.iter().any(|k| path.starts_with(k)) {
363 continue;
364 }
365
366 let holds_kept = keep.iter().any(|k| k.starts_with(&path));
367 let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
368
369 if holds_kept && is_dir {
370 read_only_around(&path, keep, out);
371 } else if !holds_kept {
372 out.push(path);
373 }
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use std::fs;
381
382 fn fixture(internal: bool) -> (tempfile::TempDir, Run) {
384 let root = tempfile::tempdir().unwrap();
385 let r = root.path().canonicalize().unwrap();
386 let (home, orch) = (r.join("home"), r.join("home/.orch"));
387 for dir in [
388 "home/.orch/worktrees/proj/mine",
389 "home/.orch/worktrees/proj/other",
390 "home/.orch/state",
391 "home/proj/.git/worktrees/mine",
392 "home/proj/.git/refs/heads/feat",
393 "home/proj/src",
394 "home/proj/.git/objects",
395 "home/proj/.git/worktrees/other",
396 "home/.config/nvim",
397 "home/.claude",
398 "home/.cargo/bin",
399 "home/Library/Caches",
400 "home/Library/LaunchAgents",
401 ] {
402 fs::create_dir_all(r.join(dir)).unwrap();
403 }
404 for file in [
405 "home/.orch/config.yml",
406 "home/.orch/orch.db",
407 "home/proj/README.md",
408 "home/proj/.git/refs/heads/main",
409 "home/proj/.git/refs/heads/feat/mine",
410 "home/proj/.git/worktrees/mine/HEAD",
411 "home/.zshrc",
412 "home/.claude.json",
413 ] {
414 fs::write(r.join(file), "").unwrap();
415 }
416 fs::write(
417 r.join("home/proj/.git/packed-refs"),
418 "# pack-refs with: peeled\nabc refs/heads/release\n",
419 )
420 .unwrap();
421
422 let run = Run {
423 work_dir: orch.join("worktrees/proj/mine"),
424 git_dir: Some(home.join("proj/.git/worktrees/mine")),
425 common_dir: Some(home.join("proj/.git")),
426 own_ref: Some("refs/heads/feat/mine".into()),
427 home: home.clone(),
428 orch_home: orch,
429 projects: vec![home.join("proj")],
430 path_dirs: vec![home.join(".cargo/bin"), "/usr/bin".into()],
431 temp: r.join("tmp"),
432 internal,
433 seatbelt: false,
434 };
435 (root, run)
436 }
437
438 #[test]
439 fn orch_home_is_read_only_around_the_task_worktree() {
440 let (_root, run) = fixture(false);
441 let p = policy(&run);
442 let o = &run.orch_home;
443 for path in [
444 o.join("config.yml"),
445 o.join("state"),
446 o.join("orch.db"),
447 o.join("worktrees/proj/other"),
448 ] {
449 assert!(
450 p.read_only.contains(&path),
451 "{} not read-only",
452 path.display()
453 );
454 }
455 for path in [
456 o.join("worktrees"),
457 o.join("worktrees/proj"),
458 run.work_dir.clone(),
459 ] {
460 assert!(!p.read_only.contains(&path), "{} read-only", path.display());
461 }
462 assert!(p.writable.contains(&run.work_dir));
463 }
464
465 #[test]
466 fn the_main_checkout_is_read_only_but_its_git_dir() {
467 let (_root, run) = fixture(false);
468 let p = policy(&run);
469 let proj = run.home.join("proj");
470 assert!(p.read_only.contains(&proj.join("src")));
471 assert!(p.read_only.contains(&proj.join("README.md")));
472 assert!(!p.read_only.contains(&proj.join(".git")));
473 assert!(p.read_only.contains(&proj.join(".git/hooks")));
474 assert!(p.read_only.contains(&proj.join(".git/config")));
475 }
476
477 #[test]
478 fn only_the_task_branch_is_writable_in_the_git_dir() {
479 let (_root, run) = fixture(false);
480 let p = policy(&run);
481 let git = run.home.join("proj/.git");
482 for path in [
483 "refs/heads/main",
484 "refs/heads/release",
485 "packed-refs",
486 "hooks",
487 "config",
488 ] {
489 assert!(
490 p.read_only.contains(&git.join(path)),
491 "{path} not read-only"
492 );
493 }
494 assert!(!p.read_only.contains(&git.join("refs/heads/feat/mine")));
495 assert!(!p.read_only.contains(&git.join("refs/heads/feat")));
496 assert!(!p.read_only.contains(&git.join("objects")));
497 assert!(p.read_only.contains(&git.join("worktrees/other")));
498 assert!(p.read_only.contains(&git.join("worktrees/mine/HEAD")));
499 assert!(p.read_only.contains(&run.work_dir.join(".git")));
500 assert!(p.writable.contains(&git));
501 }
502
503 #[test]
504 fn only_the_home_s_cache_dirs_are_writable() {
505 let (_root, run) = fixture(false);
506 let p = policy(&run);
507 let h = &run.home;
508 for path in [".cargo", ".orch", "Library/Caches"] {
509 assert!(p.writable.contains(&h.join(path)), "{path} not writable");
510 }
511 for path in ["", ".config", ".claude", "Library"] {
512 assert!(!p.writable.contains(&h.join(path)), "{path:?} writable");
513 }
514 assert!(p.read_only.contains(&h.join(".cargo/bin")));
515 assert!(!p.read_only.contains(&PathBuf::from("/usr/bin")));
516 }
517
518 #[test]
519 fn no_socket_outside_the_worktree_and_no_launch_services() {
520 let (_root, mut run) = fixture(false);
521 run.seatbelt = true;
522 let p = policy(&run);
523 let Access::Only(sockets) = &p.unix_sockets else {
524 panic!("every unix socket is reachable");
525 };
526 assert!(sockets.contains(&run.work_dir));
527 assert!(!sockets
528 .iter()
529 .any(|s| s.starts_with("/tmp") || s.starts_with("/private/tmp")));
530 let Access::Only(mach) = &p.mach_services else {
531 panic!("every Mach service is reachable");
532 };
533 assert!(mach.iter().any(|s| s == KEYCHAIN));
534 assert!(!mach.iter().any(|s| s.contains("launchservices")));
535 }
536
537 #[test]
538 fn code_running_config_in_a_writable_home_dir_is_read_only() {
539 let (_root, run) = fixture(false);
540 let p = policy(&run);
541 assert!(p.writable.contains(&run.home.join(".cargo")));
542 assert!(p.read_only.contains(&run.home.join(".cargo/config.toml")));
543 }
544
545 #[test]
546 fn a_git_dir_the_agent_could_have_pointed_elsewhere_is_refused() {
547 let (_root, run) = fixture(false);
548 let (work, main) = (&run.work_dir, run.home.join("proj"));
549 let (git, common) = (
550 run.git_dir.clone().unwrap(),
551 run.common_dir.clone().unwrap(),
552 );
553
554 fs::write(
555 git.join("gitdir"),
556 format!("{}\n", work.join(".git").display()),
557 )
558 .unwrap();
559 assert!(check_git_dirs(work, &main, Some(&git), Some(&common)).is_ok());
560
561 assert!(check_git_dirs(work, &main, Some(&common), Some(&common)).is_err());
563 assert!(check_git_dirs(&main, &main, Some(&common), Some(&common)).is_ok());
564 let other = common.join("worktrees/other");
566 fs::write(other.join("gitdir"), "/elsewhere/.git\n").unwrap();
567 assert!(check_git_dirs(work, &main, Some(&other), Some(&common)).is_err());
568 assert!(check_git_dirs(work, &main, Some(&run.home), Some(&common)).is_err());
569 assert!(check_git_dirs(work, &main, None, Some(&common)).is_err());
570 }
571
572 #[test]
573 fn a_job_task_may_write_the_store() {
574 let (_root, run) = fixture(true);
575 assert!(!policy(&run)
576 .read_only
577 .contains(&run.orch_home.join("orch.db")));
578 }
579
580 #[test]
581 fn keys_are_hidden_and_startup_files_read_only() {
582 let (_root, run) = fixture(false);
583 let p = policy(&run);
584 assert!(p.hidden.contains(&run.home.join(".ssh")));
585 assert!(p.hidden.contains(&run.home.join(".gnupg")));
586 assert!(!p.hidden.iter().any(|h| h.ends_with(".cargo/config.toml")));
587 assert!(p.read_only.contains(&run.work_dir.join(".orch.yml")));
588 }
589
590 #[cfg(target_os = "macos")]
592 #[test]
593 fn the_kernel_enforces_the_policy() {
594 let Ok(backend) = Backend::detect() else {
595 eprintln!("skipped: no OS sandbox starts here");
596 return;
597 };
598 let (_root, mut run) = fixture(false);
599 run.seatbelt = backend == Backend::Seatbelt;
600 let other = run.orch_home.join("worktrees/proj/other");
601 fs::create_dir_all(run.home.join(".ssh")).unwrap();
602 fs::write(run.home.join(".ssh/key"), "k").unwrap();
603 let script = format!(
604 "echo ok > {w}/f && ! echo x > {o}/config.yml && ! echo x > {other}/f \
605 && ! echo x > {w}/.orch.yml && ! echo x >> {h}/.zshrc && ! cat {h}/.ssh/key \
606 && ! echo x > {h}/.new-dotfile && ! echo x > {h}/.claude.json \
607 && echo cache > {h}/.cargo/probe",
608 w = run.work_dir.display(),
609 o = run.orch_home.display(),
610 other = other.display(),
611 h = run.home.display(),
612 );
613 let out = Sandbox::new(backend, policy(&run))
614 .unwrap()
615 .command("/bin/sh")
616 .args(["-c", &script])
617 .output()
618 .unwrap();
619 assert!(
620 out.status.success(),
621 "{}",
622 String::from_utf8_lossy(&out.stderr)
623 );
624 assert!(run.work_dir.join("f").exists());
625 assert!(!other.join("f").exists());
626 }
627
628 #[cfg(target_os = "macos")]
630 #[test]
631 fn the_kernel_keeps_sockets_and_other_branches_out_of_reach() {
632 let Ok(backend) = Backend::detect() else {
633 eprintln!("skipped: no OS sandbox starts here");
634 return;
635 };
636 let (_root, mut run) = fixture(false);
637 run.seatbelt = backend == Backend::Seatbelt;
638 for dir in [&run.work_dir, &run.home] {
639 let listener = std::os::unix::net::UnixListener::bind(dir.join("s")).unwrap();
640 std::thread::spawn(move || listener.incoming().for_each(drop));
641 }
642 let git = run.home.join("proj/.git");
643 let script = format!(
644 "/usr/bin/nc -U {w}/s </dev/null && ! /usr/bin/nc -U {h}/s </dev/null \
645 && echo sha > {g}/refs/heads/feat/mine && ! echo sha > {g}/refs/heads/main",
646 w = run.work_dir.display(),
647 h = run.home.display(),
648 g = git.display(),
649 );
650 let out = Sandbox::new(backend, policy(&run))
651 .unwrap()
652 .command("/bin/sh")
653 .args(["-c", &script])
654 .output()
655 .unwrap();
656 assert!(
657 out.status.success(),
658 "{}",
659 String::from_utf8_lossy(&out.stderr)
660 );
661 assert_eq!(fs::read_to_string(git.join("refs/heads/main")).unwrap(), "");
662 }
663
664 #[cfg(target_os = "macos")]
666 #[test]
667 #[ignore]
668 fn claude_answers_inside_the_sandbox_with_the_real_home() {
669 let root = tempfile::tempdir().unwrap();
670 let work = root.path().canonicalize().unwrap();
671 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
672 let run = Run {
673 work_dir: work.clone(),
674 git_dir: None,
675 common_dir: None,
676 own_ref: None,
677 home: home.clone(),
678 orch_home: home.join(".orch"),
679 projects: vec![],
680 path_dirs: std::env::split_paths(&std::env::var_os("PATH").unwrap()).collect(),
681 temp: std::env::temp_dir(),
682 internal: false,
683 seatbelt: true,
684 };
685 let out = Sandbox::new(Backend::Seatbelt, policy(&run))
686 .unwrap()
687 .command("/bin/sh")
688 .args(["-c", "claude -p --model haiku 'Reply with the word ok'"])
689 .current_dir(&work)
690 .output()
691 .unwrap();
692 let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
693 assert!(
694 text.contains("ok"),
695 "{text}{}",
696 String::from_utf8_lossy(&out.stderr)
697 );
698 }
699
700 #[test]
701 fn a_worktree_that_is_the_main_checkout_stays_writable() {
702 let (_root, mut run) = fixture(false);
703 run.work_dir = run.home.join("proj");
704 let p = policy(&run);
705 assert!(!p.read_only.contains(&run.work_dir));
706 assert!(!p.read_only.contains(&run.work_dir.join("src")));
707 }
708}