1use 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
20pub use super::backend::SharedFastshell as SharedShell;
22
23pub 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 cwd: std::path::PathBuf,
32 max_output_chars: usize,
34 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 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 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 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 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 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 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 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 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 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 ":(){:|:&};:", "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 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 let command = args
350 .get("command")
351 .and_then(|v| v.as_str())
352 .unwrap_or("")
353 .to_string();
354
355 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 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 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 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 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
485pub(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
502pub(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 #[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 assert!(!ShellTool::is_dangerous("echo \"rm -rf /\""));
782 assert!(!ShellTool::is_dangerous("grep \"rm -rf /\" file.txt"));
783 assert!(!ShellTool::is_dangerous(
785 "cat > x.sh <<'EOF'\nrm -rf /\nEOF"
786 ));
787 assert!(!ShellTool::is_dangerous("find . -name 'rm -rf /'"));
788 }
789}