#![expect(
clippy::indexing_slicing,
reason = "Dangerous-command matching uses validated token lengths and fixed ASCII option prefixes."
)]
pub fn command_might_be_dangerous(command: &[String]) -> bool {
let Some(command) = unwrap_command_prefix(command) else {
return !command.is_empty();
};
let Some(executable) = command.first() else {
return false;
};
if executable_is_dynamic(executable) {
return true;
}
if is_encoded_powershell_invocation(command) {
return true;
}
#[cfg(windows)]
{
if crate::command_safety::windows::is_dangerous_command_windows(command) {
return true;
}
}
if is_dangerous_to_call_with_exec(command) {
return true;
}
if command.len() >= 3
&& matches!(extract_command_name(&command[0]), "bash" | "sh" | "zsh")
&& (command[1] == "-c" || command[1] == "-lc" || command[1] == "-ilc")
{
let script = &command[2];
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
for sub_cmd in sub_commands {
if command_might_be_dangerous(&sub_cmd) {
return true;
}
}
} else {
return true;
}
}
false
}
pub fn command_requires_approval(command: &[String]) -> bool {
let Some(command) = unwrap_command_prefix(command) else {
return false;
};
if is_inline_code_execution(command) {
return true;
}
if command.len() >= 3
&& matches!(extract_command_name(&command[0]), "bash" | "sh" | "zsh")
&& matches!(command[1].as_str(), "-c" | "-lc" | "-ilc")
&& let Ok(commands) = crate::command_safety::shell_parser::parse_shell_commands(&command[2])
{
return commands.iter().any(|nested| command_requires_approval(nested));
}
false
}
fn executable_is_dynamic(executable: &str) -> bool {
executable
.chars()
.any(|character| matches!(character, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}'))
}
fn is_environment_assignment(argument: &str) -> bool {
let Some((name, _value)) = argument.split_once('=') else {
return false;
};
let mut characters = name.chars();
characters
.next()
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
pub(super) fn unwrap_command_prefix(mut command: &[String]) -> Option<&[String]> {
loop {
while command.first().is_some_and(|argument| is_environment_assignment(argument)) {
command = &command[1..];
}
let executable = command.first()?;
match extract_command_name(executable) {
"env" => {
command = &command[1..];
while let Some(argument) = command.first().map(String::as_str) {
if is_environment_assignment(argument) || matches!(argument, "-i" | "--ignore-environment") {
command = &command[1..];
} else if matches!(argument, "-u" | "--unset") {
command = command.get(2..)?;
} else if argument.starts_with("--unset=") {
command = &command[1..];
} else if argument == "--" {
command = &command[1..];
break;
} else if argument.starts_with('-') {
return None;
} else {
break;
}
}
}
"sudo" => {
command = &command[1..];
while let Some(argument) = command.first().map(String::as_str) {
if argument == "--" {
command = &command[1..];
break;
}
if matches!(argument, "-u" | "--user" | "-g" | "--group" | "-h" | "--host" | "-C" | "--chdir") {
command = command.get(2..)?;
} else if matches!(argument, "-E" | "-H" | "-n" | "-S" | "-k" | "-K" | "-b")
|| argument.starts_with("--user=")
|| argument.starts_with("--group=")
|| argument.starts_with("--host=")
|| argument.starts_with("--chdir=")
{
command = &command[1..];
} else if argument.starts_with('-') {
return None;
} else {
break;
}
}
}
_ => return Some(command),
}
}
}
fn is_inline_code_execution(command: &[String]) -> bool {
let Some(executable) = command.first().map(|value| extract_command_name(value).to_ascii_lowercase()) else {
return false;
};
let arguments = &command[1..];
match executable.as_str() {
"python" | "python3" | "python.exe" | "python3.exe" => arguments.iter().any(|argument| argument == "-c"),
"node" | "node.exe" | "ruby" | "ruby.exe" | "perl" | "perl.exe" | "osascript" => {
arguments.iter().any(|argument| argument == "-e")
}
"php" | "php.exe" => arguments.iter().any(|argument| argument == "-r"),
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => arguments.iter().any(|argument| {
matches!(
argument.to_ascii_lowercase().as_str(),
"-command" | "-c" | "-encodedcommand" | "-encoded" | "-enc" | "-e"
)
}),
_ => false,
}
}
fn is_encoded_powershell_invocation(command: &[String]) -> bool {
let Some(executable) = command.first() else {
return false;
};
let executable = extract_command_name(executable).to_ascii_lowercase();
if !matches!(executable.as_str(), "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe") {
return false;
}
command.iter().skip(1).any(|argument| {
matches!(argument.to_ascii_lowercase().as_str(), "-encodedcommand" | "-encoded" | "-enc" | "-e")
})
}
fn is_git_global_option_with_value(arg: &str) -> bool {
matches!(
arg,
"-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
)
}
fn is_git_global_option_with_inline_value(arg: &str) -> bool {
matches!(
arg,
s if s.starts_with("--config-env=")
|| s.starts_with("--exec-path=")
|| s.starts_with("--git-dir=")
|| s.starts_with("--namespace=")
|| s.starts_with("--super-prefix=")
|| s.starts_with("--work-tree=")
) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
}
pub fn git_global_option_requires_prompt(arg: &str) -> bool {
matches!(
arg,
"-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
) || matches!(
arg,
s if (s.starts_with("-C") && s.len() > 2)
|| (s.starts_with("-c") && s.len() > 2)
|| s.starts_with("--config-env=")
|| s.starts_with("--exec-path=")
|| s.starts_with("--git-dir=")
|| s.starts_with("--namespace=")
|| s.starts_with("--super-prefix=")
|| s.starts_with("--work-tree=")
)
}
pub(crate) fn find_git_subcommand<'a>(command: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
let cmd0 = command.first().map(String::as_str)?;
if !cmd0.ends_with("git") {
return None;
}
let mut skip_next = false;
for (idx, arg) in command.iter().enumerate().skip(1) {
if skip_next {
skip_next = false;
continue;
}
let arg = arg.as_str();
if is_git_global_option_with_inline_value(arg) {
continue;
}
if is_git_global_option_with_value(arg) {
skip_next = true;
continue;
}
if arg == "--" || arg.starts_with('-') {
continue;
}
if subcommands.contains(&arg) {
return Some((idx, arg));
}
return None;
}
None
}
fn short_flag_group_contains(arg: &str, target: char) -> bool {
arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
}
fn git_branch_is_delete(branch_args: &[String]) -> bool {
branch_args.iter().map(String::as_str).any(|arg| {
matches!(arg, "-d" | "-D" | "--delete")
|| arg.starts_with("--delete=")
|| short_flag_group_contains(arg, 'd')
|| short_flag_group_contains(arg, 'D')
})
}
fn git_push_is_dangerous(push_args: &[String]) -> bool {
push_args.iter().map(String::as_str).any(|arg| {
matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
|| arg.starts_with("--force-with-lease=")
|| arg.starts_with("--force-if-includes=")
|| arg.starts_with("--delete=")
|| short_flag_group_contains(arg, 'f')
|| short_flag_group_contains(arg, 'd')
|| git_push_refspec_is_dangerous(arg)
})
}
fn git_push_refspec_is_dangerous(arg: &str) -> bool {
(arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
}
fn git_clean_is_force(clean_args: &[String]) -> bool {
clean_args.iter().map(String::as_str).any(|arg| {
matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
})
}
fn is_dangerous_git_subcommand(command: &[String]) -> bool {
if command.is_empty() {
return false;
}
let first_arg = command[0].as_str();
match first_arg {
"reset" | "rm" => true,
"branch" => git_branch_is_delete(&command[1..]),
"push" => git_push_is_dangerous(&command[1..]),
"clean" => git_clean_is_force(&command[1..]),
opt if opt.starts_with('-') => {
if let Some((idx, subcommand)) =
find_git_subcommand_from_args(command, &["reset", "rm", "branch", "push", "clean"])
{
match subcommand {
"reset" | "rm" => true,
"branch" => git_branch_is_delete(&command[idx + 1..]),
"push" => git_push_is_dangerous(&command[idx + 1..]),
"clean" => git_clean_is_force(&command[idx + 1..]),
_ => false,
}
} else {
false
}
}
_ => false,
}
}
fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
let mut skip_next = false;
for (idx, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}
let arg = arg.as_str();
if is_git_global_option_with_inline_value(arg) {
continue;
}
if is_git_global_option_with_value(arg) {
skip_next = true;
continue;
}
if arg == "--" || arg.starts_with('-') {
continue;
}
if subcommands.contains(&arg) {
return Some((idx, arg));
}
return None;
}
None
}
fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
if command.is_empty() {
return false;
}
let cmd0 = command.first().map(String::as_str);
let base_cmd = extract_command_name(cmd0.unwrap_or(""));
match base_cmd {
"git" => {
let Some((subcommand_idx, subcommand)) =
find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
else {
return false;
};
match subcommand {
"reset" | "rm" => true,
"branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
"push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
"clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
other => {
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
false
}
}
}
"rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
_ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
"dd" | "shutdown" | "reboot" | "init" => true,
_ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
"sudo" => {
if command.len() > 1 {
is_dangerous_to_call_with_exec(&command[1..])
} else {
false
}
}
_ => is_dangerous_git_subcommand(command),
}
}
fn extract_command_name(cmd: &str) -> &str {
std::path::Path::new(cmd)
.file_name()
.and_then(|osstr| osstr.to_str())
.unwrap_or(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
fn vec_str(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
#[test]
fn git_reset_is_dangerous() {
let cmd = vec!["git".to_string(), "reset".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn git_reset_hard_is_dangerous() {
let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn git_status_is_safe() {
let cmd = vec!["git".to_string(), "status".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn git_log_is_safe() {
let cmd = vec!["git".to_string(), "log".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn rm_f_is_dangerous() {
let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn rm_rf_is_dangerous() {
let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn rm_without_flags_is_safe() {
let cmd = vec!["rm".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn mkfs_is_dangerous() {
let cmd = vec!["mkfs".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn mkfs_variants_are_dangerous() {
let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn dd_is_dangerous() {
let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn shutdown_is_dangerous() {
let cmd = vec!["shutdown".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn sudo_git_reset_is_dangerous() {
let cmd = vec![
"sudo".to_string(),
"git".to_string(),
"reset".to_string(),
"--hard".to_string(),
];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn sudo_git_status_is_safe() {
let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn absolute_path_git_reset_is_dangerous() {
let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn empty_command_is_safe() {
let cmd: Vec<String> = vec![];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn command_might_be_dangerous_detects_git_reset() {
let cmd = vec!["git".to_string(), "reset".to_string()];
assert!(command_might_be_dangerous(&cmd));
}
#[test]
fn command_might_be_dangerous_allows_git_status() {
let cmd = vec!["git".to_string(), "status".to_string()];
assert!(!command_might_be_dangerous(&cmd));
}
#[test]
fn wrappers_and_absolute_executables_do_not_hide_dangerous_commands() {
assert!(command_might_be_dangerous(&vec_str(&[
"env",
"MODE=test",
"sudo",
"-u",
"root",
"/usr/bin/git",
"reset",
"--hard",
])));
assert!(command_might_be_dangerous(&vec_str(&["MODE=test", "/bin/sh", "-c", "rm -rf /",])));
}
#[test]
fn inline_interpreter_programs_are_code_execution_boundaries() {
for command in [
vec_str(&["/usr/bin/python3", "-c", "print('ok')"]),
vec_str(&["node", "-e", "console.log('ok')"]),
vec_str(&["ruby", "-e", "puts 'ok'"]),
vec_str(&["perl", "-e", "print 'ok'"]),
vec_str(&["php", "-r", "echo 'ok';"]),
vec_str(&["osascript", "-e", "return 1"]),
vec_str(&["pwsh", "-Command", "Write-Output ok"]),
] {
assert!(!command_might_be_dangerous(&command), "inline code is not forbidden outright: {command:?}");
assert!(command_requires_approval(&command), "inline code should require policy admission: {command:?}");
}
let nested = vec_str(&["bash", "-lc", "python3 -c 'print(1)'"]);
assert!(command_requires_approval(&nested));
assert!(!command_might_be_dangerous(&nested));
}
#[test]
fn dynamic_or_unknown_wrapped_executables_fail_closed() {
assert!(command_might_be_dangerous(&vec_str(&["$TOOL", "status"])));
assert!(command_might_be_dangerous(&vec_str(&["env", "--unknown", "git", "status"])));
assert!(command_might_be_dangerous(&vec_str(&["sudo", "--unknown", "git", "status"])));
}
#[test]
fn git_branch_delete_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
let script = "git branch --delete feature";
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
for sub_cmd in sub_commands {
assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
}
}
}
#[test]
fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
}
#[test]
fn git_branch_delete_with_global_options_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
let script = "git -C . branch -d feature";
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
for sub_cmd in sub_commands {
assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
}
}
}
#[test]
fn git_checkout_reset_is_not_dangerous() {
assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
}
#[test]
fn git_push_force_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
assert!(command_might_be_dangerous(&vec_str(&[
"git",
"-C",
".",
"push",
"--force-with-lease",
"origin",
"main",
])));
}
#[test]
fn git_push_plus_refspec_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
assert!(command_might_be_dangerous(&vec_str(
&["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
)));
}
#[test]
fn git_push_delete_flag_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
}
#[test]
fn git_push_delete_refspec_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
let script = "git push origin :feature";
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
for sub_cmd in sub_commands {
assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
}
}
}
#[test]
fn git_push_without_force_is_not_dangerous() {
assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
}
#[test]
fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
}
}