use anyhow::{Result, anyhow};
use regex::Regex;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use tracing::warn;
#[derive(Clone, Debug)]
pub struct ShellPolicyCacheEntry {
pub signature: u64,
pub deny_regexes: Vec<(String, Regex)>,
pub deny_globs: Vec<(String, Regex)>,
}
pub struct ShellPolicyChecker {
cache: Option<ShellPolicyCacheEntry>,
commands_config: Option<crate::config::CommandsConfig>,
}
impl ShellPolicyChecker {
pub fn new() -> Self {
Self { cache: None, commands_config: None }
}
}
impl Default for ShellPolicyChecker {
fn default() -> Self {
Self::new()
}
}
impl ShellPolicyChecker {
pub fn set_commands_config(&mut self, commands_config: &crate::config::CommandsConfig) {
self.commands_config = Some(commands_config.clone());
self.reset_cache();
}
pub fn commands_config(&self) -> Option<&crate::config::CommandsConfig> {
self.commands_config.as_ref()
}
pub fn check_command(
&mut self,
command: &str,
agent_type: &str,
deny_regex_patterns: &[String],
deny_glob_patterns: &[String],
) -> Result<()> {
let mut hasher = DefaultHasher::new();
deny_regex_patterns.hash(&mut hasher);
deny_glob_patterns.hash(&mut hasher);
let signature = hasher.finish();
let entry = if let Some(ref entry) = self.cache
&& entry.signature == signature
{
entry
} else {
let compiled_regexes = deny_regex_patterns
.iter()
.filter_map(|pattern| {
if pattern.is_empty() {
return None;
}
match Regex::new(pattern) {
Ok(re) => Some((pattern.clone(), re)),
Err(err) => {
warn!(agent = agent_type, pattern, error = %err, "Invalid deny regex pattern skipped");
None
}
}
})
.collect::<Vec<_>>();
let compiled_globs = deny_glob_patterns
.iter()
.filter_map(|pattern| {
if pattern.is_empty() {
return None;
}
let re_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*").replace(r"\?", "."));
match Regex::new(&re_pattern) {
Ok(re) => Some((pattern.clone(), re)),
Err(err) => {
warn!(agent = agent_type, pattern, error = %err, "Invalid deny glob pattern skipped");
None
}
}
})
.collect::<Vec<_>>();
let new_entry = ShellPolicyCacheEntry {
signature,
deny_regexes: compiled_regexes,
deny_globs: compiled_globs,
};
self.cache = Some(new_entry);
self.cache
.as_ref()
.ok_or_else(|| anyhow!("Failed to initialize shell policy cache entry"))?
};
for sub_command in split_compound_command(command) {
let sub = sub_command.trim();
if sub.is_empty() {
continue;
}
for (pattern, compiled) in &entry.deny_regexes {
if compiled.is_match(sub) {
return Err(anyhow!("Shell command denied by agent regex policy: {pattern}"));
}
}
for (pattern, compiled) in &entry.deny_globs {
if compiled.is_match(sub) {
return Err(anyhow!("Shell command denied by agent glob policy: {pattern}"));
}
}
}
Ok(())
}
pub fn reset_cache(&mut self) {
self.cache = None;
}
}
fn split_compound_command(command: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut current_start: usize = 0;
let bytes = command.as_bytes();
let len = bytes.len();
let mut i: usize = 0;
while i < len {
if bytes[i] == b'&' && i + 1 < len && bytes[i + 1] == b'&' {
parts.push(&command[current_start..i]);
i += 2;
while i < len && bytes[i] == b' ' {
i += 1;
}
current_start = i;
} else if bytes[i] == b'|' && i + 1 < len && bytes[i + 1] == b'|' {
parts.push(&command[current_start..i]);
i += 2;
while i < len && bytes[i] == b' ' {
i += 1;
}
current_start = i;
} else if bytes[i] == b';' {
parts.push(&command[current_start..i]);
i += 1;
while i < len && bytes[i] == b' ' {
i += 1;
}
current_start = i;
} else {
i += 1;
}
}
if current_start < len {
parts.push(&command[current_start..]);
}
parts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_compound_and_operator() {
let parts = split_compound_command("echo a && echo b");
assert_eq!(parts, vec!["echo a ", "echo b"]);
}
#[test]
fn split_compound_or_operator() {
let parts = split_compound_command("echo a || echo b");
assert_eq!(parts, vec!["echo a ", "echo b"]);
}
#[test]
fn split_compound_semicolon() {
let parts = split_compound_command("echo a; echo b");
assert_eq!(parts, vec!["echo a", "echo b"]);
}
#[test]
fn split_compound_mixed_operators() {
let parts = split_compound_command("echo a && echo b || echo c; echo d");
assert_eq!(parts, vec!["echo a ", "echo b ", "echo c", "echo d"]);
}
#[test]
fn split_compound_leading_and() {
let parts = split_compound_command("&& echo a");
assert_eq!(parts, vec!["", "echo a"]);
}
#[test]
fn split_compound_trailing_and() {
let parts = split_compound_command("echo a &&");
assert_eq!(parts, vec!["echo a "]);
}
#[test]
fn split_compound_no_operators() {
let parts = split_compound_command("echo hello world");
assert_eq!(parts, vec!["echo hello world"]);
}
#[test]
fn split_compound_pipe_not_split() {
let parts = split_compound_command("echo a | grep b");
assert_eq!(parts, vec!["echo a | grep b"]);
}
#[test]
fn split_compound_empty_string() {
let parts = split_compound_command("");
assert!(parts.is_empty());
}
#[test]
fn split_compound_non_ascii() {
let parts = split_compound_command("echo 日本語 && echo test");
assert_eq!(parts, vec!["echo 日本語 ", "echo test"]);
}
#[test]
fn glob_star_matches_command() {
let mut checker = ShellPolicyChecker::new();
let globs = vec!["curl*".to_string()];
assert!(checker.check_command("curl https://example.com", "test", &[], &globs).is_err());
}
#[test]
fn glob_question_mark_matches_single_char() {
let mut checker = ShellPolicyChecker::new();
let globs = vec!["rm ?".to_string()];
assert!(checker.check_command("rm f", "test", &[], &globs).is_err());
assert!(checker.check_command("rm foo", "test", &[], &globs).is_ok());
}
#[test]
fn glob_does_not_match_unrelated_command() {
let mut checker = ShellPolicyChecker::new();
let globs = vec!["curl*".to_string()];
assert!(checker.check_command("echo hello", "test", &[], &globs).is_ok());
}
#[test]
fn deny_regex_blocks_sub_command_after_split() {
let mut checker = ShellPolicyChecker::new();
let regexes = vec![r"\brm\b".to_string()];
assert!(
checker
.check_command("echo hello && rm -rf /tmp", "test", ®exes, &[])
.is_err()
);
assert!(checker.check_command("echo hello && echo world", "test", ®exes, &[]).is_ok());
}
}