Skip to main content

apollo/tools/
shell.rs

1//! Shell/exec tool — execute commands (matches OpenClaw's exec tool).
2
3use 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
14/// Upper bound on a model-supplied timeout — an hour is already far past any
15/// legitimate single command, and beyond it the agent loop is simply stuck.
16const 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    /// Working directory (defaults to workspace)
43    #[serde(alias = "workdir")]
44    cwd: Option<String>,
45    /// Timeout in seconds
46    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        // Guard: block catastrophic commands unconditionally
88        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        // Guard: prevent self-restart/self-kill mid-conversation
96        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        // The model supplies this, so clamp it: an unbounded value parks the
114        // agent loop on a hung command with no way back.
115        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            // Canonicalize to ensure it's inside workspace
128            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            // A timed-out command must die with the tool call, not keep
153            // running against the workspace while the model retries.
154            .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        // Truncate if too long
181        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
198/// Helper: block catastrophic commands like `rm -rf /` or `mkfs`.
199///
200/// ponytail: a typo-and-accident guard, not a security boundary. A determined
201/// caller can always evade it (`eval`, base64, a shell script, an alias), so
202/// the real controls are `policy.allow_shell` and the permission hooks. Keep
203/// this cheap and obvious rather than growing it into a pattern zoo that
204/// invites misplaced trust.
205pub(crate) fn check_catastrophic_command(cmd: &str) -> Option<&'static str> {
206    let lower = cmd.to_lowercase();
207
208    // Whitespace-insensitive, so reflowed variants still match.
209    if lower
210        .chars()
211        .filter(|c| !c.is_whitespace())
212        .collect::<String>()
213        .contains(":(){:|:&};:")
214    {
215        return Some("Fork bomb.");
216    }
217
218    // Check each command in a pipeline or list separately, so the target of an
219    // `rm` is not confused with an argument to something else.
220    lower
221        .split(['&', '|', ';', '\n'])
222        .find_map(check_catastrophic_segment)
223}
224
225/// Paths whose recursive deletion destroys the machine or the whole workspace.
226///
227/// Trailing slashes and globs are stripped first, so `/`, `/*` and `/ *` all
228/// reduce to the same root target, while `./build` and `*.log` do not.
229fn 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    // Strip any path prefix so `/bin/rm` is treated as `rm`.
240    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    /// The file sandbox refuses to read `.env`; that is worth nothing if
346    /// `exec` can print the same values back out of its own environment.
347    #[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        // Would panic inside Duration/timeout arithmetic without the clamp.
375        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}