Skip to main content

atman_runtime/
sandbox.rs

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}