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 push command is dangerous (force, delete, or dangerous refspec)
292fn git_push_is_dangerous(push_args: &[String]) -> bool {
293    push_args.iter().map(String::as_str).any(|arg| {
294        matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
295            || arg.starts_with("--force-with-lease=")
296            || arg.starts_with("--force-if-includes=")
297            || arg.starts_with("--delete=")
298            || short_flag_group_contains(arg, 'f')
299            || short_flag_group_contains(arg, 'd')
300            || git_push_refspec_is_dangerous(arg)
301    })
302}
303
304/// Check if a refspec is dangerous (+refspec forces updates, :refspec deletes)
305fn git_push_refspec_is_dangerous(arg: &str) -> bool {
306    // `+<refspec>` forces updates and `:<dst>` deletes remote refs.
307    (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
308}
309
310/// Check if git clean command uses force flag
311fn git_clean_is_force(clean_args: &[String]) -> bool {
312    clean_args.iter().map(String::as_str).any(|arg| {
313        matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
314    })
315}
316
317/// Git subcommands whose destructive modes are hard-blocked at preflight.
318/// Recoverable invocations of these subcommands (`git reset --soft`,
319/// `git rm --cached`, `git branch -d`) pass preflight and proceed through
320/// normal policy/approval routing, matching `exec_policy`'s `validate_git_reset`.
321const GIT_GUARDED_SUBCOMMANDS: &[&str] = &["reset", "rm", "branch", "push", "clean"];
322
323/// Only the hard reset modes lose uncommitted changes. `--soft`/`--mixed`
324/// (and a bare reset) move HEAD or the index, which the reflog restores.
325fn git_reset_is_destructive(reset_args: &[String]) -> bool {
326    reset_args
327        .iter()
328        .any(|arg| matches!(arg.as_str(), "--hard" | "--merge" | "--keep"))
329}
330
331/// `git rm --cached` only unstages paths (the working tree is untouched);
332/// every other `git rm` deletes working-tree files.
333fn git_rm_is_destructive(rm_args: &[String]) -> bool {
334    !rm_args.iter().any(|arg| arg == "--cached")
335}
336
337/// `git branch -d`/`--delete` alone refuses unmerged branches; `-D` (or
338/// `--delete` combined with `-f`/`--force`, including stacked short flags
339/// like `-dD`) overrides that guard. Delete plus force is the destructive
340/// combination.
341fn git_branch_is_force_delete(branch_args: &[String]) -> bool {
342    let mut deletes = false;
343    let mut forces = false;
344    for arg in branch_args {
345        match arg.as_str() {
346            "-D" => {
347                deletes = true;
348                forces = true;
349            }
350            "-d" | "--delete" => deletes = true,
351            "-f" | "--force" => forces = true,
352            _ => {
353                if arg.starts_with("--delete=") {
354                    deletes = true;
355                }
356                if arg.starts_with("--force=") {
357                    forces = true;
358                }
359                if short_flag_group_contains(arg, 'd') {
360                    deletes = true;
361                }
362                if short_flag_group_contains(arg, 'D') {
363                    deletes = true;
364                    forces = true;
365                }
366                if short_flag_group_contains(arg, 'f') {
367                    forces = true;
368                }
369            }
370        }
371    }
372    deletes && forces
373}
374
375/// Single home for the guarded git subcommand decision. Returns the matched
376/// destructive pattern (for actionable preflight messages), or `None` when
377/// the invocation is recoverable and must proceed through normal policy and
378/// approval routing instead of dying at preflight.
379fn classify_git_subcommand(subcommand: &str, args: &[String]) -> Option<&'static str> {
380    // `--` ends option parsing: for the option-only subcommands every later
381    // token is a path or ref name, never a flag (`git rm -- --cached f`
382    // deletes working-tree files). Classify only the pre-`--` option
383    // arguments there; destructive flags before `--` still classify, and a
384    // post-`--` token can only turn a block into a pass when it is genuinely
385    // a name. `push` is excluded: its dangerous payloads are refspecs, which
386    // legitimately occupy the post-`--` positional slot
387    // (`git push origin -- :refs/heads/x` deletes a remote branch).
388    let scan_args = if subcommand == "push" {
389        args
390    } else {
391        match args.iter().position(|arg| arg == "--") {
392            Some(end) => &args[..end],
393            None => args,
394        }
395    };
396    match subcommand {
397        "reset" if git_reset_is_destructive(scan_args) => {
398            Some("git reset --hard/--merge/--keep discards uncommitted changes; use `git stash` or `git reset --soft`")
399        }
400        "rm" if git_rm_is_destructive(scan_args) => {
401            Some("git rm deletes working-tree files; unstage with `git rm --cached` instead")
402        }
403        "branch" if git_branch_is_force_delete(scan_args) => {
404            Some("git branch -D/--delete --force skips the unmerged-branch guard; use `-d` for merged branches")
405        }
406        "push" if git_push_is_dangerous(scan_args) => Some("git push force-updates or deletes remote refs"),
407        "clean" if git_clean_is_force(scan_args) => Some("git clean --force deletes untracked files"),
408        _ => None,
409    }
410}
411
412/// Reason a command tripped [`command_might_be_dangerous`], used for
413/// actionable preflight messages. Mirrors the git arm of
414/// [`is_dangerous_to_call_with_exec`] including env/sudo prefix unwrapping;
415/// non-git patterns keep the generic rejection text.
416pub fn dangerous_command_reason(command: &[String]) -> Option<&'static str> {
417    let command = unwrap_command_prefix(command)?;
418    let cmd0 = command.first().map(String::as_str);
419    let (idx, subcommand) = if extract_command_name(cmd0.unwrap_or("")) == "git" {
420        find_git_subcommand(command, GIT_GUARDED_SUBCOMMANDS)?
421    } else {
422        // Without the git executable in front, `rm` is ambiguous with the
423        // plain Unix command — `rm -f`/`sudo rm -rf` must not receive the
424        // `git rm --cached` remedy.
425        let (idx, subcommand) = find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)?;
426        if subcommand == "rm" {
427            return None;
428        }
429        (idx, subcommand)
430    };
431    classify_git_subcommand(subcommand, &command[idx + 1..])
432}
433
434/// Check if a command is a dangerous git subcommand (without the "git" prefix)
435/// This handles commands parsed from shell scripts where the binary name may be omitted
436fn is_dangerous_git_subcommand(command: &[String]) -> bool {
437    find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)
438        .and_then(|(idx, subcommand)| classify_git_subcommand(subcommand, &command[idx + 1..]))
439        .is_some()
440}
441
442/// Find git subcommand from a list of args (without the "git" binary name)
443fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
444    let mut skip_next = false;
445    for (idx, arg) in args.iter().enumerate() {
446        if skip_next {
447            skip_next = false;
448            continue;
449        }
450
451        let arg = arg.as_str();
452
453        if is_git_global_option_with_inline_value(arg) {
454            continue;
455        }
456
457        if is_git_global_option_with_value(arg) {
458            skip_next = true;
459            continue;
460        }
461
462        if arg == "--" || arg.starts_with('-') {
463            continue;
464        }
465
466        if subcommands.contains(&arg) {
467            return Some((idx, arg));
468        }
469
470        // First non-option token that isn't a subcommand we're looking for
471        return None;
472    }
473
474    None
475}
476
477/// Core dangerous command detection for Unix/Linux/macOS
478fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
479    if command.is_empty() {
480        return false;
481    }
482
483    let cmd0 = command.first().map(String::as_str);
484    let base_cmd = extract_command_name(cmd0.unwrap_or(""));
485
486    match base_cmd {
487        // ──── Git ────
488        "git" => {
489            let Some((subcommand_idx, subcommand)) = find_git_subcommand(command, GIT_GUARDED_SUBCOMMANDS) else {
490                return false;
491            };
492
493            classify_git_subcommand(subcommand, &command[subcommand_idx + 1..]).is_some()
494        }
495
496        // ──── Rm ────
497        "rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
498
499        // ──── Destructive system commands ────
500        _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
501        "dd" | "shutdown" | "reboot" | "init" => true,
502
503        // ──── Fork bomb ────
504        _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
505
506        // ──── Sudo: check the wrapped command ────
507        "sudo" => {
508            if command.len() > 1 {
509                is_dangerous_to_call_with_exec(&command[1..])
510            } else {
511                false
512            }
513        }
514
515        // ──── Git subcommands without "git" prefix (from shell parsing) ────
516        _ => is_dangerous_git_subcommand(command),
517    }
518}
519
520/// Extract base command name from full path
521fn extract_command_name(cmd: &str) -> &str {
522    std::path::Path::new(cmd)
523        .file_name()
524        .and_then(|osstr| osstr.to_str())
525        .unwrap_or(cmd)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    fn vec_str(args: &[&str]) -> Vec<String> {
533        args.iter().map(|s| s.to_string()).collect()
534    }
535
536    #[test]
537    fn git_reset_recoverable_modes_pass_preflight() {
538        // Bare reset and --soft/--mixed only move HEAD or the index; the
539        // reflog restores them, matching exec_policy's validate_git_reset.
540        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset"])));
541        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--soft", "HEAD~1"])));
542        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--mixed", "HEAD~1"])));
543        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "HEAD~1", "--", "file.txt"])));
544    }
545
546    #[test]
547    fn git_reset_destructive_modes_still_preflight_blocked() {
548        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--hard"])));
549        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--merge"])));
550        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--keep"])));
551        // Destructive mode hidden behind global options and sudo wrappers.
552        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "-C", "sub", "reset", "--hard"])));
553        assert!(command_might_be_dangerous(&vec_str(&["sudo", "git", "reset", "--hard"])));
554        assert!(
555            command_might_be_dangerous(&vec_str(&["FOO=bar", "env", "git", "reset", "--hard"])),
556            "env-prefixed destructive reset must stay blocked"
557        );
558    }
559
560    #[test]
561    fn git_rm_index_only_passes_preflight_and_working_tree_delete_stays_blocked() {
562        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "file.txt"])));
563        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "-rf", "dir"])));
564        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--cached", "file.txt"])));
565        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "-r", "--cached", "dir"])));
566        assert!(
567            is_dangerous_to_call_with_exec(&vec_str(&["sudo", "git", "rm", "file.txt"])),
568            "sudo-wrapped working-tree delete must stay blocked"
569        );
570    }
571
572    #[test]
573    fn git_branch_force_delete_is_blocked_but_merged_delete_passes() {
574        // -d/--delete refuse unmerged branches; only the force forms are blocked.
575        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-d", "feature"])));
576        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--delete", "feature"])));
577        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-D", "feature"])));
578        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--delete", "--force", "feature"])));
579        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-d", "-f", "feature"])));
580        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-df", "feature"])));
581    }
582
583    #[test]
584    fn end_of_options_separator_cannot_hide_working_tree_git_rm() {
585        // After `--`, git treats `--cached` as a PATH, not the index-only
586        // flag: `git rm --ignore-unmatch -- --cached f` deletes working-tree
587        // files while carrying a literal `--cached` argument.
588        assert!(
589            is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--", "--cached", "file.txt"])),
590            "`--cached` after `--` is a path; the invocation deletes working-tree files"
591        );
592        assert!(
593            is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--ignore-unmatch", "--", "--cached", "file.txt"])),
594            "--ignore-unmatch must not let a missing `--cached` path mask the deletion"
595        );
596        assert!(is_dangerous_to_call_with_exec(&vec_str(&["sudo", "git", "rm", "--", "--cached", "file.txt"])));
597        // The flag itself before `--` keeps the index-only pass.
598        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--cached", "--", "file.txt"])));
599        // The rejection must keep the pattern-specific remedy.
600        let reason = dangerous_command_reason(&vec_str(&["git", "rm", "--", "--cached", "file.txt"]))
601            .expect("end-of-options git rm carries a reason");
602        assert!(reason.contains("git rm"), "reason should name the pattern: {reason}");
603    }
604
605    #[test]
606    fn end_of_options_separator_stops_flag_classification_for_other_guarded_subcommands() {
607        // Post-`--` tokens are paths/refs for the option-only subcommands, so
608        // they can never carry flag semantics.
609        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--", "--hard"])));
610        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--", "-D", "feature"])));
611        assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "clean", "--", "-fd"])));
612        // Flags before `--` still classify.
613        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--hard", "--", "file.txt"])));
614        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-D", "--", "feature"])));
615        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "clean", "-fd", "--", "dir"])));
616        // `push` scans all args: its dangerous payloads are refspecs, which
617        // legitimately occupy the post-`--` positional slot.
618        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "origin", "--", "--force"])));
619        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "origin", "--", ":refs/heads/main"])));
620        assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "--force", "--", "origin", "main"])));
621    }
622
623    #[test]
624    fn dangerous_command_reason_names_git_pattern_and_skips_safe_commands() {
625        let destructive = vec_str(&["git", "reset", "--hard"]);
626        let reason = dangerous_command_reason(&destructive).expect("git reset --hard carries a reason");
627        assert!(reason.contains("git reset"), "reason should name the pattern: {reason}");
628
629        let recoverable = vec_str(&["git", "reset", "--soft", "HEAD~1"]);
630        assert!(dangerous_command_reason(&recoverable).is_none());
631
632        let safe = vec_str(&["git", "status"]);
633        assert!(dangerous_command_reason(&safe).is_none());
634
635        let pushed = vec_str(&["sudo", "git", "rm", "file.txt"]);
636        let sudo_reason = dangerous_command_reason(&pushed).expect("sudo-wrapped git rm carries a reason");
637        assert!(sudo_reason.contains("git rm"), "sudo prefix must be unwrapped: {sudo_reason}");
638    }
639
640    #[test]
641    fn dangerous_command_reason_does_not_label_plain_rm_as_git_rm() {
642        // `rm -f` is dangerous via the plain-rm arm, not the git matcher, so
643        // it must keep the generic rejection instead of the `git rm --cached`
644        // remedy.
645        for cmd in [
646            vec_str(&["rm", "-f", "build.log"]),
647            vec_str(&["rm", "-rf", "dir"]),
648            vec_str(&["sudo", "rm", "-rf", "/tmp/x"]),
649        ] {
650            assert!(command_might_be_dangerous(&cmd), "plain rm must stay classified dangerous: {cmd:?}");
651            assert_eq!(dangerous_command_reason(&cmd), None, "plain rm must not receive the git rm remedy: {cmd:?}");
652        }
653    }
654
655    #[test]
656    fn git_status_is_safe() {
657        let cmd = vec!["git".to_string(), "status".to_string()];
658        assert!(!is_dangerous_to_call_with_exec(&cmd));
659    }
660
661    #[test]
662    fn git_log_is_safe() {
663        let cmd = vec!["git".to_string(), "log".to_string()];
664        assert!(!is_dangerous_to_call_with_exec(&cmd));
665    }
666
667    #[test]
668    fn rm_f_is_dangerous() {
669        let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
670        assert!(is_dangerous_to_call_with_exec(&cmd));
671    }
672
673    #[test]
674    fn rm_rf_is_dangerous() {
675        let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
676        assert!(is_dangerous_to_call_with_exec(&cmd));
677    }
678
679    #[test]
680    fn rm_without_flags_is_safe() {
681        let cmd = vec!["rm".to_string()];
682        assert!(!is_dangerous_to_call_with_exec(&cmd));
683    }
684
685    #[test]
686    fn mkfs_is_dangerous() {
687        let cmd = vec!["mkfs".to_string()];
688        assert!(is_dangerous_to_call_with_exec(&cmd));
689    }
690
691    #[test]
692    fn mkfs_variants_are_dangerous() {
693        let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
694        assert!(is_dangerous_to_call_with_exec(&cmd));
695    }
696
697    #[test]
698    fn dd_is_dangerous() {
699        let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
700        assert!(is_dangerous_to_call_with_exec(&cmd));
701    }
702
703    #[test]
704    fn shutdown_is_dangerous() {
705        let cmd = vec!["shutdown".to_string()];
706        assert!(is_dangerous_to_call_with_exec(&cmd));
707    }
708
709    #[test]
710    fn sudo_git_reset_is_dangerous() {
711        let cmd = vec![
712            "sudo".to_string(),
713            "git".to_string(),
714            "reset".to_string(),
715            "--hard".to_string(),
716        ];
717        assert!(is_dangerous_to_call_with_exec(&cmd));
718    }
719
720    #[test]
721    fn sudo_git_status_is_safe() {
722        let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
723        assert!(!is_dangerous_to_call_with_exec(&cmd));
724    }
725
726    #[test]
727    fn absolute_path_git_reset_hard_is_dangerous() {
728        let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string(), "--hard".to_string()];
729        assert!(is_dangerous_to_call_with_exec(&cmd));
730        // A bare reset via an absolute path is mixed-mode and recoverable.
731        let bare = vec!["/usr/bin/git".to_string(), "reset".to_string()];
732        assert!(!is_dangerous_to_call_with_exec(&bare));
733    }
734
735    #[test]
736    fn empty_command_is_safe() {
737        let cmd: Vec<String> = vec![];
738        assert!(!is_dangerous_to_call_with_exec(&cmd));
739    }
740
741    #[test]
742    fn command_might_be_dangerous_detects_git_reset_hard() {
743        let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
744        assert!(command_might_be_dangerous(&cmd));
745        // Bare reset (mixed mode) is recoverable and passes preflight.
746        let bare = vec!["git".to_string(), "reset".to_string()];
747        assert!(!command_might_be_dangerous(&bare));
748    }
749
750    #[test]
751    fn command_might_be_dangerous_allows_git_status() {
752        let cmd = vec!["git".to_string(), "status".to_string()];
753        assert!(!command_might_be_dangerous(&cmd));
754    }
755
756    #[test]
757    fn wrappers_and_absolute_executables_do_not_hide_dangerous_commands() {
758        assert!(command_might_be_dangerous(&vec_str(&[
759            "env",
760            "MODE=test",
761            "sudo",
762            "-u",
763            "root",
764            "/usr/bin/git",
765            "reset",
766            "--hard",
767        ])));
768        assert!(command_might_be_dangerous(&vec_str(&["MODE=test", "/bin/sh", "-c", "rm -rf /",])));
769    }
770
771    #[test]
772    fn inline_interpreter_programs_are_code_execution_boundaries() {
773        for command in [
774            vec_str(&["/usr/bin/python3", "-c", "print('ok')"]),
775            vec_str(&["node", "-e", "console.log('ok')"]),
776            vec_str(&["ruby", "-e", "puts 'ok'"]),
777            vec_str(&["perl", "-e", "print 'ok'"]),
778            vec_str(&["php", "-r", "echo 'ok';"]),
779            vec_str(&["osascript", "-e", "return 1"]),
780            vec_str(&["pwsh", "-Command", "Write-Output ok"]),
781        ] {
782            assert!(!command_might_be_dangerous(&command), "inline code is not forbidden outright: {command:?}");
783            assert!(command_requires_approval(&command), "inline code should require policy admission: {command:?}");
784        }
785
786        let nested = vec_str(&["bash", "-lc", "python3 -c 'print(1)'"]);
787        assert!(command_requires_approval(&nested));
788        assert!(!command_might_be_dangerous(&nested));
789    }
790
791    #[test]
792    fn dynamic_or_unknown_wrapped_executables_fail_closed() {
793        assert!(command_might_be_dangerous(&vec_str(&["$TOOL", "status"])));
794        assert!(command_might_be_dangerous(&vec_str(&["env", "--unknown", "git", "status"])));
795        assert!(command_might_be_dangerous(&vec_str(&["sudo", "--unknown", "git", "status"])));
796    }
797
798    // ──── Git Branch Delete Tests ────
799
800    #[test]
801    fn git_branch_delete_is_dangerous_only_when_forced() {
802        // -d/--delete refuse unmerged branches, so they pass preflight.
803        assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
804        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
805        // Test shell script parsing separately
806        let script = "git branch --delete --force feature";
807        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
808            for sub_cmd in sub_commands {
809                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
810            }
811        }
812    }
813
814    #[test]
815    fn git_branch_delete_with_stacked_short_flags_is_dangerous_only_when_forced() {
816        // Plain delete groups (-dv/-vd) keep the unmerged guard; groups
817        // containing D (or f) force it.
818        assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
819        assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
820        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
821        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
822        assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-df", "feature",])));
823    }
824
825    #[test]
826    fn git_branch_delete_with_global_options_is_dangerous_only_when_forced() {
827        assert!(!command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
828        assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
829        // Test shell script parsing separately
830        let script = "git -C . branch -D feature";
831        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
832            for sub_cmd in sub_commands {
833                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
834            }
835        }
836    }
837
838    #[test]
839    fn git_checkout_reset_is_not_dangerous() {
840        // The first non-option token is "checkout", so later positional args
841        // like branch names must not be treated as subcommands.
842        assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
843    }
844
845    // ──── Git Push Dangerous Tests ────
846
847    #[test]
848    fn git_push_force_is_dangerous() {
849        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
850        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
851        assert!(command_might_be_dangerous(&vec_str(&[
852            "git",
853            "-C",
854            ".",
855            "push",
856            "--force-with-lease",
857            "origin",
858            "main",
859        ])));
860    }
861
862    #[test]
863    fn git_push_plus_refspec_is_dangerous() {
864        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
865        assert!(command_might_be_dangerous(&vec_str(
866            &["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
867        )));
868    }
869
870    #[test]
871    fn git_push_delete_flag_is_dangerous() {
872        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
873        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
874    }
875
876    #[test]
877    fn git_push_delete_refspec_is_dangerous() {
878        assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
879        // Test shell script parsing separately
880        let script = "git push origin :feature";
881        if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
882            for sub_cmd in sub_commands {
883                assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
884            }
885        }
886    }
887
888    #[test]
889    fn git_push_without_force_is_not_dangerous() {
890        assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
891    }
892
893    // ──── Git Clean Tests ────
894
895    #[test]
896    fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
897        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
898        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
899        assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
900    }
901}