1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::action::DeletionConfig;
6
7#[derive(Debug, PartialEq, Serialize, Deserialize)]
9pub enum DestructivePattern {
10 Rm { raw: String, paths: Vec<String> },
12 PythonRemove { raw: String, paths: Vec<String> },
14 McpDelete {
16 tool_name: String,
17 paths: Vec<String>,
18 },
19 A2ADelete { paths: Vec<String> },
21}
22
23#[derive(Debug, Clone)]
24pub enum GuardError {
25 BatchTooLarge { count: usize, max: u32 },
26 PathOutOfScope { path: PathBuf },
27 WildcardRejected { path: String },
28 Other(String),
29}
30
31impl std::fmt::Display for GuardError {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 GuardError::BatchTooLarge { count, max } => {
35 write!(f, "batch size {count} exceeds max {max}")
36 }
37 GuardError::PathOutOfScope { path } => {
38 write!(f, "path outside allowed scope: {}", path.display())
39 }
40 GuardError::WildcardRejected { path } => write!(f, "wildcard pattern rejected: {path}"),
41 GuardError::Other(msg) => write!(f, "{msg}"),
42 }
43 }
44}
45
46impl DestructivePattern {
47 pub fn detect_in_shell(cmd: &str) -> Vec<DestructivePattern> {
49 let mut patterns = Vec::new();
50 let cmd_trimmed = cmd.trim();
51
52 if let Some(rest) = cmd_trimmed
53 .strip_prefix("rm ")
54 .or_else(|| cmd_trimmed.strip_prefix("/bin/rm "))
55 .or_else(|| cmd_trimmed.strip_prefix("/usr/bin/rm "))
56 {
57 let paths = extract_paths(rest);
58 patterns.push(DestructivePattern::Rm {
59 raw: cmd.to_string(),
60 paths,
61 });
62 }
63
64 if let Some(rest) = cmd_trimmed.strip_prefix("unlink ") {
65 let paths = extract_paths(rest);
66 patterns.push(DestructivePattern::Rm {
67 raw: cmd.to_string(),
68 paths,
69 });
70 }
71
72 if cmd_trimmed.contains(" /dev/null") || cmd_trimmed.contains(" /dev/null\n") {
73 patterns.push(DestructivePattern::Rm {
74 raw: cmd.to_string(),
75 paths: vec![],
76 });
77 }
78
79 patterns
80 }
81
82 pub fn detect_in_code(code: &str) -> Vec<DestructivePattern> {
84 let mut patterns = Vec::new();
85
86 for keyword in &[
87 "os.remove",
88 "os.unlink",
89 "shutil.rmtree",
90 "pathlib.Path.unlink",
91 ] {
92 if code.contains(keyword) {
93 let paths = extract_python_paths(code, keyword);
94 patterns.push(DestructivePattern::PythonRemove {
95 raw: code.to_string(),
96 paths,
97 });
98 }
99 }
100
101 patterns
102 }
103
104 pub fn contains_wildcard(&self) -> bool {
106 let paths = match self {
107 DestructivePattern::Rm { paths, .. } => paths,
108 DestructivePattern::PythonRemove { paths, .. } => paths,
109 DestructivePattern::McpDelete { paths, .. } => paths,
110 DestructivePattern::A2ADelete { paths } => paths,
111 };
112 paths.iter().any(|p| p.contains('*') || p.contains('?'))
113 }
114
115 pub fn detect_in_mcp_tool(
117 tool_name: &str,
118 arguments: &serde_json::Value,
119 ) -> Vec<DestructivePattern> {
120 let delete_tools = [
121 "delete_file",
122 "delete_files",
123 "remove_file",
124 "remove_files",
125 "fs_delete",
126 "fs.remove",
127 ];
128 if delete_tools
129 .iter()
130 .any(|t| tool_name.eq_ignore_ascii_case(t))
131 || tool_name.to_lowercase().contains("delete")
132 || tool_name.to_lowercase().contains("remove")
133 {
134 let paths = extract_json_paths(arguments);
135 return vec![DestructivePattern::McpDelete {
136 tool_name: tool_name.to_string(),
137 paths,
138 }];
139 }
140 vec![]
141 }
142}
143
144pub struct TrashGuardLogic {
146 config: DeletionConfig,
147}
148
149impl TrashGuardLogic {
150 pub fn new(config: DeletionConfig) -> Self {
151 Self { config }
152 }
153
154 pub fn check_batch_size(&self, count: usize) -> Result<(), GuardError> {
156 if count > self.config.max_batch as usize {
157 return Err(GuardError::BatchTooLarge {
158 count,
159 max: self.config.max_batch,
160 });
161 }
162 Ok(())
163 }
164
165 pub fn check_path_scope(&self, path: &Path) -> Result<(), GuardError> {
167 if self.config.trusted_paths.is_empty() {
168 return Ok(());
169 }
170 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
171 for trusted in &self.config.trusted_paths {
172 let trusted_path = PathBuf::from(trusted);
173 let trusted_canonical = trusted_path
174 .canonicalize()
175 .unwrap_or_else(|_| trusted_path.clone());
176 if canonical.starts_with(&trusted_canonical) {
177 return Ok(());
178 }
179 }
180 Err(GuardError::PathOutOfScope {
181 path: path.to_path_buf(),
182 })
183 }
184
185 pub fn detect(
187 &self,
188 tool_name: &str,
189 tool_input: &serde_json::Value,
190 ) -> Vec<DestructivePattern> {
191 let mut patterns = Vec::new();
192
193 patterns.extend(DestructivePattern::detect_in_mcp_tool(
194 tool_name, tool_input,
195 ));
196
197 if let Some(cmd) = tool_input.get("command").and_then(|v| v.as_str()) {
198 patterns.extend(DestructivePattern::detect_in_shell(cmd));
199 }
200 if let Some(code) = tool_input.get("code").and_then(|v| v.as_str()) {
201 patterns.extend(DestructivePattern::detect_in_code(code));
202 }
203
204 if let Some(path) = tool_input.get("path").and_then(|v| v.as_str())
205 && (path.contains('*') || path.contains('?'))
206 {
207 patterns.push(DestructivePattern::Rm {
208 raw: format!("delete path={path}"),
209 paths: vec![path.to_string()],
210 });
211 }
212
213 patterns
214 }
215
216 pub fn config(&self) -> &DeletionConfig {
217 &self.config
218 }
219}
220
221fn extract_paths(s: &str) -> Vec<String> {
222 s.split_whitespace()
223 .filter(|w| !w.starts_with('-') && !w.is_empty())
224 .map(|w| w.to_string())
225 .collect()
226}
227
228fn extract_python_paths(code: &str, _keyword: &str) -> Vec<String> {
229 let mut paths = Vec::new();
230 for ch in ['\'', '\"'] {
231 let pattern = format!("({ch}");
232 for part in code.split(&pattern).skip(1) {
233 if let Some(end) = part.find(ch) {
234 paths.push(part[..end].to_string());
235 }
236 }
237 }
238 paths
239}
240
241fn extract_json_paths(args: &serde_json::Value) -> Vec<String> {
242 let mut paths = Vec::new();
243 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
244 paths.push(path.to_string());
245 }
246 if let Some(paths_arr) = args.get("paths").and_then(|v| v.as_array()) {
247 for p in paths_arr {
248 if let Some(s) = p.as_str() {
249 paths.push(s.to_string());
250 }
251 }
252 }
253 if let Some(file_path) = args.get("file_path").and_then(|v| v.as_str()) {
254 paths.push(file_path.to_string());
255 }
256 paths
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn test_config() -> DeletionConfig {
264 DeletionConfig {
265 trash_enabled: true,
266 cancel_window_minutes: 10,
267 trash_retention_days: 30,
268 trash_max_mb: 1024,
269 max_batch: 50,
270 auto_permanent_delete: false,
271 trusted_paths: vec![],
272 }
273 }
274
275 #[test]
276 fn detect_shell_rm_pattern() {
277 let detections = DestructivePattern::detect_in_shell("rm -rf /tmp/foo");
278 assert!(!detections.is_empty());
279 assert!(
280 detections
281 .iter()
282 .any(|d| matches!(d, DestructivePattern::Rm { .. }))
283 );
284 }
285
286 #[test]
287 fn detect_python_os_remove() {
288 let detections = DestructivePattern::detect_in_code("os.remove('/tmp/x')");
289 assert!(!detections.is_empty());
290 }
291
292 #[test]
293 fn detect_wildcard_in_path() {
294 let detections = DestructivePattern::detect_in_shell("rm /tmp/*.txt");
295 assert!(detections.iter().any(|d| d.contains_wildcard()));
296 }
297
298 #[test]
299 fn reject_batch_above_max() {
300 let config = DeletionConfig {
301 max_batch: 5,
302 ..test_config()
303 };
304 let guard = TrashGuardLogic::new(config);
305 let result = guard.check_batch_size(10);
306 assert!(result.is_err());
307 match result.unwrap_err() {
308 GuardError::BatchTooLarge { count, max } => {
309 assert_eq!(count, 10);
310 assert_eq!(max, 5);
311 }
312 e => panic!("expected BatchTooLarge, got {e:?}"),
313 }
314 }
315
316 #[test]
317 fn allow_batch_at_or_below_max() {
318 let config = DeletionConfig {
319 max_batch: 5,
320 ..test_config()
321 };
322 let guard = TrashGuardLogic::new(config);
323 assert!(guard.check_batch_size(5).is_ok());
324 assert!(guard.check_batch_size(1).is_ok());
325 }
326
327 #[test]
328 fn path_within_allowed_scope() {
329 let config = DeletionConfig {
330 trusted_paths: vec!["/tmp/allowed/".into()],
331 ..test_config()
332 };
333 let guard = TrashGuardLogic::new(config);
334 let allowed = PathBuf::from("/tmp/allowed/test.txt");
335 assert!(guard.check_path_scope(&allowed).is_ok());
336 }
337
338 #[test]
339 fn path_outside_scope_rejected() {
340 let config = DeletionConfig {
341 trusted_paths: vec!["/tmp/allowed/".into()],
342 ..test_config()
343 };
344 let guard = TrashGuardLogic::new(config);
345 let denied = PathBuf::from("/etc/passwd");
346 assert!(guard.check_path_scope(&denied).is_err());
347 }
348}