1use super::{strip_ansi, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4use zeroize::Zeroize;
5
6const BASH_INTERMEDIARY_CHANNEL_CAPACITY: usize = 64;
7static BASH_INTERMEDIARY_PRODUCED_BYTES: std::sync::atomic::AtomicU64 =
8 std::sync::atomic::AtomicU64::new(0);
9static BASH_INTERMEDIARY_ACCEPTED_BYTES: std::sync::atomic::AtomicU64 =
10 std::sync::atomic::AtomicU64::new(0);
11static BASH_INTERMEDIARY_CONSUMED_BYTES: std::sync::atomic::AtomicU64 =
12 std::sync::atomic::AtomicU64::new(0);
13static BASH_INTERMEDIARY_DROPPED_BYTES: std::sync::atomic::AtomicU64 =
14 std::sync::atomic::AtomicU64::new(0);
15
16#[derive(Debug, Clone, Copy)]
17pub struct BashIntermediarySnapshot {
18 pub produced_bytes: u64,
19 pub accepted_bytes: u64,
20 pub consumed_bytes: u64,
21 pub dropped_bytes: u64,
22 pub retained_bytes: u64,
23}
24
25pub fn bash_intermediary_snapshot() -> BashIntermediarySnapshot {
26 use std::sync::atomic::Ordering;
27 let produced = BASH_INTERMEDIARY_PRODUCED_BYTES.load(Ordering::Relaxed);
28 let accepted = BASH_INTERMEDIARY_ACCEPTED_BYTES.load(Ordering::Relaxed);
29 let consumed = BASH_INTERMEDIARY_CONSUMED_BYTES.load(Ordering::Relaxed);
30 let dropped = BASH_INTERMEDIARY_DROPPED_BYTES.load(Ordering::Relaxed);
31 BashIntermediarySnapshot {
32 produced_bytes: produced,
33 accepted_bytes: accepted,
34 consumed_bytes: consumed,
35 dropped_bytes: dropped,
36 retained_bytes: produced.saturating_sub(consumed).saturating_sub(dropped),
37 }
38}
39
40pub struct BashTool;
41
42const READ_CHUNK_SIZE: usize = 1024;
43const MAX_STREAMED_DELTA_BYTES: usize = 16 * 1024;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum PromptKind {
47 Sudo,
48 Password,
49}
50
51fn sanitize_output(input: &[u8]) -> String {
52 let lossy = String::from_utf8_lossy(input);
53 let stripped = strip_ansi(&lossy);
54 stripped
55 .chars()
56 .filter(|ch| {
57 *ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
58 })
59 .collect()
60}
61
62fn detect_password_prompt(text: &str) -> Option<PromptKind> {
63 let lower = text.to_ascii_lowercase();
64 let has_password = lower.contains("password");
65 if !has_password {
66 return None;
67 }
68 if lower.contains("[sudo]") || lower.contains("sudo") {
69 Some(PromptKind::Sudo)
70 } else if lower.trim_end().ends_with(':') || lower.contains("password:") {
71 Some(PromptKind::Password)
72 } else {
73 None
74 }
75}
76
77fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
78 if output.len() >= max_output {
79 return false;
80 }
81 let remaining = max_output - output.len();
82 if text.len() <= remaining {
83 output.push_str(text);
84 true
85 } else {
86 let mut end = remaining;
87 while end > 0 && !text.is_char_boundary(end) {
88 end -= 1;
89 }
90 output.push_str(&text[..end]);
91 false
92 }
93}
94
95pub(crate) fn bash_script_with_secure_sudo(command: &str) -> String {
96 format!(
102 r#"sudo() {{
103 command sudo -S -p '[sudo] password required: ' "$@"
104}}
105{command}"#
106 )
107}
108
109#[async_trait::async_trait]
110impl Tool for BashTool {
111 fn origin(&self) -> crate::tools::ToolOrigin {
112 crate::tools::ToolOrigin::Builtin
113 }
114
115 fn effect(&self) -> crate::tools::catalog::ToolEffect {
118 crate::tools::catalog::ToolEffect::NonIdempotent
119 }
120
121 fn name(&self) -> &str {
122 "bash"
123 }
124
125 fn description(&self) -> &str {
126 "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."
127 }
128
129 fn parameters(&self) -> Value {
130 json!({
131 "type": "object",
132 "properties": {
133 "command": {
134 "type": "string",
135 "description": "The bash command to execute"
136 },
137 "timeout": {
138 "type": "integer",
139 "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
140 }
141 },
142 "required": ["command"]
143 })
144 }
145
146 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
147 let command = params["command"]
148 .as_str()
149 .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
150
151 let requested_timeout = params["timeout"]
152 .as_u64()
153 .unwrap_or(ctx.limits.bash_timeout);
154 let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
157 requested_timeout.min(ctx.limits.bash_max_timeout)
158 } else {
159 requested_timeout
160 };
161 let max_output = ctx.limits.max_tool_buffer;
162
163 let script = bash_script_with_secure_sudo(command);
164 let mut cmd = tokio::process::Command::new("bash");
165 cmd.arg("-c")
166 .arg(&script)
167 .stdin(std::process::Stdio::piped())
168 .stdout(std::process::Stdio::piped())
169 .stderr(std::process::Stdio::piped())
170 .kill_on_drop(true);
171
172 #[cfg(unix)]
179 unsafe {
180 cmd.pre_exec(|| {
181 libc::setsid();
182 Ok(())
183 });
184 }
185
186 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
187
188 let stdout = child
189 .stdout
190 .take()
191 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
192 let stderr = child
193 .stderr
194 .take()
195 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
196 let stdin = child
197 .stdin
198 .take()
199 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
200
201 let (tx_inter, mut rx_inter) =
202 tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
203
204 let tx_o = tx_inter.clone();
205 tokio::spawn(async move {
206 use tokio::io::AsyncReadExt;
207 let mut reader = stdout;
208 let mut buf = vec![0u8; READ_CHUNK_SIZE];
209 loop {
210 match reader.read(&mut buf).await {
211 Ok(0) => break,
212 Ok(n) => {
213 let msg = sanitize_output(&buf[..n]);
214 if !msg.is_empty() {
215 use std::sync::atomic::Ordering;
216 BASH_INTERMEDIARY_PRODUCED_BYTES
217 .fetch_add(msg.len() as u64, Ordering::Relaxed);
218 let len = msg.len();
219 if tx_o.send((false, msg)).await.is_ok() {
220 BASH_INTERMEDIARY_ACCEPTED_BYTES
221 .fetch_add(len as u64, Ordering::Relaxed);
222 } else {
223 BASH_INTERMEDIARY_DROPPED_BYTES
224 .fetch_add(len as u64, Ordering::Relaxed);
225 break;
226 }
227 }
228 }
229 Err(_) => break,
230 }
231 }
232 });
233
234 let tx_e = tx_inter.clone();
235 tokio::spawn(async move {
236 use tokio::io::AsyncReadExt;
237 let mut reader = stderr;
238 let mut buf = vec![0u8; READ_CHUNK_SIZE];
239 loop {
240 match reader.read(&mut buf).await {
241 Ok(0) => break,
242 Ok(n) => {
243 let msg = sanitize_output(&buf[..n]);
244 if !msg.is_empty() {
245 use std::sync::atomic::Ordering;
246 BASH_INTERMEDIARY_PRODUCED_BYTES
247 .fetch_add(msg.len() as u64, Ordering::Relaxed);
248 let len = msg.len();
249 if tx_e.send((true, msg)).await.is_ok() {
250 BASH_INTERMEDIARY_ACCEPTED_BYTES
251 .fetch_add(len as u64, Ordering::Relaxed);
252 } else {
253 BASH_INTERMEDIARY_DROPPED_BYTES
254 .fetch_add(len as u64, Ordering::Relaxed);
255 break;
256 }
257 }
258 }
259 Err(_) => break,
260 }
261 }
262 });
263
264 drop(tx_inter);
265
266 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
267 use tokio::io::AsyncWriteExt;
268
269 let mut stdin = stdin;
270 let mut full_output = String::new();
271 let mut stderr_tail = String::new();
272 let mut truncated = false;
273 let mut streamed_bytes = 0usize;
274 let mut redactions: Vec<String> = Vec::new();
275
276 while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
277 BASH_INTERMEDIARY_CONSUMED_BYTES
278 .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
279 if is_stderr {
280 stderr_tail.push_str(&msg);
281 if stderr_tail.len() > 512 {
282 let keep_from = stderr_tail.len() - 512;
283 if let Some((idx, _)) =
284 stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
285 {
286 stderr_tail.drain(..idx);
287 }
288 }
289 if let Some(kind) = detect_password_prompt(&stderr_tail) {
290 let prompt_text = stderr_tail.trim().to_string();
291 let secret = match &ctx.capabilities.secret_prompt {
292 Some(prompt) => {
293 prompt
294 .prompt(
295 match kind {
296 PromptKind::Sudo => {
297 "sudo password required".to_string()
298 }
299 PromptKind::Password => "password required".to_string(),
300 },
301 prompt_text.clone(),
302 )
303 .await
304 }
305 None => None,
306 };
307 match secret {
308 Some(mut value) => {
309 let secret_value = value.clone();
310 if !secret_value.is_empty() {
311 redactions.push(secret_value);
312 }
313 value.push('\n');
314 let write_result = stdin.write_all(value.as_bytes()).await;
315 let flush_result = stdin.flush().await;
316 value.zeroize();
318 write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
319 flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
320 }
321 None => {
322 let _ = child.kill().await;
323 return Err(RuntimeError::Tool(
324 "Command canceled while waiting for password".to_string(),
325 ));
326 }
327 }
328 let prompt_len = prompt_text.len();
329 if prompt_len <= msg.len() {
330 let keep_len = msg.len() - prompt_len;
331 msg.truncate(keep_len);
332 } else {
333 msg.clear();
334 }
335 stderr_tail.clear();
336 }
337 }
338
339 for secret in &redactions {
340 if !secret.is_empty() {
341 msg = msg.replace(secret, "[redacted]");
342 }
343 }
344
345 if truncated {
346 continue;
347 }
348
349 let added_all = append_bounded(&mut full_output, &msg, max_output);
350 if let Some(ref txd) = ctx.channels.tx_delta {
351 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
352 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
353 let delta = if msg.len() <= remaining {
354 msg.clone()
355 } else {
356 crate::truncate_str(&msg, remaining).to_string()
357 };
358 streamed_bytes += delta.len();
359 if !delta.is_empty() {
360 let _ = txd.send(delta);
361 }
362 }
363 }
364
365 if !added_all {
366 full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
367 if let Some(ref txd) = ctx.channels.tx_delta {
368 let _ = txd.send(format!("\n\n[output truncated at {}]", max_output));
369 }
370 truncated = true;
371 let _ = child.kill().await;
372 }
373 }
374 let status = child
375 .wait()
376 .await
377 .map_err(|e| RuntimeError::Tool(e.to_string()))?;
378 for secret in &mut redactions {
380 secret.zeroize();
381 }
382 Ok::<_, RuntimeError>((status, full_output, truncated))
383 })
384 .await;
385
386 match result {
387 Ok(Ok((status, output, was_truncated))) => {
388 if status.success() || was_truncated {
389 Ok(output)
390 } else {
391 Err(RuntimeError::Tool(format!(
392 "Command failed (exit {}):\n{}",
393 status.code().unwrap_or(-1),
394 output
395 )))
396 }
397 }
398 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
399 "Failed to execute command: {}",
400 e
401 ))),
402 Err(_) => Err(RuntimeError::Tool(format!(
403 "Command timed out after {}s",
404 timeout_secs
405 ))),
406 }
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[tokio::test]
415 async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
416 let before = bash_intermediary_snapshot();
417 let tool = BashTool;
418 let mut ctx = create_tool_context();
419 ctx.limits.max_tool_buffer = 64 * 1024;
420 let result = tool
421 .execute(
422 json!({
423 "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
424 "timeout": 30
425 }),
426 ctx,
427 )
428 .await
429 .unwrap();
430 assert!(result.contains("output truncated"));
431
432 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
442 let (mut produced, mut consumed, mut accepted, mut dropped);
443 loop {
444 let after = bash_intermediary_snapshot();
445 produced = after.produced_bytes - before.produced_bytes;
446 consumed = after.consumed_bytes - before.consumed_bytes;
447 accepted = after.accepted_bytes - before.accepted_bytes;
448 dropped = after.dropped_bytes - before.dropped_bytes;
449 let balanced =
450 produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
451 if balanced || std::time::Instant::now() >= deadline {
452 assert_eq!(
453 produced,
454 consumed + dropped,
455 "handoff bytes must be conserved at quiescence"
456 );
457 assert_eq!(after.retained_bytes, before.retained_bytes);
458 break;
459 }
460 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
461 }
462 assert!(accepted >= consumed);
463 assert!(produced >= 64 * 1024);
464 }
465
466 #[test]
467 fn detects_sudo_password_prompt_without_newline() {
468 assert_eq!(
469 detect_password_prompt("[sudo] password for me: "),
470 Some(PromptKind::Sudo)
471 );
472 }
473
474 #[test]
475 fn sanitizes_terminal_control_sequences_and_nuls() {
476 let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
477 assert_eq!(cleaned, "okdone");
478 }
479
480 use super::super::test_helpers::create_tool_context;
481 use crate::tools::Tool;
482 use serde_json::json;
483
484 #[test]
485 fn test_bash_tool_schema() {
486 let tool = BashTool;
487 assert_eq!(tool.name(), "bash");
488 assert!(!tool.description().is_empty());
489
490 let params = tool.parameters();
491 assert_eq!(params["type"], "object");
492 assert!(params["properties"].is_object());
493 assert!(params["required"].is_array());
494 }
495
496 #[tokio::test]
497 async fn test_bash_tool_execution() {
498 let tool = BashTool;
499
500 let ctx = create_tool_context();
502 let params = json!({
503 "command": "echo hello"
504 });
505
506 let result = tool.execute(params, ctx).await.unwrap();
507 assert!(result.contains("hello"));
508
509 let ctx = create_tool_context();
511 let params = json!({
512 "command": "sleep 1",
513 "timeout": 2
514 });
515
516 let result = tool.execute(params, ctx).await;
517 assert!(result.is_ok());
519
520 let ctx = create_tool_context();
522 let params = json!({
523 "command": "sleep 3",
524 "timeout": 1
525 });
526
527 let result = tool.execute(params, ctx).await;
528 assert!(result.is_err());
530 assert!(result.unwrap_err().to_string().contains("timed out"));
531 }
532
533 #[tokio::test]
534 async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
535 let tool = BashTool;
536 let mut ctx = create_tool_context();
537 ctx.limits.bash_max_timeout = 1;
538
539 let params = json!({
540 "command": "sleep 5; echo done",
541 "timeout": 10
542 });
543
544 let result = tool.execute(params, ctx).await;
545 assert!(
546 result.unwrap_err().to_string().contains("timed out"),
547 "requested timeout MUST be clamped by bash_max_timeout"
548 );
549 }
550
551 #[tokio::test]
552 async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
553 let tool = BashTool;
554 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
555 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
556 let channel = crate::tools::output::delta_channel();
557 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
558
559 let responder = tokio::spawn(async move {
560 let req = prompt_rx
561 .recv()
562 .await
563 .expect("bash should request a secret prompt");
564 assert!(
565 req.prompt.to_ascii_lowercase().contains("password"),
566 "prompt was {:?}",
567 req.prompt
568 );
569 req.response_tx.send(Some("swordfish".to_string())).unwrap();
570 });
571
572 let mut ctx = create_tool_context();
573 ctx.capabilities.secret_prompt = Some(prompt_handle);
574 ctx.channels.tx_delta = Some(delta_tx);
575 let params = json!({
576 "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
577 "timeout": 30
578 });
579
580 let result = tool.execute(params, ctx).await.unwrap();
581 responder.await.unwrap();
582 let mut streamed = String::new();
583 while let Some(delta) = delta_rx.try_drain() {
584 streamed.push_str(&delta);
585 }
586
587 assert!(result.contains("AUTH_OK"));
588 assert!(!result.contains("swordfish"));
589 assert!(!result.contains("[sudo] password"));
590 assert!(!streamed.contains("[sudo] password"));
591 }
592
593 #[test]
594 fn test_bash_wraps_sudo_to_force_stdin_prompt() {
595 let script = super::bash_script_with_secure_sudo("sudo id");
596
597 assert!(script.contains("sudo()"));
598 assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
599 assert!(script.ends_with("sudo id"));
600 }
601
602 #[cfg(unix)]
608 fn write_fake_sudo(dir: &std::path::Path) {
609 use std::os::unix::fs::PermissionsExt;
610 let sudo_path = dir.join("sudo");
611 std::fs::write(
612 &sudo_path,
613 r#"#!/bin/sh
614prompt=""
615while [ $# -gt 0 ]; do
616 case "$1" in
617 -S) shift ;;
618 -p) prompt="$2"; shift 2 ;;
619 -k) exit 0 ;;
620 *) break ;;
621 esac
622done
623printf '%s' "$prompt" >&2
624read -r _pw
625exit 1
626"#,
627 )
628 .expect("write fake sudo");
629 let mut perms = std::fs::metadata(&sudo_path)
630 .expect("stat fake sudo")
631 .permissions();
632 perms.set_mode(0o755);
633 std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
634 }
635
636 #[cfg(unix)]
649 #[tokio::test]
650 async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
651 let fake_bin = tempfile::tempdir().expect("tempdir");
652 write_fake_sudo(fake_bin.path());
653 let tool = BashTool;
654 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
655 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
656 let channel = crate::tools::output::delta_channel();
657 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
658
659 let responder = tokio::spawn(async move {
660 let req = prompt_rx
661 .recv()
662 .await
663 .expect("bash should request a secret prompt");
664 assert!(
665 req.prompt.contains("[sudo] password required"),
666 "prompt was {:?}",
667 req.prompt
668 );
669 req.response_tx
670 .send(Some("wrong-password-for-test".to_string()))
671 .unwrap();
672 });
673
674 let mut ctx = create_tool_context();
675 ctx.capabilities.secret_prompt = Some(prompt_handle);
676 ctx.channels.tx_delta = Some(delta_tx);
677 let params = json!({
678 "command": format!(
679 "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
680 fake_bin.path().display()
681 ),
682 "timeout": 30
683 });
684
685 let _ = tool.execute(params, ctx).await;
686 responder.await.unwrap();
687 let mut streamed = String::new();
688 while let Some(delta) = delta_rx.try_drain() {
689 streamed.push_str(&delta);
690 }
691
692 assert!(
693 !streamed.contains("[sudo] password required"),
694 "sudo password prompt leaked into deltas: {streamed:?}"
695 );
696 }
697
698 #[tokio::test]
699 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
700 let tool = BashTool;
701 let channel = crate::tools::output::delta_channel();
702 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
703 let mut ctx = create_tool_context();
704 ctx.channels.tx_delta = Some(delta_tx);
705 ctx.limits.max_tool_buffer = 256;
706
707 let params = json!({
708 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
709 "timeout": 30
710 });
711
712 let result = tool.execute(params, ctx).await.unwrap();
713 let mut streamed = String::new();
714 while let Some(delta) = delta_rx.try_drain() {
715 streamed.push_str(&delta);
716 }
717
718 assert!(result.contains("[output truncated at 256]"));
719 assert!(!result.contains('\u{1b}'));
720 assert!(!result.contains('\0'));
721 assert!(!streamed.contains('\u{1b}'));
722 assert!(!streamed.contains('\0'));
723 assert!(
724 streamed.len() <= 2048,
725 "streamed deltas must be bounded, got {} bytes",
726 streamed.len()
727 );
728 }
729
730 #[tokio::test]
731 async fn test_bash_echoed_secret_is_redacted_from_output() {
732 let tool = BashTool;
733 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
734 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
735
736 let responder = tokio::spawn(async move {
737 let req = prompt_rx
738 .recv()
739 .await
740 .expect("bash should request a secret prompt");
741 req.response_tx.send(Some("swordfish".to_string())).unwrap();
742 });
743
744 let mut ctx = create_tool_context();
745 ctx.capabilities.secret_prompt = Some(prompt_handle);
746 let params = json!({
747 "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
748 "timeout": 30
749 });
750
751 let result = tool.execute(params, ctx).await.unwrap();
752 responder.await.unwrap();
753
754 assert!(result.contains("seen:[redacted]"));
755 assert!(!result.contains("swordfish"));
756 }
757
758 #[tokio::test]
759 async fn test_bash_sequential_password_prompts_are_each_handled() {
760 let tool = BashTool;
761 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
762 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
763
764 let responder = tokio::spawn(async move {
765 for value in ["first", "second"] {
766 let req = prompt_rx
767 .recv()
768 .await
769 .expect("bash should request each secret prompt");
770 assert!(req.prompt.to_ascii_lowercase().contains("password"));
771 req.response_tx.send(Some(value.to_string())).unwrap();
772 }
773 });
774
775 let mut ctx = create_tool_context();
776 ctx.capabilities.secret_prompt = Some(prompt_handle);
777 let params = json!({
778 "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
779 "timeout": 30
780 });
781
782 let result = tool.execute(params, ctx).await.unwrap();
783 responder.await.unwrap();
784
785 assert!(result.contains("done:[redacted]:[redacted]"));
786 assert!(!result.contains("first"));
787 assert!(!result.contains("second"));
788 }
789
790 #[tokio::test]
791 async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
792 let tool = BashTool;
793 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
794 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
795
796 let responder = tokio::spawn(async move {
797 let req = prompt_rx
798 .recv()
799 .await
800 .expect("bash should request a secret prompt");
801 req.response_tx.send(None).unwrap();
802 });
803
804 let mut ctx = create_tool_context();
805 ctx.capabilities.secret_prompt = Some(prompt_handle);
806 let params = json!({
807 "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
808 "timeout": 30
809 });
810
811 let err = tool.execute(params, ctx).await.unwrap_err().to_string();
812 responder.await.unwrap();
813
814 assert!(err.contains("waiting for password"));
815 assert!(!err.contains("should-not-run"));
816 }
817
818 #[tokio::test]
819 async fn test_bash_binary_output_is_sanitized() {
820 let tool = BashTool;
821 let ctx = create_tool_context();
822 let params = json!({
823 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
824 "timeout": 30
825 });
826
827 let result = tool.execute(params, ctx).await.unwrap();
828
829 assert!(result.contains("visible"));
830 assert!(!result.contains('\0'));
831 assert!(!result.contains('\u{1b}'));
832 }
833
834 #[tokio::test]
835 async fn test_bash_tool_timeout() {
836 let tool = BashTool;
837 let ctx = create_tool_context();
838
839 let params = json!({
840 "command": "sleep 10",
841 "timeout": 1
842 });
843
844 let result = tool.execute(params, ctx).await;
845
846 assert!(result.is_err());
848 let error = result.unwrap_err().to_string();
849 assert!(error.contains("timed out"));
850 }
851
852 #[tokio::test]
853 async fn test_bash_tool_failure() {
854 let tool = BashTool;
855 let ctx = create_tool_context();
856
857 let params = json!({
858 "command": "exit 1"
859 });
860
861 let result = tool.execute(params, ctx).await;
862
863 assert!(result.is_err());
865 let error = result.unwrap_err().to_string();
866 assert!(error.contains("failed") || error.contains("exit"));
867 }
868}