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 }
37 AskForApproval::UnlessTrusted => {
40 return SafetyCheck::AskUser;
41 }
42 }
43
44 if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd)
49 || policy == AskForApproval::OnFailure
50 {
51 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
68pub 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 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 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 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 SafetyCheck::AskUser
148 } else {
149 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 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 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 => { }
198 other => out.push(other.as_os_str()),
199 }
200 }
201 Some(out)
202 }
203
204 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 let tmp = TempDir::new().unwrap();
256 let cwd = tmp.path().to_path_buf();
257 let parent = cwd.parent().unwrap().to_path_buf();
258
259 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 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 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 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}