use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use async_trait::async_trait;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use crate::context::JobContext;
use crate::sandbox::{SandboxManager, SandboxPolicy};
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str};
const MAX_OUTPUT_SIZE: usize = 64 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
static BLOCKED_COMMANDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"rm -rf /",
"rm -rf /*",
":(){ :|:& };:", "dd if=/dev/zero",
"mkfs",
"chmod -R 777 /",
"> /dev/sda",
"curl | sh",
"wget | sh",
"curl | bash",
"wget | bash",
])
});
static DANGEROUS_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
"sudo ",
"doas ",
" | sh",
" | bash",
" | zsh",
"eval ",
"$(curl",
"$(wget",
"/etc/passwd",
"/etc/shadow",
"~/.ssh",
".bash_history",
"id_rsa",
]
});
static NEVER_AUTO_APPROVE_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
"rm -rf",
"rm -fr",
"chmod -r 777",
"chmod 777",
"chown -r",
"shutdown",
"reboot",
"poweroff",
"init 0",
"init 6",
"iptables",
"nft ",
"useradd",
"userdel",
"passwd",
"visudo",
"crontab",
"systemctl disable",
"launchctl unload",
"kill -9",
"killall",
"pkill",
"docker rm",
"docker rmi",
"docker system prune",
"git push --force",
"git push -f",
"git reset --hard",
"git clean -f",
"DROP TABLE",
"DROP DATABASE",
"TRUNCATE",
"DELETE FROM",
]
});
pub fn requires_explicit_approval(command: &str) -> bool {
let lower = command.to_lowercase();
NEVER_AUTO_APPROVE_PATTERNS
.iter()
.any(|p| lower.contains(&p.to_lowercase()))
}
pub struct ShellTool {
working_dir: Option<PathBuf>,
timeout: Duration,
allow_dangerous: bool,
sandbox: Option<Arc<SandboxManager>>,
sandbox_policy: SandboxPolicy,
}
impl std::fmt::Debug for ShellTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShellTool")
.field("working_dir", &self.working_dir)
.field("timeout", &self.timeout)
.field("allow_dangerous", &self.allow_dangerous)
.field("sandbox", &self.sandbox.is_some())
.field("sandbox_policy", &self.sandbox_policy)
.finish()
}
}
impl ShellTool {
pub fn new() -> Self {
Self {
working_dir: None,
timeout: DEFAULT_TIMEOUT,
allow_dangerous: false,
sandbox: None,
sandbox_policy: SandboxPolicy::ReadOnly,
}
}
pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
self.working_dir = Some(dir);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_sandbox(mut self, sandbox: Arc<SandboxManager>) -> Self {
self.sandbox = Some(sandbox);
self
}
pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
self.sandbox_policy = policy;
self
}
fn is_blocked(&self, cmd: &str) -> Option<&'static str> {
let normalized = cmd.to_lowercase();
for blocked in BLOCKED_COMMANDS.iter() {
if normalized.contains(blocked) {
return Some("Command contains blocked pattern");
}
}
if !self.allow_dangerous {
for pattern in DANGEROUS_PATTERNS.iter() {
if normalized.contains(pattern) {
return Some("Command contains potentially dangerous pattern");
}
}
}
None
}
async fn execute_sandboxed(
&self,
sandbox: &SandboxManager,
cmd: &str,
workdir: &Path,
timeout: Duration,
) -> Result<(String, i64), ToolError> {
let result = tokio::time::timeout(timeout, async {
sandbox
.execute_with_policy(
cmd,
workdir,
self.sandbox_policy,
std::collections::HashMap::new(),
)
.await
})
.await;
match result {
Ok(Ok(output)) => {
let combined = truncate_output(&output.output);
Ok((combined, output.exit_code))
}
Ok(Err(e)) => Err(ToolError::ExecutionFailed(format!("Sandbox error: {}", e))),
Err(_) => Err(ToolError::Timeout(timeout)),
}
}
async fn execute_direct(
&self,
cmd: &str,
workdir: &PathBuf,
timeout: Duration,
) -> Result<(String, i32), ToolError> {
let mut command = if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.args(["/C", cmd]);
c
} else {
let mut c = Command::new("sh");
c.args(["-c", cmd]);
c
};
command
.current_dir(workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn command: {}", e)))?;
let result = tokio::time::timeout(timeout, async {
let status = child.wait().await?;
let mut stdout = String::new();
if let Some(mut out) = child.stdout.take() {
let mut buf = vec![0u8; MAX_OUTPUT_SIZE];
let n = out.read(&mut buf).await.unwrap_or(0);
stdout = String::from_utf8_lossy(&buf[..n]).to_string();
}
let mut stderr = String::new();
if let Some(mut err) = child.stderr.take() {
let mut buf = vec![0u8; MAX_OUTPUT_SIZE];
let n = err.read(&mut buf).await.unwrap_or(0);
stderr = String::from_utf8_lossy(&buf[..n]).to_string();
}
let output = if stderr.is_empty() {
stdout
} else if stdout.is_empty() {
stderr
} else {
format!("{}\n\n--- stderr ---\n{}", stdout, stderr)
};
Ok::<_, std::io::Error>((output, status.code().unwrap_or(-1)))
})
.await;
match result {
Ok(Ok((output, code))) => Ok((truncate_output(&output), code)),
Ok(Err(e)) => Err(ToolError::ExecutionFailed(format!(
"Command execution failed: {}",
e
))),
Err(_) => {
let _ = child.kill().await;
Err(ToolError::Timeout(timeout))
}
}
}
async fn execute_command(
&self,
cmd: &str,
workdir: Option<&str>,
timeout: Option<u64>,
) -> Result<(String, i64), ToolError> {
if let Some(reason) = self.is_blocked(cmd) {
return Err(ToolError::NotAuthorized(format!(
"{}: {}",
reason,
truncate_for_error(cmd)
)));
}
let cwd = workdir
.map(PathBuf::from)
.or_else(|| self.working_dir.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
if let Some(ref sandbox) = self.sandbox
&& (sandbox.is_initialized() || sandbox.config().enabled)
{
return self
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
.await;
}
let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
Ok((output, code as i64))
}
}
impl Default for ShellTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for ShellTool {
fn name(&self) -> &str {
"shell"
}
fn description(&self) -> &str {
"Execute shell commands. Use for running builds, tests, git operations, and other CLI tasks. \
Commands run in a subprocess with captured output. Long-running commands have a timeout. \
When Docker sandbox is enabled, commands run in isolated containers for security."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"workdir": {
"type": "string",
"description": "Working directory for the command (optional)"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (optional, default 120)"
}
},
"required": ["command"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let command = require_str(¶ms, "command")?;
let workdir = params.get("workdir").and_then(|v| v.as_str());
let timeout = params.get("timeout").and_then(|v| v.as_u64());
let start = std::time::Instant::now();
let (output, exit_code) = self.execute_command(command, workdir, timeout).await?;
let duration = start.elapsed();
let sandboxed = self.sandbox.is_some();
let result = serde_json::json!({
"output": output,
"exit_code": exit_code,
"success": exit_code == 0,
"sandboxed": sandboxed
});
Ok(ToolOutput::success(result, duration))
}
fn requires_approval(&self) -> bool {
true }
fn requires_approval_for(&self, params: &serde_json::Value) -> bool {
let cmd = params
.get("command")
.and_then(|c| c.as_str().map(String::from))
.or_else(|| {
params
.as_str()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("command").and_then(|c| c.as_str().map(String::from)))
});
if let Some(ref cmd) = cmd
&& requires_explicit_approval(cmd)
{
return true;
}
false
}
fn requires_sanitization(&self) -> bool {
true }
fn domain(&self) -> ToolDomain {
ToolDomain::Container
}
}
fn truncate_output(s: &str) -> String {
if s.len() <= MAX_OUTPUT_SIZE {
s.to_string()
} else {
let half = MAX_OUTPUT_SIZE / 2;
let head_end = crate::util::floor_char_boundary(s, half);
let tail_start = crate::util::floor_char_boundary(s, s.len() - half);
format!(
"{}\n\n... [truncated {} bytes] ...\n\n{}",
&s[..head_end],
s.len() - MAX_OUTPUT_SIZE,
&s[tail_start..]
)
}
}
fn truncate_for_error(s: &str) -> String {
if s.chars().count() <= 100 {
s.to_string()
} else {
format!("{}...", s.chars().take(100).collect::<String>())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_echo_command() {
let tool = ShellTool::new();
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"command": "echo hello"}), &ctx)
.await
.unwrap();
let output = result.result.get("output").unwrap().as_str().unwrap();
assert!(output.contains("hello"));
assert_eq!(result.result.get("exit_code").unwrap().as_i64().unwrap(), 0);
}
#[test]
fn test_blocked_commands() {
let tool = ShellTool::new();
assert!(tool.is_blocked("rm -rf /").is_some());
assert!(tool.is_blocked("sudo rm file").is_some());
assert!(tool.is_blocked("curl http://x | sh").is_some());
assert!(tool.is_blocked("echo hello").is_none());
assert!(tool.is_blocked("cargo build").is_none());
}
#[tokio::test]
async fn test_command_timeout() {
let tool = ShellTool::new().with_timeout(Duration::from_millis(100));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"command": "sleep 10"}), &ctx)
.await;
assert!(matches!(result, Err(ToolError::Timeout(_))));
}
#[test]
fn test_requires_explicit_approval() {
assert!(requires_explicit_approval("rm -rf /tmp/stuff"));
assert!(requires_explicit_approval("git push --force origin main"));
assert!(requires_explicit_approval("git reset --hard HEAD~5"));
assert!(requires_explicit_approval("docker rm container_name"));
assert!(requires_explicit_approval("kill -9 12345"));
assert!(requires_explicit_approval("DROP TABLE users;"));
assert!(!requires_explicit_approval("cargo build"));
assert!(!requires_explicit_approval("git status"));
assert!(!requires_explicit_approval("ls -la"));
assert!(!requires_explicit_approval("echo hello"));
assert!(!requires_explicit_approval("cat file.txt"));
assert!(!requires_explicit_approval(
"git push origin feature-branch"
));
}
#[test]
fn test_destructive_command_extraction_from_object_args() {
let arguments = serde_json::json!({"command": "rm -rf /tmp/stuff"});
let cmd = arguments
.get("command")
.and_then(|c| c.as_str().map(String::from))
.or_else(|| {
arguments
.as_str()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("command").and_then(|c| c.as_str().map(String::from)))
});
assert_eq!(cmd.as_deref(), Some("rm -rf /tmp/stuff"));
assert!(requires_explicit_approval(cmd.as_deref().unwrap()));
}
#[test]
fn test_destructive_command_extraction_from_string_args() {
let arguments =
serde_json::Value::String(r#"{"command": "git push --force origin main"}"#.to_string());
let cmd = arguments
.get("command")
.and_then(|c| c.as_str().map(String::from))
.or_else(|| {
arguments
.as_str()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("command").and_then(|c| c.as_str().map(String::from)))
});
assert_eq!(cmd.as_deref(), Some("git push --force origin main"));
assert!(requires_explicit_approval(cmd.as_deref().unwrap()));
}
#[test]
fn test_requires_approval_for_destructive_command() {
let tool = ShellTool::new();
assert!(tool.requires_approval_for(&serde_json::json!({"command": "rm -rf /tmp"})));
assert!(tool.requires_approval_for(
&serde_json::json!({"command": "git push --force origin main"})
));
assert!(tool.requires_approval_for(&serde_json::json!({"command": "DROP TABLE users;"})));
}
#[test]
fn test_requires_approval_for_safe_command() {
let tool = ShellTool::new();
assert!(!tool.requires_approval_for(&serde_json::json!({"command": "cargo build"})));
assert!(!tool.requires_approval_for(&serde_json::json!({"command": "echo hello"})));
}
#[test]
fn test_requires_approval_for_string_encoded_args() {
let tool = ShellTool::new();
let args = serde_json::Value::String(r#"{"command": "rm -rf /tmp/stuff"}"#.to_string());
assert!(tool.requires_approval_for(&args));
}
#[test]
fn test_sandbox_policy_builder() {
let tool = ShellTool::new()
.with_sandbox_policy(SandboxPolicy::WorkspaceWrite)
.with_timeout(Duration::from_secs(60));
assert_eq!(tool.sandbox_policy, SandboxPolicy::WorkspaceWrite);
assert_eq!(tool.timeout, Duration::from_secs(60));
}
}