agent_code_lib/sandbox/
seatbelt.rs1use std::path::Path;
13
14use tokio::process::Command;
15
16use super::{SandboxPolicy, SandboxStrategy};
17
18pub 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 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 wrapped
63 }
64}
65
66pub(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 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 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
120fn 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 assert!(p.contains("(allow file-write* (subpath \"/work/repo\"))"));
225 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 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 #[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 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 assert_eq!(envs.get(&std::ffi::OsString::from("SECRET")), Some(&None));
343 }
344}