1use std::path::{Path, PathBuf};
2
3use crate::error::RuntimeError;
4use crate::tool::BoxFut;
5
6pub struct PtySpawnResult {
7 pub child: Box<dyn portable_pty::Child + Send + Sync>,
8 pub reader: Box<dyn std::io::Read + Send>,
9 pub writer: Box<dyn std::io::Write + Send>,
10 pub master: Box<dyn portable_pty::MasterPty + Send>,
11}
12
13pub trait Sandbox: Send + Sync {
14 fn spawn<'a>(
15 &'a self,
16 cmd: &'a [&'a str],
17 env: &'a [(String, String)],
18 cwd: &'a Path,
19 ) -> BoxFut<'a, Result<std::process::Output, RuntimeError>>;
20
21 fn spawn_relaxed<'a>(
22 &'a self,
23 cmd: &'a [&'a str],
24 env: &'a [(String, String)],
25 cwd: &'a Path,
26 ) -> BoxFut<'a, Result<std::process::Output, RuntimeError>> {
27 self.spawn(cmd, env, cwd)
28 }
29
30 fn spawn_pty<'a>(
31 &'a self,
32 cmd: &'a [&'a str],
33 env: &'a [(String, String)],
34 cwd: &'a Path,
35 pty_size: portable_pty::PtySize,
36 ) -> BoxFut<'a, Result<PtySpawnResult, RuntimeError>>;
37
38 fn spawn_pty_relaxed<'a>(
39 &'a self,
40 cmd: &'a [&'a str],
41 env: &'a [(String, String)],
42 cwd: &'a Path,
43 pty_size: portable_pty::PtySize,
44 ) -> BoxFut<'a, Result<PtySpawnResult, RuntimeError>> {
45 self.spawn_pty(cmd, env, cwd, pty_size)
46 }
47
48 fn is_available(&self) -> bool;
49
50 fn kind(&self) -> &'static str;
51}
52
53pub struct SandboxExec {
54 project_root: PathBuf,
55 extra_read: Vec<PathBuf>,
56 extra_write: Vec<PathBuf>,
57 profile_template: String,
58 allow_network: bool,
59 relaxed_template: String,
60}
61
62impl SandboxExec {
63 pub fn new(project_root: impl Into<PathBuf>) -> Self {
64 Self {
65 project_root: project_root.into(),
66 extra_read: Vec::new(),
67 extra_write: Vec::new(),
68 profile_template: DEFAULT_PROFILE.to_string(),
69 allow_network: false,
70 relaxed_template: RELAXED_PROFILE.to_string(),
71 }
72 }
73
74 pub fn with_extra_read(mut self, roots: Vec<PathBuf>) -> Self {
75 self.extra_read = roots;
76 self
77 }
78
79 pub fn with_extra_write(mut self, roots: Vec<PathBuf>) -> Self {
80 self.extra_write = roots;
81 self
82 }
83
84 pub fn with_template(mut self, template: impl Into<String>) -> Self {
85 self.profile_template = template.into();
86 self
87 }
88
89 pub fn with_allow_network(mut self, allow: bool) -> Self {
90 self.allow_network = allow;
91 self
92 }
93
94 pub fn render_profile(&self, cwd: &Path) -> String {
95 render_template(
96 &self.profile_template,
97 &self.project_root,
98 cwd,
99 &self.extra_read,
100 &self.extra_write,
101 self.allow_network,
102 )
103 }
104}
105
106fn render_template(
107 template: &str,
108 project_root: &Path,
109 cwd: &Path,
110 extra_read: &[PathBuf],
111 extra_write: &[PathBuf],
112 allow_network: bool,
113) -> String {
114 let mut out = template
115 .replace("{PROJECT_ROOT}", &project_root.display().to_string())
116 .replace("{CWD}", &cwd.display().to_string());
117 if allow_network && !out.contains("(allow network") {
118 out.push_str("\n(allow network*)\n");
119 }
120 if !extra_read.is_empty() {
121 let mut extra = String::from("\n;; extra_read\n");
122 for r in extra_read {
123 extra.push_str(&format!(
124 "(allow file-read* (subpath \"{}\"))\n",
125 r.display()
126 ));
127 }
128 out.push_str(&extra);
129 }
130 if !extra_write.is_empty() {
131 let mut extra = String::from("\n;; extra_write\n");
132 for r in extra_write {
133 extra.push_str(&format!(
134 "(allow file-write* (subpath \"{}\"))\n",
135 r.display()
136 ));
137 }
138 out.push_str(&extra);
139 }
140 out
141}
142
143impl Sandbox for SandboxExec {
144 fn spawn<'a>(
145 &'a self,
146 cmd: &'a [&'a str],
147 env: &'a [(String, String)],
148 cwd: &'a Path,
149 ) -> BoxFut<'a, Result<std::process::Output, RuntimeError>> {
150 Box::pin(async move {
151 if !self.is_available() {
152 return Err(RuntimeError::ToolFailed(
153 "sandbox-exec not available on this host".into(),
154 ));
155 }
156 let profile = self.render_profile(cwd);
157 let dir = std::env::temp_dir();
158 let profile_path = dir.join(format!("atman-sandbox-{}.sb", uuid::Uuid::new_v4()));
159 tokio::fs::write(&profile_path, profile)
160 .await
161 .map_err(|e| RuntimeError::ToolFailed(format!("write .sb: {e}")))?;
162 let mut command = tokio::process::Command::new("/usr/bin/sandbox-exec");
163 command
164 .arg("-f")
165 .arg(&profile_path)
166 .args(cmd)
167 .current_dir(cwd);
168 for (k, v) in env {
169 command.env(k, v);
170 }
171 let output = command
172 .output()
173 .await
174 .map_err(|e| RuntimeError::ToolFailed(format!("sandbox-exec spawn: {e}")));
175 let _ = tokio::fs::remove_file(&profile_path).await;
176 output
177 })
178 }
179
180 fn is_available(&self) -> bool {
181 cfg!(target_os = "macos") && std::path::Path::new("/usr/bin/sandbox-exec").exists()
182 }
183
184 fn kind(&self) -> &'static str {
185 "sandbox-exec"
186 }
187
188 fn spawn_relaxed<'a>(
189 &'a self,
190 cmd: &'a [&'a str],
191 env: &'a [(String, String)],
192 cwd: &'a Path,
193 ) -> BoxFut<'a, Result<std::process::Output, RuntimeError>> {
194 Box::pin(async move {
195 if !self.is_available() {
196 return Err(RuntimeError::ToolFailed(
197 "sandbox-exec not available on this host".into(),
198 ));
199 }
200 let template = self.relaxed_template.clone();
201 let profile = render_template(
202 &template,
203 &self.project_root,
204 cwd,
205 &self.extra_read,
206 &self.extra_write,
207 self.allow_network,
208 );
209 let dir = std::env::temp_dir();
210 let profile_path = dir.join(format!("atman-sandbox-{}.sb", uuid::Uuid::new_v4()));
211 tokio::fs::write(&profile_path, profile)
212 .await
213 .map_err(|e| RuntimeError::ToolFailed(format!("write .sb: {e}")))?;
214 let mut command = tokio::process::Command::new("/usr/bin/sandbox-exec");
215 command
216 .arg("-f")
217 .arg(&profile_path)
218 .args(cmd)
219 .current_dir(cwd);
220 for (k, v) in env {
221 command.env(k, v);
222 }
223 let output = command
224 .output()
225 .await
226 .map_err(|e| RuntimeError::ToolFailed(format!("sandbox-exec spawn: {e}")));
227 let _ = tokio::fs::remove_file(&profile_path).await;
228 output
229 })
230 }
231
232 fn spawn_pty<'a>(
233 &'a self,
234 cmd: &'a [&'a str],
235 env: &'a [(String, String)],
236 cwd: &'a Path,
237 pty_size: portable_pty::PtySize,
238 ) -> BoxFut<'a, Result<PtySpawnResult, RuntimeError>> {
239 Box::pin(async move {
240 if !self.is_available() {
241 return Err(RuntimeError::ToolFailed(
242 "sandbox-exec not available on this host".into(),
243 ));
244 }
245 let profile = self.render_profile(cwd);
246 spawn_pty_with_profile("/usr/bin/sandbox-exec", &profile, cmd, env, cwd, pty_size)
247 })
248 }
249
250 fn spawn_pty_relaxed<'a>(
251 &'a self,
252 cmd: &'a [&'a str],
253 env: &'a [(String, String)],
254 cwd: &'a Path,
255 pty_size: portable_pty::PtySize,
256 ) -> BoxFut<'a, Result<PtySpawnResult, RuntimeError>> {
257 Box::pin(async move {
258 if !self.is_available() {
259 return Err(RuntimeError::ToolFailed(
260 "sandbox-exec not available on this host".into(),
261 ));
262 }
263 let profile = render_template(
264 &self.relaxed_template,
265 &self.project_root,
266 cwd,
267 &self.extra_read,
268 &self.extra_write,
269 self.allow_network,
270 );
271 spawn_pty_with_profile("/usr/bin/sandbox-exec", &profile, cmd, env, cwd, pty_size)
272 })
273 }
274}
275
276fn spawn_pty_with_profile(
277 sandbox_exec: &str,
278 profile: &str,
279 cmd: &[&str],
280 env: &[(String, String)],
281 cwd: &Path,
282 pty_size: portable_pty::PtySize,
283) -> Result<PtySpawnResult, RuntimeError> {
284 let dir = std::env::temp_dir();
285 let profile_path = dir.join(format!("atman-sandbox-{}.sb", uuid::Uuid::new_v4()));
286 std::fs::write(&profile_path, profile)
287 .map_err(|e| RuntimeError::ToolFailed(format!("write .sb: {e}")))?;
288
289 let pty_system = portable_pty::native_pty_system();
290 let pair = pty_system
291 .openpty(pty_size)
292 .map_err(|e| RuntimeError::ToolFailed(format!("openpty: {e}")))?;
293
294 let mut builder = portable_pty::CommandBuilder::new(sandbox_exec);
295 builder.arg("-f");
296 builder.arg(&profile_path);
297 for arg in cmd {
298 builder.arg(arg);
299 }
300 builder.cwd(cwd);
301 for (k, v) in env {
302 builder.env(k, v);
303 }
304
305 let child = pair
306 .slave
307 .spawn_command(builder)
308 .map_err(|e| RuntimeError::ToolFailed(format!("pty spawn: {e}")))?;
309 let reader = pair
310 .master
311 .try_clone_reader()
312 .map_err(|e| RuntimeError::ToolFailed(format!("pty reader: {e}")))?;
313 let writer = pair
314 .master
315 .take_writer()
316 .map_err(|e| RuntimeError::ToolFailed(format!("pty writer: {e}")))?;
317
318 Ok(PtySpawnResult {
319 child,
320 reader,
321 writer,
322 master: pair.master,
323 })
324}
325
326pub const DEFAULT_PROFILE: &str = r#"(version 1)
327(deny default)
328(allow process-exec (regex #"^/bin/"))
329(allow process-exec (regex #"^/usr/bin/"))
330(allow process-exec (regex #"^/opt/homebrew/"))
331(allow process-fork)
332(allow file-read* (subpath "/"))
333(allow file-write* (subpath "/tmp"))
334(allow file-write* (subpath "/private/tmp"))
335(allow file-write* (subpath "/private/var/folders"))
336(allow file-write* (regex #"^/dev/(null|zero|tty|dtracehelper|urandom|random|stdout|stderr|fd/|pts/)"))
337(allow file-write* (regex #"^/dev/ptmx"))
338(allow sysctl*)
339(allow mach*)
340(allow signal)
341(allow process-info* (target self))
342"#;
343
344pub const RELAXED_PROFILE: &str = r#"(version 1)
345(deny default)
346(allow process-exec (regex #"^/bin/"))
347(allow process-exec (regex #"^/usr/bin/"))
348(allow process-exec (regex #"^/opt/homebrew/"))
349(allow process-fork)
350(allow file-read* (subpath "/"))
351(allow file-write* (subpath "{PROJECT_ROOT}"))
352(allow file-write* (subpath "{CWD}"))
353(allow file-write* (subpath "/tmp"))
354(allow file-write* (subpath "/private/tmp"))
355(allow file-write* (subpath "/private/var/folders"))
356(allow file-write* (regex #"^/dev/(null|zero|tty|dtracehelper|urandom|random|stdout|stderr|fd/|pts/)"))
357(allow file-write* (regex #"^/dev/ptmx"))
358(allow sysctl*)
359(allow mach*)
360(allow signal)
361(allow process-info* (target self))
362"#;
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn render_profile_substitutes_project_root() {
370 let sb = SandboxExec::new("/tmp/proj");
371 let rendered = sb.render_profile(Path::new("/tmp/proj/sub"));
372 assert!(
373 !rendered.contains("{PROJECT_ROOT}"),
374 "no residue: {rendered}"
375 );
376 assert!(!rendered.contains("{CWD}"), "no residue: {rendered}");
377 }
378
379 #[test]
380 fn render_profile_appends_extra_read_and_write() {
381 let sb = SandboxExec::new("/tmp/proj")
382 .with_extra_read(vec![PathBuf::from("/opt/homebrew/etc/gitconfig")])
383 .with_extra_write(vec![PathBuf::from("/tmp/scratch")]);
384 let rendered = sb.render_profile(Path::new("/tmp/proj"));
385 assert!(
386 rendered.contains("(allow file-read* (subpath \"/opt/homebrew/etc/gitconfig\"))"),
387 "profile: {rendered}"
388 );
389 assert!(
390 rendered.contains("(allow file-write* (subpath \"/tmp/scratch\"))"),
391 "profile: {rendered}"
392 );
393 }
394
395 #[test]
396 fn is_available_returns_true_only_on_macos_with_binary() {
397 let sb = SandboxExec::new("/tmp/proj");
398 let expected =
399 cfg!(target_os = "macos") && std::path::Path::new("/usr/bin/sandbox-exec").exists();
400 assert_eq!(sb.is_available(), expected);
401 }
402
403 #[test]
404 fn custom_template_is_used_when_set() {
405 let sb = SandboxExec::new("/tmp/proj").with_template("(version 1)\n(deny default)\n");
406 assert_eq!(
407 sb.render_profile(Path::new("/tmp/proj")),
408 "(version 1)\n(deny default)\n"
409 );
410 }
411}