use std::path::Path; use std::process::Stdio; use std::time::Duration; use async_trait::async_trait; use serde_json::json; use tokio::process::Command; use tokio::time::timeout; use crate::tools::traits::{Tool, ToolResult}; const MAX_TIMEOUT_SECS: u64 = 600; const MAX_OUTPUT_CHARS: usize = 50_000; pub struct BashTool { timeout_secs: u64, working_dir: Option, deny_patterns: Vec, } impl BashTool { pub fn new() -> Self { Self { timeout_secs: 60, working_dir: None, deny_patterns: vec![ r"\brm\s+-[rf]{1,2}\b".to_string(), r"\bdel\s+/[fq]\b".to_string(), r"\brmdir\s+/s\b".to_string(), r":\(\)\s*\{.*\};\s*:".to_string(), ], } } pub fn with_timeout(mut self, timeout_secs: u64) -> Self { self.timeout_secs = timeout_secs; self } pub fn with_working_dir(mut self, dir: String) -> Self { self.working_dir = Some(dir); self } fn guard_command(&self, command: &str) -> Option { let lower = command.to_lowercase(); for pattern in &self.deny_patterns { if regex::Regex::new(pattern) .ok() .map(|re| re.is_match(&lower)) .unwrap_or(false) { return Some(format!( "Command blocked by safety guard (dangerous pattern: {})", pattern )); } } None } fn truncate_output(&self, output: &str) -> String { if output.len() <= MAX_OUTPUT_CHARS { return output.to_string(); } let half = MAX_OUTPUT_CHARS / 2; format!( "{}...\n\n(... {} chars truncated ...)\n\n{}", &output[..output.ceil_char_boundary(half)], output.len() - MAX_OUTPUT_CHARS, &output[output.floor_char_boundary(output.len() - half)..] ) } } impl Default for BashTool { fn default() -> Self { Self::new() } } #[async_trait] impl Tool for BashTool { fn name(&self) -> &str { "bash" } fn delegation_policy(&self) -> crate::tools::DelegationPolicy { crate::tools::DelegationPolicy::Delegatable } fn description(&self) -> &str { "Execute a bash shell command and return its output. Use with caution." } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "command": { "type": "string", "description": "The shell command to execute" }, "timeout": { "type": "integer", "description": format!("Timeout in seconds (default {}, max {})", self.timeout_secs, MAX_TIMEOUT_SECS), "minimum": 1, "maximum": MAX_TIMEOUT_SECS } }, "required": ["command"] }) } fn exclusive(&self) -> bool { true // Shell commands should not run concurrently } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let command = match args.get("command").and_then(|v| v.as_str()) { Some(c) => c, None => { return Ok(ToolResult { success: false, output: String::new(), error: Some("Missing required parameter: command".to_string()), }); } }; // Safety check if let Some(error) = self.guard_command(command) { return Ok(ToolResult { success: false, output: String::new(), error: Some(error), }); } let timeout_secs = args .get("timeout") .and_then(|v| v.as_u64()) .unwrap_or(self.timeout_secs) .min(MAX_TIMEOUT_SECS); let cwd = self .working_dir .as_ref() .map(Path::new) .unwrap_or_else(|| Path::new(".")); match self.run_command(command, cwd, timeout_secs).await { Ok(output) => Ok(ToolResult { success: true, output, error: None, }), Err(e) => Ok(ToolResult { success: false, output: String::new(), error: Some(e), }), } } } impl BashTool { async fn run_command( &self, command: &str, cwd: &Path, timeout_secs: u64, ) -> Result { let mut cmd = Command::new("bash"); cmd.args(["-c", command]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(cwd) .kill_on_drop(true); let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?; let process_output = match timeout(Duration::from_secs(timeout_secs), child.wait_with_output()).await { Ok(Ok(output)) => output, Ok(Err(e)) => return Err(format!("Failed to wait: {}", e)), Err(_) => { return Err(format!("Command timed out after {} seconds", timeout_secs)); } }; let mut output = String::new(); if !process_output.stdout.is_empty() { let stdout_str = String::from_utf8_lossy(&process_output.stdout); output.push_str(&stdout_str); } if !process_output.stderr.is_empty() { let stderr_str = String::from_utf8_lossy(&process_output.stderr); if !stderr_str.trim().is_empty() { if !output.is_empty() { output.push('\n'); } output.push_str("STDERR:\n"); output.push_str(&stderr_str); } } output.push_str(&format!( "\nExit code: {}", process_output.status.code().unwrap_or(-1) )); Ok(self.truncate_output(&output)) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_simple_command() { let tool = BashTool::new(); let result = tool .execute(json!({ "command": "echo 'Hello World'" })) .await .unwrap(); assert!(result.success); assert!(result.output.contains("Hello World")); } #[tokio::test] async fn test_pwd_command() { let tool = BashTool::new(); let result = tool.execute(json!({ "command": "pwd" })).await.unwrap(); assert!(result.success); } #[tokio::test] async fn test_ls_command() { let tool = BashTool::new(); let result = tool .execute(json!({ "command": "ls -la /tmp" })) .await .unwrap(); assert!(result.success); } #[tokio::test] async fn test_dangerous_rm() { let tool = BashTool::new(); let result = tool .execute(json!({ "command": "rm -rf /" })) .await .unwrap(); assert!(!result.success); assert!(result.error.unwrap().contains("blocked")); } #[tokio::test] async fn test_dangerous_fork_bomb() { let tool = BashTool::new(); let result = tool .execute(json!({ "command": ":(){ :|:& };:" })) .await .unwrap(); assert!(!result.success); assert!(result.error.unwrap().contains("blocked")); } #[tokio::test] async fn test_missing_command() { let tool = BashTool::new(); let result = tool.execute(json!({})).await.unwrap(); assert!(!result.success); assert!(result.error.unwrap().contains("command")); } #[tokio::test] async fn test_timeout() { let tool = BashTool::new(); let result = tool .execute(json!({ "command": "sleep 10", "timeout": 1 })) .await .unwrap(); assert!(!result.success); assert!(result.error.unwrap().contains("timed out")); } #[tokio::test] async fn test_large_stderr_does_not_deadlock() { let tool = BashTool::new().with_timeout(5); let result = tool .execute(json!({ "command": "for i in $(seq 1 2000); do echo noisy-error-line >&2; done; echo done" })) .await .unwrap(); assert!(result.success); assert!(result.output.contains("done")); assert!(result.output.contains("STDERR")); } }