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, Backend, Policy, Sandbox};
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9/// Files in the home that run later as the operator, so an agent may never write them
10const STARTUP: &[&str] = &[
11    ".zshrc",
12    ".zshenv",
13    ".zprofile",
14    ".bashrc",
15    ".bash_profile",
16    ".profile",
17    ".gitconfig",
18    ".config/git",
19    ".local/bin",
20    "bin",
21    "Library/LaunchAgents",
22];
23
24/// Whether the project runs its agents in the sandbox: `workflow.sandbox: os` in its `.orch.yml`
25/// or the global config. `false` or no setting leaves it off
26pub fn enabled(project_dir: &Path) -> bool {
27    crate::config::get_for_project(project_dir, "workflow.sandbox").is_ok_and(|v| v.trim() == "os")
28}
29
30/// The program and arguments that go before the agent command, or why the sandbox cannot start
31pub async fn argv(inv: &AgentInvocation) -> anyhow::Result<Vec<OsString>> {
32    let backend = Backend::detect()?;
33    let mut git_dirs = vec![];
34
35    for arg in ["--git-dir", "--git-common-dir"] {
36        if let Some(dir) = super::agent::resolve_git_dir_arg(&inv.work_dir, arg).await? {
37            git_dirs.push(dir);
38        }
39    }
40
41    let home = std::env::var_os("HOME")
42        .map(PathBuf::from)
43        .ok_or_else(|| anyhow::anyhow!("HOME is not set, so the sandbox cannot hide its keys"))?;
44    let mut projects: Vec<PathBuf> = crate::config::get_projects_with_paths()
45        .unwrap_or_default()
46        .into_iter()
47        .map(|(_, dir)| dir)
48        .collect();
49    projects.push(inv.main_project_dir.clone());
50
51    let run = Run {
52        work_dir: inv.work_dir.clone(),
53        git_dirs,
54        home,
55        orch_home: crate::home::orch_home()?,
56        projects,
57        internal: inv.task_id.starts_with("internal:"),
58        seatbelt: backend == Backend::Seatbelt,
59    };
60    let sandbox = Sandbox::new(backend, policy(&run))?;
61    Ok(sandbox.argv().to_vec())
62}
63
64/// What the policy of one run depends on
65struct Run {
66    work_dir: PathBuf,
67    git_dirs: Vec<PathBuf>,
68    home: PathBuf,
69    orch_home: PathBuf,
70    projects: Vec<PathBuf>,
71    internal: bool,
72    seatbelt: bool,
73}
74
75fn policy(run: &Run) -> Policy {
76    let mut p = Policy::default();
77    // The home is writable for agent state and every language's caches, minus what runs later
78    p.writable.push(run.work_dir.clone());
79    p.writable.extend(run.git_dirs.iter().cloned());
80    p.writable.extend([std::env::temp_dir(), run.home.clone()]);
81
82    if run.seatbelt {
83        p.writable.push("/private/tmp".into());
84    }
85
86    // A hook or config written to the git dir runs outside the sandbox the next time the operator runs git
87    for dir in &run.git_dirs {
88        p.read_only.extend([dir.join("hooks"), dir.join("config")]);
89    }
90
91    p.read_only.push(run.work_dir.join(".orch.yml"));
92    p.read_only
93        .extend(STARTUP.iter().map(|path| run.home.join(path)));
94
95    let mut keep: Vec<PathBuf> = vec![run.work_dir.clone()];
96    keep.extend(run.git_dirs.iter().cloned());
97
98    if run.internal {
99        keep.extend(["orch.db", "orch.db-wal", "orch.db-shm"].map(|f| run.orch_home.join(f)));
100    }
101
102    read_only_around(&run.orch_home, &keep, &mut p.read_only);
103
104    for project in &run.projects {
105        read_only_around(project, &keep, &mut p.read_only);
106    }
107
108    // The crate's list hides cargo's config too, which holds build settings agents need
109    p.hidden = known::credentials(&run.home)
110        .into_iter()
111        .filter(|path| {
112            ![".cargo/config", ".cargo/config.toml"]
113                .map(|c| run.home.join(c))
114                .contains(path)
115        })
116        .collect();
117    p.hidden
118        .extend([".gnupg", "Library/Keychains"].map(|path| run.home.join(path)));
119    p.own_signals = true;
120
121    // launchctl and setuid binaries start processes outside the sandbox
122    if run.seatbelt {
123        p.apple_events = false;
124        p.no_exec.push(known::LAUNCHCTL.into());
125        p.no_exec.extend(known::setuid_binaries().iter().cloned());
126    }
127
128    p
129}
130
131/**
132 * Every entry of `dir` read-only but the paths in `keep`. An entry that holds a kept path is
133 * walked instead, so `~/.orch` stays read-only around the task's own worktree, and a main checkout
134 * around its `.git`. A missing `dir` adds nothing
135 **/
136fn read_only_around(dir: &Path, keep: &[PathBuf], out: &mut Vec<PathBuf>) {
137    let Ok(entries) = std::fs::read_dir(dir) else {
138        return;
139    };
140
141    for entry in entries.flatten() {
142        let path = entry.path();
143
144        if keep.iter().any(|k| path.starts_with(k)) {
145            continue;
146        }
147
148        let holds_kept = keep.iter().any(|k| k.starts_with(&path));
149        let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
150
151        if holds_kept && is_dir {
152            read_only_around(&path, keep, out);
153        } else if !holds_kept {
154            out.push(path);
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::fs;
163
164    /// A home with an orch home inside, two worktrees and a main checkout, and the run of one task
165    fn fixture(internal: bool) -> (tempfile::TempDir, Run) {
166        let root = tempfile::tempdir().unwrap();
167        let r = root.path().canonicalize().unwrap();
168        let (home, orch) = (r.join("home"), r.join("home/.orch"));
169        for dir in [
170            "home/.orch/worktrees/proj/mine",
171            "home/.orch/worktrees/proj/other",
172            "home/.orch/state",
173            "home/proj/.git/worktrees/mine",
174            "home/proj/src",
175        ] {
176            fs::create_dir_all(r.join(dir)).unwrap();
177        }
178        for file in [
179            "home/.orch/config.yml",
180            "home/.orch/orch.db",
181            "home/proj/README.md",
182        ] {
183            fs::write(r.join(file), "").unwrap();
184        }
185
186        let run = Run {
187            work_dir: orch.join("worktrees/proj/mine"),
188            git_dirs: vec![
189                home.join("proj/.git/worktrees/mine"),
190                home.join("proj/.git"),
191            ],
192            home: home.clone(),
193            orch_home: orch,
194            projects: vec![home.join("proj")],
195            internal,
196            seatbelt: false,
197        };
198        (root, run)
199    }
200
201    #[test]
202    fn orch_home_is_read_only_around_the_task_worktree() {
203        let (_root, run) = fixture(false);
204        let p = policy(&run);
205        let o = &run.orch_home;
206        for path in [
207            o.join("config.yml"),
208            o.join("state"),
209            o.join("orch.db"),
210            o.join("worktrees/proj/other"),
211        ] {
212            assert!(
213                p.read_only.contains(&path),
214                "{} not read-only",
215                path.display()
216            );
217        }
218        for path in [
219            o.join("worktrees"),
220            o.join("worktrees/proj"),
221            run.work_dir.clone(),
222        ] {
223            assert!(!p.read_only.contains(&path), "{} read-only", path.display());
224        }
225        assert!(p.writable.contains(&run.work_dir));
226    }
227
228    #[test]
229    fn the_main_checkout_is_read_only_but_its_git_dir() {
230        let (_root, run) = fixture(false);
231        let p = policy(&run);
232        let proj = run.home.join("proj");
233        assert!(p.read_only.contains(&proj.join("src")));
234        assert!(p.read_only.contains(&proj.join("README.md")));
235        assert!(!p.read_only.contains(&proj.join(".git")));
236        assert!(p.read_only.contains(&proj.join(".git/hooks")));
237        assert!(p.read_only.contains(&proj.join(".git/config")));
238    }
239
240    #[test]
241    fn a_job_task_may_write_the_store() {
242        let (_root, run) = fixture(true);
243        assert!(!policy(&run)
244            .read_only
245            .contains(&run.orch_home.join("orch.db")));
246    }
247
248    #[test]
249    fn keys_are_hidden_and_startup_files_read_only() {
250        let (_root, run) = fixture(false);
251        let p = policy(&run);
252        assert!(p.hidden.contains(&run.home.join(".ssh")));
253        assert!(p.hidden.contains(&run.home.join(".gnupg")));
254        assert!(!p.hidden.iter().any(|h| h.ends_with(".cargo/config.toml")));
255        assert!(p.read_only.contains(&run.home.join(".zshrc")));
256        assert!(p.read_only.contains(&run.work_dir.join(".orch.yml")));
257    }
258
259    // A real Seatbelt run of the policy. Skipped where no sandbox starts, such as inside another one
260    #[cfg(target_os = "macos")]
261    #[test]
262    fn the_kernel_enforces_the_policy() {
263        let Ok(backend) = Backend::detect() else {
264            eprintln!("skipped: no OS sandbox starts here");
265            return;
266        };
267        let (_root, mut run) = fixture(false);
268        run.seatbelt = backend == Backend::Seatbelt;
269        let other = run.orch_home.join("worktrees/proj/other");
270        fs::create_dir_all(run.home.join(".ssh")).unwrap();
271        fs::write(run.home.join(".ssh/key"), "k").unwrap();
272        let script = format!(
273            "echo ok > {w}/f && ! echo x > {o}/config.yml && ! echo x > {other}/f \
274             && ! echo x > {w}/.orch.yml && ! echo x >> {h}/.zshrc && ! cat {h}/.ssh/key \
275             && echo cache > {h}/.cache-probe",
276            w = run.work_dir.display(),
277            o = run.orch_home.display(),
278            other = other.display(),
279            h = run.home.display(),
280        );
281        let out = Sandbox::new(backend, policy(&run))
282            .unwrap()
283            .command("/bin/sh")
284            .args(["-c", &script])
285            .output()
286            .unwrap();
287        assert!(
288            out.status.success(),
289            "{}",
290            String::from_utf8_lossy(&out.stderr)
291        );
292        assert!(run.work_dir.join("f").exists());
293        assert!(!other.join("f").exists());
294    }
295
296    #[test]
297    fn a_worktree_that_is_the_main_checkout_stays_writable() {
298        let (_root, mut run) = fixture(false);
299        run.work_dir = run.home.join("proj");
300        let p = policy(&run);
301        assert!(!p.read_only.contains(&run.work_dir));
302        assert!(!p.read_only.contains(&run.work_dir.join("src")));
303    }
304}