1use super::{strip_ansi, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4use zeroize::Zeroize;
5
6pub struct BashTool;
7
8const READ_CHUNK_SIZE: usize = 1024;
9const MAX_STREAMED_DELTA_BYTES: usize = 16 * 1024;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum PromptKind {
13 Sudo,
14 Password,
15}
16
17fn sanitize_output(input: &[u8]) -> String {
18 let lossy = String::from_utf8_lossy(input);
19 let stripped = strip_ansi(&lossy);
20 stripped
21 .chars()
22 .filter(|ch| {
23 *ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
24 })
25 .collect()
26}
27
28fn detect_password_prompt(text: &str) -> Option<PromptKind> {
29 let lower = text.to_ascii_lowercase();
30 let has_password = lower.contains("password");
31 if !has_password {
32 return None;
33 }
34 if lower.contains("[sudo]") || lower.contains("sudo") {
35 Some(PromptKind::Sudo)
36 } else if lower.trim_end().ends_with(':') || lower.contains("password:") {
37 Some(PromptKind::Password)
38 } else {
39 None
40 }
41}
42
43fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
44 if output.len() >= max_output {
45 return false;
46 }
47 let remaining = max_output - output.len();
48 if text.len() <= remaining {
49 output.push_str(text);
50 true
51 } else {
52 let mut end = remaining;
53 while end > 0 && !text.is_char_boundary(end) {
54 end -= 1;
55 }
56 output.push_str(&text[..end]);
57 false
58 }
59}
60
61pub(crate) fn bash_script_with_secure_sudo(command: &str) -> String {
62 format!(
68 r#"sudo() {{
69 command sudo -S -p '[sudo] password required: ' "$@"
70}}
71{command}"#
72 )
73}
74
75#[async_trait::async_trait]
76impl Tool for BashTool {
77 fn name(&self) -> &str {
78 "bash"
79 }
80
81 fn description(&self) -> &str {
82 "Execute a bash command and return its output. Use for running programs, installing packages, git operations, and any shell commands. Commands time out after 30 seconds by default; pass a larger timeout when needed. If sudo asks for a password, the user is prompted securely in the TUI and the password is never shown to the model."
83 }
84
85 fn parameters(&self) -> Value {
86 json!({
87 "type": "object",
88 "properties": {
89 "command": {
90 "type": "string",
91 "description": "The bash command to execute"
92 },
93 "timeout": {
94 "type": "integer",
95 "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
96 }
97 },
98 "required": ["command"]
99 })
100 }
101
102 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
103 let command = params["command"]
104 .as_str()
105 .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
106
107 let timeout_secs = params["timeout"]
108 .as_u64()
109 .unwrap_or(ctx.limits.bash_timeout);
110 let max_output = ctx.limits.max_tool_buffer;
111
112 let script = bash_script_with_secure_sudo(command);
113 let mut cmd = tokio::process::Command::new("bash");
114 cmd.arg("-c")
115 .arg(&script)
116 .stdin(std::process::Stdio::piped())
117 .stdout(std::process::Stdio::piped())
118 .stderr(std::process::Stdio::piped())
119 .kill_on_drop(true);
120
121 #[cfg(unix)]
128 unsafe {
129 cmd.pre_exec(|| {
130 libc::setsid();
131 Ok(())
132 });
133 }
134
135 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
136
137 let stdout = child
138 .stdout
139 .take()
140 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
141 let stderr = child
142 .stderr
143 .take()
144 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
145 let stdin = child
146 .stdin
147 .take()
148 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
149
150 let (tx_inter, mut rx_inter) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
151
152 let tx_o = tx_inter.clone();
153 tokio::spawn(async move {
154 use tokio::io::AsyncReadExt;
155 let mut reader = stdout;
156 let mut buf = vec![0u8; READ_CHUNK_SIZE];
157 loop {
158 match reader.read(&mut buf).await {
159 Ok(0) => break,
160 Ok(n) => {
161 let msg = sanitize_output(&buf[..n]);
162 if !msg.is_empty() {
163 let _ = tx_o.send((false, msg));
164 }
165 }
166 Err(_) => break,
167 }
168 }
169 });
170
171 let tx_e = tx_inter.clone();
172 tokio::spawn(async move {
173 use tokio::io::AsyncReadExt;
174 let mut reader = stderr;
175 let mut buf = vec![0u8; READ_CHUNK_SIZE];
176 loop {
177 match reader.read(&mut buf).await {
178 Ok(0) => break,
179 Ok(n) => {
180 let msg = sanitize_output(&buf[..n]);
181 if !msg.is_empty() {
182 let _ = tx_e.send((true, msg));
183 }
184 }
185 Err(_) => break,
186 }
187 }
188 });
189
190 drop(tx_inter);
191
192 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
193 use tokio::io::AsyncWriteExt;
194
195 let mut stdin = stdin;
196 let mut full_output = String::new();
197 let mut stderr_tail = String::new();
198 let mut truncated = false;
199 let mut streamed_bytes = 0usize;
200 let mut redactions: Vec<String> = Vec::new();
201
202 while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
203 if is_stderr {
204 stderr_tail.push_str(&msg);
205 if stderr_tail.len() > 512 {
206 let keep_from = stderr_tail.len() - 512;
207 if let Some((idx, _)) =
208 stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
209 {
210 stderr_tail.drain(..idx);
211 }
212 }
213 if let Some(kind) = detect_password_prompt(&stderr_tail) {
214 let prompt_text = stderr_tail.trim().to_string();
215 let secret = match &ctx.capabilities.secret_prompt {
216 Some(prompt) => {
217 prompt
218 .prompt(
219 match kind {
220 PromptKind::Sudo => {
221 "sudo password required".to_string()
222 }
223 PromptKind::Password => "password required".to_string(),
224 },
225 prompt_text.clone(),
226 )
227 .await
228 }
229 None => None,
230 };
231 match secret {
232 Some(mut value) => {
233 let secret_value = value.clone();
234 if !secret_value.is_empty() {
235 redactions.push(secret_value);
236 }
237 value.push('\n');
238 let write_result = stdin.write_all(value.as_bytes()).await;
239 let flush_result = stdin.flush().await;
240 value.zeroize();
242 write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
243 flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
244 }
245 None => {
246 let _ = child.kill().await;
247 return Err(RuntimeError::Tool(
248 "Command canceled while waiting for password".to_string(),
249 ));
250 }
251 }
252 let prompt_len = prompt_text.len();
253 if prompt_len <= msg.len() {
254 let keep_len = msg.len() - prompt_len;
255 msg.truncate(keep_len);
256 } else {
257 msg.clear();
258 }
259 stderr_tail.clear();
260 }
261 }
262
263 for secret in &redactions {
264 if !secret.is_empty() {
265 msg = msg.replace(secret, "[redacted]");
266 }
267 }
268
269 if truncated {
270 continue;
271 }
272
273 let added_all = append_bounded(&mut full_output, &msg, max_output);
274 if let Some(ref txd) = ctx.channels.tx_delta {
275 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
276 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
277 let delta = if msg.len() <= remaining {
278 msg.clone()
279 } else {
280 let mut end = remaining;
281 while end > 0 && !msg.is_char_boundary(end) {
282 end -= 1;
283 }
284 msg[..end].to_string()
285 };
286 streamed_bytes += delta.len();
287 if !delta.is_empty() {
288 let _ = txd.send(delta);
289 }
290 }
291 }
292
293 if !added_all {
294 full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
295 if let Some(ref txd) = ctx.channels.tx_delta {
296 let _ = txd.send(format!("\n\n[output truncated at {}]", max_output));
297 }
298 truncated = true;
299 let _ = child.kill().await;
300 }
301 }
302 let status = child
303 .wait()
304 .await
305 .map_err(|e| RuntimeError::Tool(e.to_string()))?;
306 for secret in &mut redactions {
308 secret.zeroize();
309 }
310 Ok::<_, RuntimeError>((status, full_output, truncated))
311 })
312 .await;
313
314 match result {
315 Ok(Ok((status, output, was_truncated))) => {
316 if status.success() || was_truncated {
317 Ok(output)
318 } else {
319 Err(RuntimeError::Tool(format!(
320 "Command failed (exit {}):\n{}",
321 status.code().unwrap_or(-1),
322 output
323 )))
324 }
325 }
326 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
327 "Failed to execute command: {}",
328 e
329 ))),
330 Err(_) => Err(RuntimeError::Tool(format!(
331 "Command timed out after {}s",
332 timeout_secs
333 ))),
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn detects_sudo_password_prompt_without_newline() {
344 assert_eq!(
345 detect_password_prompt("[sudo] password for me: "),
346 Some(PromptKind::Sudo)
347 );
348 }
349
350 #[test]
351 fn sanitizes_terminal_control_sequences_and_nuls() {
352 let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
353 assert_eq!(cleaned, "okdone");
354 }
355
356 use super::super::test_helpers::create_tool_context;
357 use crate::tools::Tool;
358 use serde_json::json;
359
360 #[test]
361 fn test_bash_tool_schema() {
362 let tool = BashTool;
363 assert_eq!(tool.name(), "bash");
364 assert!(!tool.description().is_empty());
365
366 let params = tool.parameters();
367 assert_eq!(params["type"], "object");
368 assert!(params["properties"].is_object());
369 assert!(params["required"].is_array());
370 }
371
372 #[tokio::test]
373 async fn test_bash_tool_execution() {
374 let tool = BashTool;
375
376 let ctx = create_tool_context();
378 let params = json!({
379 "command": "echo hello"
380 });
381
382 let result = tool.execute(params, ctx).await.unwrap();
383 assert!(result.contains("hello"));
384
385 let ctx = create_tool_context();
387 let params = json!({
388 "command": "sleep 1",
389 "timeout": 2
390 });
391
392 let result = tool.execute(params, ctx).await;
393 assert!(result.is_ok());
395
396 let ctx = create_tool_context();
398 let params = json!({
399 "command": "sleep 3",
400 "timeout": 1
401 });
402
403 let result = tool.execute(params, ctx).await;
404 assert!(result.is_err());
406 assert!(result.unwrap_err().to_string().contains("timed out"));
407 }
408
409 #[tokio::test]
410 async fn test_bash_tool_requested_timeout_is_not_clamped_by_max_timeout() {
411 let tool = BashTool;
412 let mut ctx = create_tool_context();
413 ctx.limits.bash_max_timeout = 1;
414
415 let params = json!({
416 "command": "sleep 2; echo done",
417 "timeout": 3
418 });
419
420 let result = tool.execute(params, ctx).await;
421 assert!(
422 result.is_ok(),
423 "requested timeout should not be clamped by bash_max_timeout: {result:?}"
424 );
425 assert!(result.unwrap().contains("done"));
426 }
427
428 #[tokio::test]
429 async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
430 let tool = BashTool;
431 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
432 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
433 let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel();
434
435 let responder = tokio::spawn(async move {
436 let req = prompt_rx
437 .recv()
438 .await
439 .expect("bash should request a secret prompt");
440 assert!(
441 req.prompt.to_ascii_lowercase().contains("password"),
442 "prompt was {:?}",
443 req.prompt
444 );
445 req.response_tx.send(Some("swordfish".to_string())).unwrap();
446 });
447
448 let mut ctx = create_tool_context();
449 ctx.capabilities.secret_prompt = Some(prompt_handle);
450 ctx.channels.tx_delta = Some(delta_tx);
451 let params = json!({
452 "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
453 "timeout": 30
454 });
455
456 let result = tool.execute(params, ctx).await.unwrap();
457 responder.await.unwrap();
458 let mut streamed = String::new();
459 while let Ok(delta) = delta_rx.try_recv() {
460 streamed.push_str(&delta);
461 }
462
463 assert!(result.contains("AUTH_OK"));
464 assert!(!result.contains("swordfish"));
465 assert!(!result.contains("[sudo] password"));
466 assert!(!streamed.contains("[sudo] password"));
467 }
468
469 #[test]
470 fn test_bash_wraps_sudo_to_force_stdin_prompt() {
471 let script = super::bash_script_with_secure_sudo("sudo id");
472
473 assert!(script.contains("sudo()"));
474 assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
475 assert!(script.ends_with("sudo id"));
476 }
477
478 #[tokio::test]
479 async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
480 let tool = BashTool;
481 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
482 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
483 let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel();
484
485 let responder = tokio::spawn(async move {
486 let req = prompt_rx
487 .recv()
488 .await
489 .expect("bash should request a secret prompt");
490 assert!(
491 req.prompt.contains("[sudo] password required"),
492 "prompt was {:?}",
493 req.prompt
494 );
495 req.response_tx
496 .send(Some("wrong-password-for-test".to_string()))
497 .unwrap();
498 });
499
500 let mut ctx = create_tool_context();
501 ctx.capabilities.secret_prompt = Some(prompt_handle);
502 ctx.channels.tx_delta = Some(delta_tx);
503 let params = json!({
504 "command": "sudo -k; sudo -v",
505 "timeout": 30
506 });
507
508 let _ = tool.execute(params, ctx).await;
509 responder.await.unwrap();
510 let mut streamed = String::new();
511 while let Ok(delta) = delta_rx.try_recv() {
512 streamed.push_str(&delta);
513 }
514
515 assert!(
516 !streamed.contains("[sudo] password required"),
517 "sudo password prompt leaked into deltas: {streamed:?}"
518 );
519 }
520
521 #[tokio::test]
522 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
523 let tool = BashTool;
524 let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel();
525 let mut ctx = create_tool_context();
526 ctx.channels.tx_delta = Some(delta_tx);
527 ctx.limits.max_tool_buffer = 256;
528
529 let params = json!({
530 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
531 "timeout": 30
532 });
533
534 let result = tool.execute(params, ctx).await.unwrap();
535 let mut streamed = String::new();
536 while let Ok(delta) = delta_rx.try_recv() {
537 streamed.push_str(&delta);
538 }
539
540 assert!(result.contains("[output truncated at 256]"));
541 assert!(!result.contains('\u{1b}'));
542 assert!(!result.contains('\0'));
543 assert!(!streamed.contains('\u{1b}'));
544 assert!(!streamed.contains('\0'));
545 assert!(
546 streamed.len() <= 2048,
547 "streamed deltas must be bounded, got {} bytes",
548 streamed.len()
549 );
550 }
551
552 #[tokio::test]
553 async fn test_bash_echoed_secret_is_redacted_from_output() {
554 let tool = BashTool;
555 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
556 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
557
558 let responder = tokio::spawn(async move {
559 let req = prompt_rx
560 .recv()
561 .await
562 .expect("bash should request a secret prompt");
563 req.response_tx.send(Some("swordfish".to_string())).unwrap();
564 });
565
566 let mut ctx = create_tool_context();
567 ctx.capabilities.secret_prompt = Some(prompt_handle);
568 let params = json!({
569 "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
570 "timeout": 30
571 });
572
573 let result = tool.execute(params, ctx).await.unwrap();
574 responder.await.unwrap();
575
576 assert!(result.contains("seen:[redacted]"));
577 assert!(!result.contains("swordfish"));
578 }
579
580 #[tokio::test]
581 async fn test_bash_sequential_password_prompts_are_each_handled() {
582 let tool = BashTool;
583 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
584 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
585
586 let responder = tokio::spawn(async move {
587 for value in ["first", "second"] {
588 let req = prompt_rx
589 .recv()
590 .await
591 .expect("bash should request each secret prompt");
592 assert!(req.prompt.to_ascii_lowercase().contains("password"));
593 req.response_tx.send(Some(value.to_string())).unwrap();
594 }
595 });
596
597 let mut ctx = create_tool_context();
598 ctx.capabilities.secret_prompt = Some(prompt_handle);
599 let params = json!({
600 "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
601 "timeout": 30
602 });
603
604 let result = tool.execute(params, ctx).await.unwrap();
605 responder.await.unwrap();
606
607 assert!(result.contains("done:[redacted]:[redacted]"));
608 assert!(!result.contains("first"));
609 assert!(!result.contains("second"));
610 }
611
612 #[tokio::test]
613 async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
614 let tool = BashTool;
615 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
616 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
617
618 let responder = tokio::spawn(async move {
619 let req = prompt_rx
620 .recv()
621 .await
622 .expect("bash should request a secret prompt");
623 req.response_tx.send(None).unwrap();
624 });
625
626 let mut ctx = create_tool_context();
627 ctx.capabilities.secret_prompt = Some(prompt_handle);
628 let params = json!({
629 "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
630 "timeout": 30
631 });
632
633 let err = tool.execute(params, ctx).await.unwrap_err().to_string();
634 responder.await.unwrap();
635
636 assert!(err.contains("waiting for password"));
637 assert!(!err.contains("should-not-run"));
638 }
639
640 #[tokio::test]
641 async fn test_bash_binary_output_is_sanitized() {
642 let tool = BashTool;
643 let ctx = create_tool_context();
644 let params = json!({
645 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
646 "timeout": 30
647 });
648
649 let result = tool.execute(params, ctx).await.unwrap();
650
651 assert!(result.contains("visible"));
652 assert!(!result.contains('\0'));
653 assert!(!result.contains('\u{1b}'));
654 }
655
656 #[tokio::test]
657 async fn test_bash_tool_timeout() {
658 let tool = BashTool;
659 let ctx = create_tool_context();
660
661 let params = json!({
662 "command": "sleep 10",
663 "timeout": 1
664 });
665
666 let result = tool.execute(params, ctx).await;
667
668 assert!(result.is_err());
670 let error = result.unwrap_err().to_string();
671 assert!(error.contains("timed out"));
672 }
673
674 #[tokio::test]
675 async fn test_bash_tool_failure() {
676 let tool = BashTool;
677 let ctx = create_tool_context();
678
679 let params = json!({
680 "command": "exit 1"
681 });
682
683 let result = tool.execute(params, ctx).await;
684
685 assert!(result.is_err());
687 let error = result.unwrap_err().to_string();
688 assert!(error.contains("failed") || error.contains("exit"));
689 }
690}