1use 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
20pub use super::backend::SharedFastshell as SharedShell;
22
23pub struct ShellTool {
24 backend: Arc<dyn ShellBackend>,
25 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 backend,
45 cwd,
46 max_output_chars,
47 default_timeout_secs,
48 default_idle_timeout_secs,
49 safety,
50 }
51 }
52
53 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 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 fn is_dangerous(command: &str) -> bool {
82 let c = command.to_lowercase();
83 let patterns = [
84 "rm -rf /",
85 "rm -rf /*",
86 ":(){:|:&};:", "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 let command = args
145 .get("command")
146 .and_then(|v| v.as_str())
147 .unwrap_or("")
148 .to_string();
149
150 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 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 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 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
223pub(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
240pub(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 #[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}