use crate::commands::hook_rewrite::{effective_tool_name, first_command_token};
pub(crate) const SHELL_EDIT_REASON: &str = "PM must not edit files via shell tools \
(sed/awk/patch/git apply/redirection) (prohibitions P1–P3). \
Delegate the change to rust-engineer via the Task/Agent tool.";
pub(crate) const BUILD_TEST_REASON: &str = "PM must not run builds or tests directly \
(prohibitions P4–P5). Delegate to rust-engineer / QA via the Task/Agent tool.";
pub(crate) const NETWORK_REASON: &str = "PM must not fetch over the network from Bash \
(prohibition P-network). Use WebFetch/WebSearch or delegate the task.";
const MAX_SUBSTITUTION_DEPTH: usize = 32;
pub(crate) fn evaluate_bash_command(command: &str) -> Option<&'static str> {
evaluate_bash_command_inner(command, 0)
}
fn evaluate_bash_command_inner(command: &str, depth: usize) -> Option<&'static str> {
let trimmed = command.trim();
if trimmed.is_empty() {
return None;
}
for segment in split_shell_segments(trimmed) {
if let Some(reason) = classify_bash_segment(segment, depth) {
return Some(reason);
}
}
if has_file_write_redirection(trimmed) {
return Some(SHELL_EDIT_REASON);
}
None
}
fn split_shell_segments(command: &str) -> Vec<&str> {
let bytes = command.as_bytes();
let mut segments = Vec::new();
let mut start = 0;
let mut i = 0;
while i < bytes.len() {
let two = command.get(i..i + 2);
if two == Some("&&") || two == Some("||") {
segments.push(&command[start..i]);
i += 2;
start = i;
continue;
}
if bytes[i] == b';' || bytes[i] == b'|' || bytes[i] == b'\n' {
segments.push(&command[start..i]);
i += 1;
start = i;
continue;
}
if bytes[i] == b'&' {
let prev_is_redirect = i > 0 && bytes[i - 1] == b'>';
let next_is_redirect = i + 1 < bytes.len() && bytes[i + 1] == b'>';
let nothing_follows = command[i + 1..].trim().is_empty();
if prev_is_redirect || next_is_redirect || nothing_follows {
i += 1;
continue;
}
segments.push(&command[start..i]);
i += 1;
start = i;
continue;
}
i += 1;
}
segments.push(&command[start..]);
segments
}
fn classify_bash_segment(segment: &str, depth: usize) -> Option<&'static str> {
let trimmed = segment.trim();
if trimmed.is_empty() {
return None;
}
if let Some(program) = first_command_token(trimmed) {
match program {
"sed" | "awk" | "patch" => return Some(SHELL_EDIT_REASON),
"curl" | "wget" => return Some(NETWORK_REASON),
"make" | "pytest" => return Some(BUILD_TEST_REASON),
_ => {}
}
}
match effective_tool_name(trimmed).as_str() {
"git apply" => return Some(SHELL_EDIT_REASON),
"npm test" => return Some(BUILD_TEST_REASON),
_ => {}
}
classify_command_substitutions(trimmed, depth)
}
fn classify_command_substitutions(segment: &str, depth: usize) -> Option<&'static str> {
if depth >= MAX_SUBSTITUTION_DEPTH {
return Some(SHELL_EDIT_REASON);
}
let bytes = segment.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
let mut paren = 1usize;
let mut j = i + 2;
while j < bytes.len() && paren > 0 {
match bytes[j] {
b'(' => paren += 1,
b')' => paren -= 1,
_ => {}
}
j += 1;
}
if paren != 0 {
return Some(SHELL_EDIT_REASON);
}
if let Some(reason) = evaluate_bash_command_inner(&segment[i + 2..j - 1], depth + 1) {
return Some(reason);
}
i = j;
continue;
}
if bytes[i] == b'`' {
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'`' {
j += 1;
}
if j >= bytes.len() {
return Some(SHELL_EDIT_REASON);
}
if let Some(reason) = evaluate_bash_command_inner(&segment[i + 1..j], depth + 1) {
return Some(reason);
}
i = j + 1;
continue;
}
i += 1;
}
None
}
pub(crate) fn has_file_write_redirection(command: &str) -> bool {
let bytes = command.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'>' {
let mut j = i + 1;
if j < bytes.len() && bytes[j] == b'>' {
j += 1;
}
while j < bytes.len() && bytes[j] == b' ' {
j += 1;
}
if j < bytes.len() && bytes[j] == b'&' {
i = j + 1;
continue;
}
let start = j;
while j < bytes.len() && !matches!(bytes[j], b' ' | b'>' | b'<' | b'|' | b';' | b'&') {
j += 1;
}
if &command[start..j] == "/dev/null" {
i = j;
continue;
}
return true;
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluate_bash_command_denies_shell_edit_verbs() {
for cmd in [
"sed -i s/a/b/ src/lib.rs",
"awk '{print}' f",
"patch -p1 < d.patch",
"git apply my.patch",
"FOO=1 sed -i s/a/b/ f",
"sudo patch -p0 x",
] {
assert_eq!(
evaluate_bash_command(cmd),
Some(SHELL_EDIT_REASON),
"expected shell-edit deny for: {cmd}"
);
}
}
#[test]
fn evaluate_bash_command_denies_redirection_write() {
assert_eq!(
evaluate_bash_command("echo 'code' > src/lib.rs"),
Some(SHELL_EDIT_REASON)
);
assert_eq!(
evaluate_bash_command("printf x >> notes.txt"),
Some(SHELL_EDIT_REASON)
);
}
#[test]
fn evaluate_bash_command_denies_build_and_test() {
for cmd in ["make build", "pytest -q", "npm test"] {
assert_eq!(
evaluate_bash_command(cmd),
Some(BUILD_TEST_REASON),
"expected build/test deny for: {cmd}"
);
}
}
#[test]
fn evaluate_bash_command_denies_network() {
for cmd in ["curl https://example.com", "wget https://example.com/x"] {
assert_eq!(
evaluate_bash_command(cmd),
Some(NETWORK_REASON),
"expected network deny for: {cmd}"
);
}
}
#[test]
fn evaluate_bash_command_allows_normal_shell() {
for cmd in [
"",
" ",
"git status",
"git add -A",
"git commit -m x",
"git log --oneline",
"git diff",
"git push",
"ls -la",
"grep -rn foo src",
"cat README.md",
"cargo tree 2>&1", ] {
assert_eq!(
evaluate_bash_command(cmd),
None,
"expected allow for: {cmd:?}"
);
}
}
#[test]
fn evaluate_bash_command_denies_composition_hidden_verbs() {
assert_eq!(
evaluate_bash_command("cd repo && sed -i s/a/b/ f"),
Some(SHELL_EDIT_REASON)
);
assert_eq!(
evaluate_bash_command("true; make build"),
Some(BUILD_TEST_REASON)
);
assert_eq!(
evaluate_bash_command("x || pytest"),
Some(BUILD_TEST_REASON)
);
assert_eq!(
evaluate_bash_command("ls && git apply p.diff"),
Some(SHELL_EDIT_REASON)
);
assert_eq!(
evaluate_bash_command("cd repo && echo x > out.txt"),
Some(SHELL_EDIT_REASON)
);
}
#[test]
fn evaluate_bash_command_denies_bare_ampersand_composition() {
assert_eq!(
evaluate_bash_command("true & sed -i s/a/b/ f"),
Some(SHELL_EDIT_REASON)
);
assert_eq!(
evaluate_bash_command("cd x & make build"),
Some(BUILD_TEST_REASON)
);
assert_eq!(evaluate_bash_command("x & pytest"), Some(BUILD_TEST_REASON));
assert_eq!(
evaluate_bash_command("cd repo\nsed -i s/a/b/ f"),
Some(SHELL_EDIT_REASON)
);
}
#[test]
fn evaluate_bash_command_allows_trailing_background() {
for cmd in ["sleep 1 &", "cargo tree 2>&1", "foo >&2"] {
assert_eq!(
evaluate_bash_command(cmd),
None,
"expected allow for: {cmd:?}"
);
}
}
#[test]
fn evaluate_bash_command_allows_benign_pipes() {
for cmd in [
"git log | head",
"cat f | grep x",
"ls | wc -l",
"git diff && git status",
"cd repo; ls -la",
] {
assert_eq!(
evaluate_bash_command(cmd),
None,
"expected allow for benign composition: {cmd:?}"
);
}
}
#[test]
fn evaluate_bash_command_allows_dev_null_redirect() {
for cmd in ["which cargo 2>/dev/null", "command -v foo >/dev/null"] {
assert_eq!(
evaluate_bash_command(cmd),
None,
"expected allow for /dev/null discard: {cmd:?}"
);
}
}
#[test]
fn evaluate_bash_command_denies_hidden_substitution_verb() {
assert_eq!(
evaluate_bash_command("echo \"$(sed -i s/a/b/ f)\""),
Some(SHELL_EDIT_REASON)
);
assert_eq!(
evaluate_bash_command("x=`make build`"),
Some(BUILD_TEST_REASON)
);
}
#[test]
fn evaluate_bash_command_allows_benign_substitution() {
for cmd in [
"echo \"$(date)\"",
"cd \"$(git rev-parse --show-toplevel)\"",
] {
assert_eq!(
evaluate_bash_command(cmd),
None,
"expected allow for benign substitution: {cmd:?}"
);
}
}
#[test]
fn evaluate_bash_command_denies_unbalanced_substitution() {
assert_eq!(
evaluate_bash_command("echo $(sed -i"),
Some(SHELL_EDIT_REASON)
);
assert_eq!(evaluate_bash_command("echo `foo"), Some(SHELL_EDIT_REASON));
}
#[test]
fn evaluate_bash_command_bounds_deep_substitution_nesting() {
let deep = format!("echo {}", "$(".repeat(200));
let decision = evaluate_bash_command(&deep);
assert!(decision.is_some(), "deep nesting must deny, got allow");
let balanced = format!("echo {}date{}", "$(".repeat(200), ")".repeat(200));
let _ = evaluate_bash_command(&balanced);
}
#[test]
fn split_shell_segments_splits_operators() {
assert_eq!(
split_shell_segments("a && b || c ; d | e"),
vec!["a ", " b ", " c ", " d ", " e"]
);
}
#[test]
fn split_shell_segments_splits_bare_ampersand() {
assert_eq!(split_shell_segments("a & b"), vec!["a ", " b"]);
assert_eq!(split_shell_segments("foo &"), vec!["foo &"]);
assert_eq!(
split_shell_segments("cargo test 2>&1"),
vec!["cargo test 2>&1"]
);
assert_eq!(split_shell_segments("foo >&2"), vec!["foo >&2"]);
assert_eq!(split_shell_segments("a\nb"), vec!["a", "b"]);
}
#[test]
fn split_shell_segments_single_command() {
assert_eq!(split_shell_segments("git status"), vec!["git status"]);
assert_eq!(split_shell_segments("foo &"), vec!["foo &"]);
}
#[test]
fn has_file_write_redirection_detects_write() {
assert!(has_file_write_redirection("echo x > f"));
assert!(has_file_write_redirection("echo x >f.rs"));
}
#[test]
fn has_file_write_redirection_detects_append() {
assert!(has_file_write_redirection("echo x >> f"));
}
#[test]
fn has_file_write_redirection_ignores_fd_dup() {
assert!(!has_file_write_redirection("cargo test 2>&1"));
assert!(!has_file_write_redirection("foo >&2"));
}
#[test]
fn has_file_write_redirection_ignores_dev_null() {
assert!(!has_file_write_redirection("which cargo 2>/dev/null"));
assert!(!has_file_write_redirection("command -v foo >/dev/null"));
assert!(!has_file_write_redirection("foo &>/dev/null"));
assert!(has_file_write_redirection("echo x > /dev/null.txt"));
assert!(has_file_write_redirection("echo x > out.txt"));
}
#[test]
fn has_file_write_redirection_false_for_plain_command() {
assert!(!has_file_write_redirection("git status"));
}
}