Skip to main content

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