use crate::tools::command_args::{is_readonly_command_string, raw_command_text};
use serde_json::Value;
const READONLY_UNIFIED_EXEC_COMMANDS: &[&str] = &[
"rg", "ls", "cat", "diff", "find", "wc", "grep", "egrep", "fgrep", "head", "tail", "sort", "uniq", "awk", "sed",
"cut", "tr", "ast-grep", "sg", "echo", "pwd", "printf", "true", "false", "test",
];
pub fn is_readonly_base_command(command: &str) -> bool {
READONLY_UNIFIED_EXEC_COMMANDS.contains(&command)
}
pub fn is_readonly_command_session_command(args: &Value) -> bool {
let Ok(Some(parts)) = crate::tools::command_args::command_words(args) else {
return false;
};
if parts.iter().any(|part| part == "--dry-run") {
return true;
}
let Some(command) = parts.first().map(String::as_str) else {
return false;
};
if is_readonly_base_command(command) {
if !is_readonly_command_string(args) {
return false;
}
if !is_readonly_pipeline_segments(args) {
return false;
}
return is_readonly_chained_segments(args);
}
match command {
"git" => matches!(parts.get(1).map(String::as_str), Some("status")),
"cargo" => matches!(parts.get(1).map(String::as_str), Some("check" | "test")),
"npm" | "pnpm" | "yarn" => match parts.get(1).map(String::as_str) {
Some("test") => true,
Some("run") => matches!(parts.get(2).map(String::as_str), Some("test")),
_ => false,
},
_ => false,
}
}
pub fn is_readonly_chained_segments(args: &Value) -> bool {
let Some(raw) = raw_command_text(args) else {
return false;
};
let segments: Vec<&str> = raw.split("&&").map(str::trim).collect();
if segments.len() <= 1 {
return true;
}
for segment in &segments {
if segment.is_empty() {
return false;
}
let first_part = segment.split('|').next().unwrap_or(segment).trim();
let first_command = first_part
.split_whitespace()
.find(|token| !token.starts_with('-') && !token.contains('='))
.map(|token| token.to_ascii_lowercase());
let Some(first_command) = first_command else {
return false;
};
if !is_readonly_base_command(&first_command) {
return false;
}
}
true
}
pub fn is_readonly_pipeline_segments(args: &Value) -> bool {
let Some(raw) = raw_command_text(args) else {
return false;
};
let segments: Vec<&str> = raw.split('|').map(str::trim).collect();
if segments.len() <= 1 {
return true;
}
for segment in segments {
if segment.is_empty() {
return false;
}
let first_command = segment
.split_whitespace()
.find(|token| !token.starts_with('-') && !token.contains('='))
.map(|token| token.to_ascii_lowercase());
let Some(first_command) = first_command else {
return false;
};
if !is_readonly_base_command(&first_command) {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn and_chain_allows_readonly_segments() {
assert!(is_readonly_chained_segments(&json!({"command": "ls -la && echo '---' && ls -la crates/"})));
assert!(is_readonly_chained_segments(&json!({"command": "pwd && ls src/"})));
assert!(is_readonly_chained_segments(&json!({"command": "cat foo.txt && grep bar"})));
}
#[test]
fn and_chain_rejects_destructive_segments() {
assert!(!is_readonly_chained_segments(&json!({"command": "ls -la && rm foo.txt"})));
assert!(!is_readonly_chained_segments(&json!({"command": "cat x && mv a b"})));
assert!(!is_readonly_chained_segments(&json!({"command": "true && cp a b"})));
}
#[test]
fn and_chain_rejects_non_allowlisted_segments() {
assert!(!is_readonly_chained_segments(&json!({"command": "ls -la && python script.py"})));
assert!(!is_readonly_chained_segments(&json!({"command": "ls -la && cargo build"})));
}
#[test]
fn and_chain_allows_pipeline_within_segment() {
assert!(is_readonly_chained_segments(&json!({"command": "ls -la | head -5 && echo done"})));
}
#[test]
fn and_chain_single_command_passes() {
assert!(is_readonly_chained_segments(&json!({"command": "ls -la"})));
assert!(is_readonly_chained_segments(&json!({"command": "echo hi"})));
}
#[test]
fn readonly_command_session_allows_and_chain() {
assert!(is_readonly_command_session_command(&json!({
"action": "run",
"command": "ls -la /path/ && echo '---' && ls -la /path/crates/"
})));
}
#[test]
fn readonly_command_session_rejects_destructive_and_chain() {
assert!(!is_readonly_command_session_command(&json!({
"action": "run",
"command": "ls -la && rm foo.txt"
})));
}
}