Skip to main content

aacode_rs/tools/
shell.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! `run_shell` tool — executes commands through a pluggable `ShellBackend`.
5//!
6//! On desktop the default backend is the real OS shell (`NativeShell`); on
7//! mobile it is the fastshell sandbox engine (`FastshellBackend`). See
8//! `backend.rs`. Returns a JSON string with `{success, returncode, stdout,
9//! stderr, command}`. Oversized output is archived under `.aacode/extracts/`.
10
11use super::backend::ShellBackend;
12use super::registry::Tool;
13use super::schema::{ParamType, ToolParameter, ToolSchema};
14use crate::config::{DangerAction, SafetyConfig};
15use crate::error::{AacodeError, Result};
16use serde_json::{json, Value};
17use std::sync::atomic::AtomicBool;
18use std::sync::Arc;
19
20// Re-export the fastshell handle type for the rest of the crate.
21pub use super::backend::SharedFastshell as SharedShell;
22
23pub struct ShellTool {
24    backend: Arc<dyn ShellBackend>,
25    /// Working directory / archive root for command execution.
26    cwd: std::path::PathBuf,
27    max_output_chars: usize,
28    default_timeout_secs: u64,
29    default_idle_timeout_secs: u64,
30    safety: SafetyConfig,
31}
32
33impl ShellTool {
34    pub fn new(
35        backend: Arc<dyn ShellBackend>,
36        cwd: std::path::PathBuf,
37        max_output_chars: usize,
38        default_timeout_secs: u64,
39        default_idle_timeout_secs: u64,
40        safety: SafetyConfig,
41    ) -> Self {
42        ShellTool {
43            // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
44            backend,
45            cwd,
46            max_output_chars,
47            default_timeout_secs,
48            default_idle_timeout_secs,
49            safety,
50        }
51    }
52
53    /// Archive a large string to `.aacode/extracts/` and return the path.
54    fn archive(&self, content: &str, prefix: &str) -> Option<String> {
55        let dir = self.cwd.join(".aacode").join("extracts");
56        std::fs::create_dir_all(&dir).ok()?;
57        let name = format!("tool_{}_{}.txt", prefix, uuid::Uuid::new_v4().simple());
58        let path = dir.join(&name);
59        std::fs::write(&path, content).ok()?;
60        Some(path.to_string_lossy().to_string())
61    }
62
63    /// Truncate a field, archiving the full content when too long.
64    fn maybe_truncate(&self, text: String, prefix: &str, limit: usize) -> String {
65        if limit == 0 || text.chars().count() <= limit {
66            return text;
67        }
68        let head: String = text.chars().take(limit).collect();
69        let total = text.chars().count();
70        match self.archive(&text, prefix) {
71            Some(path) => format!(
72                "{head}\n\n[max_output={limit} truncated output ({total} chars total). Full output archived at {path}; raise max_output, or read the source file in chunks with head/tail/sed -n.]"
73            ),
74            None => format!(
75                "{head}\n\n[max_output={limit} truncated output ({total} chars total); raise max_output or read in chunks.]"
76            ),
77        }
78    }
79
80    /// Detect blatantly dangerous commands (used only in `reject` mode).
81    fn is_dangerous(command: &str) -> bool {
82        let c = command.to_lowercase();
83        let patterns = [
84            "rm -rf /",
85            "rm -rf /*",
86            ":(){:|:&};:", // fork bomb
87            "mkfs",
88            "dd if=/dev/zero",
89            "> /dev/sda",
90            "chmod -r 000 /",
91        ];
92        patterns.iter().any(|p| c.contains(p))
93    }
94}
95
96#[async_trait::async_trait]
97impl Tool for ShellTool {
98    fn schema(&self) -> ToolSchema {
99        ToolSchema::new(
100            "run_shell",
101            "Execute shell commands — the universal Swiss Army knife. This is the ONLY tool for ALL file operations. Use shell commands for: locating (grep -n pattern file, find), reading full content (cat/tail/head file), writing (echo text > file, cat > file << 'EOF'), editing (sed/awk), running code (python/node/go/rustc/gcc), testing (pytest/cargo test), git, and more. Supports pipes (|), redirection (>), heredocs (<< 'EOF'), chaining (&& / || / ;), command substitution ($(...)), and variable expansion ($VAR). Always returns a result object with stdout, stderr, and returncode — check returncode for success. A returncode of 0 means success — treat the action as done and do not re-run it to verify. Opening actions (`open` / `open_settings`) are idempotent and already echo their result in stdout.",
102            vec![
103                ToolParameter::new(
104                    "command",
105                    ParamType::String,
106                    true,
107                    "The shell command to execute. Supports pipes (|), redirection (>), chaining (&& / ;). Always quote filenames with spaces/special chars.",
108                    &["cmd", "shell", "script", "exec"],
109                ),
110                ToolParameter::new(
111                    "timeout",
112                    ParamType::Integer,
113                    false,
114                    "Total command timeout in seconds.",
115                    &["time_limit", "max_time", "wait"],
116                ),
117                ToolParameter::new(
118                    "idle_timeout",
119                    ParamType::Integer,
120                    false,
121                    "Max seconds with no output before killing (idle timeout). Default: 30.",
122                    &["idle_time", "no_output_timeout"],
123                ),
124                ToolParameter::new(
125                    "stdin_input",
126                    ParamType::String,
127                    false,
128                    "Standard input piped to the program (for input()). Separate lines with \\n.",
129                    &["input", "stdin"],
130                ),
131                ToolParameter::new(
132                    "max_output",
133                    ParamType::Integer,
134                    false,
135                    "Cap returned output characters. Omit (0) = NO truncation — the full output is returned, so read large files in chunks (head/tail, sed -n 'A,Bp', grep -n) or cap explicitly with this param.",
136                    &["max_chars", "limit", "output_limit"],
137                ),
138            ],
139        )
140    }
141
142    async fn call(&self, args: &Value, _cancel: &AtomicBool) -> Result<String> {
143        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
144        let command = args
145            .get("command")
146            .and_then(|v| v.as_str())
147            .unwrap_or("")
148            .to_string();
149
150        // Danger policy.
151        if self.safety.dangerous_command_action == DangerAction::Reject
152            && Self::is_dangerous(&command)
153        {
154            return Ok(json!({
155                "success": false,
156                "error": "Command rejected by safety guard (dangerous pattern).",
157                "command": command,
158            })
159            .to_string());
160        }
161
162        let stdin_input = args.get("stdin_input").and_then(|v| v.as_str());
163        let timeout = args
164            .get("timeout")
165            .and_then(|v| v.as_u64())
166            .unwrap_or(self.default_timeout_secs);
167        let idle_timeout = args
168            .get("idle_timeout")
169            .and_then(|v| v.as_u64())
170            .unwrap_or(self.default_idle_timeout_secs);
171
172        let stdin_owned = stdin_input.map(|s| s.to_string());
173        let backend = self.backend.clone();
174        let cwd = self.cwd.clone();
175        let command2 = command.clone();
176        let result = tokio::task::spawn_blocking(move || {
177            backend.run(
178                &command2,
179                stdin_owned.as_deref(),
180                timeout,
181                idle_timeout,
182                &cwd,
183            )
184        })
185        .await
186        .map_err(|e| AacodeError::Other(format!("shell spawn: {e}")))?;
187
188        // Per-call max_output override; else the tool's configured cap.
189        let limit = args
190            .get("max_output")
191            .and_then(|v| v.as_u64())
192            .map(|n| n as usize)
193            .unwrap_or(self.max_output_chars);
194
195        // Binary stdout must never be lossily pasted into the context. Detect
196        // it, persist it as an artifact, and return only a bounded preview.
197        let stdout_value = if is_binary_text(&result.stdout) {
198            let store = crate::artifacts::ArtifactStore::new(&self.cwd);
199            binary_output_json(&store, "stdout", &result.stdout)
200        } else {
201            json!(self.maybe_truncate(result.stdout, "stdout", limit))
202        };
203        let stderr = self.maybe_truncate(result.stderr, "stderr", limit);
204
205        // `success` must reflect the exit code: a timed-out (124) or failing
206        // command is not a success (the model otherwise treats 124 as OK).
207        let mut out = json!({
208            "success": result.exit_code == 0,
209            "returncode": result.exit_code,
210            "stdout": stdout_value,
211            "stderr": stderr,
212            "backend": self.backend.kind(),
213        });
214        if result.exit_code == 124 {
215            out["hint"] = json!(
216                "command timed out — split it into smaller steps or add explicit per-command timeouts (e.g. `curl -m 20`)"
217            );
218        }
219        Ok(out.to_string())
220    }
221}
222
223/// Does this decoded stdout look like binary (NUL bytes / replacement chars /
224/// control chars)? Used to route it to an artifact instead of the context.
225pub(crate) fn is_binary_text(s: &str) -> bool {
226    if s.is_empty() {
227        return false;
228    }
229    if s.contains('\0') {
230        return true;
231    }
232    let total = s.chars().count();
233    let weird = s
234        .chars()
235        .filter(|c| *c == '\u{FFFD}' || (*c < ' ' && *c != '\n' && *c != '\r' && *c != '\t'))
236        .count();
237    weird * 100 / total.max(1) >= 2
238}
239
240/// Persist a binary stdout blob and return a bounded, model-facing JSON value.
241pub(crate) fn binary_output_json(
242    store: &crate::artifacts::ArtifactStore,
243    label: &str,
244    text: &str,
245) -> Value {
246    let bytes = text.as_bytes();
247    let preview = crate::artifacts::hex_preview(bytes, 64);
248    match store.put_bytes(bytes, None, label) {
249        Ok(r) => json!({
250            "binary": true,
251            "bytes": r.bytes,
252            "mime": r.mime,
253            "artifact": r.path,
254            "preview_hex": preview,
255            "truncated": r.bytes > 64,
256            "hint": "Output is binary; saved as an artifact. Inspect a bounded sample with run_shell (e.g. `xxd -l 64 <path>` / `od -N 64 -tx1 <path>` / `head -c 64 <path>`), or read the source file directly.",
257        }),
258        Err(_) => json!({
259            "binary": true,
260            "bytes": bytes.len(),
261            "preview_hex": preview,
262            "truncated": bytes.len() > 64,
263        }),
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::super::backend::{FastshellBackend, NativeShell};
270
271    #[test]
272    fn binary_stdout_detected_and_artifacted() {
273        assert!(is_binary_text("a\0b"));
274        assert!(is_binary_text(&"\u{FFFD}".repeat(50)));
275        assert!(!is_binary_text("normal text\nwith lines\n"));
276        let dir = std::env::temp_dir().join(format!("aacode_shellbin_{}", uuid::Uuid::new_v4()));
277        std::fs::create_dir_all(&dir).unwrap();
278        let store = crate::artifacts::ArtifactStore::new(&dir);
279        let v = binary_output_json(&store, "stdout", "PNG\u{FFFD}\u{FFFD}\u{FFFD}data");
280        assert_eq!(v["binary"], true);
281        assert!(v.get("artifact").and_then(|x| x.as_str()).is_some());
282        assert!(v["preview_hex"].as_str().unwrap().len() > 4);
283        assert!(v["truncated"].is_boolean());
284    }
285    use super::*;
286    use fastshell::{Config, Fastshell};
287    use std::sync::Mutex;
288
289    fn native_tool(cwd: std::path::PathBuf) -> ShellTool {
290        ShellTool::new(
291            Arc::new(NativeShell::new()),
292            cwd,
293            24000,
294            30,
295            30,
296            SafetyConfig::default(),
297        )
298    }
299
300    fn tmp() -> std::path::PathBuf {
301        let d = std::env::temp_dir().join(format!(
302            "aacode_shelltool_{}_{}",
303            std::process::id(),
304            uuid::Uuid::new_v4().simple()
305        ));
306        std::fs::create_dir_all(&d).unwrap();
307        d
308    }
309
310    #[tokio::test]
311    async fn native_echo_roundtrip() {
312        let dir = tmp();
313        let t = native_tool(dir);
314        let cancel = AtomicBool::new(false);
315        let out = t
316            .call(&json!({"command": "echo hello"}), &cancel)
317            .await
318            .unwrap();
319        let v: Value = serde_json::from_str(&out).unwrap();
320        assert_eq!(v["success"], true);
321        assert_eq!(v["returncode"], 0);
322        assert_eq!(v["backend"], "native");
323        assert!(v["stdout"].as_str().unwrap().contains("hello"));
324    }
325
326    #[tokio::test]
327    async fn native_writes_to_real_cwd() {
328        let dir = tmp();
329        let t = native_tool(dir.clone());
330        let cancel = AtomicBool::new(false);
331        t.call(&json!({"command": "echo content > note.txt"}), &cancel)
332            .await
333            .unwrap();
334        assert!(dir.join("note.txt").exists());
335    }
336
337    #[tokio::test]
338    async fn native_heredoc_via_tool() {
339        let dir = tmp();
340        let t = native_tool(dir);
341        let cancel = AtomicBool::new(false);
342        let out = t
343            .call(
344                &json!({"command": "cat > x.txt <<'EOF'\nhi\nEOF\ncat x.txt"}),
345                &cancel,
346            )
347            .await
348            .unwrap();
349        let v: Value = serde_json::from_str(&out).unwrap();
350        assert!(v["stdout"].as_str().unwrap().contains("hi"));
351    }
352
353    #[tokio::test]
354    async fn max_output_truncation() {
355        let dir = tmp();
356        let t = native_tool(dir);
357        let cancel = AtomicBool::new(false);
358        let out = t.call(&json!({"command": "for i in $(seq 1 500); do echo linelineline; done", "max_output": 100}), &cancel).await.unwrap();
359        let v: Value = serde_json::from_str(&out).unwrap();
360        assert!(v["stdout"].as_str().unwrap().len() < 5000);
361    }
362
363    /// Default (max_output_chars = 0) must NOT truncate plain text — the model
364    /// reads its own files at full length.
365    #[tokio::test]
366    async fn unlimited_output_by_default() {
367        let dir = tmp();
368        let t = ShellTool::new(
369            Arc::new(NativeShell::new()),
370            dir,
371            0,
372            30,
373            30,
374            SafetyConfig::default(),
375        );
376        let cancel = AtomicBool::new(false);
377        let out = t
378            .call(
379                &json!({"command": "for i in $(seq 1 5000); do echo linelineline; done"}),
380                &cancel,
381            )
382            .await
383            .unwrap();
384        let v: Value = serde_json::from_str(&out).unwrap();
385        let s = v["stdout"].as_str().unwrap();
386        assert!(s.len() > 50_000, "unexpected truncation, len={}", s.len());
387        assert!(!s.contains("truncated"));
388    }
389
390    #[tokio::test]
391    async fn dangerous_reject_mode() {
392        let dir = tmp();
393        let mut safety = SafetyConfig::default();
394        safety.dangerous_command_action = DangerAction::Reject;
395        let t = ShellTool::new(Arc::new(NativeShell::new()), dir, 24000, 30, 30, safety);
396        let cancel = AtomicBool::new(false);
397        let out = t
398            .call(&json!({"command": "rm -rf /"}), &cancel)
399            .await
400            .unwrap();
401        let v: Value = serde_json::from_str(&out).unwrap();
402        assert_eq!(v["success"], false);
403    }
404
405    #[tokio::test]
406    async fn fastshell_backend_still_works() {
407        let dir = tmp();
408        let mut fs = Fastshell::new();
409        let mut cfg = Config::default();
410        cfg.sandbox_path = dir.to_string_lossy().to_string();
411        cfg.python_enabled = false;
412        fs.init(cfg).unwrap();
413        let backend = Arc::new(FastshellBackend::new(Arc::new(Mutex::new(fs))));
414        let t = ShellTool::new(backend, dir, 24000, 30, 30, SafetyConfig::default());
415        let cancel = AtomicBool::new(false);
416        let out = t
417            .call(&json!({"command": "echo sandboxed"}), &cancel)
418            .await
419            .unwrap();
420        let v: Value = serde_json::from_str(&out).unwrap();
421        assert_eq!(v["backend"], "fastshell");
422        assert!(v["stdout"].as_str().unwrap().contains("sandboxed"));
423    }
424
425    #[test]
426    fn is_dangerous_detects() {
427        assert!(ShellTool::is_dangerous("rm -rf /"));
428        assert!(!ShellTool::is_dangerous("ls -la"));
429    }
430}