use crate::tools::ToolEffect;
const READ_ONLY_PREFIXES: &[&str] = &[
"cat ",
"head ",
"tail ",
"less ",
"more ",
"wc ",
"file ",
"stat ",
"bat ",
"ls",
"tree",
"du ",
"df",
"pwd",
"grep ",
"rg ",
"ag ",
"find ",
"fd ",
"fzf",
"echo ",
"printf ",
"whoami",
"hostname",
"uname",
"date",
"which ",
"type ",
"command -v ",
"env",
"printenv",
"rustc --version",
"node --version",
"npm --version",
"python --version",
"python3 --version",
"git status",
"git log",
"git diff",
"git branch",
"git show",
"git remote",
"git stash list",
"git tag",
"git describe",
"git rev-parse",
"git ls-files",
"git blame",
"docker ps",
"docker images",
"docker logs",
"docker compose ps",
"docker compose logs",
"sort ",
"uniq ",
"cut ",
"awk ",
"sed ",
"tr ",
"diff ",
"jq ",
"yq ",
"xargs ",
"dirname ",
"basename ",
"realpath ",
"readlink ",
"tput ",
"true",
"false",
"test ",
"[ ",
"gh issue view",
"gh issue list",
"gh issue status",
"gh pr view",
"gh pr list",
"gh pr status",
"gh pr checks",
"gh pr diff",
"gh repo view",
"gh repo clone",
"gh release list",
"gh release view",
"gh run view",
"gh run list",
"gh run watch",
];
#[derive(Debug, Clone, Copy)]
enum DangerCheck {
Cmd(&'static str),
CmdFlag(&'static str, &'static str),
CmdSub(&'static str, &'static str),
CmdSubFlag(&'static str, &'static str, &'static str),
CmdSubSub(&'static str, &'static str, &'static str),
}
fn flag_matches(t: &str, flag: &str) -> bool {
if t == flag {
return true;
}
if flag.len() == 2 && flag.starts_with('-') && t.starts_with('-') && !t.starts_with("--") {
let ch = flag.chars().nth(1).unwrap();
return t[1..].contains(ch);
}
false
}
impl DangerCheck {
fn matches(&self, tokens: &[String]) -> bool {
use DangerCheck::*;
let Some(cmd) = tokens.first() else {
return false;
};
match *self {
Cmd(c) => cmd == c,
CmdFlag(c, flag) => cmd == c && tokens[1..].iter().any(|t| flag_matches(t, flag)),
CmdSub(c, sub) => cmd == c && tokens.get(1).map(|s| s.as_str()) == Some(sub),
CmdSubFlag(c, sub, flag) => {
cmd == c
&& tokens.get(1).map(|s| s.as_str()) == Some(sub)
&& tokens[2..].iter().any(|t| flag_matches(t, flag))
}
CmdSubSub(c, sub, sub2) => {
cmd == c
&& tokens.get(1).map(|s| s.as_str()) == Some(sub)
&& tokens.get(2).map(|s| s.as_str()) == Some(sub2)
}
}
}
}
const DANGER_CHECKS: &[DangerCheck] = &[
DangerCheck::Cmd("rm"),
DangerCheck::Cmd("rmdir"),
DangerCheck::Cmd("sudo"),
DangerCheck::Cmd("su"),
DangerCheck::Cmd("dd"),
DangerCheck::Cmd("mkfs"),
DangerCheck::Cmd("fdisk"),
DangerCheck::Cmd("chmod"),
DangerCheck::Cmd("chown"),
DangerCheck::Cmd("kill"),
DangerCheck::Cmd("killall"),
DangerCheck::Cmd("pkill"),
DangerCheck::Cmd("eval"),
DangerCheck::Cmd("reboot"),
DangerCheck::Cmd("shutdown"),
DangerCheck::Cmd("halt"),
DangerCheck::CmdFlag("sed", "-i"),
DangerCheck::CmdFlag("sed", "--in-place"),
DangerCheck::CmdFlag("python", "-c"),
DangerCheck::CmdFlag("python3", "-c"),
DangerCheck::CmdFlag("perl", "-e"),
DangerCheck::CmdFlag("ruby", "-e"),
DangerCheck::CmdFlag("node", "-e"),
DangerCheck::CmdFlag("sh", "-c"),
DangerCheck::CmdFlag("bash", "-c"),
DangerCheck::CmdFlag("zsh", "-c"),
DangerCheck::CmdSub("npm", "publish"),
DangerCheck::CmdSub("cargo", "publish"),
DangerCheck::CmdSubFlag("git", "push", "-f"),
DangerCheck::CmdSubFlag("git", "push", "--force"),
DangerCheck::CmdSubFlag("git", "reset", "--hard"),
DangerCheck::CmdSubFlag("git", "clean", "-f"), DangerCheck::CmdSubSub("gh", "pr", "merge"),
DangerCheck::CmdSubSub("gh", "issue", "delete"),
DangerCheck::CmdSubSub("gh", "repo", "delete"),
DangerCheck::CmdSubSub("gh", "release", "delete"),
DangerCheck::CmdSub("gh", "api"),
DangerCheck::CmdSub("gh", "auth"),
];
const RAW_DANGER_PATTERNS: &[&str] = &[
"$(", "`", "| sh", "| bash", "| zsh", "> /dev/", "(){", "() {",
];
pub fn classify_bash_command(command: &str) -> ToolEffect {
let trimmed = command.trim();
if trimmed.is_empty() {
return ToolEffect::ReadOnly;
}
let unquoted = strip_quoted_strings(trimmed);
for pat in RAW_DANGER_PATTERNS {
if unquoted.contains(pat) {
return ToolEffect::Destructive;
}
}
if has_write_side_effect(trimmed) {
return ToolEffect::LocalMutation;
}
let segments = split_command_segments(trimmed);
let mut worst = ToolEffect::ReadOnly;
for seg in &segments {
let effect = classify_segment(seg);
match effect {
ToolEffect::Destructive => return ToolEffect::Destructive,
ToolEffect::LocalMutation => worst = ToolEffect::LocalMutation,
_ => {}
}
}
worst
}
fn classify_segment(segment: &str) -> ToolEffect {
let seg = strip_env_vars(segment.trim());
let seg = strip_redirections(&seg);
let seg = seg.trim().to_string();
if seg.is_empty() {
return ToolEffect::ReadOnly;
}
let tokens = match shlex::split(&seg) {
Some(t) if !t.is_empty() => t,
_ => return ToolEffect::LocalMutation,
};
for check in DANGER_CHECKS {
if check.matches(&tokens) {
return ToolEffect::Destructive;
}
}
let canonical = tokens.join(" ");
if matches_prefix_list(&canonical, READ_ONLY_PREFIXES) {
ToolEffect::ReadOnly
} else {
ToolEffect::LocalMutation
}
}
fn has_write_side_effect(command: &str) -> bool {
let chars: Vec<char> = command.chars().collect();
let mut in_sq = false;
let mut in_dq = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\'' && !in_dq {
in_sq = !in_sq;
} else if c == '"' && !in_sq {
in_dq = !in_dq;
} else if !in_sq && !in_dq && c == '>' {
let before = if i > 0 { chars[i - 1] } else { ' ' };
if before == '&' {
i += 1;
continue;
}
let after: String = chars[i + 1..].iter().collect();
let after_trimmed = after.trim_start();
if after_trimmed.starts_with("/dev/null")
|| after_trimmed.starts_with("&1")
|| after_trimmed.starts_with("&2")
{
i += 1;
continue;
}
return true;
}
i += 1;
}
let segments = split_command_segments(command);
for (idx, seg) in segments.iter().enumerate() {
if idx > 0 {
let t = seg.trim();
if t.starts_with("tee ") || t == "tee" {
return true;
}
}
}
false
}
fn matches_prefix_list(seg: &str, prefixes: &[&str]) -> bool {
for prefix in prefixes {
if prefix.ends_with(' ') {
if seg.starts_with(prefix) {
return true;
}
} else if seg == *prefix
|| seg.starts_with(&format!("{prefix} "))
|| seg.starts_with(&format!("{prefix}\t"))
{
return true;
}
}
false
}
pub fn split_command_segments(command: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0;
let chars: Vec<char> = command.chars().collect();
let mut i = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
while i < chars.len() {
let c = chars[i];
if c == '\'' && !in_double_quote {
in_single_quote = !in_single_quote;
} else if c == '"' && !in_single_quote {
in_double_quote = !in_double_quote;
} else if !in_single_quote && !in_double_quote {
let sep_len = if (c == '|' || c == '&') && i + 1 < chars.len() && chars[i + 1] == c {
2 } else if c == '|' || c == ';' {
1
} else {
0
};
if sep_len > 0 {
segments.push(&command[start..i]);
i += sep_len;
start = i;
continue;
}
}
i += 1;
}
if start < chars.len() {
segments.push(&command[start..]);
}
segments
}
pub fn strip_quoted_strings(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' {
result.push(c);
let mut found_close = false;
for inner in chars.by_ref() {
if inner == '\'' {
result.push(c);
found_close = true;
break;
}
result.push(' ');
}
let _ = found_close;
} else if c == '"' {
result.push(c);
let mut found_close = false;
while let Some(inner) = chars.next() {
if inner == '\\' {
result.push(' ');
if chars.next().is_some() {
result.push(' ');
}
continue;
}
if inner == '"' {
result.push(c);
found_close = true;
break;
}
result.push(' ');
}
let _ = found_close;
} else {
result.push(c);
}
}
result
}
pub fn strip_env_vars(segment: &str) -> String {
let mut rest = segment;
loop {
let trimmed = rest.trim_start();
if let Some(eq_pos) = trimmed.find('=') {
let before_eq = &trimmed[..eq_pos];
if !before_eq.is_empty()
&& before_eq
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
let after_eq = &trimmed[eq_pos + 1..];
if let Some(space_pos) = find_unquoted_space(after_eq) {
rest = &after_eq[space_pos..];
continue;
}
}
}
return trimmed.to_string();
}
}
fn strip_redirections(segment: &str) -> String {
let mut result = segment.to_string();
for pat in ["2>&1", "2>/dev/null", ">/dev/null", "</dev/null"] {
result = result.replace(pat, "");
}
result
}
fn find_unquoted_space(s: &str) -> Option<usize> {
let mut in_sq = false;
let mut in_dq = false;
for (i, c) in s.chars().enumerate() {
match c {
'\'' if !in_dq => in_sq = !in_sq,
'"' if !in_sq => in_dq = !in_dq,
' ' | '\t' if !in_sq && !in_dq => return Some(i),
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flag_matches_exact() {
assert!(flag_matches("-i", "-i"));
assert!(flag_matches("--force", "--force"));
assert!(!flag_matches("-n", "-i"));
assert!(!flag_matches("--force", "-f"));
}
#[test]
fn test_flag_matches_combined_short() {
assert!(flag_matches("-fd", "-f"));
assert!(flag_matches("-fdc", "-f"));
assert!(!flag_matches("-nd", "-f"));
assert!(!flag_matches("--force", "-f"));
}
#[test]
fn test_danger_check_cmd() {
let t = |s: &str| s.to_string();
let rm = vec![t("rm"), t("-rf"), t("/")];
assert!(DangerCheck::Cmd("rm").matches(&rm));
assert!(!DangerCheck::Cmd("ls").matches(&rm));
}
#[test]
fn test_danger_check_cmd_flag() {
let t = |s: &str| s.to_string();
let sed_i = vec![t("sed"), t("-i"), t("s/a/b/")];
assert!(DangerCheck::CmdFlag("sed", "-i").matches(&sed_i));
assert!(!DangerCheck::CmdFlag("sed", "--in-place").matches(&sed_i));
}
#[test]
fn test_danger_check_combined_flag() {
let t = |s: &str| s.to_string();
let git_clean_fd = vec![t("git"), t("clean"), t("-fd")];
assert!(DangerCheck::CmdSubFlag("git", "clean", "-f").matches(&git_clean_fd));
let git_clean_n = vec![t("git"), t("clean"), t("-nd")];
assert!(!DangerCheck::CmdSubFlag("git", "clean", "-f").matches(&git_clean_n));
}
#[test]
fn test_danger_check_cmd_sub_sub() {
let t = |s: &str| s.to_string();
let merge = vec![t("gh"), t("pr"), t("merge"), t("42")];
assert!(DangerCheck::CmdSubSub("gh", "pr", "merge").matches(&merge));
assert!(!DangerCheck::CmdSubSub("gh", "pr", "view").matches(&merge));
}
#[test]
fn test_split_pipe() {
let segs = split_command_segments("cat file | grep pattern");
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].trim(), "cat file");
assert_eq!(segs[1].trim(), "grep pattern");
}
#[test]
fn test_split_chain_and_semicolon() {
assert_eq!(split_command_segments("cargo build && cargo test").len(), 2);
assert_eq!(split_command_segments("echo a; echo b; echo c").len(), 3);
}
#[test]
fn test_split_respects_quotes() {
let segs = split_command_segments("echo 'a | b' | grep x");
assert_eq!(segs.len(), 2);
assert!(segs[0].contains("'a | b'"));
}
#[test]
fn test_strip_quoted_backslash_escaped() {
assert_eq!(
strip_quoted_strings(r#"echo "it\"s fine" ; ls"#),
r#"echo " " ; ls"#,
);
let stripped = strip_quoted_strings(r#"echo "safe\" ; rm -rf /""#);
assert!(!stripped.contains("rm -rf"));
}
#[test]
fn test_strip_env_vars_basic() {
assert_eq!(strip_env_vars("FOO=bar cargo build"), "cargo build");
assert_eq!(strip_env_vars("ls -la"), "ls -la");
}
}