Skip to main content

mur_common/
guard.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::action::DeletionConfig;
6
7/// Destructive patterns the guard detects.
8#[derive(Debug, PartialEq, Serialize, Deserialize)]
9pub enum DestructivePattern {
10    /// Shell: `rm`, `unlink`, `mv ... /dev/null`
11    Rm { raw: String, paths: Vec<String> },
12    /// Python: `os.remove`, `os.unlink`, `shutil.rmtree`
13    PythonRemove { raw: String, paths: Vec<String> },
14    /// MCP: tool named `delete_file` or similar
15    McpDelete {
16        tool_name: String,
17        paths: Vec<String>,
18    },
19    /// A2A: delete intent in message
20    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    /// Scan a shell command string for destructive patterns.
48    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    /// Scan code (Python, etc.) for destructive calls.
83    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    /// Check if any path in this pattern contains a wildcard.
105    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    /// Check if pattern matches MCP delete_file tool.
116    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
144/// Pure guard logic — no I/O. Callable from Hook impl AND tests.
145pub struct TrashGuardLogic {
146    config: DeletionConfig,
147}
148
149impl TrashGuardLogic {
150    pub fn new(config: DeletionConfig) -> Self {
151        Self { config }
152    }
153
154    /// Check batch size is within limits.
155    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    /// Check a path is within allowed scope (trusted_paths or not).
166    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    /// Detect all destructive patterns in a tool call.
186    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}