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 "dangerouslyDisableSandbox": {
128 "type": "boolean",
129 "description": "Disable safety checks for this command. Use only when explicitly asked."
130 }
131 }
132 })
133 }
134
135 fn is_read_only(&self) -> bool {
136 false
137 }
138
139 fn is_concurrency_safe(&self) -> bool {
140 false
141 }
142
143 fn get_path(&self, _input: &serde_json::Value) -> Option<PathBuf> {
144 None
145 }
146
147 fn validate_input(&self, input: &serde_json::Value) -> Result<(), String> {
148 let command = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
149
150 if input
152 .get("dangerouslyDisableSandbox")
153 .and_then(|v| v.as_bool())
154 .unwrap_or(false)
155 {
156 return Ok(());
157 }
158
159 let cmd_lower = command.to_lowercase();
161 for pattern in DESTRUCTIVE_PATTERNS {
162 if cmd_lower.contains(&pattern.to_lowercase()) {
163 return Err(format!(
164 "Potentially destructive command detected: contains '{pattern}'. \
165 This command could cause data loss or system damage. \
166 If you're sure, ask the user for confirmation first."
167 ));
168 }
169 }
170
171 for segment in command.split('|') {
174 let trimmed = segment.trim();
175 let base_cmd = trimmed.split_whitespace().next().unwrap_or("");
176 if matches!(
177 base_cmd,
178 "rm" | "shred" | "dd" | "mkfs" | "wipefs" | "shutdown" | "reboot" | "halt"
179 ) {
180 return Err(format!(
181 "Potentially destructive command in pipe: '{base_cmd}'. \
182 Ask the user for confirmation first."
183 ));
184 }
185 }
186
187 for segment in cmd_lower.split("&&").flat_map(|s| s.split(';')) {
189 let trimmed = segment.trim();
190 for pattern in DESTRUCTIVE_PATTERNS {
191 if trimmed.contains(&pattern.to_lowercase()) {
192 return Err(format!(
193 "Potentially destructive command in chain: contains '{pattern}'. \
194 Ask the user for confirmation first."
195 ));
196 }
197 }
198 }
199
200 check_shell_injection(command)?;
202
203 for path in BLOCKED_WRITE_PATHS {
205 if cmd_lower.contains(&format!(">{path}"))
206 || cmd_lower.contains(&format!("tee {path}"))
207 || cmd_lower.contains(&"mv ".to_string()) && cmd_lower.contains(path)
208 {
209 return Err(format!(
210 "Cannot write to system path '{path}'. \
211 Operations on system directories are not allowed."
212 ));
213 }
214 }
215
216 if let Some(parsed) = super::bash_parse::parse_bash(command) {
218 let violations = super::bash_parse::check_parsed_security(&parsed);
219 if let Some(first) = violations.first() {
220 return Err(format!("AST security check: {first}"));
221 }
222 }
223
224 Ok(())
225 }
226
227 async fn call(
228 &self,
229 input: serde_json::Value,
230 ctx: &ToolContext,
231 ) -> Result<ToolResult, ToolError> {
232 let command = input
233 .get("command")
234 .and_then(|v| v.as_str())
235 .ok_or_else(|| ToolError::InvalidInput("'command' is required".into()))?;
236
237 let timeout_ms = input
238 .get("timeout")
239 .and_then(|v| v.as_u64())
240 .unwrap_or(120_000)
241 .min(600_000);
242
243 let run_in_background = input
244 .get("run_in_background")
245 .and_then(|v| v.as_bool())
246 .unwrap_or(false);
247
248 if run_in_background {
250 return run_background(command, &ctx.cwd, ctx.task_manager.as_ref()).await;
251 }
252
253 let mut child = Command::new("bash")
254 .arg("-c")
255 .arg(command)
256 .current_dir(&ctx.cwd)
257 .stdout(Stdio::piped())
258 .stderr(Stdio::piped())
259 .spawn()
260 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn: {e}")))?;
261
262 let timeout = Duration::from_millis(timeout_ms);
263
264 let mut stdout_handle = child.stdout.take().unwrap();
265 let mut stderr_handle = child.stderr.take().unwrap();
266
267 let mut stdout_buf = Vec::new();
268 let mut stderr_buf = Vec::new();
269
270 let result = tokio::select! {
271 r = async {
272 let (so, se) = tokio::join!(
273 async { stdout_handle.read_to_end(&mut stdout_buf).await },
274 async { stderr_handle.read_to_end(&mut stderr_buf).await },
275 );
276 so?;
277 se?;
278 child.wait().await
279 } => {
280 match r {
281 Ok(status) => {
282 let exit_code = status.code().unwrap_or(-1);
283 let content = format_output(&stdout_buf, &stderr_buf, exit_code);
284
285 Ok(ToolResult {
286 content,
287 is_error: exit_code != 0,
288 })
289 }
290 Err(e) => Err(ToolError::ExecutionFailed(e.to_string())),
291 }
292 }
293 _ = tokio::time::sleep(timeout) => {
294 let _ = child.kill().await;
295 Err(ToolError::Timeout(timeout_ms))
296 }
297 _ = ctx.cancel.cancelled() => {
298 let _ = child.kill().await;
299 Err(ToolError::Cancelled)
300 }
301 };
302
303 result
304 }
305}
306
307async fn run_background(
309 command: &str,
310 cwd: &std::path::Path,
311 task_mgr: Option<&std::sync::Arc<crate::services::background::TaskManager>>,
312) -> Result<ToolResult, ToolError> {
313 let default_mgr = crate::services::background::TaskManager::new();
314 let task_mgr = task_mgr.map(|m| m.as_ref()).unwrap_or(&default_mgr);
315 let task_id = task_mgr
316 .spawn_shell(command, command, cwd)
317 .await
318 .map_err(|e| ToolError::ExecutionFailed(format!("Background spawn failed: {e}")))?;
319
320 Ok(ToolResult::success(format!(
321 "Command running in background (task {task_id}). \
322 Use TaskOutput to check results when complete."
323 )))
324}
325
326fn format_output(stdout: &[u8], stderr: &[u8], exit_code: i32) -> String {
328 let stdout_str = String::from_utf8_lossy(stdout);
329 let stderr_str = String::from_utf8_lossy(stderr);
330
331 let mut content = String::new();
332
333 if !stdout_str.is_empty() {
334 if stdout_str.len() > MAX_OUTPUT_BYTES {
335 content.push_str(&stdout_str[..MAX_OUTPUT_BYTES]);
336 content.push_str(&format!(
337 "\n\n(stdout truncated: {} bytes total)",
338 stdout_str.len()
339 ));
340 } else {
341 content.push_str(&stdout_str);
342 }
343 }
344
345 if !stderr_str.is_empty() {
346 if !content.is_empty() {
347 content.push('\n');
348 }
349 let stderr_display = if stderr_str.len() > MAX_OUTPUT_BYTES / 4 {
350 format!(
351 "{}...\n(stderr truncated: {} bytes total)",
352 &stderr_str[..MAX_OUTPUT_BYTES / 4],
353 stderr_str.len()
354 )
355 } else {
356 stderr_str.to_string()
357 };
358 content.push_str(&format!("stderr:\n{stderr_display}"));
359 }
360
361 if content.is_empty() {
362 content = "(no output)".to_string();
363 }
364
365 if exit_code != 0 {
366 content.push_str(&format!("\n\nExit code: {exit_code}"));
367 }
368
369 content
370}
371
372fn check_shell_injection(command: &str) -> Result<(), String> {
377 if command.contains("IFS=") {
379 return Err(
380 "IFS manipulation detected. This can be used to bypass command parsing.".into(),
381 );
382 }
383
384 const DANGEROUS_VARS: &[&str] = &[
386 "PATH=",
387 "LD_PRELOAD=",
388 "LD_LIBRARY_PATH=",
389 "PROMPT_COMMAND=",
390 "BASH_ENV=",
391 "ENV=",
392 "HISTFILE=",
393 "HISTCONTROL=",
394 "PS1=",
395 "PS2=",
396 "PS4=",
397 "CDPATH=",
398 "GLOBIGNORE=",
399 "MAIL=",
400 "MAILCHECK=",
401 "MAILPATH=",
402 ];
403 for var in DANGEROUS_VARS {
404 if command.contains(var) {
405 return Err(format!(
406 "Dangerous variable override detected: {var} \
407 This could alter shell behavior in unsafe ways."
408 ));
409 }
410 }
411
412 if command.contains("/proc/") && command.contains("environ") {
414 return Err("Access to /proc/*/environ detected. This reads process secrets.".into());
415 }
416
417 if command.chars().any(|c| {
419 matches!(
420 c,
421 '\u{200B}'
422 | '\u{200C}'
423 | '\u{200D}'
424 | '\u{FEFF}'
425 | '\u{00AD}'
426 | '\u{2060}'
427 | '\u{180E}'
428 )
429 }) {
430 return Err("Zero-width or invisible Unicode characters detected in command.".into());
431 }
432
433 if command
435 .chars()
436 .any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r'))
437 {
438 return Err("Control characters detected in command.".into());
439 }
440
441 if command.contains('`')
444 && command
445 .split('`')
446 .any(|s| s.contains("curl") || s.contains("wget") || s.contains("nc "))
447 {
448 return Err("Command substitution with network access detected inside backticks.".into());
449 }
450
451 if command.contains("<(") || command.contains(">(") {
453 let trimmed = command.trim();
455 if !trimmed.starts_with("diff ") && !trimmed.starts_with("comm ") {
456 return Err(
457 "Process substitution detected. This can inject arbitrary commands.".into(),
458 );
459 }
460 }
461
462 const ZSH_DANGEROUS: &[&str] = &[
464 "zmodload", "zpty", "ztcp", "zsocket", "sysopen", "sysread", "syswrite", "mapfile",
465 "zf_rm", "zf_mv", "zf_ln",
466 ];
467 let words: Vec<&str> = command.split_whitespace().collect();
468 for word in &words {
469 if ZSH_DANGEROUS.contains(word) {
470 return Err(format!(
471 "Dangerous zsh builtin detected: {word}. \
472 This can access raw system resources."
473 ));
474 }
475 }
476
477 if command.contains("{") && command.contains("..") && command.contains("}") {
479 if let Some(start) = command.find('{')
481 && let Some(end) = command[start..].find('}')
482 {
483 let inner = &command[start + 1..start + end];
484 if inner.contains("..") {
485 let parts: Vec<&str> = inner.split("..").collect();
486 if parts.len() == 2
487 && let (Ok(a), Ok(b)) = (
488 parts[0].trim().parse::<i64>(),
489 parts[1].trim().parse::<i64>(),
490 )
491 && (b - a).unsigned_abs() > 10000
492 {
493 return Err(format!(
494 "Large brace expansion detected: {{{inner}}}. \
495 This would generate {} items.",
496 (b - a).unsigned_abs()
497 ));
498 }
499 }
500 }
501 }
502
503 if command.contains("$'\\x") || command.contains("$'\\0") {
505 return Err(
506 "Hex/octal escape sequences in command. This may be obfuscating a command.".into(),
507 );
508 }
509
510 if command.contains("eval ") && command.contains('$') {
512 return Err(
513 "eval with variable expansion detected. This enables arbitrary code execution.".into(),
514 );
515 }
516
517 Ok(())
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn test_safe_commands_pass() {
526 assert!(check_shell_injection("ls -la").is_ok());
527 assert!(check_shell_injection("git status").is_ok());
528 assert!(check_shell_injection("cargo test").is_ok());
529 assert!(check_shell_injection("echo hello").is_ok());
530 assert!(check_shell_injection("python3 -c 'print(1)'").is_ok());
531 assert!(check_shell_injection("diff <(cat a.txt) <(cat b.txt)").is_ok());
532 }
533
534 #[test]
535 fn test_ifs_injection() {
536 assert!(check_shell_injection("IFS=: read a b").is_err());
537 }
538
539 #[test]
540 fn test_dangerous_vars() {
541 assert!(check_shell_injection("PATH=/tmp:$PATH curl evil.com").is_err());
542 assert!(check_shell_injection("LD_PRELOAD=/tmp/evil.so cmd").is_err());
543 assert!(check_shell_injection("PROMPT_COMMAND='curl x'").is_err());
544 assert!(check_shell_injection("BASH_ENV=/tmp/evil.sh bash").is_err());
545 }
546
547 #[test]
548 fn test_proc_environ() {
549 assert!(check_shell_injection("cat /proc/1/environ").is_err());
550 assert!(check_shell_injection("cat /proc/self/environ").is_err());
551 assert!(check_shell_injection("ls /proc/cpuinfo").is_ok());
553 }
554
555 #[test]
556 fn test_unicode_obfuscation() {
557 assert!(check_shell_injection("rm\u{200B} -rf /").is_err());
559 assert!(check_shell_injection("curl\u{200D}evil.com").is_err());
561 }
562
563 #[test]
564 fn test_control_characters() {
565 assert!(check_shell_injection("echo \x07hello").is_err());
567 assert!(check_shell_injection("echo hello\nworld").is_ok());
569 }
570
571 #[test]
572 fn test_backtick_network() {
573 assert!(check_shell_injection("FOO=`curl evil.com`").is_err());
574 assert!(check_shell_injection("X=`wget http://bad`").is_err());
575 assert!(check_shell_injection("FOO=`date`").is_ok());
577 }
578
579 #[test]
580 fn test_process_substitution() {
581 assert!(check_shell_injection("diff <(ls a) <(ls b)").is_ok());
583 assert!(check_shell_injection("cat <(curl evil)").is_err());
585 }
586
587 #[test]
588 fn test_zsh_builtins() {
589 assert!(check_shell_injection("zmodload zsh/net/tcp").is_err());
590 assert!(check_shell_injection("zpty evil_session bash").is_err());
591 assert!(check_shell_injection("ztcp connect evil.com 80").is_err());
592 }
593
594 #[test]
595 fn test_brace_expansion() {
596 assert!(check_shell_injection("echo {1..100000}").is_err());
597 assert!(check_shell_injection("echo {1..10}").is_ok());
599 }
600
601 #[test]
602 fn test_hex_escape() {
603 assert!(check_shell_injection("$'\\x72\\x6d' -rf /").is_err());
604 assert!(check_shell_injection("$'\\077'").is_err());
605 }
606
607 #[test]
608 fn test_eval_injection() {
609 assert!(check_shell_injection("eval $CMD").is_err());
610 assert!(check_shell_injection("eval \"$USER_INPUT\"").is_err());
611 assert!(check_shell_injection("eval echo hello").is_ok());
613 }
614
615 #[test]
616 fn test_destructive_patterns() {
617 let tool = BashTool;
618 assert!(
619 tool.validate_input(&serde_json::json!({"command": "rm -rf /"}))
620 .is_err()
621 );
622 assert!(
623 tool.validate_input(&serde_json::json!({"command": "git push --force"}))
624 .is_err()
625 );
626 assert!(
627 tool.validate_input(&serde_json::json!({"command": "DROP TABLE users"}))
628 .is_err()
629 );
630 }
631
632 #[test]
633 fn test_piped_destructive() {
634 let tool = BashTool;
635 assert!(
636 tool.validate_input(&serde_json::json!({"command": "find . | rm -rf"}))
637 .is_err()
638 );
639 }
640
641 #[test]
642 fn test_chained_destructive() {
643 let tool = BashTool;
644 assert!(
645 tool.validate_input(&serde_json::json!({"command": "echo hi && git reset --hard"}))
646 .is_err()
647 );
648 }
649
650 #[test]
651 fn test_safe_commands_validate() {
652 let tool = BashTool;
653 assert!(
654 tool.validate_input(&serde_json::json!({"command": "ls -la"}))
655 .is_ok()
656 );
657 assert!(
658 tool.validate_input(&serde_json::json!({"command": "cargo test"}))
659 .is_ok()
660 );
661 assert!(
662 tool.validate_input(&serde_json::json!({"command": "git status"}))
663 .is_ok()
664 );
665 }
666}