1use std::collections::HashMap;
2use std::path::Path;
3use std::path::PathBuf;
4use tokio::process::Child;
5
6use crate::protocol::SandboxPolicy;
7use crate::spawn::CODEX_SANDBOX_ENV_VAR;
8use crate::spawn::StdioPolicy;
9use crate::spawn::spawn_child_async;
10
11const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
12
13const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
18
19pub async fn spawn_command_under_seatbelt(
20 command: Vec<String>,
21 sandbox_policy: &SandboxPolicy,
22 cwd: PathBuf,
23 stdio_policy: StdioPolicy,
24 mut env: HashMap<String, String>,
25) -> std::io::Result<Child> {
26 let args = create_seatbelt_command_args(command, sandbox_policy, &cwd);
27 let arg0 = None;
28 env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
29 spawn_child_async(
30 PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
31 args,
32 arg0,
33 cwd,
34 sandbox_policy,
35 stdio_policy,
36 env,
37 )
38 .await
39}
40
41fn create_seatbelt_command_args(
42 command: Vec<String>,
43 sandbox_policy: &SandboxPolicy,
44 cwd: &Path,
45) -> Vec<String> {
46 let (file_write_policy, extra_cli_args) = {
47 if sandbox_policy.has_full_disk_write_access() {
48 (
50 r#"(allow file-write* (regex #"^/"))"#.to_string(),
51 Vec::<String>::new(),
52 )
53 } else {
54 let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
55
56 let mut writable_folder_policies: Vec<String> = Vec::new();
57 let mut cli_args: Vec<String> = Vec::new();
58
59 for (index, wr) in writable_roots.iter().enumerate() {
60 let canonical_root = wr.root.canonicalize().unwrap_or_else(|_| wr.root.clone());
62 let root_param = format!("WRITABLE_ROOT_{index}");
63 cli_args.push(format!(
64 "-D{root_param}={}",
65 canonical_root.to_string_lossy()
66 ));
67
68 if wr.read_only_subpaths.is_empty() {
69 writable_folder_policies.push(format!("(subpath (param \"{root_param}\"))"));
70 } else {
71 let mut require_parts: Vec<String> = Vec::new();
74 require_parts.push(format!("(subpath (param \"{root_param}\"))"));
75 for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() {
76 let canonical_ro = ro.canonicalize().unwrap_or_else(|_| ro.clone());
77 let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
78 cli_args.push(format!("-D{ro_param}={}", canonical_ro.to_string_lossy()));
79 require_parts
80 .push(format!("(require-not (subpath (param \"{ro_param}\")))"));
81 }
82 let policy_component = format!("(require-all {} )", require_parts.join(" "));
83 writable_folder_policies.push(policy_component);
84 }
85 }
86
87 if writable_folder_policies.is_empty() {
88 ("".to_string(), Vec::<String>::new())
89 } else {
90 let file_write_policy = format!(
91 "(allow file-write*\n{}\n)",
92 writable_folder_policies.join(" ")
93 );
94 (file_write_policy, cli_args)
95 }
96 }
97 };
98
99 let file_read_policy = if sandbox_policy.has_full_disk_read_access() {
100 "; allow read-only file operations\n(allow file-read*)"
101 } else {
102 ""
103 };
104
105 let network_policy = if sandbox_policy.has_full_network_access() {
107 "(allow network-outbound)\n(allow network-inbound)\n(allow system-socket)"
108 } else {
109 ""
110 };
111
112 let full_policy = format!(
113 "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
114 );
115
116 let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
117 seatbelt_args.extend(extra_cli_args);
118 seatbelt_args.push("--".to_string());
119 seatbelt_args.extend(command);
120 seatbelt_args
121}
122
123#[cfg(test)]
124mod tests {
125 use super::MACOS_SEATBELT_BASE_POLICY;
126 use super::create_seatbelt_command_args;
127 use crate::protocol::SandboxPolicy;
128 use pretty_assertions::assert_eq;
129 use std::fs;
130 use std::path::Path;
131 use std::path::PathBuf;
132 use tempfile::TempDir;
133
134 #[test]
135 fn create_seatbelt_args_with_read_only_git_subpath() {
136 if cfg!(target_os = "windows") {
137 return;
139 }
140
141 let tmp = TempDir::new().expect("tempdir");
144 let PopulatedTmp {
145 root_with_git,
146 root_without_git,
147 root_with_git_canon,
148 root_with_git_git_canon,
149 root_without_git_canon,
150 } = populate_tmpdir(tmp.path());
151 let cwd = tmp.path().join("cwd");
152
153 let policy = SandboxPolicy::WorkspaceWrite {
156 writable_roots: vec![root_with_git.clone(), root_without_git.clone()],
157 network_access: false,
158 exclude_tmpdir_env_var: true,
159 exclude_slash_tmp: true,
160 };
161
162 let args = create_seatbelt_command_args(
163 vec!["/bin/echo".to_string(), "hello".to_string()],
164 &policy,
165 &cwd,
166 );
167
168 let expected_policy = format!(
174 r#"{MACOS_SEATBELT_BASE_POLICY}
175; allow read-only file operations
176(allow file-read*)
177(allow file-write*
178(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
179)
180"#,
181 );
182
183 let mut expected_args = vec![
184 "-p".to_string(),
185 expected_policy,
186 format!(
187 "-DWRITABLE_ROOT_0={}",
188 root_with_git_canon.to_string_lossy()
189 ),
190 format!(
191 "-DWRITABLE_ROOT_0_RO_0={}",
192 root_with_git_git_canon.to_string_lossy()
193 ),
194 format!(
195 "-DWRITABLE_ROOT_1={}",
196 root_without_git_canon.to_string_lossy()
197 ),
198 format!("-DWRITABLE_ROOT_2={}", cwd.to_string_lossy()),
199 ];
200
201 expected_args.extend(vec![
202 "--".to_string(),
203 "/bin/echo".to_string(),
204 "hello".to_string(),
205 ]);
206
207 assert_eq!(expected_args, args);
208 }
209
210 #[test]
211 fn create_seatbelt_args_for_cwd_as_git_repo() {
212 if cfg!(target_os = "windows") {
213 return;
215 }
216
217 let tmp = TempDir::new().expect("tempdir");
220 let PopulatedTmp {
221 root_with_git,
222 root_with_git_canon,
223 root_with_git_git_canon,
224 ..
225 } = populate_tmpdir(tmp.path());
226
227 let policy = SandboxPolicy::WorkspaceWrite {
231 writable_roots: vec![],
232 network_access: false,
233 exclude_tmpdir_env_var: false,
234 exclude_slash_tmp: false,
235 };
236
237 let args = create_seatbelt_command_args(
238 vec!["/bin/echo".to_string(), "hello".to_string()],
239 &policy,
240 root_with_git.as_path(),
241 );
242
243 let tmpdir_env_var = std::env::var("TMPDIR")
244 .ok()
245 .map(PathBuf::from)
246 .and_then(|p| p.canonicalize().ok())
247 .map(|p| p.to_string_lossy().to_string());
248
249 let tempdir_policy_entry = if tmpdir_env_var.is_some() {
250 r#" (subpath (param "WRITABLE_ROOT_2"))"#
251 } else {
252 ""
253 };
254
255 let expected_policy = format!(
261 r#"{MACOS_SEATBELT_BASE_POLICY}
262; allow read-only file operations
263(allow file-read*)
264(allow file-write*
265(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
266)
267"#,
268 );
269
270 let mut expected_args = vec![
271 "-p".to_string(),
272 expected_policy,
273 format!(
274 "-DWRITABLE_ROOT_0={}",
275 root_with_git_canon.to_string_lossy()
276 ),
277 format!(
278 "-DWRITABLE_ROOT_0_RO_0={}",
279 root_with_git_git_canon.to_string_lossy()
280 ),
281 format!(
282 "-DWRITABLE_ROOT_1={}",
283 PathBuf::from("/tmp")
284 .canonicalize()
285 .expect("canonicalize /tmp")
286 .to_string_lossy()
287 ),
288 ];
289
290 if let Some(p) = tmpdir_env_var {
291 expected_args.push(format!("-DWRITABLE_ROOT_2={p}"));
292 }
293
294 expected_args.extend(vec![
295 "--".to_string(),
296 "/bin/echo".to_string(),
297 "hello".to_string(),
298 ]);
299
300 assert_eq!(expected_args, args);
301 }
302
303 struct PopulatedTmp {
304 root_with_git: PathBuf,
305 root_without_git: PathBuf,
306 root_with_git_canon: PathBuf,
307 root_with_git_git_canon: PathBuf,
308 root_without_git_canon: PathBuf,
309 }
310
311 fn populate_tmpdir(tmp: &Path) -> PopulatedTmp {
312 let root_with_git = tmp.join("with_git");
313 let root_without_git = tmp.join("no_git");
314 fs::create_dir_all(&root_with_git).expect("create with_git");
315 fs::create_dir_all(&root_without_git).expect("create no_git");
316 fs::create_dir_all(root_with_git.join(".git")).expect("create .git");
317
318 let root_with_git_canon = root_with_git.canonicalize().expect("canonicalize with_git");
320 let root_with_git_git_canon = root_with_git_canon.join(".git");
321 let root_without_git_canon = root_without_git
322 .canonicalize()
323 .expect("canonicalize no_git");
324 PopulatedTmp {
325 root_with_git,
326 root_without_git,
327 root_with_git_canon,
328 root_with_git_git_canon,
329 root_without_git_canon,
330 }
331 }
332}