1use super::agent::AgentInvocation;
5use sandbox_jail::{known, Backend, Policy, Sandbox};
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9const 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
24pub 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
30pub 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
64struct 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 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 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 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 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
131fn 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 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 #[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}