Skip to main content

atman_runtime/
sandbox.rs

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}