1use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9use super::traits::*;
10use crate::policy::ExecutionPolicy;
11use crate::text::truncate_chars;
12
13pub struct ShellTool {
14 workspace: PathBuf,
15 timeout_secs: u64,
16 policy: Arc<ExecutionPolicy>,
17}
18
19impl ShellTool {
20 pub fn new(workspace: PathBuf, policy: Arc<ExecutionPolicy>) -> Self {
21 Self {
22 workspace,
23 timeout_secs: 120,
24 policy,
25 }
26 }
27
28 pub fn with_timeout(mut self, secs: u64) -> Self {
29 self.timeout_secs = secs;
30 self
31 }
32}
33
34#[derive(Deserialize)]
35struct ShellArgs {
36 command: String,
37 #[serde(alias = "workdir")]
39 cwd: Option<String>,
40 timeout: Option<u64>,
42}
43
44#[async_trait]
45impl Tool for ShellTool {
46 fn name(&self) -> &str {
47 "exec"
48 }
49
50 fn spec(&self) -> ToolSpec {
51 ToolSpec {
52 name: "exec".to_string(),
53 description: "Execute shell commands. Returns stdout/stderr and exit code.".to_string(),
54 parameters: serde_json::json!({
55 "type": "object",
56 "properties": {
57 "command": {
58 "type": "string",
59 "description": "Shell command to execute"
60 },
61 "cwd": {
62 "type": "string",
63 "description": "Working directory (defaults to workspace)"
64 },
65 "timeout": {
66 "type": "integer",
67 "description": "Timeout in seconds (default 120)"
68 }
69 },
70 "required": ["command"]
71 }),
72 }
73 }
74
75 async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
76 if !self.policy.allow_shell {
77 return ExecutionPolicy::deny("Shell execution is disabled by policy");
78 }
79
80 let args: ShellArgs = serde_json::from_str(arguments)?;
81
82 if let Some(reason) = check_catastrophic_command(&args.command) {
84 return Ok(ToolResult::error(format!(
85 "⛔ Blocked catastrophic command: {}",
86 reason
87 )));
88 }
89
90 let cmd_lower = args.command.to_lowercase();
92 let self_name = std::env::current_exe()
93 .ok()
94 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
95 .unwrap_or_else(|| "apollo".to_string());
96
97 if (cmd_lower.contains("systemctl") && cmd_lower.contains(&self_name))
98 || (cmd_lower.contains("pkill") && cmd_lower.contains(&self_name))
99 || (cmd_lower.contains("kill") && cmd_lower.contains(&self_name))
100 || cmd_lower.contains("shutdown")
101 || cmd_lower.contains("reboot")
102 {
103 return Ok(ToolResult::error(
104 "⚠️ Restricted command: Cannot restart/kill the host or agent mid-conversation.",
105 ));
106 }
107
108 let timeout = args.timeout.unwrap_or(self.timeout_secs);
109
110 let cwd = if let Some(dir) = &args.cwd {
111 let requested = if dir.starts_with('/') {
112 PathBuf::from(dir)
113 } else {
114 self.workspace.join(dir)
115 };
116
117 let canonical_workspace = self
119 .workspace
120 .canonicalize()
121 .unwrap_or_else(|_| self.workspace.clone());
122 let canonical_requested = requested.canonicalize().unwrap_or(requested);
123
124 if !canonical_requested.starts_with(&canonical_workspace) {
125 return Ok(ToolResult::error(format!(
126 "Access denied: directory '{}' is outside the workspace.",
127 dir
128 )));
129 }
130 canonical_requested
131 } else {
132 self.workspace.clone()
133 };
134
135 let child = tokio::process::Command::new("bash")
136 .arg("-c")
137 .arg(&args.command)
138 .current_dir(&cwd)
139 .stdout(std::process::Stdio::piped())
140 .stderr(std::process::Stdio::piped())
141 .spawn()?;
142
143 let output = match tokio::time::timeout(
144 Duration::from_secs(timeout),
145 child.wait_with_output(),
146 )
147 .await
148 {
149 Ok(result) => result?,
150 Err(_) => {
151 return Ok(ToolResult::error(format!(
152 "Command timed out after {}s",
153 timeout
154 )));
155 }
156 };
157
158 let stdout = String::from_utf8_lossy(&output.stdout);
159 let stderr = String::from_utf8_lossy(&output.stderr);
160
161 let result = if stdout.is_empty() && !stderr.is_empty() {
162 stderr.to_string()
163 } else if !stderr.is_empty() {
164 format!("{}\n{}", stdout, stderr)
165 } else {
166 stdout.to_string()
167 };
168
169 let truncated = if result.len() > 20_000 {
171 format!(
172 "{}...\n[truncated {} chars]",
173 truncate_chars(&result, 20_000),
174 result.len() - 20_000
175 )
176 } else {
177 result
178 };
179
180 Ok(if output.status.success() {
181 ToolResult::success(truncated)
182 } else {
183 ToolResult::error(format!(
184 "Exit code {}: {}",
185 output.status.code().unwrap_or(-1),
186 truncated
187 ))
188 })
189 }
190}
191
192fn check_catastrophic_command(cmd: &str) -> Option<&'static str> {
194 let lower = cmd.to_lowercase();
195
196 if (lower.contains("rm ")
198 && lower.contains("-rf")
199 && (lower.contains(" /") || lower.contains(" *")))
200 || (lower.contains("rm ")
201 && lower.contains("-fr")
202 && (lower.contains(" /") || lower.contains(" *")))
203 {
204 return Some("Destructive recursive delete on root or wildcard.");
205 }
206
207 if lower.contains("mkfs") || lower.contains("fdisk") || lower.contains("dd if=") {
209 return Some("Disk formatting or low-level block write.");
210 }
211
212 if lower.contains(":(){ :|:& };:") {
214 return Some("Fork bomb.");
215 }
216
217 None
218}