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