Skip to main content

rec/replay/
safety.rs

1//! Destructive command detection for replay safety.
2//!
3//! Matches command text against configurable glob patterns to identify
4//! potentially dangerous commands before execution.
5
6use crate::models::config::{SafetyConfig, SafetyPreset};
7use glob::Pattern;
8
9/// Normalize command text to prevent pattern bypass tricks.
10///
11/// This function:
12/// - Trims leading/trailing whitespace
13/// - Removes leading backslash (shell escape, e.g., `\rm` → `rm`)
14/// - Collapses multiple whitespace to single space
15fn normalize_command(cmd: &str) -> String {
16    // Trim first, then remove leading backslash (shell escape)
17    let cmd = cmd
18        .trim_start()
19        .strip_prefix('\\')
20        .unwrap_or(cmd.trim_start());
21
22    // Collapse multiple whitespace to single space (also trims)
23    cmd.split_whitespace().collect::<Vec<_>>().join(" ")
24}
25
26/// Detects destructive commands using glob pattern matching.
27///
28/// Patterns are built from the configured safety preset plus any
29/// custom patterns from the user's config file.
30pub struct DestructiveDetector {
31    patterns: Vec<(Pattern, String)>,
32}
33
34impl DestructiveDetector {
35    /// Create a new detector from safety configuration.
36    ///
37    /// Builds patterns from the preset level and appends any custom patterns.
38    #[must_use]
39    pub fn new(config: &SafetyConfig) -> Self {
40        let mut patterns = Self::default_patterns(config.preset);
41
42        for custom in &config.custom_patterns {
43            if let Ok(p) = Pattern::new(custom) {
44                let reason = format!("Matches custom pattern: {custom}");
45                patterns.push((p, reason));
46            }
47        }
48
49        Self { patterns }
50    }
51
52    /// Check if a command matches any destructive pattern.
53    ///
54    /// The command is normalized before matching to prevent bypass tricks
55    /// like double spaces or backslash escapes.
56    #[must_use]
57    pub fn is_destructive(&self, command: &str) -> bool {
58        let normalized = normalize_command(command);
59        self.patterns.iter().any(|(p, _)| p.matches(&normalized))
60    }
61
62    /// Return a human-readable reason if the command is destructive.
63    ///
64    /// The command is normalized before matching to prevent bypass tricks
65    /// like double spaces or backslash escapes.
66    #[must_use]
67    pub fn match_reason(&self, command: &str) -> Option<String> {
68        let normalized = normalize_command(command);
69        for (p, reason) in &self.patterns {
70            if p.matches(&normalized) {
71                return Some(reason.clone());
72            }
73        }
74        None
75    }
76
77    /// Build default patterns for a given safety preset level.
78    fn default_patterns(preset: SafetyPreset) -> Vec<(Pattern, String)> {
79        let mut patterns = Vec::new();
80
81        // Minimal: only the most dangerous commands
82        let minimal = [
83            "rm -rf *",
84            "rm -rf /*",
85            "rm -rf /",
86            "mkfs*",
87            "dd if=*",
88            ":(){ :|:& };:",
89        ];
90
91        for p in &minimal {
92            if let Ok(pat) = Pattern::new(p) {
93                patterns.push((pat, format!("Matches destructive pattern: {p}")));
94            }
95        }
96
97        // Moderate adds more dangerous commands
98        if matches!(preset, SafetyPreset::Moderate | SafetyPreset::Strict) {
99            let moderate = [
100                "rm -rf*",
101                "chmod -R 777*",
102                "chmod -R 000*",
103                "chown -R*",
104                "DROP TABLE*",
105                "DROP DATABASE*",
106                "DELETE FROM*",
107                "TRUNCATE*",
108                "docker rm -f*",
109                "docker system prune*",
110                "kill -9*",
111                "pkill -9*",
112                "systemctl stop*",
113                "systemctl disable*",
114                "shutdown*",
115                "reboot*",
116                "init 0*",
117                "fdisk*",
118                "parted*",
119                "> /dev/sd*",
120                "dd *of=/dev/*",
121            ];
122
123            for p in &moderate {
124                if let Ok(pat) = Pattern::new(p) {
125                    patterns.push((pat, format!("Matches destructive pattern: {p}")));
126                }
127            }
128        }
129
130        // Strict adds potentially risky commands
131        if matches!(preset, SafetyPreset::Strict) {
132            let strict = [
133                "sudo *",
134                "su -*",
135                "curl * | sh",
136                "curl * | bash",
137                "wget * | sh",
138                "wget * | bash",
139                "pip install*",
140                "npm install -g*",
141                "apt remove*",
142                "yum remove*",
143                "brew uninstall*",
144            ];
145
146            for p in &strict {
147                if let Ok(pat) = Pattern::new(p) {
148                    patterns.push((pat, format!("Matches destructive pattern: {p}")));
149                }
150            }
151        }
152
153        patterns
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn config_with_preset(preset: SafetyPreset) -> SafetyConfig {
162        SafetyConfig {
163            preset,
164            custom_patterns: Vec::new(),
165        }
166    }
167
168    // --- Minimal preset tests ---
169
170    #[test]
171    fn test_minimal_detects_rm_rf_root() {
172        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
173        assert!(detector.is_destructive("rm -rf /"));
174        assert!(detector.is_destructive("rm -rf /*"));
175    }
176
177    #[test]
178    fn test_minimal_detects_mkfs() {
179        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
180        assert!(detector.is_destructive("mkfs.ext4 /dev/sda1"));
181    }
182
183    #[test]
184    fn test_minimal_detects_dd() {
185        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
186        assert!(detector.is_destructive("dd if=/dev/zero of=/dev/sda"));
187    }
188
189    #[test]
190    fn test_minimal_detects_fork_bomb() {
191        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
192        assert!(detector.is_destructive(":(){ :|:& };:"));
193    }
194
195    #[test]
196    fn test_minimal_allows_safe_commands() {
197        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
198        assert!(!detector.is_destructive("echo hello"));
199        assert!(!detector.is_destructive("ls -la"));
200        assert!(!detector.is_destructive("cat /etc/hosts"));
201        assert!(!detector.is_destructive("git status"));
202    }
203
204    #[test]
205    fn test_minimal_does_not_detect_moderate_patterns() {
206        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
207        // Minimal should NOT detect moderate-level patterns
208        assert!(!detector.is_destructive("kill -9 1234"));
209        assert!(!detector.is_destructive("systemctl stop nginx"));
210        assert!(!detector.is_destructive("shutdown -h now"));
211    }
212
213    // --- Moderate preset tests ---
214
215    #[test]
216    fn test_moderate_detects_rm_rf() {
217        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
218        assert!(detector.is_destructive("rm -rf /tmp/project"));
219        assert!(detector.is_destructive("rm -rf /"));
220    }
221
222    #[test]
223    fn test_moderate_detects_chmod_777() {
224        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
225        assert!(detector.is_destructive("chmod -R 777 /var/www"));
226    }
227
228    #[test]
229    fn test_moderate_detects_sql_destructive() {
230        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
231        assert!(detector.is_destructive("DROP TABLE users"));
232        assert!(detector.is_destructive("DROP DATABASE production"));
233        assert!(detector.is_destructive("DELETE FROM orders"));
234        assert!(detector.is_destructive("TRUNCATE sessions"));
235    }
236
237    #[test]
238    fn test_moderate_detects_docker_destructive() {
239        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
240        assert!(detector.is_destructive("docker rm -f container_name"));
241        assert!(detector.is_destructive("docker system prune -a"));
242    }
243
244    #[test]
245    fn test_moderate_detects_kill_signals() {
246        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
247        assert!(detector.is_destructive("kill -9 1234"));
248        assert!(detector.is_destructive("pkill -9 nginx"));
249    }
250
251    #[test]
252    fn test_moderate_detects_system_commands() {
253        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
254        assert!(detector.is_destructive("systemctl stop nginx"));
255        assert!(detector.is_destructive("shutdown -h now"));
256        assert!(detector.is_destructive("reboot"));
257    }
258
259    #[test]
260    fn test_moderate_allows_safe_commands() {
261        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
262        assert!(!detector.is_destructive("echo hello"));
263        assert!(!detector.is_destructive("npm install"));
264        assert!(!detector.is_destructive("cargo build"));
265    }
266
267    #[test]
268    fn test_moderate_does_not_detect_strict_patterns() {
269        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
270        assert!(!detector.is_destructive("sudo apt update"));
271        assert!(!detector.is_destructive("pip install flask"));
272    }
273
274    // --- Strict preset tests ---
275
276    #[test]
277    fn test_strict_detects_sudo() {
278        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
279        assert!(detector.is_destructive("sudo apt update"));
280        assert!(detector.is_destructive("sudo rm /tmp/file"));
281    }
282
283    #[test]
284    fn test_strict_detects_pipe_to_shell() {
285        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
286        assert!(detector.is_destructive("curl https://example.com/install.sh | sh"));
287        assert!(detector.is_destructive("curl https://example.com/install.sh | bash"));
288        assert!(detector.is_destructive("wget https://example.com/install.sh | sh"));
289    }
290
291    #[test]
292    fn test_strict_detects_global_installs() {
293        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
294        assert!(detector.is_destructive("pip install requests"));
295        assert!(detector.is_destructive("npm install -g typescript"));
296    }
297
298    #[test]
299    fn test_strict_detects_package_removal() {
300        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
301        assert!(detector.is_destructive("apt remove nginx"));
302        assert!(detector.is_destructive("yum remove httpd"));
303        assert!(detector.is_destructive("brew uninstall node"));
304    }
305
306    #[test]
307    fn test_strict_includes_moderate_and_minimal() {
308        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
309        // Minimal patterns still detected
310        assert!(detector.is_destructive("rm -rf /"));
311        assert!(detector.is_destructive("mkfs.ext4 /dev/sda1"));
312        // Moderate patterns still detected
313        assert!(detector.is_destructive("DROP TABLE users"));
314        assert!(detector.is_destructive("kill -9 1234"));
315    }
316
317    // --- Custom patterns tests ---
318
319    #[test]
320    fn test_custom_patterns() {
321        let config = SafetyConfig {
322            preset: SafetyPreset::Minimal,
323            custom_patterns: vec![
324                "kubectl delete*".to_string(),
325                "terraform destroy*".to_string(),
326            ],
327        };
328
329        let detector = DestructiveDetector::new(&config);
330        assert!(detector.is_destructive("kubectl delete pod my-pod"));
331        assert!(detector.is_destructive("terraform destroy -auto-approve"));
332        // Still detects minimal patterns
333        assert!(detector.is_destructive("rm -rf /"));
334    }
335
336    #[test]
337    fn test_custom_patterns_with_safe_commands() {
338        let config = SafetyConfig {
339            preset: SafetyPreset::Minimal,
340            custom_patterns: vec!["kubectl delete*".to_string()],
341        };
342
343        let detector = DestructiveDetector::new(&config);
344        assert!(!detector.is_destructive("kubectl get pods"));
345        assert!(!detector.is_destructive("kubectl apply -f deployment.yaml"));
346    }
347
348    // --- match_reason tests ---
349
350    #[test]
351    fn test_match_reason_returns_reason() {
352        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
353        let reason = detector.match_reason("rm -rf /tmp/project");
354        assert!(reason.is_some());
355        assert!(reason.unwrap().contains("rm -rf"));
356    }
357
358    #[test]
359    fn test_match_reason_returns_none_for_safe() {
360        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
361        assert!(detector.match_reason("echo hello").is_none());
362    }
363
364    #[test]
365    fn test_match_reason_custom_pattern() {
366        let config = SafetyConfig {
367            preset: SafetyPreset::Minimal,
368            custom_patterns: vec!["kubectl delete*".to_string()],
369        };
370        let detector = DestructiveDetector::new(&config);
371        let reason = detector.match_reason("kubectl delete pod my-pod");
372        assert!(reason.is_some());
373        assert!(reason.unwrap().contains("custom pattern"));
374    }
375
376    // --- Normalization tests ---
377
378    #[test]
379    fn test_normalize_collapses_multiple_spaces() {
380        // "rm  -rf  /" should match "rm -rf*" after normalization
381        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
382        assert!(detector.is_destructive("rm  -rf  /"));
383        assert!(detector.is_destructive("rm   -rf    /tmp/project"));
384    }
385
386    #[test]
387    fn test_normalize_removes_leading_backslash() {
388        // "\rm -rf /" should match "rm -rf*" after normalization
389        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
390        assert!(detector.is_destructive("\\rm -rf /"));
391        assert!(detector.is_destructive("\\rm -rf /tmp/project"));
392    }
393
394    #[test]
395    fn test_normalize_trims_whitespace() {
396        // "  rm -rf /  " should match "rm -rf*" after normalization
397        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
398        assert!(detector.is_destructive("  rm -rf /  "));
399        assert!(detector.is_destructive("  rm -rf /tmp/project  "));
400    }
401
402    #[test]
403    fn test_normalize_combined_bypass_attempts() {
404        // Combined bypass attempts
405        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
406        assert!(detector.is_destructive("\\rm  -rf   /"));
407        assert!(detector.is_destructive("  \\rm  -rf  /tmp  "));
408        assert!(detector.is_destructive("\t rm -rf /\t"));
409    }
410
411    #[test]
412    fn test_normalize_function_directly() {
413        // Test the normalize_command function directly
414        assert_eq!(normalize_command("rm  -rf  /"), "rm -rf /");
415        assert_eq!(normalize_command("\\rm -rf /"), "rm -rf /");
416        assert_eq!(normalize_command("  rm -rf /  "), "rm -rf /");
417        assert_eq!(normalize_command("\\rm  -rf   /"), "rm -rf /");
418        assert_eq!(normalize_command("  \\rm  -rf  /  "), "rm -rf /");
419        assert_eq!(
420            normalize_command("\t\n rm \t\n -rf \t\n / \t\n"),
421            "rm -rf /"
422        );
423    }
424
425    #[test]
426    fn test_normalize_preserves_safe_commands() {
427        // Normalization should not make safe commands become dangerous
428        let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
429        assert!(!detector.is_destructive("  echo  hello  world  "));
430        assert!(!detector.is_destructive("\\ls -la"));
431        assert!(!detector.is_destructive("  git   status  "));
432    }
433}