Skip to main content

vtcode_core/command_safety/
dangerous_commands.rs

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