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 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
17/// Top-level dirs of the home that stay read-only: what loads or runs later as the operator, the
18/// agents' own settings, and keys. Everything at the top of the home that is not a dir is read-only too
19const HOME_READ_ONLY: &[&str] = &[
20    ".config", ".claude", ".ssh", ".gnupg", "bin", "Library", ".local",
21];
22
23/// Writable parts of the read-only dirs above, for caches and agent state
24const HOME_WRITABLE: &[&str] = &[
25    "Library/Caches",
26    "Library/Logs",
27    ".local/share",
28    ".local/state",
29];
30
31/// Codex's settings and instructions, which sit beside the state it rewrites
32const AGENT_CONFIG: &[&str] = &[".codex/config.toml", ".codex/AGENTS.md", ".codex/rules"];
33
34/// Config inside writable home dirs that runs code on the operator's next build or shell, read-only
35/// even when missing. Cargo's `rustc-wrapper` and `runner`, gradle's init scripts, shell frameworks
36const 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
47/// The tmux, ssh-agent and Docker sockets that would let an agent start a process outside
48const SCRATCH: &[&str] = &["/tmp", "/run", "/var/tmp"];
49
50/// macOS services an agent may look up: the basics, and securityd for its keychain login. Not
51/// LaunchServices, which `open` uses to start an app outside the sandbox
52const KEYCHAIN: &str = "com.apple.SecurityServer";
53
54/// The run's own temp dir, so no socket of the operator's apps in the shared one is in reach
55pub fn run_temp_dir(attempt_dir: &Path) -> PathBuf {
56    attempt_dir.join("tmp")
57}
58
59/// Whether the project runs its agents in the sandbox: `workflow.sandbox: os` in its `.orch.yml`
60/// or the global config. `false` or no setting leaves it off
61pub fn enabled(project_dir: &Path) -> bool {
62    config::get_for_project(project_dir, "workflow.sandbox").is_ok_and(|v| v.trim() == "os")
63}
64
65/// Like [`enabled`] from the global config alone, for a run whose project is unknown
66pub fn enabled_globally() -> bool {
67    config::get_global("workflow.sandbox").is_ok_and(|v| v.trim() == "os")
68}
69
70/**
71 * The program and arguments that go before the agent command, or why the sandbox cannot start.
72 * The worktree's `.git` file is the agent's to write, so the common dir comes from the main
73 * checkout and the branch from orch's records, and a git dir outside them refuses the run
74 **/
75pub 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/// The sandbox cannot start. It is the operator's setup, so it blocks the task and counts against no agent
82#[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
136/**
137 * Refuses a worktree whose git dir the agent could have pointed elsewhere. Only the main checkout
138 * has the common dir as its git dir. A linked worktree's git dir is in `<common>/worktrees`, and
139 * its `gitdir` file, which the agent cannot write, points back at this worktree
140 **/
141fn 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
181/// What the policy of one run depends on
182struct Run {
183    work_dir: PathBuf,
184    git_dir: Option<PathBuf>,
185    common_dir: Option<PathBuf>,
186    /// The task's branch, `refs/heads/<branch>`, from orch's records
187    own_ref: Option<String>,
188    home: PathBuf,
189    orch_home: PathBuf,
190    projects: Vec<PathBuf>,
191    path_dirs: Vec<PathBuf>,
192    /// The run's own temp dir, where the agent may also connect to a socket
193    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        // bwrap has no socket filter, so the dirs that hold sockets start empty instead
222        p.scratch = SCRATCH
223            .iter()
224            .map(PathBuf::from)
225            .filter(|d| d.is_dir())
226            .collect();
227    }
228
229    // The tmux server runs outside the sandbox, so its socket in /tmp must stay out of reach
230    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    // /private/tmp stays writable, but the agent must not delete or replace these sockets.
250    // bwrap hides those in a scratch dir, and binding one back would expose it
251    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    // A tool on PATH inside the home, such as ~/.cargo/bin, runs later as the operator
266    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    // The daemon's loopback write endpoint takes this token, so reading it is a write to the store
280    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    // The crate's list hides cargo's config too, which holds build settings agents need
289    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    // launchctl and setuid binaries start processes outside the sandbox
306    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
315/// How many levels below a writable home dir are searched for repositories
316const GIT_SCAN_DEPTH: usize = 4;
317
318// Hooks and config of a repository orch does not know run as the operator on its next git command
319fn 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
339/// The tmux and launchd socket dirs in the shared temp dirs, which the tmux server and the
340/// ssh-agent keep running outside the sandbox
341fn 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
360/// The home's writable parts: its top-level dirs but [`HOME_READ_ONLY`], and [`HOME_WRITABLE`]
361/// inside those. The home itself stays read-only, so no new dotfile appears at its top
362fn 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
380/**
381 * The git common dir is writable for objects, the task's branch, remote refs and the task's own
382 * worktree dir, and read-only around those: hooks and config run the next time the operator runs
383 * git, and another branch is the operator's work. Paths git would pick up when they appear are
384 * read-only even when missing, and so is a loose ref for a branch only `packed-refs` holds, since
385 * it would win over the packed one
386 **/
387fn 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        // The worktree is the main checkout, so its index and HEAD live in the common dir
424        _ => 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
440/**
441 * Every entry of `dir` read-only but the paths in `keep`. An entry that holds a kept path is
442 * walked instead, so `~/.orch` stays read-only around the task's own worktree, and a main checkout
443 * around its `.git`. A missing `dir` adds nothing
444 **/
445fn 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    /// A home with an orch home inside, two worktrees and a main checkout, and the run of one task
505    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        // Pointed at the common dir, which only the main checkout uses
700        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        // Another worktree's git dir, or none at all
703        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    // A real Seatbelt run of the policy. Skipped where no sandbox starts, such as inside another one
740    #[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    // A socket outside the worktree, such as tmux's, is out of reach, and another branch's ref too
778    #[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    // A missing commondir in the common dir would redirect git to a dir the agent controls
814    #[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    // Removing or replacing tmux's socket file is refused
845    #[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    // A logged-in claude answers under the policy with the real home: `--run-ignored only claude`
881    #[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}