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, UnknownAction};
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
23/// Default output caps for `run_shell` (aligned with the Python AACode
24/// defaults). `full_output=true` (or `max_output=0`) disables them.
25pub const DEFAULT_MAX_OUTPUT_CHARS: usize = 20_000;
26pub const DEFAULT_MAX_OUTPUT_LINES: usize = 500;
27
28pub struct ShellTool {
29    backend: Arc<dyn ShellBackend>,
30    /// Working directory / archive root for command execution.
31    cwd: std::path::PathBuf,
32    /// Configured char cap. 0 = unset → [`DEFAULT_MAX_OUTPUT_CHARS`].
33    max_output_chars: usize,
34    /// Configured line cap. 0 = unset → [`DEFAULT_MAX_OUTPUT_LINES`].
35    max_output_lines: usize,
36    default_timeout_secs: u64,
37    default_idle_timeout_secs: u64,
38    safety: SafetyConfig,
39}
40
41impl ShellTool {
42    #[allow(clippy::too_many_arguments)]
43    pub fn new(
44        backend: Arc<dyn ShellBackend>,
45        cwd: std::path::PathBuf,
46        max_output_chars: usize,
47        max_output_lines: usize,
48        default_timeout_secs: u64,
49        default_idle_timeout_secs: u64,
50        safety: SafetyConfig,
51    ) -> Self {
52        ShellTool {
53            // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
54            backend,
55            cwd,
56            max_output_chars,
57            max_output_lines,
58            default_timeout_secs,
59            default_idle_timeout_secs,
60            safety,
61        }
62    }
63
64    /// Archive a large string to `.aacode/extracts/` and return the path.
65    fn archive(&self, content: &str, prefix: &str) -> Option<String> {
66        let dir = self.cwd.join(".aacode").join("extracts");
67        std::fs::create_dir_all(&dir).ok()?;
68        let name = format!("tool_{}_{}.txt", prefix, uuid::Uuid::new_v4().simple());
69        let path = dir.join(&name);
70        std::fs::write(&path, content).ok()?;
71        Some(path.to_string_lossy().to_string())
72    }
73
74    /// Apply the line cap first, then the char cap. When either truncates, the
75    /// **full** output is archived once so the model can grep/read it later.
76    fn maybe_truncate(
77        &self,
78        text: &str,
79        prefix: &str,
80        char_limit: usize,
81        line_limit: usize,
82    ) -> String {
83        if text.is_empty() {
84            return String::new();
85        }
86        let mut truncated = false;
87        let mut body = text.to_string();
88
89        if line_limit > 0 {
90            let mut newlines = 0usize;
91            let mut cut = None;
92            for (i, ch) in body.char_indices() {
93                if ch == '\n' {
94                    newlines += 1;
95                    if newlines == line_limit {
96                        cut = Some(i + 1);
97                        break;
98                    }
99                }
100            }
101            if let Some(cut) = cut {
102                if cut < body.len() {
103                    body.truncate(cut);
104                    truncated = true;
105                }
106            }
107        }
108
109        if char_limit > 0 && body.chars().count() > char_limit {
110            body = body.chars().take(char_limit).collect();
111            truncated = true;
112        }
113
114        if !truncated {
115            return body;
116        }
117
118        let total = text.chars().count();
119        match self.archive(text, prefix) {
120            Some(path) => format!(
121                "{body}\n\n[output truncated: max_output={char_limit} chars / {line_limit} lines ({total} chars total). Full output archived at {path}; raise max_output, pass full_output=true, or read it in chunks with head/tail/sed -n.]"
122            ),
123            None => format!(
124                "{body}\n\n[output truncated: max_output={char_limit} chars / {line_limit} lines ({total} chars total); raise max_output or pass full_output=true.]"
125            ),
126        }
127    }
128
129    /// First executable word, skipping leading `VAR=value` assignments.
130    fn first_command(command: &str) -> String {
131        for raw in command.split_whitespace() {
132            let token = raw.trim_matches(|c| {
133                matches!(
134                    c,
135                    '"' | '\'' | '(' | ')' | '{' | '}' | '[' | ']' | ';' | '&' | '|'
136                )
137            });
138            if token.is_empty() {
139                continue;
140            }
141            if let Some((key, _)) = token.split_once('=') {
142                if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
143                    continue;
144                }
145            }
146            return token.to_string();
147        }
148        String::new()
149    }
150
151    /// Commands whose arguments are data, not commands (avoid false positives
152    /// like `grep "rm -rf /" file`). `find` only counts when it doesn't run or
153    /// delete anything.
154    fn is_data_command(name: &str, command: &str) -> bool {
155        const DATA: &[&str] = &[
156            "echo", "printf", "rg", "ripgrep", "grep", "egrep", "fgrep", "git", "sed", "awk",
157            "cat", "head", "tail", "ls", "find",
158        ];
159        if !DATA.contains(&name) {
160            return false;
161        }
162        if name == "find"
163            && ["-exec", "-execdir", "-delete"]
164                .iter()
165                .any(|f| command.contains(f))
166        {
167            return false;
168        }
169        true
170    }
171
172    /// Shell wrappers execute their arguments, so quoted/heredoc content must
173    /// still be scanned.
174    fn is_wrapper(name: &str) -> bool {
175        const WRAPPERS: &[&str] = &[
176            "bash",
177            "sh",
178            "zsh",
179            "dash",
180            "ksh",
181            "fish",
182            "csh",
183            "tcsh",
184            "pwsh",
185            "powershell",
186            "cmd",
187            "eval",
188            "exec",
189            "env",
190            "nohup",
191            "time",
192            "command",
193            "xargs",
194            "sudo",
195        ];
196        WRAPPERS.contains(&name)
197    }
198
199    /// Drop heredoc bodies so script text is not mistaken for commands.
200    fn strip_heredoc_bodies(command: &str) -> String {
201        let mut out = Vec::new();
202        let mut delimiter: Option<String> = None;
203        for line in command.lines() {
204            if let Some(delim) = &delimiter {
205                if line.trim() == delim {
206                    delimiter = None;
207                }
208                continue;
209            }
210            out.push(line);
211            if let Some(pos) = line.find("<<") {
212                let rest = line[pos + 2..].trim_start_matches('-').trim();
213                let token = rest.split_whitespace().next().unwrap_or("");
214                let token = token.trim_matches(|c| c == '"' || c == '\'');
215                if !token.is_empty() && token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
216                {
217                    delimiter = Some(token.to_string());
218                }
219            }
220        }
221        out.join("\n")
222    }
223
224    /// Text the dangerous patterns should inspect.
225    fn dangerous_scan_text(command: &str) -> String {
226        let name = Self::first_command(command);
227        if name.is_empty() {
228            return String::new();
229        }
230        if Self::is_data_command(&name, command) {
231            return String::new();
232        }
233        if Self::is_wrapper(&name) {
234            return command.to_string();
235        }
236        Self::strip_heredoc_bodies(command)
237    }
238
239    /// Detect blatantly dangerous commands (used only in `reject` mode).
240    fn is_dangerous(command: &str) -> bool {
241        let c = Self::dangerous_scan_text(command).to_lowercase();
242        if c.is_empty() {
243            return false;
244        }
245        let patterns = [
246            "rm -rf /",
247            "rm -rf /*",
248            ":(){:|:&};:", // fork bomb
249            "mkfs",
250            "dd if=/dev/zero",
251            "> /dev/sda",
252            "chmod -r 000 /",
253        ];
254        patterns.iter().any(|p| c.contains(p))
255    }
256
257    /// Resolve a command name on PATH / as a shell builtin (native backend).
258    fn command_exists(name: &str) -> bool {
259        if name.is_empty() {
260            return true;
261        }
262        #[cfg(target_os = "windows")]
263        {
264            matches!(
265                std::process::Command::new("where").arg(name).output(),
266                Ok(out) if out.status.success()
267            )
268        }
269        #[cfg(not(target_os = "windows"))]
270        {
271            matches!(
272                std::process::Command::new("sh")
273                    .arg("-c")
274                    .arg("command -v \"$1\" >/dev/null 2>&1")
275                    .arg("sh")
276                    .arg(name)
277                    .status(),
278                Ok(status) if status.success()
279            )
280        }
281    }
282}
283
284#[async_trait::async_trait]
285impl Tool for ShellTool {
286    fn lane(&self) -> Option<&'static str> {
287        Some("shell")
288    }
289    fn schema(&self) -> ToolSchema {
290        ToolSchema::new(
291            "run_shell",
292            "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.",
293            vec![
294                ToolParameter::new(
295                    "command",
296                    ParamType::String,
297                    true,
298                    "The shell command to execute. Supports pipes (|), redirection (>), chaining (&& / ;). Always quote filenames with spaces/special chars.",
299                    &["cmd", "shell", "script", "exec"],
300                ),
301                ToolParameter::new(
302                    "timeout",
303                    ParamType::Integer,
304                    false,
305                    "Total command timeout in seconds.",
306                    &["time_limit", "max_time", "wait"],
307                ),
308                ToolParameter::new(
309                    "idle_timeout",
310                    ParamType::Integer,
311                    false,
312                    "Max seconds with no output before killing (idle timeout). Default: 30.",
313                    &["idle_time", "no_output_timeout"],
314                ),
315                ToolParameter::new(
316                    "stdin_input",
317                    ParamType::String,
318                    false,
319                    "Standard input piped to the program (for input()). Separate lines with \\n.",
320                    &["input", "stdin"],
321                ),
322                ToolParameter::new(
323                    "max_output",
324                    ParamType::Integer,
325                    false,
326                    "Cap returned output characters (default 20000). Pass 0 to disable the cap, or a larger number for more. Truncated output is archived under .aacode/extracts.",
327                    &["max_chars", "limit", "output_limit"],
328                ),
329                ToolParameter::new(
330                    "max_output_lines",
331                    ParamType::Integer,
332                    false,
333                    "Cap returned output lines (default 500). Use full_output=true to disable.",
334                    &["output_lines", "max_result_lines"],
335                ),
336                ToolParameter::new(
337                    "full_output",
338                    ParamType::Boolean,
339                    false,
340                    "Disable the default output caps and return the full stdout/stderr. Only use when you truly need everything — large outputs cost many tokens.",
341                    &["full", "unlimited"],
342                ),
343            ],
344        )
345    }
346
347    async fn call(&self, args: &Value, _cancel: &AtomicBool) -> Result<String> {
348        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
349        let command = args
350            .get("command")
351            .and_then(|v| v.as_str())
352            .unwrap_or("")
353            .to_string();
354
355        // Danger policy.
356        if self.safety.dangerous_command_action == DangerAction::Reject
357            && Self::is_dangerous(&command)
358        {
359            return Ok(json!({
360                "success": false,
361                "error": "Command rejected by safety guard (dangerous pattern).",
362                "command": command,
363            })
364            .to_string());
365        }
366
367        // Unknown-command policy. Default is `allow` (no-op); when set to
368        // `reject`, only a native shell can be checked reliably (fastshell's
369        // builtins are not on PATH).
370        if self.safety.unknown_command_action == UnknownAction::Reject
371            && self.backend.kind() == "native"
372        {
373            let name = Self::first_command(&command);
374            let looks_like_command = !name.is_empty()
375                && name
376                    .chars()
377                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'));
378            if looks_like_command && !Self::command_exists(&name) {
379                return Ok(json!({
380                    "success": false,
381                    "error": format!("Command rejected by safety guard (unknown command '{name}')."),
382                    "command": command,
383                })
384                .to_string());
385            }
386        }
387
388        let stdin_input = args.get("stdin_input").and_then(|v| v.as_str());
389        let timeout = args
390            .get("timeout")
391            .and_then(|v| v.as_u64())
392            .unwrap_or(self.default_timeout_secs);
393        let idle_timeout = args
394            .get("idle_timeout")
395            .and_then(|v| v.as_u64())
396            .unwrap_or(self.default_idle_timeout_secs);
397
398        let stdin_owned = stdin_input.map(|s| s.to_string());
399        let backend = self.backend.clone();
400        let cwd = self.cwd.clone();
401        let command2 = command.clone();
402        let result = tokio::task::spawn_blocking(move || {
403            backend.run(
404                &command2,
405                stdin_owned.as_deref(),
406                timeout,
407                idle_timeout,
408                &cwd,
409            )
410        })
411        .await
412        .map_err(|e| AacodeError::Other(format!("shell spawn: {e}")))?;
413
414        // Output budget: default caps keep huge outputs out of the context; the
415        // full text is archived. `full_output=true` / `max_output=0` opts out.
416        let wants_full = args
417            .get("full_output")
418            .and_then(|v| v.as_bool())
419            .unwrap_or(false)
420            || match args.get("max_output") {
421                Some(Value::String(s)) => {
422                    matches!(
423                        s.to_ascii_lowercase().as_str(),
424                        "full" | "none" | "null" | ""
425                    )
426                }
427                Some(Value::Number(n)) => n.as_u64() == Some(0),
428                _ => false,
429            };
430        let per_call_chars = args
431            .get("max_output")
432            .and_then(|v| v.as_u64())
433            .filter(|n| *n > 0)
434            .map(|n| n as usize);
435
436        let (char_limit, line_limit) = if wants_full {
437            (0usize, 0usize)
438        } else {
439            let chars = per_call_chars.unwrap_or(if self.max_output_chars == 0 {
440                DEFAULT_MAX_OUTPUT_CHARS
441            } else {
442                self.max_output_chars
443            });
444            let lines = args
445                .get("max_output_lines")
446                .and_then(|v| v.as_u64())
447                .filter(|n| *n > 0)
448                .map(|n| n as usize)
449                .unwrap_or(if self.max_output_lines == 0 {
450                    DEFAULT_MAX_OUTPUT_LINES
451                } else {
452                    self.max_output_lines
453                });
454            (chars, lines)
455        };
456
457        // Binary stdout must never be lossily pasted into the context. Detect
458        // it, persist it as an artifact, and return only a bounded preview.
459        let stdout_value = if is_binary_text(&result.stdout) {
460            let store = crate::artifacts::ArtifactStore::new(&self.cwd);
461            binary_output_json(&store, "stdout", &result.stdout)
462        } else {
463            json!(self.maybe_truncate(&result.stdout, "stdout", char_limit, line_limit))
464        };
465        let stderr = self.maybe_truncate(&result.stderr, "stderr", char_limit, line_limit);
466
467        // `success` must reflect the exit code: a timed-out (124) or failing
468        // command is not a success (the model otherwise treats 124 as OK).
469        let mut out = json!({
470            "success": result.exit_code == 0,
471            "returncode": result.exit_code,
472            "stdout": stdout_value,
473            "stderr": stderr,
474            "backend": self.backend.kind(),
475        });
476        if result.exit_code == 124 {
477            out["hint"] = json!(
478                "command timed out — split it into smaller steps or add explicit per-command timeouts (e.g. `curl -m 20`)"
479            );
480        }
481        Ok(out.to_string())
482    }
483}
484
485/// Does this decoded stdout look like binary (NUL bytes / replacement chars /
486/// control chars)? Used to route it to an artifact instead of the context.
487pub(crate) fn is_binary_text(s: &str) -> bool {
488    if s.is_empty() {
489        return false;
490    }
491    if s.contains('\0') {
492        return true;
493    }
494    let total = s.chars().count();
495    let weird = s
496        .chars()
497        .filter(|c| *c == '\u{FFFD}' || (*c < ' ' && *c != '\n' && *c != '\r' && *c != '\t'))
498        .count();
499    weird * 100 / total.max(1) >= 2
500}
501
502/// Persist a binary stdout blob and return a bounded, model-facing JSON value.
503pub(crate) fn binary_output_json(
504    store: &crate::artifacts::ArtifactStore,
505    label: &str,
506    text: &str,
507) -> Value {
508    let bytes = text.as_bytes();
509    let preview = crate::artifacts::hex_preview(bytes, 64);
510    match store.put_bytes(bytes, None, label) {
511        Ok(r) => json!({
512            "binary": true,
513            "bytes": r.bytes,
514            "mime": r.mime,
515            "artifact": r.path,
516            "preview_hex": preview,
517            "truncated": r.bytes > 64,
518            "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.",
519        }),
520        Err(_) => json!({
521            "binary": true,
522            "bytes": bytes.len(),
523            "preview_hex": preview,
524            "truncated": bytes.len() > 64,
525        }),
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::super::backend::{FastshellBackend, NativeShell};
532
533    #[test]
534    fn binary_stdout_detected_and_artifacted() {
535        assert!(is_binary_text("a\0b"));
536        assert!(is_binary_text(&"\u{FFFD}".repeat(50)));
537        assert!(!is_binary_text("normal text\nwith lines\n"));
538        let dir = std::env::temp_dir().join(format!("aacode_shellbin_{}", uuid::Uuid::new_v4()));
539        std::fs::create_dir_all(&dir).unwrap();
540        let store = crate::artifacts::ArtifactStore::new(&dir);
541        let v = binary_output_json(&store, "stdout", "PNG\u{FFFD}\u{FFFD}\u{FFFD}data");
542        assert_eq!(v["binary"], true);
543        assert!(v.get("artifact").and_then(|x| x.as_str()).is_some());
544        assert!(v["preview_hex"].as_str().unwrap().len() > 4);
545        assert!(v["truncated"].is_boolean());
546    }
547    use super::*;
548    use fastshell::{Config, Fastshell};
549    use std::sync::Mutex;
550
551    fn native_tool(cwd: std::path::PathBuf) -> ShellTool {
552        ShellTool::new(
553            Arc::new(NativeShell::new()),
554            cwd,
555            24000,
556            500,
557            30,
558            30,
559            SafetyConfig::default(),
560        )
561    }
562
563    fn tmp() -> std::path::PathBuf {
564        let d = std::env::temp_dir().join(format!(
565            "aacode_shelltool_{}_{}",
566            std::process::id(),
567            uuid::Uuid::new_v4().simple()
568        ));
569        std::fs::create_dir_all(&d).unwrap();
570        d
571    }
572
573    #[tokio::test]
574    async fn native_echo_roundtrip() {
575        let dir = tmp();
576        let t = native_tool(dir);
577        let cancel = AtomicBool::new(false);
578        let out = t
579            .call(&json!({"command": "echo hello"}), &cancel)
580            .await
581            .unwrap();
582        let v: Value = serde_json::from_str(&out).unwrap();
583        assert_eq!(v["success"], true);
584        assert_eq!(v["returncode"], 0);
585        assert_eq!(v["backend"], "native");
586        assert!(v["stdout"].as_str().unwrap().contains("hello"));
587    }
588
589    #[tokio::test]
590    async fn native_writes_to_real_cwd() {
591        let dir = tmp();
592        let t = native_tool(dir.clone());
593        let cancel = AtomicBool::new(false);
594        t.call(&json!({"command": "echo content > note.txt"}), &cancel)
595            .await
596            .unwrap();
597        assert!(dir.join("note.txt").exists());
598    }
599
600    #[tokio::test]
601    async fn native_heredoc_via_tool() {
602        let dir = tmp();
603        let t = native_tool(dir);
604        let cancel = AtomicBool::new(false);
605        let out = t
606            .call(
607                &json!({"command": "cat > x.txt <<'EOF'\nhi\nEOF\ncat x.txt"}),
608                &cancel,
609            )
610            .await
611            .unwrap();
612        let v: Value = serde_json::from_str(&out).unwrap();
613        assert!(v["stdout"].as_str().unwrap().contains("hi"));
614    }
615
616    #[tokio::test]
617    async fn max_output_truncation() {
618        let dir = tmp();
619        let t = native_tool(dir);
620        let cancel = AtomicBool::new(false);
621        let out = t.call(&json!({"command": "for i in $(seq 1 500); do echo linelineline; done", "max_output": 100}), &cancel).await.unwrap();
622        let v: Value = serde_json::from_str(&out).unwrap();
623        assert!(v["stdout"].as_str().unwrap().len() < 5000);
624    }
625
626    #[tokio::test]
627    async fn max_output_lines_caps_lines() {
628        let dir = tmp();
629        let t = native_tool(dir);
630        let cancel = AtomicBool::new(false);
631        let out = t
632            .call(
633                &json!({
634                    "command": "for i in $(seq 1 200); do echo l$i; done",
635                    "max_output": 100000,
636                    "max_output_lines": 10
637                }),
638                &cancel,
639            )
640            .await
641            .unwrap();
642        let v: Value = serde_json::from_str(&out).unwrap();
643        let s = v["stdout"].as_str().unwrap();
644        let content = s.split("\n\n[output truncated").next().unwrap();
645        assert!(
646            content.lines().count() <= 10,
647            "expected <=10 lines, got {}: {content}",
648            content.lines().count()
649        );
650        assert!(s.contains("truncated"));
651    }
652
653    /// With no configured cap (0 = unset) `run_shell` now applies the default
654    /// 20k-char / 500-line budget and archives the full output; `full_output`
655    /// opts out.
656    #[tokio::test]
657    async fn default_output_is_capped_and_full_output_opts_out() {
658        let dir = tmp();
659        let t = ShellTool::new(
660            Arc::new(NativeShell::new()),
661            dir,
662            0,
663            0,
664            30,
665            30,
666            SafetyConfig::default(),
667        );
668        let cancel = AtomicBool::new(false);
669
670        let capped = t
671            .call(
672                &json!({"command": "for i in $(seq 1 5000); do echo linelineline; done"}),
673                &cancel,
674            )
675            .await
676            .unwrap();
677        let v: Value = serde_json::from_str(&capped).unwrap();
678        let s = v["stdout"].as_str().unwrap();
679        assert!(s.contains("truncated"), "default cap should truncate: {s}");
680        assert!(s.len() <= 20_000 + 500, "unexpected size {}", s.len());
681
682        let full = t
683            .call(
684                &json!({
685                    "command": "for i in $(seq 1 5000); do echo linelineline; done",
686                    "full_output": true
687                }),
688                &cancel,
689            )
690            .await
691            .unwrap();
692        let v: Value = serde_json::from_str(&full).unwrap();
693        let s = v["stdout"].as_str().unwrap();
694        assert!(
695            s.len() > 50_000,
696            "full_output should keep everything, len={}",
697            s.len()
698        );
699        assert!(!s.contains("truncated"));
700    }
701
702    #[tokio::test]
703    async fn dangerous_reject_mode() {
704        let dir = tmp();
705        let mut safety = SafetyConfig::default();
706        safety.dangerous_command_action = DangerAction::Reject;
707        let t = ShellTool::new(
708            Arc::new(NativeShell::new()),
709            dir,
710            24000,
711            500,
712            30,
713            30,
714            safety,
715        );
716        let cancel = AtomicBool::new(false);
717        let out = t
718            .call(&json!({"command": "rm -rf /"}), &cancel)
719            .await
720            .unwrap();
721        let v: Value = serde_json::from_str(&out).unwrap();
722        assert_eq!(v["success"], false);
723    }
724
725    #[tokio::test]
726    async fn unknown_command_reject_mode() {
727        let dir = tmp();
728        let mut safety = SafetyConfig::default();
729        safety.unknown_command_action = crate::config::UnknownAction::Reject;
730        let t = ShellTool::new(
731            Arc::new(NativeShell::new()),
732            dir,
733            24000,
734            500,
735            30,
736            30,
737            safety,
738        );
739        let cancel = AtomicBool::new(false);
740        let out = t
741            .call(
742                &json!({"command": "definitely_not_a_command_zzz --flag"}),
743                &cancel,
744            )
745            .await
746            .unwrap();
747        let v: Value = serde_json::from_str(&out).unwrap();
748        assert_eq!(v["success"], false);
749        assert!(v["error"].as_str().unwrap().contains("unknown command"));
750    }
751
752    #[tokio::test]
753    async fn fastshell_backend_still_works() {
754        let dir = tmp();
755        let mut fs = Fastshell::new();
756        let mut cfg = Config::default();
757        cfg.sandbox_path = dir.to_string_lossy().to_string();
758        cfg.python_enabled = false;
759        fs.init(cfg).unwrap();
760        let backend = Arc::new(FastshellBackend::new(Arc::new(Mutex::new(fs))));
761        let t = ShellTool::new(backend, dir, 24000, 500, 30, 30, SafetyConfig::default());
762        let cancel = AtomicBool::new(false);
763        let out = t
764            .call(&json!({"command": "echo sandboxed"}), &cancel)
765            .await
766            .unwrap();
767        let v: Value = serde_json::from_str(&out).unwrap();
768        assert_eq!(v["backend"], "fastshell");
769        assert!(v["stdout"].as_str().unwrap().contains("sandboxed"));
770    }
771
772    #[test]
773    fn is_dangerous_detects() {
774        assert!(ShellTool::is_dangerous("rm -rf /"));
775        assert!(ShellTool::is_dangerous("sudo rm -rf /"));
776        assert!(ShellTool::is_dangerous("bash -c \"rm -rf /\""));
777        assert!(ShellTool::is_dangerous("bash <<'EOF'\nrm -rf /\nEOF"));
778        assert!(ShellTool::is_dangerous("find . -exec rm -rf / \\;"));
779        assert!(!ShellTool::is_dangerous("ls -la"));
780        // Data commands: quoted arguments are not commands.
781        assert!(!ShellTool::is_dangerous("echo \"rm -rf /\""));
782        assert!(!ShellTool::is_dangerous("grep \"rm -rf /\" file.txt"));
783        // A heredoc body written to a file is data, not an executed command.
784        assert!(!ShellTool::is_dangerous(
785            "cat > x.sh <<'EOF'\nrm -rf /\nEOF"
786        ));
787        assert!(!ShellTool::is_dangerous("find . -name 'rm -rf /'"));
788    }
789}