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    let Some(command) = unwrap_command_prefix(command) else {
21        return !command.is_empty();
22    };
23    let Some(executable) = command.first() else {
24        return false;
25    };
26    if executable_is_dynamic(executable) {
27        return true;
28    }
29
30    // PowerShell's encoded-command form hides the script from every
31    // platform-neutral parser. Treat it as dangerous before policy or shell
32    // evaluation can classify the base64 payload as an ordinary argument.
33    if is_encoded_powershell_invocation(command) {
34        return true;
35    }
36
37    #[cfg(windows)]
38    {
39        if crate::command_safety::windows::is_dangerous_command_windows(command) {
40            return true;
41        }
42    }
43
44    if is_dangerous_to_call_with_exec(command) {
45        return true;
46    }
47
48    // Support bash -lc "..." parsing for chained commands
49    // If the command is bash -c "..." or similar, parse the script and check each command
50    if command.len() >= 3
51        && matches!(extract_command_name(&command[0]), "bash" | "sh" | "zsh")
52        && (command[1] == "-c" || command[1] == "-lc" || command[1] == "-ilc")
53    {
54        let script = &command[2];
55        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
56            for sub_cmd in sub_commands {
57                if command_might_be_dangerous(&sub_cmd) {
58                    return true;
59                }
60            }
61        } else {
62            return true;
63        }
64    }
65
66    false
67}
68
69/// Returns whether the command crosses an inline-code boundary that must be
70/// admitted by an enforceable sandbox or explicit human approval.
71///
72/// This is deliberately separate from [`command_might_be_dangerous`]: inline
73/// interpreter programs are not forbidden outright, but their source text can
74/// perform arbitrary effects that argv-level command classification cannot
75/// prove safe.
76pub fn command_requires_approval(command: &[String]) -> bool {
77    let Some(command) = unwrap_command_prefix(command) else {
78        return false;
79    };
80    if is_inline_code_execution(command) {
81        return true;
82    }
83
84    if command.len() >= 3
85        && matches!(extract_command_name(&command[0]), "bash" | "sh" | "zsh")
86        && matches!(command[1].as_str(), "-c" | "-lc" | "-ilc")
87        && let Ok(commands) = crate::command_safety::shell_parser::parse_shell_commands(&command[2])
88    {
89        return commands.iter().any(|nested| command_requires_approval(nested));
90    }
91
92    false
93}
94
95fn executable_is_dynamic(executable: &str) -> bool {
96    executable
97        .chars()
98        .any(|character| matches!(character, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}'))
99}
100
101fn is_environment_assignment(argument: &str) -> bool {
102    let Some((name, _value)) = argument.split_once('=') else {
103        return false;
104    };
105    let mut characters = name.chars();
106    characters
107        .next()
108        .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
109        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
110}
111
112pub(super) fn unwrap_command_prefix(mut command: &[String]) -> Option<&[String]> {
113    loop {
114        while command.first().is_some_and(|argument| is_environment_assignment(argument)) {
115            command = &command[1..];
116        }
117        let executable = command.first()?;
118        match extract_command_name(executable) {
119            "env" => {
120                command = &command[1..];
121                while let Some(argument) = command.first().map(String::as_str) {
122                    if is_environment_assignment(argument) || matches!(argument, "-i" | "--ignore-environment") {
123                        command = &command[1..];
124                    } else if matches!(argument, "-u" | "--unset") {
125                        command = command.get(2..)?;
126                    } else if argument.starts_with("--unset=") {
127                        command = &command[1..];
128                    } else if argument == "--" {
129                        command = &command[1..];
130                        break;
131                    } else if argument.starts_with('-') {
132                        return None;
133                    } else {
134                        break;
135                    }
136                }
137            }
138            "sudo" => {
139                command = &command[1..];
140                while let Some(argument) = command.first().map(String::as_str) {
141                    if argument == "--" {
142                        command = &command[1..];
143                        break;
144                    }
145                    if matches!(argument, "-u" | "--user" | "-g" | "--group" | "-h" | "--host" | "-C" | "--chdir") {
146                        command = command.get(2..)?;
147                    } else if matches!(argument, "-E" | "-H" | "-n" | "-S" | "-k" | "-K" | "-b")
148                        || argument.starts_with("--user=")
149                        || argument.starts_with("--group=")
150                        || argument.starts_with("--host=")
151                        || argument.starts_with("--chdir=")
152                    {
153                        command = &command[1..];
154                    } else if argument.starts_with('-') {
155                        return None;
156                    } else {
157                        break;
158                    }
159                }
160            }
161            _ => return Some(command),
162        }
163    }
164}
165
166fn is_inline_code_execution(command: &[String]) -> bool {
167    let Some(executable) = command.first().map(|value| extract_command_name(value).to_ascii_lowercase()) else {
168        return false;
169    };
170    let arguments = &command[1..];
171    match executable.as_str() {
172        "python" | "python3" | "python.exe" | "python3.exe" => arguments.iter().any(|argument| argument == "-c"),
173        "node" | "node.exe" | "ruby" | "ruby.exe" | "perl" | "perl.exe" | "osascript" => {
174            arguments.iter().any(|argument| argument == "-e")
175        }
176        "php" | "php.exe" => arguments.iter().any(|argument| argument == "-r"),
177        "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => arguments.iter().any(|argument| {
178            matches!(
179                argument.to_ascii_lowercase().as_str(),
180                "-command" | "-c" | "-encodedcommand" | "-encoded" | "-enc" | "-e"
181            )
182        }),
183        _ => false,
184    }
185}
186
187fn is_encoded_powershell_invocation(command: &[String]) -> bool {
188    let Some(executable) = command.first() else {
189        return false;
190    };
191    let executable = extract_command_name(executable).to_ascii_lowercase();
192    if !matches!(executable.as_str(), "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe") {
193        return false;
194    }
195
196    command.iter().skip(1).any(|argument| {
197        matches!(argument.to_ascii_lowercase().as_str(), "-encodedcommand" | "-encoded" | "-enc" | "-e")
198    })
199}
200
201/// Git global options that take a value (skip these and their values when finding subcommand)
202fn is_git_global_option_with_value(arg: &str) -> bool {
203    matches!(
204        arg,
205        "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
206    )
207}
208
209/// Git global options with inline values (e.g., --git-dir=/path)
210fn is_git_global_option_with_inline_value(arg: &str) -> bool {
211    matches!(
212        arg,
213        s if s.starts_with("--config-env=")
214            || s.starts_with("--exec-path=")
215            || s.starts_with("--git-dir=")
216            || s.starts_with("--namespace=")
217        || s.starts_with("--super-prefix=")
218        || s.starts_with("--work-tree=")
219    ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
220}
221
222/// Returns whether a git global option can redirect repository, config, or
223/// helper lookup and therefore must not be treated as an inspection flag.
224pub fn git_global_option_requires_prompt(arg: &str) -> bool {
225    matches!(
226        arg,
227        "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
228    ) || matches!(
229        arg,
230        s if (s.starts_with("-C") && s.len() > 2)
231            || (s.starts_with("-c") && s.len() > 2)
232            || s.starts_with("--config-env=")
233            || s.starts_with("--exec-path=")
234            || s.starts_with("--git-dir=")
235            || s.starts_with("--namespace=")
236            || s.starts_with("--super-prefix=")
237            || s.starts_with("--work-tree=")
238    )
239}
240
241/// Find the first matching git subcommand, skipping known global options that
242/// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
243///
244/// Shared with `is_safe_command` to avoid git-global-option bypasses.
245pub(crate) fn find_git_subcommand<'a>(command: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
246    let cmd0 = command.first().map(String::as_str)?;
247    if !cmd0.ends_with("git") {
248        return None;
249    }
250
251    let mut skip_next = false;
252    for (idx, arg) in command.iter().enumerate().skip(1) {
253        if skip_next {
254            skip_next = false;
255            continue;
256        }
257
258        let arg = arg.as_str();
259
260        if is_git_global_option_with_inline_value(arg) {
261            continue;
262        }
263
264        if is_git_global_option_with_value(arg) {
265            skip_next = true;
266            continue;
267        }
268
269        if arg == "--" || arg.starts_with('-') {
270            continue;
271        }
272
273        if subcommands.contains(&arg) {
274            return Some((idx, arg));
275        }
276
277        // In git, the first non-option token is the subcommand. If it isn't
278        // one of the subcommands we're looking for, we must stop scanning to
279        // avoid misclassifying later positional args (e.g., branch names).
280        return None;
281    }
282
283    None
284}
285
286/// Check if a short flag group contains a specific character (e.g., -fdx contains 'f')
287fn short_flag_group_contains(arg: &str, target: char) -> bool {
288    arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
289}
290
291/// Check if git branch command is a delete operation
292fn git_branch_is_delete(branch_args: &[String]) -> bool {
293    // Git allows stacking short flags (for example, `-dv` or `-vd`). Treat any
294    // short-flag group containing `d`/`D` as a delete flag.
295    branch_args.iter().map(String::as_str).any(|arg| {
296        matches!(arg, "-d" | "-D" | "--delete")
297            || arg.starts_with("--delete=")
298            || short_flag_group_contains(arg, 'd')
299            || short_flag_group_contains(arg, 'D')
300    })
301}
302
303/// Check if git push command is dangerous (force, delete, or dangerous refspec)
304fn git_push_is_dangerous(push_args: &[String]) -> bool {
305    push_args.iter().map(String::as_str).any(|arg| {
306        matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
307            || arg.starts_with("--force-with-lease=")
308            || arg.starts_with("--force-if-includes=")
309            || arg.starts_with("--delete=")
310            || short_flag_group_contains(arg, 'f')
311            || short_flag_group_contains(arg, 'd')
312            || git_push_refspec_is_dangerous(arg)
313    })
314}
315
316/// Check if a refspec is dangerous (+refspec forces updates, :refspec deletes)
317fn git_push_refspec_is_dangerous(arg: &str) -> bool {
318    // `+<refspec>` forces updates and `:<dst>` deletes remote refs.
319    (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
320}
321
322/// Check if git clean command uses force flag
323fn git_clean_is_force(clean_args: &[String]) -> bool {
324    clean_args.iter().map(String::as_str).any(|arg| {
325        matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
326    })
327}
328
329/// Check if a command is a dangerous git subcommand (without the "git" prefix)
330/// This handles commands parsed from shell scripts where the binary name may be omitted
331fn is_dangerous_git_subcommand(command: &[String]) -> bool {
332    if command.is_empty() {
333        return false;
334    }
335
336    let first_arg = command[0].as_str();
337
338    // Check if first arg is a git subcommand
339    match first_arg {
340        "reset" | "rm" => true,
341        "branch" => git_branch_is_delete(&command[1..]),
342        "push" => git_push_is_dangerous(&command[1..]),
343        "clean" => git_clean_is_force(&command[1..]),
344        // Handle global options that appear before subcommand (e.g., -C, -c)
345        // These would be from shell parser extracting partial commands
346        opt if opt.starts_with('-') => {
347            // Try to find the subcommand after global options
348            if let Some((idx, subcommand)) =
349                find_git_subcommand_from_args(command, &["reset", "rm", "branch", "push", "clean"])
350            {
351                match subcommand {
352                    "reset" | "rm" => true,
353                    "branch" => git_branch_is_delete(&command[idx + 1..]),
354                    "push" => git_push_is_dangerous(&command[idx + 1..]),
355                    "clean" => git_clean_is_force(&command[idx + 1..]),
356                    _ => false,
357                }
358            } else {
359                false
360            }
361        }
362        _ => false,
363    }
364}
365
366/// Find git subcommand from a list of args (without the "git" binary name)
367fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
368    let mut skip_next = false;
369    for (idx, arg) in args.iter().enumerate() {
370        if skip_next {
371            skip_next = false;
372            continue;
373        }
374
375        let arg = arg.as_str();
376
377        if is_git_global_option_with_inline_value(arg) {
378            continue;
379        }
380
381        if is_git_global_option_with_value(arg) {
382            skip_next = true;
383            continue;
384        }
385
386        if arg == "--" || arg.starts_with('-') {
387            continue;
388        }
389
390        if subcommands.contains(&arg) {
391            return Some((idx, arg));
392        }
393
394        // First non-option token that isn't a subcommand we're looking for
395        return None;
396    }
397
398    None
399}
400
401/// Core dangerous command detection for Unix/Linux/macOS
402fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
403    if command.is_empty() {
404        return false;
405    }
406
407    let cmd0 = command.first().map(String::as_str);
408    let base_cmd = extract_command_name(cmd0.unwrap_or(""));
409
410    match base_cmd {
411        // ──── Git ────
412        "git" => {
413            let Some((subcommand_idx, subcommand)) =
414                find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
415            else {
416                return false;
417            };
418
419            match subcommand {
420                "reset" | "rm" => true,
421                "branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
422                "push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
423                "clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
424                other => {
425                    debug_assert!(false, "unexpected git subcommand from matcher: {other}");
426                    false
427                }
428            }
429        }
430
431        // ──── Rm ────
432        "rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
433
434        // ──── Destructive system commands ────
435        _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
436        "dd" | "shutdown" | "reboot" | "init" => true,
437
438        // ──── Fork bomb ────
439        _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
440
441        // ──── Sudo: check the wrapped command ────
442        "sudo" => {
443            if command.len() > 1 {
444                is_dangerous_to_call_with_exec(&command[1..])
445            } else {
446                false
447            }
448        }
449
450        // ──── Git subcommands without "git" prefix (from shell parsing) ────
451        _ => is_dangerous_git_subcommand(command),
452    }
453}
454
455/// Extract base command name from full path
456fn extract_command_name(cmd: &str) -> &str {
457    std::path::Path::new(cmd)
458        .file_name()
459        .and_then(|osstr| osstr.to_str())
460        .unwrap_or(cmd)
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    fn vec_str(args: &[&str]) -> Vec<String> {
468        args.iter().map(|s| s.to_string()).collect()
469    }
470
471    #[test]
472    fn git_reset_is_dangerous() {
473        let cmd = vec!["git".to_string(), "reset".to_string()];
474        assert!(is_dangerous_to_call_with_exec(&cmd));
475    }
476
477    #[test]
478    fn git_reset_hard_is_dangerous() {
479        let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
480        assert!(is_dangerous_to_call_with_exec(&cmd));
481    }
482
483    #[test]
484    fn git_status_is_safe() {
485        let cmd = vec!["git".to_string(), "status".to_string()];
486        assert!(!is_dangerous_to_call_with_exec(&cmd));
487    }
488
489    #[test]
490    fn git_log_is_safe() {
491        let cmd = vec!["git".to_string(), "log".to_string()];
492        assert!(!is_dangerous_to_call_with_exec(&cmd));
493    }
494
495    #[test]
496    fn rm_f_is_dangerous() {
497        let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
498        assert!(is_dangerous_to_call_with_exec(&cmd));
499    }
500
501    #[test]
502    fn rm_rf_is_dangerous() {
503        let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
504        assert!(is_dangerous_to_call_with_exec(&cmd));
505    }
506
507    #[test]
508    fn rm_without_flags_is_safe() {
509        let cmd = vec!["rm".to_string()];
510        assert!(!is_dangerous_to_call_with_exec(&cmd));
511    }
512
513    #[test]
514    fn mkfs_is_dangerous() {
515        let cmd = vec!["mkfs".to_string()];
516        assert!(is_dangerous_to_call_with_exec(&cmd));
517    }
518
519    #[test]
520    fn mkfs_variants_are_dangerous() {
521        let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
522        assert!(is_dangerous_to_call_with_exec(&cmd));
523    }
524
525    #[test]
526    fn dd_is_dangerous() {
527        let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
528        assert!(is_dangerous_to_call_with_exec(&cmd));
529    }
530
531    #[test]
532    fn shutdown_is_dangerous() {
533        let cmd = vec!["shutdown".to_string()];
534        assert!(is_dangerous_to_call_with_exec(&cmd));
535    }
536
537    #[test]
538    fn sudo_git_reset_is_dangerous() {
539        let cmd = vec![
540            "sudo".to_string(),
541            "git".to_string(),
542            "reset".to_string(),
543            "--hard".to_string(),
544        ];
545        assert!(is_dangerous_to_call_with_exec(&cmd));
546    }
547
548    #[test]
549    fn sudo_git_status_is_safe() {
550        let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
551        assert!(!is_dangerous_to_call_with_exec(&cmd));
552    }
553
554    #[test]
555    fn absolute_path_git_reset_is_dangerous() {
556        let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string()];
557        assert!(is_dangerous_to_call_with_exec(&cmd));
558    }
559
560    #[test]
561    fn empty_command_is_safe() {
562        let cmd: Vec<String> = vec![];
563        assert!(!is_dangerous_to_call_with_exec(&cmd));
564    }
565
566    #[test]
567    fn command_might_be_dangerous_detects_git_reset() {
568        let cmd = vec!["git".to_string(), "reset".to_string()];
569        assert!(command_might_be_dangerous(&cmd));
570    }
571
572    #[test]
573    fn command_might_be_dangerous_allows_git_status() {
574        let cmd = vec!["git".to_string(), "status".to_string()];
575        assert!(!command_might_be_dangerous(&cmd));
576    }
577
578    #[test]
579    fn wrappers_and_absolute_executables_do_not_hide_dangerous_commands() {
580        assert!(command_might_be_dangerous(&vec_str(&[
581            "env",
582            "MODE=test",
583            "sudo",
584            "-u",
585            "root",
586            "/usr/bin/git",
587            "reset",
588            "--hard",
589        ])));
590        assert!(command_might_be_dangerous(&vec_str(&["MODE=test", "/bin/sh", "-c", "rm -rf /",])));
591    }
592
593    #[test]
594    fn inline_interpreter_programs_are_code_execution_boundaries() {
595        for command in [
596            vec_str(&["/usr/bin/python3", "-c", "print('ok')"]),
597            vec_str(&["node", "-e", "console.log('ok')"]),
598            vec_str(&["ruby", "-e", "puts 'ok'"]),
599            vec_str(&["perl", "-e", "print 'ok'"]),
600            vec_str(&["php", "-r", "echo 'ok';"]),
601            vec_str(&["osascript", "-e", "return 1"]),
602            vec_str(&["pwsh", "-Command", "Write-Output ok"]),
603        ] {
604            assert!(!command_might_be_dangerous(&command), "inline code is not forbidden outright: {command:?}");
605            assert!(command_requires_approval(&command), "inline code should require policy admission: {command:?}");
606        }
607
608        let nested = vec_str(&["bash", "-lc", "python3 -c 'print(1)'"]);
609        assert!(command_requires_approval(&nested));
610        assert!(!command_might_be_dangerous(&nested));
611    }
612
613    #[test]
614    fn dynamic_or_unknown_wrapped_executables_fail_closed() {
615        assert!(command_might_be_dangerous(&vec_str(&["$TOOL", "status"])));
616        assert!(command_might_be_dangerous(&vec_str(&["env", "--unknown", "git", "status"])));
617        assert!(command_might_be_dangerous(&vec_str(&["sudo", "--unknown", "git", "status"])));
618    }
619
620    // ──── Git Branch Delete Tests ────
621
622    #[test]
623    fn git_branch_delete_is_dangerous() {
624        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
625        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
626        // Test shell script parsing separately
627        let script = "git branch --delete feature";
628        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
629            for sub_cmd in sub_commands {
630                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
631            }
632        }
633    }
634
635    #[test]
636    fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
637        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
638        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
639        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
640        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
641    }
642
643    #[test]
644    fn git_branch_delete_with_global_options_is_dangerous() {
645        assert!(command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
646        assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
647        // Test shell script parsing separately
648        let script = "git -C . branch -d feature";
649        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
650            for sub_cmd in sub_commands {
651                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
652            }
653        }
654    }
655
656    #[test]
657    fn git_checkout_reset_is_not_dangerous() {
658        // The first non-option token is "checkout", so later positional args
659        // like branch names must not be treated as subcommands.
660        assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
661    }
662
663    // ──── Git Push Dangerous Tests ────
664
665    #[test]
666    fn git_push_force_is_dangerous() {
667        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
668        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
669        assert!(command_might_be_dangerous(&vec_str(&[
670            "git",
671            "-C",
672            ".",
673            "push",
674            "--force-with-lease",
675            "origin",
676            "main",
677        ])));
678    }
679
680    #[test]
681    fn git_push_plus_refspec_is_dangerous() {
682        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
683        assert!(command_might_be_dangerous(&vec_str(
684            &["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
685        )));
686    }
687
688    #[test]
689    fn git_push_delete_flag_is_dangerous() {
690        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
691        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
692    }
693
694    #[test]
695    fn git_push_delete_refspec_is_dangerous() {
696        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
697        // Test shell script parsing separately
698        let script = "git push origin :feature";
699        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
700            for sub_cmd in sub_commands {
701                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
702            }
703        }
704    }
705
706    #[test]
707    fn git_push_without_force_is_not_dangerous() {
708        assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
709    }
710
711    // ──── Git Clean Tests ────
712
713    #[test]
714    fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
715        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
716        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
717        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
718    }
719}