Skip to main content

vtcode_safety/command_safety/
dangerous_commands.rs

1#![expect(
2    clippy::indexing_slicing,
3    reason = "Dangerous-command matching uses validated token lengths and fixed ASCII option prefixes."
4)]
5
6//! Detection of dangerous commands that should never be executed.
7//!
8//! This module implements hardcoded detection for commands that are inherently
9//! destructive or dangerous, regardless of their options.
10//!
11//! Examples:
12//! - `rm -rf /` (destructive)
13//! - `git reset --hard` (destructive)
14//! - `dd if=/dev/zero of=/dev/sda` (very destructive)
15//! - `sudo rm` (privilege escalation + destruction)
16
17/// Checks if a command appears dangerous to execute.
18/// Returns true if the command should be blocked before execution.
19pub fn command_might_be_dangerous(command: &[String]) -> bool {
20    #[cfg(windows)]
21    {
22        if crate::command_safety::windows::is_dangerous_command_windows(command) {
23            return true;
24        }
25    }
26
27    if is_dangerous_to_call_with_exec(command) {
28        return true;
29    }
30
31    // Support bash -lc "..." parsing for chained commands
32    // If the command is bash -c "..." or similar, parse the script and check each command
33    if command.len() >= 3
34        && (command[0] == "bash" || command[0] == "sh" || command[0] == "zsh")
35        && (command[1] == "-c" || command[1] == "-lc" || command[1] == "-ilc")
36    {
37        let script = &command[2];
38        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
39            for sub_cmd in sub_commands {
40                if command_might_be_dangerous(&sub_cmd) {
41                    return true;
42                }
43            }
44        }
45    }
46
47    false
48}
49
50/// Git global options that take a value (skip these and their values when finding subcommand)
51fn is_git_global_option_with_value(arg: &str) -> bool {
52    matches!(
53        arg,
54        "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
55    )
56}
57
58/// Git global options with inline values (e.g., --git-dir=/path)
59fn is_git_global_option_with_inline_value(arg: &str) -> bool {
60    matches!(
61        arg,
62        s if s.starts_with("--config-env=")
63            || s.starts_with("--exec-path=")
64            || s.starts_with("--git-dir=")
65            || s.starts_with("--namespace=")
66        || s.starts_with("--super-prefix=")
67        || s.starts_with("--work-tree=")
68    ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
69}
70
71/// Git global options that can redirect config, repository, or helper lookup
72/// and therefore must never be auto-approved as "safe".
73pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool {
74    matches!(
75        arg,
76        "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
77    ) || matches!(
78        arg,
79        s if (s.starts_with("-c") && s.len() > 2)
80            || s.starts_with("--config-env=")
81            || s.starts_with("--exec-path=")
82            || s.starts_with("--git-dir=")
83            || s.starts_with("--namespace=")
84            || s.starts_with("--super-prefix=")
85            || s.starts_with("--work-tree=")
86    )
87}
88
89/// Find the first matching git subcommand, skipping known global options that
90/// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
91///
92/// Shared with `is_safe_command` to avoid git-global-option bypasses.
93pub(crate) fn find_git_subcommand<'a>(command: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
94    let cmd0 = command.first().map(String::as_str)?;
95    if !cmd0.ends_with("git") {
96        return None;
97    }
98
99    let mut skip_next = false;
100    for (idx, arg) in command.iter().enumerate().skip(1) {
101        if skip_next {
102            skip_next = false;
103            continue;
104        }
105
106        let arg = arg.as_str();
107
108        if is_git_global_option_with_inline_value(arg) {
109            continue;
110        }
111
112        if is_git_global_option_with_value(arg) {
113            skip_next = true;
114            continue;
115        }
116
117        if arg == "--" || arg.starts_with('-') {
118            continue;
119        }
120
121        if subcommands.contains(&arg) {
122            return Some((idx, arg));
123        }
124
125        // In git, the first non-option token is the subcommand. If it isn't
126        // one of the subcommands we're looking for, we must stop scanning to
127        // avoid misclassifying later positional args (e.g., branch names).
128        return None;
129    }
130
131    None
132}
133
134/// Check if a short flag group contains a specific character (e.g., -fdx contains 'f')
135fn short_flag_group_contains(arg: &str, target: char) -> bool {
136    arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
137}
138
139/// Check if git branch command is a delete operation
140fn git_branch_is_delete(branch_args: &[String]) -> bool {
141    // Git allows stacking short flags (for example, `-dv` or `-vd`). Treat any
142    // short-flag group containing `d`/`D` as a delete flag.
143    branch_args.iter().map(String::as_str).any(|arg| {
144        matches!(arg, "-d" | "-D" | "--delete")
145            || arg.starts_with("--delete=")
146            || short_flag_group_contains(arg, 'd')
147            || short_flag_group_contains(arg, 'D')
148    })
149}
150
151/// Check if git push command is dangerous (force, delete, or dangerous refspec)
152fn git_push_is_dangerous(push_args: &[String]) -> bool {
153    push_args.iter().map(String::as_str).any(|arg| {
154        matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
155            || arg.starts_with("--force-with-lease=")
156            || arg.starts_with("--force-if-includes=")
157            || arg.starts_with("--delete=")
158            || short_flag_group_contains(arg, 'f')
159            || short_flag_group_contains(arg, 'd')
160            || git_push_refspec_is_dangerous(arg)
161    })
162}
163
164/// Check if a refspec is dangerous (+refspec forces updates, :refspec deletes)
165fn git_push_refspec_is_dangerous(arg: &str) -> bool {
166    // `+<refspec>` forces updates and `:<dst>` deletes remote refs.
167    (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
168}
169
170/// Check if git clean command uses force flag
171fn git_clean_is_force(clean_args: &[String]) -> bool {
172    clean_args.iter().map(String::as_str).any(|arg| {
173        matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
174    })
175}
176
177/// Check if a command is a dangerous git subcommand (without the "git" prefix)
178/// This handles commands parsed from shell scripts where the binary name may be omitted
179fn is_dangerous_git_subcommand(command: &[String]) -> bool {
180    if command.is_empty() {
181        return false;
182    }
183
184    let first_arg = command[0].as_str();
185
186    // Check if first arg is a git subcommand
187    match first_arg {
188        "reset" | "rm" => true,
189        "branch" => git_branch_is_delete(&command[1..]),
190        "push" => git_push_is_dangerous(&command[1..]),
191        "clean" => git_clean_is_force(&command[1..]),
192        // Handle global options that appear before subcommand (e.g., -C, -c)
193        // These would be from shell parser extracting partial commands
194        opt if opt.starts_with('-') => {
195            // Try to find the subcommand after global options
196            if let Some((idx, subcommand)) =
197                find_git_subcommand_from_args(command, &["reset", "rm", "branch", "push", "clean"])
198            {
199                match subcommand {
200                    "reset" | "rm" => true,
201                    "branch" => git_branch_is_delete(&command[idx + 1..]),
202                    "push" => git_push_is_dangerous(&command[idx + 1..]),
203                    "clean" => git_clean_is_force(&command[idx + 1..]),
204                    _ => false,
205                }
206            } else {
207                false
208            }
209        }
210        _ => false,
211    }
212}
213
214/// Find git subcommand from a list of args (without the "git" binary name)
215fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
216    let mut skip_next = false;
217    for (idx, arg) in args.iter().enumerate() {
218        if skip_next {
219            skip_next = false;
220            continue;
221        }
222
223        let arg = arg.as_str();
224
225        if is_git_global_option_with_inline_value(arg) {
226            continue;
227        }
228
229        if is_git_global_option_with_value(arg) {
230            skip_next = true;
231            continue;
232        }
233
234        if arg == "--" || arg.starts_with('-') {
235            continue;
236        }
237
238        if subcommands.contains(&arg) {
239            return Some((idx, arg));
240        }
241
242        // First non-option token that isn't a subcommand we're looking for
243        return None;
244    }
245
246    None
247}
248
249/// Core dangerous command detection for Unix/Linux/macOS
250fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
251    if command.is_empty() {
252        return false;
253    }
254
255    let cmd0 = command.first().map(String::as_str);
256    let base_cmd = extract_command_name(cmd0.unwrap_or(""));
257
258    match base_cmd {
259        // ──── Git ────
260        "git" => {
261            let Some((subcommand_idx, subcommand)) =
262                find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
263            else {
264                return false;
265            };
266
267            match subcommand {
268                "reset" | "rm" => true,
269                "branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
270                "push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
271                "clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
272                other => {
273                    debug_assert!(false, "unexpected git subcommand from matcher: {other}");
274                    false
275                }
276            }
277        }
278
279        // ──── Rm ────
280        "rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
281
282        // ──── Destructive system commands ────
283        _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
284        "dd" | "shutdown" | "reboot" | "init" => true,
285
286        // ──── Fork bomb ────
287        _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
288
289        // ──── Sudo: check the wrapped command ────
290        "sudo" => {
291            if command.len() > 1 {
292                is_dangerous_to_call_with_exec(&command[1..])
293            } else {
294                false
295            }
296        }
297
298        // ──── Git subcommands without "git" prefix (from shell parsing) ────
299        _ => is_dangerous_git_subcommand(command),
300    }
301}
302
303/// Extract base command name from full path
304fn extract_command_name(cmd: &str) -> &str {
305    std::path::Path::new(cmd)
306        .file_name()
307        .and_then(|osstr| osstr.to_str())
308        .unwrap_or(cmd)
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn vec_str(args: &[&str]) -> Vec<String> {
316        args.iter().map(|s| s.to_string()).collect()
317    }
318
319    #[test]
320    fn git_reset_is_dangerous() {
321        let cmd = vec!["git".to_string(), "reset".to_string()];
322        assert!(is_dangerous_to_call_with_exec(&cmd));
323    }
324
325    #[test]
326    fn git_reset_hard_is_dangerous() {
327        let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
328        assert!(is_dangerous_to_call_with_exec(&cmd));
329    }
330
331    #[test]
332    fn git_status_is_safe() {
333        let cmd = vec!["git".to_string(), "status".to_string()];
334        assert!(!is_dangerous_to_call_with_exec(&cmd));
335    }
336
337    #[test]
338    fn git_log_is_safe() {
339        let cmd = vec!["git".to_string(), "log".to_string()];
340        assert!(!is_dangerous_to_call_with_exec(&cmd));
341    }
342
343    #[test]
344    fn rm_f_is_dangerous() {
345        let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
346        assert!(is_dangerous_to_call_with_exec(&cmd));
347    }
348
349    #[test]
350    fn rm_rf_is_dangerous() {
351        let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
352        assert!(is_dangerous_to_call_with_exec(&cmd));
353    }
354
355    #[test]
356    fn rm_without_flags_is_safe() {
357        let cmd = vec!["rm".to_string()];
358        assert!(!is_dangerous_to_call_with_exec(&cmd));
359    }
360
361    #[test]
362    fn mkfs_is_dangerous() {
363        let cmd = vec!["mkfs".to_string()];
364        assert!(is_dangerous_to_call_with_exec(&cmd));
365    }
366
367    #[test]
368    fn mkfs_variants_are_dangerous() {
369        let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
370        assert!(is_dangerous_to_call_with_exec(&cmd));
371    }
372
373    #[test]
374    fn dd_is_dangerous() {
375        let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
376        assert!(is_dangerous_to_call_with_exec(&cmd));
377    }
378
379    #[test]
380    fn shutdown_is_dangerous() {
381        let cmd = vec!["shutdown".to_string()];
382        assert!(is_dangerous_to_call_with_exec(&cmd));
383    }
384
385    #[test]
386    fn sudo_git_reset_is_dangerous() {
387        let cmd = vec![
388            "sudo".to_string(),
389            "git".to_string(),
390            "reset".to_string(),
391            "--hard".to_string(),
392        ];
393        assert!(is_dangerous_to_call_with_exec(&cmd));
394    }
395
396    #[test]
397    fn sudo_git_status_is_safe() {
398        let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
399        assert!(!is_dangerous_to_call_with_exec(&cmd));
400    }
401
402    #[test]
403    fn absolute_path_git_reset_is_dangerous() {
404        let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string()];
405        assert!(is_dangerous_to_call_with_exec(&cmd));
406    }
407
408    #[test]
409    fn empty_command_is_safe() {
410        let cmd: Vec<String> = vec![];
411        assert!(!is_dangerous_to_call_with_exec(&cmd));
412    }
413
414    #[test]
415    fn command_might_be_dangerous_detects_git_reset() {
416        let cmd = vec!["git".to_string(), "reset".to_string()];
417        assert!(command_might_be_dangerous(&cmd));
418    }
419
420    #[test]
421    fn command_might_be_dangerous_allows_git_status() {
422        let cmd = vec!["git".to_string(), "status".to_string()];
423        assert!(!command_might_be_dangerous(&cmd));
424    }
425
426    // ──── Git Branch Delete Tests ────
427
428    #[test]
429    fn git_branch_delete_is_dangerous() {
430        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
431        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
432        // Test shell script parsing separately
433        let script = "git branch --delete feature";
434        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
435            for sub_cmd in sub_commands {
436                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
437            }
438        }
439    }
440
441    #[test]
442    fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
443        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
444        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
445        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
446        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
447    }
448
449    #[test]
450    fn git_branch_delete_with_global_options_is_dangerous() {
451        assert!(command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
452        assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
453        // Test shell script parsing separately
454        let script = "git -C . branch -d feature";
455        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
456            for sub_cmd in sub_commands {
457                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
458            }
459        }
460    }
461
462    #[test]
463    fn git_checkout_reset_is_not_dangerous() {
464        // The first non-option token is "checkout", so later positional args
465        // like branch names must not be treated as subcommands.
466        assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
467    }
468
469    // ──── Git Push Dangerous Tests ────
470
471    #[test]
472    fn git_push_force_is_dangerous() {
473        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
474        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
475        assert!(command_might_be_dangerous(&vec_str(&[
476            "git",
477            "-C",
478            ".",
479            "push",
480            "--force-with-lease",
481            "origin",
482            "main",
483        ])));
484    }
485
486    #[test]
487    fn git_push_plus_refspec_is_dangerous() {
488        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
489        assert!(command_might_be_dangerous(&vec_str(
490            &["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
491        )));
492    }
493
494    #[test]
495    fn git_push_delete_flag_is_dangerous() {
496        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
497        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
498    }
499
500    #[test]
501    fn git_push_delete_refspec_is_dangerous() {
502        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
503        // Test shell script parsing separately
504        let script = "git push origin :feature";
505        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
506            for sub_cmd in sub_commands {
507                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
508            }
509        }
510    }
511
512    #[test]
513    fn git_push_without_force_is_not_dangerous() {
514        assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
515    }
516
517    // ──── Git Clean Tests ────
518
519    #[test]
520    fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
521        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
522        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
523        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
524    }
525}