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 base = Command::new("bash");
255 base.arg("-c")
256 .arg(command)
257 .current_dir(&ctx.cwd)
258 .stdout(Stdio::piped())
259 .stderr(Stdio::piped());
260
261 let disable_sandbox_requested = input
265 .get("dangerouslyDisableSandbox")
266 .and_then(|v| v.as_bool())
267 .unwrap_or(false);
268
269 let mut cmd = if let Some(ref sandbox) = ctx.sandbox {
270 if disable_sandbox_requested && sandbox.allow_bypass() {
271 tracing::warn!(
272 "bash call set dangerouslyDisableSandbox; wrapping skipped for this call"
273 );
274 base
275 } else {
276 if disable_sandbox_requested && !sandbox.allow_bypass() {
277 tracing::warn!(
278 "dangerouslyDisableSandbox ignored: security.disable_bypass_permissions is set"
279 );
280 }
281 sandbox.wrap(base)
282 }
283 } else {
284 base
285 };
286
287 let mut child = cmd
288 .spawn()
289 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn: {e}")))?;
290
291 let timeout = Duration::from_millis(timeout_ms);
292
293 let mut stdout_handle = child.stdout.take().unwrap();
294 let mut stderr_handle = child.stderr.take().unwrap();
295
296 let mut stdout_buf = Vec::new();
297 let mut stderr_buf = Vec::new();
298
299 let result = tokio::select! {
300 r = async {
301 let (so, se) = tokio::join!(
302 async { stdout_handle.read_to_end(&mut stdout_buf).await },
303 async { stderr_handle.read_to_end(&mut stderr_buf).await },
304 );
305 so?;
306 se?;
307 child.wait().await
308 } => {
309 match r {
310 Ok(status) => {
311 let exit_code = status.code().unwrap_or(-1);
312 let content = format_output(&stdout_buf, &stderr_buf, exit_code);
313
314 Ok(ToolResult {
315 content,
316 is_error: exit_code != 0,
317 })
318 }
319 Err(e) => Err(ToolError::ExecutionFailed(e.to_string())),
320 }
321 }
322 _ = tokio::time::sleep(timeout) => {
323 let _ = child.kill().await;
324 Err(ToolError::Timeout(timeout_ms))
325 }
326 _ = ctx.cancel.cancelled() => {
327 let _ = child.kill().await;
328 Err(ToolError::Cancelled)
329 }
330 };
331
332 result
333 }
334}
335
336async fn run_background(
338 command: &str,
339 cwd: &std::path::Path,
340 task_mgr: Option<&std::sync::Arc<crate::services::background::TaskManager>>,
341) -> Result<ToolResult, ToolError> {
342 let default_mgr = crate::services::background::TaskManager::new();
343 let task_mgr = task_mgr.map(|m| m.as_ref()).unwrap_or(&default_mgr);
344 let task_id = task_mgr
345 .spawn_shell(command, command, cwd)
346 .await
347 .map_err(|e| ToolError::ExecutionFailed(format!("Background spawn failed: {e}")))?;
348
349 Ok(ToolResult::success(format!(
350 "Command running in background (task {task_id}). \
351 Use TaskOutput to check results when complete."
352 )))
353}
354
355fn format_output(stdout: &[u8], stderr: &[u8], exit_code: i32) -> String {
357 let stdout_str = String::from_utf8_lossy(stdout);
358 let stderr_str = String::from_utf8_lossy(stderr);
359
360 let mut content = String::new();
361
362 if !stdout_str.is_empty() {
363 if stdout_str.len() > MAX_OUTPUT_BYTES {
364 content.push_str(&stdout_str[..MAX_OUTPUT_BYTES]);
365 content.push_str(&format!(
366 "\n\n(stdout truncated: {} bytes total)",
367 stdout_str.len()
368 ));
369 } else {
370 content.push_str(&stdout_str);
371 }
372 }
373
374 if !stderr_str.is_empty() {
375 if !content.is_empty() {
376 content.push('\n');
377 }
378 let stderr_display = if stderr_str.len() > MAX_OUTPUT_BYTES / 4 {
379 format!(
380 "{}...\n(stderr truncated: {} bytes total)",
381 &stderr_str[..MAX_OUTPUT_BYTES / 4],
382 stderr_str.len()
383 )
384 } else {
385 stderr_str.to_string()
386 };
387 content.push_str(&format!("stderr:\n{stderr_display}"));
388 }
389
390 if content.is_empty() {
391 content = "(no output)".to_string();
392 }
393
394 if exit_code != 0 {
395 content.push_str(&format!("\n\nExit code: {exit_code}"));
396 }
397
398 content
399}
400
401fn check_shell_injection(command: &str) -> Result<(), String> {
406 if command.contains("IFS=") {
408 return Err(
409 "IFS manipulation detected. This can be used to bypass command parsing.".into(),
410 );
411 }
412
413 const DANGEROUS_VARS: &[&str] = &[
415 "PATH=",
416 "LD_PRELOAD=",
417 "LD_LIBRARY_PATH=",
418 "PROMPT_COMMAND=",
419 "BASH_ENV=",
420 "ENV=",
421 "HISTFILE=",
422 "HISTCONTROL=",
423 "PS1=",
424 "PS2=",
425 "PS4=",
426 "CDPATH=",
427 "GLOBIGNORE=",
428 "MAIL=",
429 "MAILCHECK=",
430 "MAILPATH=",
431 ];
432 for var in DANGEROUS_VARS {
433 if command.contains(var) {
434 return Err(format!(
435 "Dangerous variable override detected: {var} \
436 This could alter shell behavior in unsafe ways."
437 ));
438 }
439 }
440
441 if command.contains("/proc/") && command.contains("environ") {
443 return Err("Access to /proc/*/environ detected. This reads process secrets.".into());
444 }
445
446 if command.chars().any(|c| {
448 matches!(
449 c,
450 '\u{200B}'
451 | '\u{200C}'
452 | '\u{200D}'
453 | '\u{FEFF}'
454 | '\u{00AD}'
455 | '\u{2060}'
456 | '\u{180E}'
457 )
458 }) {
459 return Err("Zero-width or invisible Unicode characters detected in command.".into());
460 }
461
462 if command
464 .chars()
465 .any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r'))
466 {
467 return Err("Control characters detected in command.".into());
468 }
469
470 if command.contains('`')
473 && command
474 .split('`')
475 .any(|s| s.contains("curl") || s.contains("wget") || s.contains("nc "))
476 {
477 return Err("Command substitution with network access detected inside backticks.".into());
478 }
479
480 if command.contains("<(") || command.contains(">(") {
482 let trimmed = command.trim();
484 if !trimmed.starts_with("diff ") && !trimmed.starts_with("comm ") {
485 return Err(
486 "Process substitution detected. This can inject arbitrary commands.".into(),
487 );
488 }
489 }
490
491 const ZSH_DANGEROUS: &[&str] = &[
493 "zmodload", "zpty", "ztcp", "zsocket", "sysopen", "sysread", "syswrite", "mapfile",
494 "zf_rm", "zf_mv", "zf_ln",
495 ];
496 let words: Vec<&str> = command.split_whitespace().collect();
497 for word in &words {
498 if ZSH_DANGEROUS.contains(word) {
499 return Err(format!(
500 "Dangerous zsh builtin detected: {word}. \
501 This can access raw system resources."
502 ));
503 }
504 }
505
506 if command.contains("{") && command.contains("..") && command.contains("}") {
508 if let Some(start) = command.find('{')
510 && let Some(end) = command[start..].find('}')
511 {
512 let inner = &command[start + 1..start + end];
513 if inner.contains("..") {
514 let parts: Vec<&str> = inner.split("..").collect();
515 if parts.len() == 2
516 && let (Ok(a), Ok(b)) = (
517 parts[0].trim().parse::<i64>(),
518 parts[1].trim().parse::<i64>(),
519 )
520 && (b - a).unsigned_abs() > 10000
521 {
522 return Err(format!(
523 "Large brace expansion detected: {{{inner}}}. \
524 This would generate {} items.",
525 (b - a).unsigned_abs()
526 ));
527 }
528 }
529 }
530 }
531
532 if command.contains("$'\\x") || command.contains("$'\\0") {
534 return Err(
535 "Hex/octal escape sequences in command. This may be obfuscating a command.".into(),
536 );
537 }
538
539 if command.contains("eval ") && command.contains('$') {
541 return Err(
542 "eval with variable expansion detected. This enables arbitrary code execution.".into(),
543 );
544 }
545
546 Ok(())
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn test_safe_commands_pass() {
555 assert!(check_shell_injection("ls -la").is_ok());
556 assert!(check_shell_injection("git status").is_ok());
557 assert!(check_shell_injection("cargo test").is_ok());
558 assert!(check_shell_injection("echo hello").is_ok());
559 assert!(check_shell_injection("python3 -c 'print(1)'").is_ok());
560 assert!(check_shell_injection("diff <(cat a.txt) <(cat b.txt)").is_ok());
561 }
562
563 #[test]
564 fn test_ifs_injection() {
565 assert!(check_shell_injection("IFS=: read a b").is_err());
566 }
567
568 #[test]
569 fn test_dangerous_vars() {
570 assert!(check_shell_injection("PATH=/tmp:$PATH curl evil.com").is_err());
571 assert!(check_shell_injection("LD_PRELOAD=/tmp/evil.so cmd").is_err());
572 assert!(check_shell_injection("PROMPT_COMMAND='curl x'").is_err());
573 assert!(check_shell_injection("BASH_ENV=/tmp/evil.sh bash").is_err());
574 }
575
576 #[test]
577 fn test_proc_environ() {
578 assert!(check_shell_injection("cat /proc/1/environ").is_err());
579 assert!(check_shell_injection("cat /proc/self/environ").is_err());
580 assert!(check_shell_injection("ls /proc/cpuinfo").is_ok());
582 }
583
584 #[test]
585 fn test_unicode_obfuscation() {
586 assert!(check_shell_injection("rm\u{200B} -rf /").is_err());
588 assert!(check_shell_injection("curl\u{200D}evil.com").is_err());
590 }
591
592 #[test]
593 fn test_control_characters() {
594 assert!(check_shell_injection("echo \x07hello").is_err());
596 assert!(check_shell_injection("echo hello\nworld").is_ok());
598 }
599
600 #[test]
601 fn test_backtick_network() {
602 assert!(check_shell_injection("FOO=`curl evil.com`").is_err());
603 assert!(check_shell_injection("X=`wget http://bad`").is_err());
604 assert!(check_shell_injection("FOO=`date`").is_ok());
606 }
607
608 #[test]
609 fn test_process_substitution() {
610 assert!(check_shell_injection("diff <(ls a) <(ls b)").is_ok());
612 assert!(check_shell_injection("cat <(curl evil)").is_err());
614 }
615
616 #[test]
617 fn test_zsh_builtins() {
618 assert!(check_shell_injection("zmodload zsh/net/tcp").is_err());
619 assert!(check_shell_injection("zpty evil_session bash").is_err());
620 assert!(check_shell_injection("ztcp connect evil.com 80").is_err());
621 }
622
623 #[test]
624 fn test_brace_expansion() {
625 assert!(check_shell_injection("echo {1..100000}").is_err());
626 assert!(check_shell_injection("echo {1..10}").is_ok());
628 }
629
630 #[test]
631 fn test_hex_escape() {
632 assert!(check_shell_injection("$'\\x72\\x6d' -rf /").is_err());
633 assert!(check_shell_injection("$'\\077'").is_err());
634 }
635
636 #[test]
637 fn test_eval_injection() {
638 assert!(check_shell_injection("eval $CMD").is_err());
639 assert!(check_shell_injection("eval \"$USER_INPUT\"").is_err());
640 assert!(check_shell_injection("eval echo hello").is_ok());
642 }
643
644 #[test]
645 fn test_destructive_patterns() {
646 let tool = BashTool;
647 assert!(
648 tool.validate_input(&serde_json::json!({"command": "rm -rf /"}))
649 .is_err()
650 );
651 assert!(
652 tool.validate_input(&serde_json::json!({"command": "git push --force"}))
653 .is_err()
654 );
655 assert!(
656 tool.validate_input(&serde_json::json!({"command": "DROP TABLE users"}))
657 .is_err()
658 );
659 }
660
661 #[test]
662 fn test_piped_destructive() {
663 let tool = BashTool;
664 assert!(
665 tool.validate_input(&serde_json::json!({"command": "find . | rm -rf"}))
666 .is_err()
667 );
668 }
669
670 #[test]
671 fn test_chained_destructive() {
672 let tool = BashTool;
673 assert!(
674 tool.validate_input(&serde_json::json!({"command": "echo hi && git reset --hard"}))
675 .is_err()
676 );
677 }
678
679 #[test]
680 fn test_safe_commands_validate() {
681 let tool = BashTool;
682 assert!(
683 tool.validate_input(&serde_json::json!({"command": "ls -la"}))
684 .is_ok()
685 );
686 assert!(
687 tool.validate_input(&serde_json::json!({"command": "cargo test"}))
688 .is_ok()
689 );
690 assert!(
691 tool.validate_input(&serde_json::json!({"command": "git status"}))
692 .is_ok()
693 );
694 }
695}