1use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9use super::child_proc;
10use super::traits::*;
11use crate::policy::ExecutionPolicy;
12use crate::text::truncate_chars_counted;
13
14const MAX_TIMEOUT_SECS: u64 = 3600;
17
18pub struct ShellTool {
19 workspace: PathBuf,
20 timeout_secs: u64,
21 policy: Arc<ExecutionPolicy>,
22}
23
24impl ShellTool {
25 pub fn new(workspace: PathBuf, policy: Arc<ExecutionPolicy>) -> Self {
26 Self {
27 workspace,
28 timeout_secs: 120,
29 policy,
30 }
31 }
32
33 pub fn with_timeout(mut self, secs: u64) -> Self {
34 self.timeout_secs = secs;
35 self
36 }
37}
38
39#[derive(Deserialize)]
40struct ShellArgs {
41 command: String,
42 #[serde(alias = "workdir")]
44 cwd: Option<String>,
45 timeout: Option<u64>,
47}
48
49#[async_trait]
50impl Tool for ShellTool {
51 fn name(&self) -> &str {
52 "exec"
53 }
54
55 fn spec(&self) -> ToolSpec {
56 ToolSpec {
57 name: "exec".to_string(),
58 description: "Execute shell commands. Returns stdout/stderr and exit code.".to_string(),
59 parameters: serde_json::json!({
60 "type": "object",
61 "properties": {
62 "command": {
63 "type": "string",
64 "description": "Shell command to execute"
65 },
66 "cwd": {
67 "type": "string",
68 "description": "Working directory (defaults to workspace)"
69 },
70 "timeout": {
71 "type": "integer",
72 "description": "Timeout in seconds (default 120)"
73 }
74 },
75 "required": ["command"]
76 }),
77 }
78 }
79
80 async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
81 if !self.policy.allow_shell {
82 return ExecutionPolicy::deny("Shell execution is disabled by policy");
83 }
84
85 let args: ShellArgs = serde_json::from_str(arguments)?;
86
87 if let Err(reason) = self.policy.check_shell_command(&args.command) {
89 return Ok(ToolResult::error(format!(
90 "⛔ Blocked catastrophic command: {}",
91 reason
92 )));
93 }
94
95 let cmd_lower = args.command.to_lowercase();
97 let self_name = std::env::current_exe()
98 .ok()
99 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
100 .unwrap_or_else(|| "apollo".to_string());
101
102 if (cmd_lower.contains("systemctl") && cmd_lower.contains(&self_name))
103 || (cmd_lower.contains("pkill") && cmd_lower.contains(&self_name))
104 || (cmd_lower.contains("kill") && cmd_lower.contains(&self_name))
105 || cmd_lower.contains("shutdown")
106 || cmd_lower.contains("reboot")
107 {
108 return Ok(ToolResult::error(
109 "⚠️ Restricted command: Cannot restart/kill the host or agent mid-conversation.",
110 ));
111 }
112
113 let timeout = args
116 .timeout
117 .unwrap_or(self.timeout_secs)
118 .clamp(1, MAX_TIMEOUT_SECS);
119
120 let cwd = if let Some(dir) = &args.cwd {
121 let requested = if dir.starts_with('/') {
122 PathBuf::from(dir)
123 } else {
124 self.workspace.join(dir)
125 };
126
127 let canonical_workspace = self
129 .workspace
130 .canonicalize()
131 .unwrap_or_else(|_| self.workspace.clone());
132 let canonical_requested = requested.canonicalize().unwrap_or(requested);
133
134 if !canonical_requested.starts_with(&canonical_workspace) {
135 return Ok(ToolResult::error(format!(
136 "Access denied: directory '{}' is outside the workspace.",
137 dir
138 )));
139 }
140 canonical_requested
141 } else {
142 self.workspace.clone()
143 };
144
145 let mut command = tokio::process::Command::new("bash");
146 command
147 .arg("-c")
148 .arg(&args.command)
149 .current_dir(&cwd)
150 .stdout(std::process::Stdio::piped())
151 .stderr(std::process::Stdio::piped())
152 .kill_on_drop(true);
155 child_proc::scrub(&mut command);
156 let mut child = command.spawn()?;
157
158 let output =
159 match child_proc::wait_with_timeout(&mut child, Duration::from_secs(timeout)).await? {
160 Some(output) => output,
161 None => {
162 return Ok(ToolResult::error(format!(
163 "Command timed out after {}s",
164 timeout
165 )));
166 }
167 };
168
169 let stdout = String::from_utf8_lossy(&output.stdout);
170 let stderr = String::from_utf8_lossy(&output.stderr);
171
172 let result = if stdout.is_empty() && !stderr.is_empty() {
173 stderr.to_string()
174 } else if !stderr.is_empty() {
175 format!("{}\n{}", stdout, stderr)
176 } else {
177 stdout.to_string()
178 };
179
180 let truncated = match truncate_chars_counted(&result, 20_000) {
182 Some((head, dropped)) => format!("{}...\n[truncated {} chars]", head, dropped),
183 None => result,
184 };
185
186 Ok(if output.status.success() {
187 ToolResult::success(truncated)
188 } else {
189 ToolResult::error(format!(
190 "Exit code {}: {}",
191 output.status.code().unwrap_or(-1),
192 truncated
193 ))
194 })
195 }
196}
197
198pub(crate) fn check_catastrophic_command(cmd: &str) -> Option<&'static str> {
206 let lower = cmd.to_lowercase();
207
208 if lower
210 .chars()
211 .filter(|c| !c.is_whitespace())
212 .collect::<String>()
213 .contains(":(){:|:&};:")
214 {
215 return Some("Fork bomb.");
216 }
217
218 lower
221 .split(['&', '|', ';', '\n'])
222 .find_map(check_catastrophic_segment)
223}
224
225fn is_catastrophic_target(token: &str) -> bool {
230 matches!(
231 token.trim_end_matches(['*', '/']),
232 "" | "." | "~" | "$home" | "${home}"
233 )
234}
235
236fn check_catastrophic_segment(segment: &str) -> Option<&'static str> {
237 let mut tokens = segment.split_whitespace();
238 let program = tokens.next()?;
239 let program = program.rsplit('/').next().unwrap_or(program);
241 let args: Vec<&str> = tokens.collect();
242
243 if program.starts_with("mkfs") || program == "fdisk" {
244 return Some("Disk formatting or low-level block write.");
245 }
246
247 if program == "dd" {
248 if args
249 .iter()
250 .any(|a| a.starts_with("of=/dev/") || a.starts_with("if=/dev/"))
251 {
252 return Some("Disk formatting or low-level block write.");
253 }
254 return None;
255 }
256
257 if program != "rm" {
258 return None;
259 }
260
261 let recursive = args.iter().any(|a| {
262 *a == "--recursive" || (a.starts_with('-') && !a.starts_with("--") && a.contains('r'))
263 });
264 if !recursive {
265 return None;
266 }
267
268 if args.contains(&"--no-preserve-root") {
269 return Some("Destructive recursive delete on root or wildcard.");
270 }
271
272 let targets_root = args
273 .iter()
274 .filter(|a| !a.starts_with('-'))
275 .any(|a| is_catastrophic_target(a));
276
277 targets_root.then_some("Destructive recursive delete on root or wildcard.")
278}
279
280#[cfg(test)]
281mod tests {
282 use super::check_catastrophic_command;
283
284 #[test]
285 fn blocks_recursive_deletes_of_root_in_any_flag_form() {
286 for cmd in [
287 "rm -rf /",
288 "rm -rf /*",
289 "rm -fr /",
290 "rm -r -f /",
291 "rm --recursive --force /",
292 "rm -rf /",
293 "rm -rf ~",
294 "rm -rf ~/",
295 "rm -rf $HOME",
296 "rm -rf .",
297 "rm -rf *",
298 "/bin/rm -rf /",
299 "rm -r --no-preserve-root /tmp/x",
300 "echo hi && rm -rf /",
301 ] {
302 assert!(
303 check_catastrophic_command(cmd).is_some(),
304 "should have blocked: {cmd}"
305 );
306 }
307 }
308
309 #[test]
310 fn blocks_disk_writes_and_fork_bombs() {
311 for cmd in [
312 "mkfs.ext4 /dev/sda1",
313 "fdisk /dev/sda",
314 "dd if=/dev/zero of=/dev/sda",
315 "dd of=/dev/sda if=/dev/zero",
316 ":(){ :|:& };:",
317 ":(){:|:&};:",
318 ] {
319 assert!(
320 check_catastrophic_command(cmd).is_some(),
321 "should have blocked: {cmd}"
322 );
323 }
324 }
325
326 #[test]
327 fn allows_ordinary_work() {
328 for cmd in [
329 "rm -rf ./build",
330 "rm -rf target",
331 "rm -rf node_modules",
332 "rm file.txt",
333 "cargo build --release",
334 "dd if=input.img of=output.img",
335 "git status",
336 "grep -r 'rm -rf /' src",
337 ] {
338 assert!(
339 check_catastrophic_command(cmd).is_none(),
340 "should have allowed: {cmd}"
341 );
342 }
343 }
344
345 #[tokio::test]
348 async fn secrets_do_not_reach_the_child_but_path_does() {
349 let tmp = tempfile::tempdir().unwrap();
350 let tool = super::ShellTool::new(
351 tmp.path().to_path_buf(),
352 std::sync::Arc::new(crate::policy::ExecutionPolicy::default()),
353 );
354 let args = serde_json::json!({ "command": "env" }).to_string();
355 let result = crate::tools::Tool::execute(&tool, &args).await.unwrap();
356 for line in result.output.lines() {
357 let name = line.split('=').next().unwrap_or_default();
358 assert!(
359 !crate::tools::child_proc::is_secret_env_name(name),
360 "secret-bearing variable {name} reached the child"
361 );
362 }
363 assert!(result.output.contains("PATH="), "PATH must survive");
364 }
365
366 #[tokio::test]
367 async fn an_over_long_timeout_is_clamped() {
368 let tmp = tempfile::tempdir().unwrap();
369 let tool = super::ShellTool::new(
370 tmp.path().to_path_buf(),
371 std::sync::Arc::new(crate::policy::ExecutionPolicy::default()),
372 );
373 let args = serde_json::json!({ "command": "true", "timeout": u64::MAX }).to_string();
374 let result = crate::tools::Tool::execute(&tool, &args).await.unwrap();
376 assert!(!result.is_error, "got: {}", result.output);
377 }
378
379 #[tokio::test]
380 async fn a_timed_out_command_is_killed_not_left_running() {
381 let tmp = tempfile::tempdir().unwrap();
382 let marker = tmp.path().join("still-alive");
383 let tool = super::ShellTool::new(
384 tmp.path().to_path_buf(),
385 std::sync::Arc::new(crate::policy::ExecutionPolicy::default()),
386 );
387 let args = serde_json::json!({
388 "command": format!("sleep 3; touch {}", marker.display()),
389 "timeout": 1,
390 })
391 .to_string();
392 let result = crate::tools::Tool::execute(&tool, &args).await.unwrap();
393 assert!(
394 result.output.contains("timed out"),
395 "got: {}",
396 result.output
397 );
398 tokio::time::sleep(std::time::Duration::from_secs(4)).await;
399 assert!(
400 !marker.exists(),
401 "the killed command must not have kept running to completion"
402 );
403 }
404
405 #[tokio::test]
406 async fn truncates_multibyte_output_by_chars() {
407 let tmp = tempfile::tempdir().unwrap();
408 let tool = super::ShellTool::new(
409 tmp.path().to_path_buf(),
410 std::sync::Arc::new(crate::policy::ExecutionPolicy::default()),
411 );
412 let args = serde_json::json!({
413 "command": "printf '日%.0s' {1..30000}"
414 })
415 .to_string();
416 let result = crate::tools::Tool::execute(&tool, &args).await.unwrap();
417 let body = result.output;
418 assert!(
419 body.chars().count() < 21_000,
420 "multibyte output must be truncated, got {} chars",
421 body.chars().count()
422 );
423 assert!(
424 body.contains("[truncated 10000 chars]"),
425 "footer must report the real dropped char count"
426 );
427 }
428}