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/// Whether the project runs its agents in the sandbox: `workflow.sandbox: os` in its `.orch.yml`
47/// or the global config. `false` or no setting leaves it off
48pub 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
52/// Like [`enabled`] from the global config alone, for a run whose project is unknown
53pub fn enabled_globally() -> bool {
54    crate::config::get_global("workflow.sandbox").is_ok_and(|v| v.trim() == "os")
55}
56
57/**
58 * The program and arguments that go before the agent command, or why the sandbox cannot start.
59 * The worktree's `.git` file is the agent's to write, so the common dir comes from the main
60 * checkout and the branch from orch's records, and a git dir outside them refuses the run
61 **/
62pub 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
105/**
106 * Refuses a worktree whose git dir the agent could have pointed elsewhere. Only the main checkout
107 * has the common dir as its git dir. A linked worktree's git dir is in `<common>/worktrees`, and
108 * its `gitdir` file, which the agent cannot write, points back at this worktree
109 **/
110fn 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
150/// What the policy of one run depends on
151struct Run {
152    work_dir: PathBuf,
153    git_dir: Option<PathBuf>,
154    common_dir: Option<PathBuf>,
155    /// The task's branch, `refs/heads/<branch>`, from orch's records
156    own_ref: Option<String>,
157    home: PathBuf,
158    orch_home: PathBuf,
159    projects: Vec<PathBuf>,
160    path_dirs: Vec<PathBuf>,
161    /// This process's temp dir, where the agent may also connect to a socket
162    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        // bwrap has no socket filter, so the dirs that hold sockets start empty instead
191        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    // The tmux server runs outside the sandbox, so its socket in /tmp must stay out of reach
203    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    // A tool on PATH inside the home, such as ~/.cargo/bin, runs later as the operator
230    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    // The crate's list hides cargo's config too, which holds build settings agents need
251    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    // launchctl and setuid binaries start processes outside the sandbox
263    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
272/// The home's writable parts: its top-level dirs but [`HOME_READ_ONLY`], and [`HOME_WRITABLE`]
273/// inside those. The home itself stays read-only, so no new dotfile appears at its top
274fn 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
292/**
293 * The git common dir is writable for objects, the task's branch, remote refs and the task's own
294 * worktree dir, and read-only around those: hooks and config run the next time the operator runs
295 * git, and another branch is the operator's work. Paths git would pick up when they appear are
296 * read-only even when missing, and so is a loose ref for a branch only `packed-refs` holds, since
297 * it would win over the packed one
298 **/
299fn 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        // The worktree is the main checkout, so its index and HEAD live in the common dir
333        _ => 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
349/**
350 * Every entry of `dir` read-only but the paths in `keep`. An entry that holds a kept path is
351 * walked instead, so `~/.orch` stays read-only around the task's own worktree, and a main checkout
352 * around its `.git`. A missing `dir` adds nothing
353 **/
354fn 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    /// A home with an orch home inside, two worktrees and a main checkout, and the run of one task
383    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        // Pointed at the common dir, which only the main checkout uses
562        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        // Another worktree's git dir, or none at all
565        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    // A real Seatbelt run of the policy. Skipped where no sandbox starts, such as inside another one
591    #[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    // A socket outside the worktree, such as tmux's, is out of reach, and another branch's ref too
629    #[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    // A logged-in claude answers under the policy with the real home: `--run-ignored only claude`
665    #[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}