agcodex_core/
seatbelt.rs

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
13/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
14/// to defend against an attacker trying to inject a malicious version on the
15/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker
16/// already has root access.
17const 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            // Allegedly, this is more permissive than `(allow file-write*)`.
49            (
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                // Canonicalize to avoid mismatches like /var vs /private/var on macOS.
61                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                    // Add parameters for each read-only subpath and generate
72                    // the `(require-not ...)` clauses.
73                    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    // TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
106    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            // /tmp does not exist on Windows, so skip this test.
138            return;
139        }
140
141        // Create a temporary workspace with two writable roots: one containing
142        // a top-level .git directory and one without it.
143        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        // Build a policy that only includes the two test roots as writable and
154        // does not automatically include defaults TMPDIR or /tmp.
155        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        // Build the expected policy text using a raw string for readability.
169        // Note that the policy includes:
170        // - the base policy,
171        // - read-only access to the filesystem,
172        // - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
173        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            // /tmp does not exist on Windows, so skip this test.
214            return;
215        }
216
217        // Create a temporary workspace with two writable roots: one containing
218        // a top-level .git directory and one without it.
219        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        // Build a policy that does not specify any writable_roots, but does
228        // use the default ones (cwd and TMPDIR) and verifies the `.git` check
229        // is done properly for cwd.
230        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        // Build the expected policy text using a raw string for readability.
256        // Note that the policy includes:
257        // - the base policy,
258        // - read-only access to the filesystem,
259        // - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
260        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        // Ensure we have canonical paths for -D parameter matching.
319        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}