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