Skip to main content

agent_code_lib/sandbox/
seatbelt.rs

1//! macOS sandbox strategy using `sandbox-exec` (Seatbelt).
2//!
3//! Builds an inline SBPL profile that denies everything by default, allows
4//! broad reads (so tools can introspect the system), and grants writes only
5//! to the project directory plus any explicitly allowed paths. Forbidden
6//! read paths are denied after the broad read rule.
7//!
8//! Note: `sandbox-exec` is documented as deprecated on newer macOS versions
9//! but remains functional. A future follow-up will add an Endpoint Security
10//! based strategy; this ships today.
11
12use std::path::Path;
13
14use tokio::process::Command;
15
16use super::{SandboxPolicy, SandboxStrategy};
17
18/// macOS Seatbelt strategy. See module docs.
19pub struct SeatbeltStrategy;
20
21impl SandboxStrategy for SeatbeltStrategy {
22    fn name(&self) -> &'static str {
23        "seatbelt"
24    }
25
26    fn wrap_command(&self, cmd: Command, policy: &SandboxPolicy) -> Command {
27        let profile = build_profile(policy);
28
29        // Extract the program and args from the existing Command. We rebuild
30        // via sandbox-exec rather than mutating in place because tokio's
31        // Command does not expose its program field for replacement.
32        let std_cmd = cmd.as_std();
33        let program = std_cmd.get_program().to_os_string();
34        let args: Vec<_> = std_cmd.get_args().map(|a| a.to_os_string()).collect();
35        let current_dir = std_cmd.get_current_dir().map(Path::to_path_buf);
36        let envs: Vec<(std::ffi::OsString, Option<std::ffi::OsString>)> = std_cmd
37            .get_envs()
38            .map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
39            .collect();
40
41        let mut wrapped = Command::new("sandbox-exec");
42        wrapped.arg("-p").arg(profile);
43        wrapped.arg(program);
44        wrapped.args(args);
45        if let Some(dir) = current_dir {
46            wrapped.current_dir(dir);
47        }
48        for (k, v) in envs {
49            match v {
50                Some(val) => {
51                    wrapped.env(k, val);
52                }
53                None => {
54                    wrapped.env_remove(k);
55                }
56            }
57        }
58        // Preserve piped stdio from the original command — tokio does not
59        // expose a getter, so the caller must re-apply stdio configuration
60        // after wrapping. See [`super::wrap_with_sandbox`] for the helper
61        // that handles this.
62        wrapped
63    }
64}
65
66/// Build an SBPL profile string for the given policy.
67///
68/// The generated profile:
69/// - denies all operations by default
70/// - imports `system.sb` for minimal OS compatibility
71/// - allows `process-fork`, `process-exec*`, `signal`, and `sysctl-read`
72///   so wrapped shells can run commands and inspect basic system state
73/// - allows reads everywhere (tools need to see the filesystem)
74/// - allows writes to the project directory and each allowed-write path
75/// - denies reads to every forbidden path (overriding the broad read rule)
76/// - conditionally allows network
77pub(super) fn build_profile(policy: &SandboxPolicy) -> String {
78    let mut profile = String::new();
79    profile.push_str("(version 1)\n");
80    profile.push_str("(deny default)\n");
81    profile.push_str("(import \"system.sb\")\n");
82    profile.push_str("(allow process-fork)\n");
83    profile.push_str("(allow process-exec*)\n");
84    profile.push_str("(allow signal)\n");
85    profile.push_str("(allow sysctl-read)\n");
86    profile.push_str("(allow file-read*)\n");
87
88    // Writable: project dir first, then explicit allow list.
89    push_subpath_allow(&mut profile, &policy.project_dir);
90    for p in &policy.allowed_write_paths {
91        push_subpath_allow(&mut profile, p);
92    }
93
94    // Forbidden reads override the broad allow above.
95    for p in &policy.forbidden_paths {
96        push_subpath_deny_read(&mut profile, p);
97    }
98
99    if policy.allow_network {
100        profile.push_str("(allow network*)\n");
101    }
102
103    profile
104}
105
106fn push_subpath_allow(profile: &mut String, path: &Path) {
107    for variant in path_variants(path) {
108        let escaped = escape_sbpl(&variant.display().to_string());
109        profile.push_str(&format!("(allow file-write* (subpath \"{escaped}\"))\n"));
110    }
111}
112
113fn push_subpath_deny_read(profile: &mut String, path: &Path) {
114    for variant in path_variants(path) {
115        let escaped = escape_sbpl(&variant.display().to_string());
116        profile.push_str(&format!("(deny file-read* (subpath \"{escaped}\"))\n"));
117    }
118}
119
120/// Return both the raw path and its canonicalized form (if different).
121///
122/// On macOS, common paths like `/tmp` and `/var/folders/...` are symlinks
123/// into `/private/...`, and Seatbelt `subpath` rules match the real path,
124/// not the symlink. Emitting both forms makes the profile behave correctly
125/// regardless of which form a child process happens to open.
126fn path_variants(path: &Path) -> Vec<std::path::PathBuf> {
127    let mut out = vec![path.to_path_buf()];
128    if let Ok(canonical) = std::fs::canonicalize(path)
129        && canonical != path
130    {
131        out.push(canonical);
132    }
133    out
134}
135
136fn escape_sbpl(s: &str) -> String {
137    s.replace('\\', "\\\\").replace('"', "\\\"")
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::path::PathBuf;
144
145    fn test_policy() -> SandboxPolicy {
146        SandboxPolicy {
147            project_dir: PathBuf::from("/work/repo"),
148            allowed_write_paths: vec![
149                PathBuf::from("/tmp"),
150                PathBuf::from("/Users/test/.cache/agent-code"),
151            ],
152            forbidden_paths: vec![PathBuf::from("/Users/test/.ssh")],
153            allow_network: false,
154        }
155    }
156
157    #[test]
158    fn profile_denies_by_default() {
159        let p = build_profile(&test_policy());
160        assert!(p.contains("(deny default)"));
161    }
162
163    #[test]
164    fn profile_allows_reads_broadly() {
165        let p = build_profile(&test_policy());
166        assert!(p.contains("(allow file-read*)"));
167    }
168
169    #[test]
170    fn profile_allows_project_writes() {
171        let p = build_profile(&test_policy());
172        assert!(p.contains("(allow file-write* (subpath \"/work/repo\"))"));
173    }
174
175    #[test]
176    fn profile_allows_extra_write_paths() {
177        let p = build_profile(&test_policy());
178        assert!(p.contains("(allow file-write* (subpath \"/tmp\"))"));
179        assert!(p.contains("(allow file-write* (subpath \"/Users/test/.cache/agent-code\"))"));
180    }
181
182    #[test]
183    fn profile_denies_forbidden_paths() {
184        let p = build_profile(&test_policy());
185        assert!(p.contains("(deny file-read* (subpath \"/Users/test/.ssh\"))"));
186    }
187
188    #[test]
189    fn profile_skips_network_when_disabled() {
190        let p = build_profile(&test_policy());
191        assert!(!p.contains("network*"));
192    }
193
194    #[test]
195    fn profile_allows_network_when_enabled() {
196        let mut policy = test_policy();
197        policy.allow_network = true;
198        let p = build_profile(&policy);
199        assert!(p.contains("(allow network*)"));
200    }
201
202    #[test]
203    fn profile_escapes_double_quotes_in_paths() {
204        let policy = SandboxPolicy {
205            project_dir: PathBuf::from("/weird\"path"),
206            allowed_write_paths: vec![],
207            forbidden_paths: vec![],
208            allow_network: false,
209        };
210        let p = build_profile(&policy);
211        assert!(p.contains("\"/weird\\\"path\""));
212    }
213
214    #[test]
215    fn profile_empty_allow_and_forbid_lists_still_builds() {
216        let policy = SandboxPolicy {
217            project_dir: PathBuf::from("/work/repo"),
218            allowed_write_paths: vec![],
219            forbidden_paths: vec![],
220            allow_network: false,
221        };
222        let p = build_profile(&policy);
223        // Must still allow project-dir writes even with empty extra lists.
224        assert!(p.contains("(allow file-write* (subpath \"/work/repo\"))"));
225        // And still deny default.
226        assert!(p.contains("(deny default)"));
227    }
228
229    #[test]
230    fn profile_multiple_forbidden_paths_all_appear() {
231        let policy = SandboxPolicy {
232            project_dir: PathBuf::from("/work/repo"),
233            allowed_write_paths: vec![],
234            forbidden_paths: vec![
235                PathBuf::from("/Users/test/.ssh"),
236                PathBuf::from("/Users/test/.aws"),
237                PathBuf::from("/Users/test/.gnupg"),
238            ],
239            allow_network: false,
240        };
241        let p = build_profile(&policy);
242        assert!(p.contains("/Users/test/.ssh"));
243        assert!(p.contains("/Users/test/.aws"));
244        assert!(p.contains("/Users/test/.gnupg"));
245    }
246
247    #[test]
248    fn profile_contains_process_and_signal_allows() {
249        // Regression guard: subprocess execution inside the sandbox
250        // requires these rules to be present. Breaking them would make
251        // bash unable to fork/exec/signal its children.
252        let p = build_profile(&test_policy());
253        assert!(p.contains("(allow process-fork)"));
254        assert!(p.contains("(allow process-exec*)"));
255        assert!(p.contains("(allow signal)"));
256    }
257
258    #[test]
259    fn profile_imports_system_sb() {
260        let p = build_profile(&test_policy());
261        assert!(p.contains("(import \"system.sb\")"));
262    }
263
264    // ──────────────────────────────────────────────────────────────
265    //  SeatbeltStrategy argv / cwd / env preservation
266    // ──────────────────────────────────────────────────────────────
267
268    #[test]
269    fn wrap_command_sets_sandbox_exec_as_program() {
270        let policy = test_policy();
271        let cmd = Command::new("bash");
272        let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
273        assert_eq!(wrapped.as_std().get_program(), "sandbox-exec");
274    }
275
276    #[test]
277    fn wrap_command_prepends_profile_flag() {
278        let policy = test_policy();
279        let mut cmd = Command::new("bash");
280        cmd.arg("-c").arg("echo hi");
281        let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
282        let args: Vec<_> = wrapped
283            .as_std()
284            .get_args()
285            .map(|a| a.to_os_string())
286            .collect();
287        // argv: -p, <profile>, bash, -c, "echo hi"
288        assert_eq!(args[0], "-p");
289        assert!(args[1].to_str().unwrap().contains("(deny default)"));
290        assert_eq!(args[2], "bash");
291        assert_eq!(args[3], "-c");
292        assert_eq!(args[4], "echo hi");
293    }
294
295    #[test]
296    fn wrap_command_preserves_current_dir() {
297        let policy = test_policy();
298        let mut cmd = Command::new("bash");
299        cmd.current_dir("/work/repo");
300        let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
301        assert_eq!(
302            wrapped.as_std().get_current_dir(),
303            Some(std::path::Path::new("/work/repo"))
304        );
305    }
306
307    #[test]
308    fn wrap_command_preserves_env_vars() {
309        let policy = test_policy();
310        let mut cmd = Command::new("bash");
311        cmd.env("MY_VAR", "hello").env("OTHER_VAR", "world");
312        let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
313        let envs: std::collections::HashMap<_, _> = wrapped
314            .as_std()
315            .get_envs()
316            .map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
317            .collect();
318        assert_eq!(
319            envs.get(&std::ffi::OsString::from("MY_VAR"))
320                .and_then(|v| v.clone()),
321            Some("hello".into())
322        );
323        assert_eq!(
324            envs.get(&std::ffi::OsString::from("OTHER_VAR"))
325                .and_then(|v| v.clone()),
326            Some("world".into())
327        );
328    }
329
330    #[test]
331    fn wrap_command_preserves_env_removals() {
332        let policy = test_policy();
333        let mut cmd = Command::new("bash");
334        cmd.env_remove("SECRET");
335        let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
336        let envs: std::collections::HashMap<_, _> = wrapped
337            .as_std()
338            .get_envs()
339            .map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
340            .collect();
341        // env_remove stores a None value under the key.
342        assert_eq!(envs.get(&std::ffi::OsString::from("SECRET")), Some(&None));
343    }
344}