Skip to main content

orch/engine/runner/
sandbox.rs

1//! The OS sandbox around a task or review agent, when `workflow.sandbox` is `os`. The policy and
2//! why it is shaped this way are in docs/plans/2026-10-28-agent-sandboxes.md
3
4use super::agent::AgentInvocation;
5use sandbox_jail::{known, Access, Backend, Policy, Sandbox};
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9/// Top-level dirs of the home that stay read-only: what loads or runs later as the operator, the
10/// agents' own settings, and keys. Everything at the top of the home that is not a dir is read-only too
11const HOME_READ_ONLY: &[&str] = &[
12    ".config", ".claude", ".ssh", ".gnupg", "bin", "Library", ".local",
13];
14
15/// Writable parts of the read-only dirs above, for caches and agent state
16const HOME_WRITABLE: &[&str] = &[
17    "Library/Caches",
18    "Library/Logs",
19    ".local/share",
20    ".local/state",
21];
22
23/// Codex's settings and instructions, which sit beside the state it rewrites
24const AGENT_CONFIG: &[&str] = &[".codex/config.toml", ".codex/AGENTS.md", ".codex/rules"];
25
26/// Config inside writable home dirs that runs code on the operator's next build or shell, read-only
27/// even when missing. Cargo's `rustc-wrapper` and `runner`, gradle's init scripts, shell frameworks
28const 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
39/// The tmux, ssh-agent and Docker sockets that would let an agent start a process outside
40const SCRATCH: &[&str] = &["/tmp", "/run", "/var/tmp"];
41
42/// macOS services an agent may look up: the basics, and securityd for its keychain login. Not
43/// LaunchServices, which `open` uses to start an app outside the sandbox
44const KEYCHAIN: &str = "com.apple.SecurityServer";
45
46/// The run's own temp dir, so no socket of the operator's apps in the shared one is in reach
47pub fn run_temp_dir(attempt_dir: &Path) -> PathBuf {
48    attempt_dir.join("tmp")
49}
50
51/// Whether the project runs its agents in the sandbox: `workflow.sandbox: os` in its `.orch.yml`
52/// or the global config. `false` or no setting leaves it off
53pub 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
57/// Like [`enabled`] from the global config alone, for a run whose project is unknown
58pub fn enabled_globally() -> bool {
59    crate::config::get_global("workflow.sandbox").is_ok_and(|v| v.trim() == "os")
60}
61
62/**
63 * The program and arguments that go before the agent command, or why the sandbox cannot start.
64 * The worktree's `.git` file is the agent's to write, so the common dir comes from the main
65 * checkout and the branch from orch's records, and a git dir outside them refuses the run
66 **/
67pub 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/// The sandbox cannot start. It is the operator's setup, so it blocks the task and counts against no agent
74#[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
128/**
129 * Refuses a worktree whose git dir the agent could have pointed elsewhere. Only the main checkout
130 * has the common dir as its git dir. A linked worktree's git dir is in `<common>/worktrees`, and
131 * its `gitdir` file, which the agent cannot write, points back at this worktree
132 **/
133fn 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
173/// What the policy of one run depends on
174struct Run {
175    work_dir: PathBuf,
176    git_dir: Option<PathBuf>,
177    common_dir: Option<PathBuf>,
178    /// The task's branch, `refs/heads/<branch>`, from orch's records
179    own_ref: Option<String>,
180    home: PathBuf,
181    orch_home: PathBuf,
182    projects: Vec<PathBuf>,
183    path_dirs: Vec<PathBuf>,
184    /// The run's own temp dir, where the agent may also connect to a socket
185    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(|s| s.to_string())
210                .collect(),
211        );
212    } else {
213        // bwrap has no socket filter, so the dirs that hold sockets start empty instead
214        p.scratch = SCRATCH
215            .iter()
216            .map(PathBuf::from)
217            .filter(|d| d.is_dir())
218            .collect();
219    }
220
221    // The tmux server runs outside the sandbox, so its socket in /tmp must stay out of reach
222    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    // /private/tmp stays writable, but the agent must not delete or replace these sockets.
242    // bwrap hides those in a scratch dir, and binding one back would expose it
243    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    // A tool on PATH inside the home, such as ~/.cargo/bin, runs later as the operator
258    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    // The daemon's loopback write endpoint takes this token, so reading it is a write to the store
272    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    // The crate's list hides cargo's config too, which holds build settings agents need
281    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    // launchctl and setuid binaries start processes outside the sandbox
298    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
307/// How many levels below a writable home dir are searched for repositories
308const GIT_SCAN_DEPTH: usize = 4;
309
310// Hooks and config of a repository orch does not know run as the operator on its next git command
311fn 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
331/// The tmux and launchd socket dirs in the shared temp dirs, which the tmux server and the
332/// ssh-agent keep running outside the sandbox
333fn 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
352/// The home's writable parts: its top-level dirs but [`HOME_READ_ONLY`], and [`HOME_WRITABLE`]
353/// inside those. The home itself stays read-only, so no new dotfile appears at its top
354fn 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
372/**
373 * The git common dir is writable for objects, the task's branch, remote refs and the task's own
374 * worktree dir, and read-only around those: hooks and config run the next time the operator runs
375 * git, and another branch is the operator's work. Paths git would pick up when they appear are
376 * read-only even when missing, and so is a loose ref for a branch only `packed-refs` holds, since
377 * it would win over the packed one
378 **/
379fn 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        // The worktree is the main checkout, so its index and HEAD live in the common dir
416        _ => 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
432/**
433 * Every entry of `dir` read-only but the paths in `keep`. An entry that holds a kept path is
434 * walked instead, so `~/.orch` stays read-only around the task's own worktree, and a main checkout
435 * around its `.git`. A missing `dir` adds nothing
436 **/
437fn 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    /// A home with an orch home inside, two worktrees and a main checkout, and the run of one task
493    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.clone(), run.temp.clone()]);
651        assert!(!sockets
652            .iter()
653            .any(|s| s.starts_with("/tmp") || s.starts_with("/private/tmp")));
654        let Access::Only(mach) = &p.mach_services else {
655            panic!("every Mach service is reachable");
656        };
657        assert!(mach.iter().any(|s| s == KEYCHAIN));
658        assert!(!mach.iter().any(|s| s.contains("launchservices")));
659    }
660
661    #[test]
662    fn code_running_config_in_a_writable_home_dir_is_read_only() {
663        let (_root, run) = fixture(false);
664        let p = policy(&run);
665        assert!(p.writable.contains(&run.home.join(".cargo")));
666        assert!(p.read_only.contains(&run.home.join(".cargo/config.toml")));
667    }
668
669    #[test]
670    fn a_git_dir_the_agent_could_have_pointed_elsewhere_is_refused() {
671        let (_root, run) = fixture(false);
672        let (work, main) = (&run.work_dir, run.home.join("proj"));
673        let (git, common) = (
674            run.git_dir.clone().unwrap(),
675            run.common_dir.clone().unwrap(),
676        );
677
678        fs::write(
679            git.join("gitdir"),
680            format!("{}\n", work.join(".git").display()),
681        )
682        .unwrap();
683        assert!(check_git_dirs(work, &main, Some(&git), Some(&common)).is_ok());
684
685        // Pointed at the common dir, which only the main checkout uses
686        assert!(check_git_dirs(work, &main, Some(&common), Some(&common)).is_err());
687        assert!(check_git_dirs(&main, &main, Some(&common), Some(&common)).is_ok());
688        // Another worktree's git dir, or none at all
689        let other = common.join("worktrees/other");
690        fs::write(other.join("gitdir"), "/elsewhere/.git\n").unwrap();
691        assert!(check_git_dirs(work, &main, Some(&other), Some(&common)).is_err());
692        assert!(check_git_dirs(work, &main, Some(&run.home), Some(&common)).is_err());
693        assert!(check_git_dirs(work, &main, None, Some(&common)).is_err());
694    }
695
696    #[test]
697    fn a_job_task_may_write_the_store() {
698        let (_root, run) = fixture(true);
699        assert!(!policy(&run)
700            .read_only
701            .contains(&run.orch_home.join("orch.db")));
702    }
703
704    #[test]
705    fn only_a_job_task_sees_the_write_endpoint_token() {
706        let (_root, run) = fixture(false);
707        let token = run.orch_home.join("state/cluster.port");
708        assert!(policy(&run).hidden.contains(&token));
709        let (_root, run) = fixture(true);
710        assert!(!policy(&run).hidden.contains(&token));
711    }
712
713    #[test]
714    fn keys_are_hidden_and_startup_files_read_only() {
715        let (_root, run) = fixture(false);
716        let p = policy(&run);
717        assert!(p.hidden.contains(&run.home.join(".ssh")));
718        assert!(p.hidden.contains(&run.home.join(".gnupg")));
719        assert!(!p.hidden.iter().any(|h| h.ends_with(".cargo/config.toml")));
720        assert!(p.read_only.contains(&run.work_dir.join(".orch.yml")));
721    }
722
723    // A real Seatbelt run of the policy. Skipped where no sandbox starts, such as inside another one
724    #[cfg(target_os = "macos")]
725    #[test]
726    fn the_kernel_enforces_the_policy() {
727        let Ok(backend) = Backend::detect() else {
728            eprintln!("skipped: no OS sandbox starts here");
729            return;
730        };
731        let (_root, mut run) = fixture(false);
732        run.seatbelt = backend == Backend::Seatbelt;
733        let other = run.orch_home.join("worktrees/proj/other");
734        fs::create_dir_all(run.home.join(".ssh")).unwrap();
735        fs::write(run.home.join(".ssh/key"), "k").unwrap();
736        let script = format!(
737            "echo ok > {w}/f && ! echo x > {o}/config.yml && ! echo x > {other}/f \
738             && ! echo x > {w}/.orch.yml && ! echo x >> {h}/.zshrc && ! cat {h}/.ssh/key \
739             && ! echo x > {h}/.new-dotfile && ! echo x > {h}/.claude.json \
740             && echo cache > {h}/.cargo/probe",
741            w = run.work_dir.display(),
742            o = run.orch_home.display(),
743            other = other.display(),
744            h = run.home.display(),
745        );
746        let out = Sandbox::new(backend, policy(&run))
747            .unwrap()
748            .command("/bin/sh")
749            .args(["-c", &script])
750            .output()
751            .unwrap();
752        assert!(
753            out.status.success(),
754            "{}",
755            String::from_utf8_lossy(&out.stderr)
756        );
757        assert!(run.work_dir.join("f").exists());
758        assert!(!other.join("f").exists());
759    }
760
761    // A socket outside the worktree, such as tmux's, is out of reach, and another branch's ref too
762    #[cfg(target_os = "macos")]
763    #[test]
764    fn the_kernel_keeps_sockets_and_other_branches_out_of_reach() {
765        let Ok(backend) = Backend::detect() else {
766            eprintln!("skipped: no OS sandbox starts here");
767            return;
768        };
769        let (_root, mut run) = fixture(false);
770        run.seatbelt = backend == Backend::Seatbelt;
771        for dir in [&run.work_dir, &run.home] {
772            let listener = std::os::unix::net::UnixListener::bind(dir.join("s")).unwrap();
773            std::thread::spawn(move || listener.incoming().for_each(drop));
774        }
775        let git = run.home.join("proj/.git");
776        let script = format!(
777            "/usr/bin/nc -U {w}/s </dev/null && ! /usr/bin/nc -U {h}/s </dev/null \
778             && echo sha > {g}/refs/heads/feat/mine && ! echo sha > {g}/refs/heads/main",
779            w = run.work_dir.display(),
780            h = run.home.display(),
781            g = git.display(),
782        );
783        let out = Sandbox::new(backend, policy(&run))
784            .unwrap()
785            .command("/bin/sh")
786            .args(["-c", &script])
787            .output()
788            .unwrap();
789        assert!(
790            out.status.success(),
791            "{}",
792            String::from_utf8_lossy(&out.stderr)
793        );
794        assert_eq!(fs::read_to_string(git.join("refs/heads/main")).unwrap(), "");
795    }
796
797    // A missing commondir in the common dir would redirect git to a dir the agent controls
798    #[cfg(target_os = "macos")]
799    #[test]
800    fn the_kernel_refuses_a_new_commondir_in_the_common_dir() {
801        let Ok(backend) = Backend::detect() else {
802            eprintln!("skipped: no OS sandbox starts here");
803            return;
804        };
805        let (_root, mut run) = fixture(false);
806        run.seatbelt = backend == Backend::Seatbelt;
807        let git = run.home.join("proj/.git");
808        assert!(!git.join("commondir").exists());
809        let script = format!(
810            "! echo {w} > {g}/commondir",
811            w = run.work_dir.display(),
812            g = git.display()
813        );
814        let out = Sandbox::new(backend, policy(&run))
815            .unwrap()
816            .command("/bin/sh")
817            .args(["-c", &script])
818            .output()
819            .unwrap();
820        assert!(
821            out.status.success(),
822            "{}",
823            String::from_utf8_lossy(&out.stderr)
824        );
825        assert!(!git.join("commondir").exists());
826    }
827
828    // Removing or replacing tmux's socket file is refused
829    #[cfg(target_os = "macos")]
830    #[test]
831    fn the_kernel_keeps_the_tmux_socket_in_the_temp_dir_in_place() {
832        let Ok(backend) = Backend::detect() else {
833            eprintln!("skipped: no OS sandbox starts here");
834            return;
835        };
836        let (_root, mut run) = fixture(false);
837        run.seatbelt = backend == Backend::Seatbelt;
838        let dir = tempfile::Builder::new()
839            .prefix("tmux-")
840            .tempdir_in(&run.home)
841            .unwrap();
842        run.temp = run.home.clone();
843        let sock = dir.path().join("default");
844        fs::write(&sock, "").unwrap();
845        let script = format!(
846            "! rm {s} && ! mv {s} {s}2 && ! echo x > {d}/new",
847            s = sock.display(),
848            d = dir.path().display(),
849        );
850        let out = Sandbox::new(backend, policy(&run))
851            .unwrap()
852            .command("/bin/sh")
853            .args(["-c", &script])
854            .output()
855            .unwrap();
856        assert!(
857            out.status.success(),
858            "{}",
859            String::from_utf8_lossy(&out.stderr)
860        );
861        assert!(sock.exists());
862    }
863
864    // A logged-in claude answers under the policy with the real home: `--run-ignored only claude`
865    #[cfg(target_os = "macos")]
866    #[test]
867    #[ignore]
868    fn claude_answers_inside_the_sandbox_with_the_real_home() {
869        let root = tempfile::tempdir().unwrap();
870        let work = root.path().canonicalize().unwrap();
871        let home = PathBuf::from(std::env::var_os("HOME").unwrap());
872        let run = Run {
873            work_dir: work.clone(),
874            git_dir: None,
875            common_dir: None,
876            own_ref: None,
877            home: home.clone(),
878            orch_home: home.join(".orch"),
879            projects: vec![],
880            path_dirs: std::env::split_paths(&std::env::var_os("PATH").unwrap()).collect(),
881            temp: std::env::temp_dir(),
882            internal: false,
883            seatbelt: true,
884        };
885        let out = Sandbox::new(Backend::Seatbelt, policy(&run))
886            .unwrap()
887            .command("/bin/sh")
888            .args(["-c", "claude -p --model haiku 'Reply with the word ok'"])
889            .current_dir(&work)
890            .output()
891            .unwrap();
892        let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
893        assert!(
894            text.contains("ok"),
895            "{text}{}",
896            String::from_utf8_lossy(&out.stderr)
897        );
898    }
899
900    #[test]
901    fn a_worktree_that_is_the_main_checkout_stays_writable() {
902        let (_root, mut run) = fixture(false);
903        run.work_dir = run.home.join("proj");
904        let p = policy(&run);
905        assert!(!p.read_only.contains(&run.work_dir));
906        assert!(!p.read_only.contains(&run.work_dir.join("src")));
907    }
908}