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::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    /// Working directory (defaults to workspace)
38    #[serde(alias = "workdir")]
39    cwd: Option<String>,
40    /// Timeout in seconds
41    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        // Guard: block catastrophic commands unconditionally
83        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        // Guard: prevent self-restart/self-kill mid-conversation
91        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            // Canonicalize to ensure it's inside workspace
118            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        // Truncate if too long
170        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
192/// Helper: block catastrophic commands like `rm -rf /` or `mkfs`.
193fn check_catastrophic_command(cmd: &str) -> Option<&'static str> {
194    let lower = cmd.to_lowercase();
195
196    // rm -rf / or similar
197    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    // Disk formatting
208    if lower.contains("mkfs") || lower.contains("fdisk") || lower.contains("dd if=") {
209        return Some("Disk formatting or low-level block write.");
210    }
211
212    // Fork bomb
213    if lower.contains(":(){ :|:& };:") {
214        return Some("Fork bomb.");
215    }
216
217    None
218}