1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3
4use command_group::{AsyncCommandGroup, AsyncGroupChild};
5
6use crate::error::RuntimeError;
7use crate::permission::{InvocationAuthorization, ResourceProvenance};
8use crate::tool::BoxFut;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SandboxOperation {
12 BackgroundSpawn,
13 PtySpawn,
14}
15
16#[derive(Debug, Clone)]
17pub struct SandboxDenial {
18 pub operation: SandboxOperation,
19 pub reason: String,
20 pub provenance: ResourceProvenance,
21}
22
23#[derive(Debug, Clone)]
24pub enum SandboxLaunchError {
25 Denied(Box<SandboxDenial>),
26 Runtime(RuntimeError),
27}
28
29impl From<RuntimeError> for SandboxLaunchError {
30 fn from(error: RuntimeError) -> Self {
31 Self::Runtime(error)
32 }
33}
34
35impl SandboxLaunchError {
36 pub fn into_runtime(self, tool: &str) -> RuntimeError {
37 match self {
38 Self::Denied(denial) => RuntimeError::ToolFailed(format!(
39 "{tool}: sandbox denied {:?}: {}",
40 denial.operation, denial.reason
41 )),
42 Self::Runtime(error) => error,
43 }
44 }
45}
46
47pub(crate) struct TempProfile {
48 path: PathBuf,
49}
50
51impl TempProfile {
52 pub(crate) fn create(profile: &str) -> Result<Self, RuntimeError> {
53 let path = std::env::temp_dir().join(format!("atman-sandbox-{}.sb", uuid::Uuid::new_v4()));
54 let guard = Self { path };
55 std::fs::write(&guard.path, profile)
56 .map_err(|error| RuntimeError::ToolFailed(format!("write .sb: {error}")))?;
57 Ok(guard)
58 }
59
60 #[cfg(test)]
61 pub(crate) fn path(&self) -> &Path {
62 &self.path
63 }
64}
65
66impl Drop for TempProfile {
67 fn drop(&mut self) {
68 let _ = std::fs::remove_file(&self.path);
69 }
70}
71
72pub struct BackgroundSpawnResult {
73 pub child: AsyncGroupChild,
74 pub(crate) profile: Option<TempProfile>,
75}
76
77impl BackgroundSpawnResult {
78 pub(crate) fn direct(child: AsyncGroupChild) -> Self {
79 Self {
80 child,
81 profile: None,
82 }
83 }
84}
85
86pub trait BackgroundLauncher: Send {
87 fn launch(self: Box<Self>) -> Result<BackgroundSpawnResult, SandboxLaunchError>;
88}
89
90pub struct PtySpawnResult {
91 pub child: Box<dyn portable_pty::Child + Send + Sync>,
92 pub reader: Box<dyn std::io::Read + Send>,
93 pub writer: Box<dyn std::io::Write + Send>,
94 pub master: Box<dyn portable_pty::MasterPty + Send>,
95 pub(crate) profile: Option<TempProfile>,
96}
97
98pub(crate) fn terminate_pty_child(child: &mut dyn portable_pty::Child) {
99 let _ = child.kill();
100 let _ = child.wait();
101}
102
103pub(crate) fn complete_pty_spawn(
104 mut child: Box<dyn portable_pty::Child + Send + Sync>,
105 master: Box<dyn portable_pty::MasterPty + Send>,
106 profile: Option<TempProfile>,
107) -> Result<PtySpawnResult, RuntimeError> {
108 let reader = match master.try_clone_reader() {
109 Ok(reader) => reader,
110 Err(error) => {
111 terminate_pty_child(child.as_mut());
112 return Err(RuntimeError::ToolFailed(format!("pty reader: {error}")));
113 }
114 };
115 let writer = match master.take_writer() {
116 Ok(writer) => writer,
117 Err(error) => {
118 terminate_pty_child(child.as_mut());
119 return Err(RuntimeError::ToolFailed(format!("pty writer: {error}")));
120 }
121 };
122 Ok(PtySpawnResult {
123 child,
124 reader,
125 writer,
126 master,
127 profile,
128 })
129}
130
131pub trait Sandbox: Send + Sync {
132 fn spawn<'a>(
133 &'a self,
134 cmd: &'a [&'a str],
135 env: &'a [(String, String)],
136 cwd: &'a Path,
137 authorization: &'a InvocationAuthorization,
138 ) -> BoxFut<'a, Result<std::process::Output, RuntimeError>>;
139
140 fn prepare_background(
141 &self,
142 cmd: &[&str],
143 env: &[(String, String)],
144 cwd: &Path,
145 authorization: &InvocationAuthorization,
146 ) -> Result<Box<dyn BackgroundLauncher>, SandboxLaunchError>;
147
148 fn spawn_pty<'a>(
149 &'a self,
150 cmd: &'a [&'a str],
151 env: &'a [(String, String)],
152 cwd: &'a Path,
153 pty_size: portable_pty::PtySize,
154 authorization: &'a InvocationAuthorization,
155 ) -> BoxFut<'a, Result<PtySpawnResult, SandboxLaunchError>>;
156
157 fn is_available(&self) -> bool;
158
159 fn kind(&self) -> &'static str;
160}
161
162pub struct SandboxExec {
163 project_root: PathBuf,
164 extra_read: Vec<PathBuf>,
165 extra_write: Vec<PathBuf>,
166 profile_template: String,
167 allow_network: bool,
168}
169
170impl SandboxExec {
171 pub fn new(project_root: impl Into<PathBuf>) -> Self {
172 Self {
173 project_root: project_root.into(),
174 extra_read: Vec::new(),
175 extra_write: Vec::new(),
176 profile_template: DEFAULT_PROFILE.to_string(),
177 allow_network: false,
178 }
179 }
180
181 pub fn with_extra_read(mut self, roots: Vec<PathBuf>) -> Self {
182 self.extra_read = roots;
183 self
184 }
185
186 pub fn with_extra_write(mut self, roots: Vec<PathBuf>) -> Self {
187 self.extra_write = roots;
188 self
189 }
190
191 pub fn with_template(mut self, template: impl Into<String>) -> Self {
192 self.profile_template = template.into();
193 self
194 }
195
196 pub fn with_allow_network(mut self, allow: bool) -> Self {
197 self.allow_network = allow;
198 self
199 }
200
201 pub fn render_profile(&self, cwd: &Path) -> String {
202 render_template(
203 &self.profile_template,
204 &self.project_root,
205 cwd,
206 &self.extra_read,
207 &self.extra_write,
208 self.allow_network,
209 )
210 }
211}
212
213fn sandbox_string(value: &Path) -> String {
214 let mut escaped = String::new();
215 for ch in value.to_string_lossy().chars() {
216 match ch {
217 '"' => escaped.push_str("\\\""),
218 '\\' => escaped.push_str("\\\\"),
219 '\n' => escaped.push_str("\\n"),
220 '\r' => escaped.push_str("\\r"),
221 '\t' => escaped.push_str("\\t"),
222 ch if ch.is_control() => {
223 use std::fmt::Write as _;
224 let _ = write!(escaped, "\\x{:02x}", ch as u32);
225 }
226 ch => escaped.push(ch),
227 }
228 }
229 escaped
230}
231
232fn render_template(
233 template: &str,
234 project_root: &Path,
235 cwd: &Path,
236 extra_read: &[PathBuf],
237 extra_write: &[PathBuf],
238 allow_network: bool,
239) -> String {
240 let mut out = template
241 .replace("{PROJECT_ROOT}", &sandbox_string(project_root))
242 .replace("{CWD}", &sandbox_string(cwd));
243 if allow_network && !out.contains("(allow network") {
244 out.push_str("\n(allow network*)\n");
245 }
246 if !extra_read.is_empty() {
247 out.push_str("\n;; extra_read\n");
248 for root in extra_read {
249 out.push_str(&format!(
250 "(allow file-read* (subpath \"{}\"))\n",
251 sandbox_string(root)
252 ));
253 }
254 }
255 if !extra_write.is_empty() {
256 out.push_str("\n;; extra_write\n");
257 for root in extra_write {
258 out.push_str(&format!(
259 "(allow file-write* (subpath \"{}\"))\n",
260 sandbox_string(root)
261 ));
262 }
263 }
264 out
265}
266
267struct SandboxExecBackgroundLauncher {
268 profile: String,
269 cmd: Vec<String>,
270 env: Vec<(String, String)>,
271 cwd: PathBuf,
272 provenance: ResourceProvenance,
273}
274
275impl BackgroundLauncher for SandboxExecBackgroundLauncher {
276 fn launch(self: Box<Self>) -> Result<BackgroundSpawnResult, SandboxLaunchError> {
277 let profile = TempProfile::create(&self.profile)?;
278 let mut command = tokio::process::Command::new("/usr/bin/sandbox-exec");
279 command
280 .arg("-f")
281 .arg(&profile.path)
282 .args(&self.cmd)
283 .stdin(Stdio::null())
284 .stdout(Stdio::piped())
285 .stderr(Stdio::piped())
286 .current_dir(&self.cwd);
287 for (key, value) in &self.env {
288 command.env(key, value);
289 }
290 match command.group().kill_on_drop(true).spawn() {
291 Ok(child) => Ok(BackgroundSpawnResult {
292 child,
293 profile: Some(profile),
294 }),
295 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
296 Err(SandboxLaunchError::Denied(Box::new(SandboxDenial {
297 operation: SandboxOperation::BackgroundSpawn,
298 reason: error.to_string(),
299 provenance: self.provenance,
300 })))
301 }
302 Err(error) => Err(SandboxLaunchError::Runtime(RuntimeError::ToolFailed(
303 format!("sandbox-exec spawn: {error}"),
304 ))),
305 }
306 }
307}
308
309impl Sandbox for SandboxExec {
310 fn spawn<'a>(
311 &'a self,
312 cmd: &'a [&'a str],
313 env: &'a [(String, String)],
314 cwd: &'a Path,
315 authorization: &'a InvocationAuthorization,
316 ) -> BoxFut<'a, Result<std::process::Output, RuntimeError>> {
317 Box::pin(async move {
318 if !authorization.is_for_call(authorization.tool_use_id(), "test.run") {
319 return Err(RuntimeError::ToolFailed(
320 "sandbox execution requires test.run authorization".into(),
321 ));
322 }
323 if !self.is_available() {
324 return Err(RuntimeError::ToolFailed(
325 "sandbox-exec not available on this host".into(),
326 ));
327 }
328 let profile = TempProfile::create(&self.render_profile(cwd))?;
329 let mut command = tokio::process::Command::new("/usr/bin/sandbox-exec");
330 command
331 .arg("-f")
332 .arg(&profile.path)
333 .args(cmd)
334 .current_dir(cwd)
335 .kill_on_drop(true);
336 for (key, value) in env {
337 command.env(key, value);
338 }
339 command
340 .output()
341 .await
342 .map_err(|error| RuntimeError::ToolFailed(format!("sandbox-exec spawn: {error}")))
343 })
344 }
345
346 fn prepare_background(
347 &self,
348 cmd: &[&str],
349 env: &[(String, String)],
350 cwd: &Path,
351 authorization: &InvocationAuthorization,
352 ) -> Result<Box<dyn BackgroundLauncher>, SandboxLaunchError> {
353 if !authorization.is_for_call(authorization.tool_use_id(), "bash.spawn") {
354 return Err(SandboxLaunchError::Runtime(RuntimeError::ToolFailed(
355 "sandbox background execution requires bash.spawn authorization".into(),
356 )));
357 }
358 if !self.is_available() {
359 return Err(SandboxLaunchError::Runtime(RuntimeError::ToolFailed(
360 "sandbox-exec not available on this host".into(),
361 )));
362 }
363 Ok(Box::new(SandboxExecBackgroundLauncher {
364 profile: self.render_profile(cwd),
365 cmd: cmd.iter().map(|arg| (*arg).to_owned()).collect(),
366 env: env.to_vec(),
367 cwd: cwd.to_path_buf(),
368 provenance: authorization.provenance().clone(),
369 }))
370 }
371
372 fn spawn_pty<'a>(
373 &'a self,
374 cmd: &'a [&'a str],
375 env: &'a [(String, String)],
376 cwd: &'a Path,
377 pty_size: portable_pty::PtySize,
378 authorization: &'a InvocationAuthorization,
379 ) -> BoxFut<'a, Result<PtySpawnResult, SandboxLaunchError>> {
380 Box::pin(async move {
381 if !authorization.is_for_call(authorization.tool_use_id(), "term.spawn") {
382 return Err(SandboxLaunchError::Runtime(RuntimeError::ToolFailed(
383 "sandbox PTY execution requires term.spawn authorization".into(),
384 )));
385 }
386 if !self.is_available() {
387 return Err(SandboxLaunchError::Runtime(RuntimeError::ToolFailed(
388 "sandbox-exec not available on this host".into(),
389 )));
390 }
391 spawn_pty_with_profile(
392 "/usr/bin/sandbox-exec",
393 &self.render_profile(cwd),
394 cmd,
395 env,
396 cwd,
397 pty_size,
398 authorization.provenance().clone(),
399 )
400 })
401 }
402
403 fn is_available(&self) -> bool {
404 cfg!(target_os = "macos") && Path::new("/usr/bin/sandbox-exec").exists()
405 }
406
407 fn kind(&self) -> &'static str {
408 "sandbox-exec"
409 }
410}
411
412#[allow(clippy::too_many_arguments)]
413fn spawn_pty_with_profile(
414 sandbox_exec: &str,
415 profile_text: &str,
416 cmd: &[&str],
417 env: &[(String, String)],
418 cwd: &Path,
419 pty_size: portable_pty::PtySize,
420 provenance: ResourceProvenance,
421) -> Result<PtySpawnResult, SandboxLaunchError> {
422 let profile = TempProfile::create(profile_text)?;
423 let pty_system = portable_pty::native_pty_system();
424 let pair = pty_system
425 .openpty(pty_size)
426 .map_err(|error| RuntimeError::ToolFailed(format!("openpty: {error}")))?;
427 let mut builder = portable_pty::CommandBuilder::new(sandbox_exec);
428 builder.arg("-f");
429 builder.arg(&profile.path);
430 for arg in cmd {
431 builder.arg(arg);
432 }
433 builder.cwd(cwd);
434 for (key, value) in env {
435 builder.env(key, value);
436 }
437
438 let child = pair.slave.spawn_command(builder).map_err(|error| {
439 if error
440 .downcast_ref::<std::io::Error>()
441 .is_some_and(|io| io.kind() == std::io::ErrorKind::PermissionDenied)
442 {
443 SandboxLaunchError::Denied(Box::new(SandboxDenial {
444 operation: SandboxOperation::PtySpawn,
445 reason: error.to_string(),
446 provenance,
447 }))
448 } else {
449 SandboxLaunchError::Runtime(RuntimeError::ToolFailed(format!("pty spawn: {error}")))
450 }
451 })?;
452 complete_pty_spawn(child, pair.master, Some(profile)).map_err(SandboxLaunchError::Runtime)
453}
454
455pub const DEFAULT_PROFILE: &str = r#"(version 1)
456(deny default)
457(import "system.sb")
458(allow process*)
459(allow signal (target same-sandbox))
460(allow file-read*
461 (subpath "/System")
462 (subpath "/usr")
463 (subpath "/bin")
464 (subpath "/sbin")
465 (subpath "/Library")
466 (subpath "/private/etc")
467 (subpath "/private/var/db")
468 (subpath "/private/var/select")
469 (subpath "/dev")
470 (subpath "/tmp")
471 (subpath "/private/tmp")
472 (subpath "{PROJECT_ROOT}")
473 (subpath "{CWD}"))
474(allow file-write*
475 (subpath "{PROJECT_ROOT}")
476 (subpath "{CWD}")
477 (subpath "/tmp")
478 (subpath "/private/tmp"))
479(allow sysctl-read)
480(allow mach-lookup)
481"#;
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn render_profile_substitutes_paths_and_options() {
489 let sandbox = SandboxExec::new("/project")
490 .with_extra_read(vec![PathBuf::from("/read")])
491 .with_extra_write(vec![PathBuf::from("/write")])
492 .with_allow_network(true);
493 let profile = sandbox.render_profile(Path::new("/cwd"));
494 assert!(profile.contains("/project"));
495 assert!(profile.contains("/cwd"));
496 assert!(profile.contains("/read"));
497 assert!(profile.contains("/write"));
498 assert!(profile.contains("/private/var/select"));
499 assert!(!profile.contains("/bin/ps"));
500 assert!(profile.contains("(allow network*)"));
501 }
502
503 #[test]
504 fn render_profile_escapes_all_path_literals() {
505 let injected = PathBuf::from("/tmp/a\"\\\n) (allow network*) (");
506 let sandbox = SandboxExec::new(&injected)
507 .with_extra_read(vec![injected.clone()])
508 .with_extra_write(vec![injected.clone()]);
509 let profile = sandbox.render_profile(&injected);
510 let escaped = "/tmp/a\\\"\\\\\\n) (allow network*) (";
511 assert_eq!(profile.matches(escaped).count(), 6);
512 assert!(!profile.contains("/tmp/a\"\\\n)"));
513 }
514
515 #[test]
516 fn temp_profile_is_removed_on_drop() {
517 let path = {
518 let profile = TempProfile::create("(version 1)").unwrap();
519 assert!(profile.path.exists());
520 profile.path.clone()
521 };
522 assert!(!path.exists());
523 }
524}