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