#![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 let Some(script) = nested_shell_script(command) {
let script = strip_nested_script_quotes(script);
if super::contains_command_substitution(script) {
return true;
}
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
}
const NESTED_SCRIPT_FLAGS: &[&str] = &["-c", "-lc", "-ilc"];
pub(super) fn nested_shell_script(command: &[String]) -> Option<&str> {
match command {
[executable, flag, script, ..]
if matches!(extract_command_name(executable), "bash" | "sh" | "zsh")
&& NESTED_SCRIPT_FLAGS.contains(&flag.as_str()) =>
{
Some(script)
}
_ => None,
}
}
pub(super) fn strip_nested_script_quotes(script: &str) -> &str {
for quote in ['\'', '"'] {
if let Some(inner) = script.strip_prefix(quote).and_then(|rest| rest.strip_suffix(quote)) {
return inner;
}
}
script
}
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 let Some(script) = nested_shell_script(command)
&& let Ok(commands) =
crate::command_safety::shell_parser::parse_shell_commands(strip_nested_script_quotes(script))
{
return commands.iter().any(|nested| command_requires_approval(nested));
}
false
}
fn executable_is_dynamic(executable: &str) -> bool {
if executable == "[" {
return false;
}
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) struct UnwrappedCommand<'a> {
pub(super) command: &'a [String],
pub(super) changes_directory: bool,
}
const PROGRAM_RUNNERS: &[&str] = &["builtin", "command", "exec", "nohup", "setsid", "time"];
const LOOKUP_ONLY_OPTIONS: &[&str] = &["-p", "-v", "-V"];
pub(super) fn unwrap_command_prefix(command: &[String]) -> Option<&[String]> {
unwrap_command_prefix_with_context(command).map(|unwrapped| unwrapped.command)
}
pub(super) fn unwrap_command_prefix_with_context(mut command: &[String]) -> Option<UnwrappedCommand<'_>> {
let mut changes_directory = false;
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") {
changes_directory |= argument == "--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=")
{
changes_directory |= argument.starts_with("--chdir=");
command = &command[1..];
} else if argument.starts_with('-') {
return None;
} else {
break;
}
}
}
name if PROGRAM_RUNNERS.contains(&name) => {
let (options, program) = split_runner_options(command.get(1..)?);
if matches!(name, "builtin" | "command")
&& options.iter().any(|option| LOOKUP_ONLY_OPTIONS.contains(&option.as_str()))
{
return Some(UnwrappedCommand { command, changes_directory });
}
command = program;
}
_ => return Some(UnwrappedCommand { command, changes_directory }),
}
}
}
fn split_runner_options(arguments: &[String]) -> (&[String], &[String]) {
let mut index = 0;
while index < arguments.len() && arguments[index].starts_with('-') {
index += 1;
}
arguments.split_at(index)
}
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| {
["-command", "-c", "-encodedcommand", "-encoded", "-enc", "-e"]
.iter()
.any(|option| argument.eq_ignore_ascii_case(option))
}),
_ => 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| {
["-encodedcommand", "-encoded", "-enc", "-e"]
.iter()
.any(|option| argument.eq_ignore_ascii_case(option))
})
}
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_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')
})
}
const GIT_GUARDED_SUBCOMMANDS: &[&str] = &["reset", "rm", "branch", "push", "clean"];
fn git_reset_is_destructive(reset_args: &[String]) -> bool {
reset_args
.iter()
.any(|arg| matches!(arg.as_str(), "--hard" | "--merge" | "--keep"))
}
fn git_rm_is_destructive(rm_args: &[String]) -> bool {
!rm_args.iter().any(|arg| arg == "--cached")
}
fn git_branch_is_force_delete(branch_args: &[String]) -> bool {
let mut deletes = false;
let mut forces = false;
for arg in branch_args {
match arg.as_str() {
"-D" => {
deletes = true;
forces = true;
}
"-d" | "--delete" => deletes = true,
"-f" | "--force" => forces = true,
_ => {
if arg.starts_with("--delete=") {
deletes = true;
}
if arg.starts_with("--force=") {
forces = true;
}
if short_flag_group_contains(arg, 'd') {
deletes = true;
}
if short_flag_group_contains(arg, 'D') {
deletes = true;
forces = true;
}
if short_flag_group_contains(arg, 'f') {
forces = true;
}
}
}
}
deletes && forces
}
fn classify_git_subcommand(subcommand: &str, args: &[String]) -> Option<&'static str> {
let scan_args = if subcommand == "push" {
args
} else {
match args.iter().position(|arg| arg == "--") {
Some(end) => &args[..end],
None => args,
}
};
match subcommand {
"reset" if git_reset_is_destructive(scan_args) => {
Some("git reset --hard/--merge/--keep discards uncommitted changes; use `git stash` or `git reset --soft`")
}
"rm" if git_rm_is_destructive(scan_args) => {
Some("git rm deletes working-tree files; unstage with `git rm --cached` instead")
}
"branch" if git_branch_is_force_delete(scan_args) => {
Some("git branch -D/--delete --force skips the unmerged-branch guard; use `-d` for merged branches")
}
"push" if git_push_is_dangerous(scan_args) => Some("git push force-updates or deletes remote refs"),
"clean" if git_clean_is_force(scan_args) => Some("git clean --force deletes untracked files"),
_ => None,
}
}
pub fn dangerous_command_reason(command: &[String]) -> Option<&'static str> {
let command = unwrap_command_prefix(command)?;
let cmd0 = command.first().map(String::as_str);
let (idx, subcommand) = if extract_command_name(cmd0.unwrap_or("")) == "git" {
find_git_subcommand(command, GIT_GUARDED_SUBCOMMANDS)?
} else {
let (idx, subcommand) = find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)?;
if subcommand == "rm" {
return None;
}
(idx, subcommand)
};
classify_git_subcommand(subcommand, &command[idx + 1..])
}
fn is_dangerous_git_subcommand(command: &[String]) -> bool {
find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)
.and_then(|(idx, subcommand)| classify_git_subcommand(subcommand, &command[idx + 1..]))
.is_some()
}
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, GIT_GUARDED_SUBCOMMANDS) else {
return false;
};
classify_git_subcommand(subcommand, &command[subcommand_idx + 1..]).is_some()
}
"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;