agcodex_core/
safety.rs

1use std::collections::HashSet;
2use std::path::Component;
3use std::path::Path;
4use std::path::PathBuf;
5
6use agcodex_apply_patch::ApplyPatchAction;
7use agcodex_apply_patch::ApplyPatchFileChange;
8
9use crate::exec::SandboxType;
10use crate::is_safe_command::is_known_safe_command;
11use crate::protocol::AskForApproval;
12use crate::protocol::SandboxPolicy;
13
14#[derive(Debug, PartialEq)]
15pub enum SafetyCheck {
16    AutoApprove { sandbox_type: SandboxType },
17    AskUser,
18    Reject { reason: String },
19}
20
21pub fn assess_patch_safety(
22    action: &ApplyPatchAction,
23    policy: AskForApproval,
24    sandbox_policy: &SandboxPolicy,
25    cwd: &Path,
26) -> SafetyCheck {
27    if action.is_empty() {
28        return SafetyCheck::Reject {
29            reason: "empty patch".to_string(),
30        };
31    }
32
33    match policy {
34        AskForApproval::OnFailure | AskForApproval::Never | AskForApproval::OnRequest => {
35            // Continue to see if this can be auto-approved.
36        }
37        // TODO(ragona): I'm not sure this is actually correct? I believe in this case
38        // we want to continue to the writable paths check before asking the user.
39        AskForApproval::UnlessTrusted => {
40            return SafetyCheck::AskUser;
41        }
42    }
43
44    // Even though the patch *appears* to be constrained to writable paths, it
45    // is possible that paths in the patch are hard links to files outside the
46    // writable roots, so we should still run `apply_patch` in a sandbox in that
47    // case.
48    if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd)
49        || policy == AskForApproval::OnFailure
50    {
51        // Only auto‑approve when we can actually enforce a sandbox. Otherwise
52        // fall back to asking the user because the patch may touch arbitrary
53        // paths outside the project.
54        match get_platform_sandbox() {
55            Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
56            None => SafetyCheck::AskUser,
57        }
58    } else if policy == AskForApproval::Never {
59        SafetyCheck::Reject {
60            reason: "writing outside of the project; rejected by user approval settings"
61                .to_string(),
62        }
63    } else {
64        SafetyCheck::AskUser
65    }
66}
67
68/// For a command to be run _without_ a sandbox, one of the following must be
69/// true:
70///
71/// - the user has explicitly approved the command
72/// - the command is on the "known safe" list
73/// - `DangerFullAccess` was specified and `UnlessTrusted` was not
74pub fn assess_command_safety(
75    command: &[String],
76    approval_policy: AskForApproval,
77    sandbox_policy: &SandboxPolicy,
78    approved: &HashSet<Vec<String>>,
79    with_escalated_permissions: bool,
80) -> SafetyCheck {
81    // A command is "trusted" because either:
82    // - it belongs to a set of commands we consider "safe" by default, or
83    // - the user has explicitly approved the command for this session
84    //
85    // Currently, whether a command is "trusted" is a simple boolean, but we
86    // should include more metadata on this command test to indicate whether it
87    // should be run inside a sandbox or not. (This could be something the user
88    // defines as part of `execpolicy`.)
89    //
90    // For example, when `is_known_safe_command(command)` returns `true`, it
91    // would probably be fine to run the command in a sandbox, but when
92    // `approved.contains(command)` is `true`, the user may have approved it for
93    // the session _because_ they know it needs to run outside a sandbox.
94    if is_known_safe_command(command) || approved.contains(command) {
95        return SafetyCheck::AutoApprove {
96            sandbox_type: SandboxType::None,
97        };
98    }
99
100    assess_safety_for_untrusted_command(approval_policy, sandbox_policy, with_escalated_permissions)
101}
102
103pub(crate) fn assess_safety_for_untrusted_command(
104    approval_policy: AskForApproval,
105    sandbox_policy: &SandboxPolicy,
106    with_escalated_permissions: bool,
107) -> SafetyCheck {
108    use AskForApproval::*;
109    use SandboxPolicy::*;
110
111    match (approval_policy, sandbox_policy) {
112        (UnlessTrusted, _) => {
113            // Even though the user may have opted into DangerFullAccess,
114            // they also requested that we ask for approval for untrusted
115            // commands.
116            SafetyCheck::AskUser
117        }
118        (OnFailure, DangerFullAccess)
119        | (Never, DangerFullAccess)
120        | (OnRequest, DangerFullAccess) => SafetyCheck::AutoApprove {
121            sandbox_type: SandboxType::None,
122        },
123        (OnRequest, ReadOnly) | (OnRequest, WorkspaceWrite { .. }) => {
124            if with_escalated_permissions {
125                SafetyCheck::AskUser
126            } else {
127                match get_platform_sandbox() {
128                    Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
129                    // Fall back to asking since the command is untrusted and
130                    // we do not have a sandbox available
131                    None => SafetyCheck::AskUser,
132                }
133            }
134        }
135        (Never, ReadOnly)
136        | (Never, WorkspaceWrite { .. })
137        | (OnFailure, ReadOnly)
138        | (OnFailure, WorkspaceWrite { .. }) => {
139            match get_platform_sandbox() {
140                Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
141                None => {
142                    if matches!(approval_policy, OnFailure) {
143                        // Since the command is not trusted, even though the
144                        // user has requested to only ask for approval on
145                        // failure, we will ask the user because no sandbox is
146                        // available.
147                        SafetyCheck::AskUser
148                    } else {
149                        // We are in non-interactive mode and lack approval, so
150                        // all we can do is reject the command.
151                        SafetyCheck::Reject {
152                            reason: "auto-rejected because command is not on trusted list"
153                                .to_string(),
154                        }
155                    }
156                }
157            }
158        }
159    }
160}
161
162pub const fn get_platform_sandbox() -> Option<SandboxType> {
163    if cfg!(target_os = "macos") {
164        Some(SandboxType::MacosSeatbelt)
165    } else if cfg!(target_os = "linux") {
166        Some(SandboxType::LinuxSeccomp)
167    } else {
168        None
169    }
170}
171
172fn is_write_patch_constrained_to_writable_paths(
173    action: &ApplyPatchAction,
174    sandbox_policy: &SandboxPolicy,
175    cwd: &Path,
176) -> bool {
177    // Early‑exit if there are no declared writable roots.
178    let writable_roots = match sandbox_policy {
179        SandboxPolicy::ReadOnly => {
180            return false;
181        }
182        SandboxPolicy::DangerFullAccess => {
183            return true;
184        }
185        SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd),
186    };
187
188    // Normalize a path by removing `.` and resolving `..` without touching the
189    // filesystem (works even if the file does not exist).
190    fn normalize(path: &Path) -> Option<PathBuf> {
191        let mut out = PathBuf::new();
192        for comp in path.components() {
193            match comp {
194                Component::ParentDir => {
195                    out.pop();
196                }
197                Component::CurDir => { /* skip */ }
198                other => out.push(other.as_os_str()),
199            }
200        }
201        Some(out)
202    }
203
204    // Determine whether `path` is inside **any** writable root. Both `path`
205    // and roots are converted to absolute, normalized forms before the
206    // prefix check.
207    let is_path_writable = |p: &PathBuf| {
208        let abs = if p.is_absolute() {
209            p.clone()
210        } else {
211            cwd.join(p)
212        };
213        let abs = match normalize(&abs) {
214            Some(v) => v,
215            None => return false,
216        };
217
218        writable_roots
219            .iter()
220            .any(|writable_root| writable_root.is_path_writable(&abs))
221    };
222
223    for (path, change) in action.changes() {
224        match change {
225            ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete => {
226                if !is_path_writable(path) {
227                    return false;
228                }
229            }
230            ApplyPatchFileChange::Update { move_path, .. } => {
231                if !is_path_writable(path) {
232                    return false;
233                }
234                if let Some(dest) = move_path
235                    && !is_path_writable(dest)
236                {
237                    return false;
238                }
239            }
240        }
241    }
242
243    true
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use tempfile::TempDir;
250
251    #[test]
252    fn test_writable_roots_constraint() {
253        // Use a temporary directory as our workspace to avoid touching
254        // the real current working directory.
255        let tmp = TempDir::new().unwrap();
256        let cwd = tmp.path().to_path_buf();
257        let parent = cwd.parent().unwrap().to_path_buf();
258
259        // Helper to build a single‑entry patch that adds a file at `p`.
260        let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string());
261
262        let add_inside = make_add_change(cwd.join("inner.txt"));
263        let add_outside = make_add_change(parent.join("outside.txt"));
264
265        // Policy limited to the workspace only; exclude system temp roots so
266        // only `cwd` is writable by default.
267        let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
268            writable_roots: vec![],
269            network_access: false,
270            exclude_tmpdir_env_var: true,
271            exclude_slash_tmp: true,
272        };
273
274        assert!(is_write_patch_constrained_to_writable_paths(
275            &add_inside,
276            &policy_workspace_only,
277            &cwd,
278        ));
279
280        assert!(!is_write_patch_constrained_to_writable_paths(
281            &add_outside,
282            &policy_workspace_only,
283            &cwd,
284        ));
285
286        // With the parent dir explicitly added as a writable root, the
287        // outside write should be permitted.
288        let policy_with_parent = SandboxPolicy::WorkspaceWrite {
289            writable_roots: vec![parent.clone()],
290            network_access: false,
291            exclude_tmpdir_env_var: true,
292            exclude_slash_tmp: true,
293        };
294        assert!(is_write_patch_constrained_to_writable_paths(
295            &add_outside,
296            &policy_with_parent,
297            &cwd,
298        ));
299    }
300
301    #[test]
302    fn test_request_escalated_privileges() {
303        // Should not be a trusted command
304        let command = vec!["git commit".to_string()];
305        let approval_policy = AskForApproval::OnRequest;
306        let sandbox_policy = SandboxPolicy::ReadOnly;
307        let approved: HashSet<Vec<String>> = HashSet::new();
308        let request_escalated_privileges = true;
309
310        let safety_check = assess_command_safety(
311            &command,
312            approval_policy,
313            &sandbox_policy,
314            &approved,
315            request_escalated_privileges,
316        );
317
318        assert_eq!(safety_check, SafetyCheck::AskUser);
319    }
320
321    #[test]
322    fn test_request_escalated_privileges_no_sandbox_fallback() {
323        let command = vec!["git".to_string(), "commit".to_string()];
324        let approval_policy = AskForApproval::OnRequest;
325        let sandbox_policy = SandboxPolicy::ReadOnly;
326        let approved: HashSet<Vec<String>> = HashSet::new();
327        let request_escalated_privileges = false;
328
329        let safety_check = assess_command_safety(
330            &command,
331            approval_policy,
332            &sandbox_policy,
333            &approved,
334            request_escalated_privileges,
335        );
336
337        let expected = match get_platform_sandbox() {
338            Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
339            None => SafetyCheck::AskUser,
340        };
341        assert_eq!(safety_check, expected);
342    }
343}