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_program() -> std::ffi::OsString {
101 #[cfg(unix)]
102 {
103 "bash".into()
104 }
105 #[cfg(windows)]
106 {
107 let on_path = std::env::var_os("PATH")
108 .map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
109 .unwrap_or(false);
110 if on_path {
111 return "bash".into();
112 }
113 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
114 if let Some(pf) = std::env::var_os(var) {
115 let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
116 if candidate.is_file() {
117 return candidate.into_os_string();
118 }
119 }
120 }
121 let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
122 if wsl.is_file() {
123 return wsl.as_os_str().to_os_string();
124 }
125 "bash".into()
127 }
128}
129
130pub(crate) fn bash_script_with_secure_sudo(command: &str) -> String {
131 format!(
137 r#"sudo() {{
138 command sudo -S -p '[sudo] password required: ' "$@"
139}}
140{command}"#
141 )
142}
143
144#[cfg(windows)]
148pub(crate) fn powershell_program() -> std::ffi::OsString {
149 let on_path = |exe: &str| {
150 std::env::var_os("PATH")
151 .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
152 .unwrap_or(false)
153 };
154 if on_path("pwsh.exe") {
155 return "pwsh".into();
156 }
157 for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
158 if let Some(pf) = std::env::var_os(var) {
159 let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
160 if candidate.is_file() {
161 return candidate.into_os_string();
162 }
163 }
164 }
165 "powershell".into()
167}
168
169#[cfg(not(windows))]
172pub(crate) fn powershell_program() -> std::ffi::OsString {
173 "pwsh".into()
174}
175
176#[async_trait::async_trait]
177impl Tool for BashTool {
178 fn origin(&self) -> crate::tools::ToolOrigin {
179 crate::tools::ToolOrigin::Builtin
180 }
181
182 fn effect(&self) -> crate::tools::catalog::ToolEffect {
185 crate::tools::catalog::ToolEffect::NonIdempotent
186 }
187
188 fn name(&self) -> &str {
189 "bash"
190 }
191
192 fn description(&self) -> &str {
193 "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."
194 }
195
196 fn parameters(&self) -> Value {
197 json!({
198 "type": "object",
199 "properties": {
200 "command": {
201 "type": "string",
202 "description": "The bash command to execute"
203 },
204 "timeout": {
205 "type": "integer",
206 "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
207 }
208 },
209 "required": ["command"]
210 })
211 }
212
213 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
214 let command = params["command"]
215 .as_str()
216 .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
217
218 let script = bash_script_with_secure_sudo(command);
219 run_shell_command(
220 &script,
221 ShellSpec::Bash,
222 params["timeout"].as_u64(),
223 ctx,
224 )
225 .await
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub(crate) enum ShellSpec {
232 Bash,
233 PowerShell,
234}
235
236pub(crate) async fn run_shell_command(
241 script: &str,
242 spec: ShellSpec,
243 requested_timeout: Option<u64>,
244 ctx: ToolContext,
245) -> Result<String> {
246 {
247 let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
248 let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
251 requested_timeout.min(ctx.limits.bash_max_timeout)
252 } else {
253 requested_timeout
254 };
255 let max_output = ctx.limits.max_tool_buffer;
256
257 let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
258 ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
259 ShellSpec::PowerShell => {
260 (powershell_program(), vec!["-NoProfile".into(), "-Command".into(), script.into()])
261 }
262 };
263 let mut cmd = tokio::process::Command::new(program);
264 cmd.args(&args)
265 .stdin(std::process::Stdio::piped())
266 .stdout(std::process::Stdio::piped())
267 .stderr(std::process::Stdio::piped())
268 .kill_on_drop(true);
269
270 #[cfg(unix)]
277 unsafe {
278 cmd.pre_exec(|| {
279 libc::setsid();
280 Ok(())
281 });
282 }
283
284 let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
285
286 let stdout = child
287 .stdout
288 .take()
289 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
290 let stderr = child
291 .stderr
292 .take()
293 .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
294 let stdin = child
295 .stdin
296 .take()
297 .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
298
299 let (tx_inter, mut rx_inter) =
300 tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
301
302 let tx_o = tx_inter.clone();
303 tokio::spawn(async move {
304 use tokio::io::AsyncReadExt;
305 let mut reader = stdout;
306 let mut buf = vec![0u8; READ_CHUNK_SIZE];
307 loop {
308 match reader.read(&mut buf).await {
309 Ok(0) => break,
310 Ok(n) => {
311 let msg = sanitize_output(&buf[..n]);
312 if !msg.is_empty() {
313 use std::sync::atomic::Ordering;
314 BASH_INTERMEDIARY_PRODUCED_BYTES
315 .fetch_add(msg.len() as u64, Ordering::Relaxed);
316 let len = msg.len();
317 if tx_o.send((false, msg)).await.is_ok() {
318 BASH_INTERMEDIARY_ACCEPTED_BYTES
319 .fetch_add(len as u64, Ordering::Relaxed);
320 } else {
321 BASH_INTERMEDIARY_DROPPED_BYTES
322 .fetch_add(len as u64, Ordering::Relaxed);
323 break;
324 }
325 }
326 }
327 Err(_) => break,
328 }
329 }
330 });
331
332 let tx_e = tx_inter.clone();
333 tokio::spawn(async move {
334 use tokio::io::AsyncReadExt;
335 let mut reader = stderr;
336 let mut buf = vec![0u8; READ_CHUNK_SIZE];
337 loop {
338 match reader.read(&mut buf).await {
339 Ok(0) => break,
340 Ok(n) => {
341 let msg = sanitize_output(&buf[..n]);
342 if !msg.is_empty() {
343 use std::sync::atomic::Ordering;
344 BASH_INTERMEDIARY_PRODUCED_BYTES
345 .fetch_add(msg.len() as u64, Ordering::Relaxed);
346 let len = msg.len();
347 if tx_e.send((true, msg)).await.is_ok() {
348 BASH_INTERMEDIARY_ACCEPTED_BYTES
349 .fetch_add(len as u64, Ordering::Relaxed);
350 } else {
351 BASH_INTERMEDIARY_DROPPED_BYTES
352 .fetch_add(len as u64, Ordering::Relaxed);
353 break;
354 }
355 }
356 }
357 Err(_) => break,
358 }
359 }
360 });
361
362 drop(tx_inter);
363
364 let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
365 use tokio::io::AsyncWriteExt;
366
367 let mut stdin = stdin;
368 let mut full_output = String::new();
369 let mut stderr_tail = String::new();
370 let mut truncated = false;
371 let mut streamed_bytes = 0usize;
372 let mut redactions: Vec<String> = Vec::new();
373
374 while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
375 BASH_INTERMEDIARY_CONSUMED_BYTES
376 .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
377 if is_stderr {
378 stderr_tail.push_str(&msg);
379 if stderr_tail.len() > 512 {
380 let keep_from = stderr_tail.len() - 512;
381 if let Some((idx, _)) =
382 stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
383 {
384 stderr_tail.drain(..idx);
385 }
386 }
387 if let Some(kind) = detect_password_prompt(&stderr_tail) {
388 let prompt_text = stderr_tail.trim().to_string();
389 let secret = match &ctx.capabilities.secret_prompt {
390 Some(prompt) => {
391 prompt
392 .prompt(
393 match kind {
394 PromptKind::Sudo => {
395 "sudo password required".to_string()
396 }
397 PromptKind::Password => "password required".to_string(),
398 },
399 prompt_text.clone(),
400 )
401 .await
402 }
403 None => None,
404 };
405 match secret {
406 Some(mut value) => {
407 let secret_value = value.clone();
408 if !secret_value.is_empty() {
409 redactions.push(secret_value);
410 }
411 value.push('\n');
412 let write_result = stdin.write_all(value.as_bytes()).await;
413 let flush_result = stdin.flush().await;
414 value.zeroize();
416 write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
417 flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
418 }
419 None => {
420 let _ = child.kill().await;
421 return Err(RuntimeError::Tool(
422 "Command canceled while waiting for password".to_string(),
423 ));
424 }
425 }
426 let prompt_len = prompt_text.len();
427 if prompt_len <= msg.len() {
428 let keep_len = msg.len() - prompt_len;
429 msg.truncate(keep_len);
430 } else {
431 msg.clear();
432 }
433 stderr_tail.clear();
434 }
435 }
436
437 for secret in &redactions {
438 if !secret.is_empty() {
439 msg = msg.replace(secret, "[redacted]");
440 }
441 }
442
443 if truncated {
444 continue;
445 }
446
447 let added_all = append_bounded(&mut full_output, &msg, max_output);
448 if let Some(ref txd) = ctx.channels.tx_delta {
449 if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
450 let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
451 let delta = if msg.len() <= remaining {
452 msg.clone()
453 } else {
454 crate::truncate_str(&msg, remaining).to_string()
455 };
456 streamed_bytes += delta.len();
457 if !delta.is_empty() {
458 txd.send(delta);
459 }
460 }
461 }
462
463 if !added_all {
464 full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
465 if let Some(ref txd) = ctx.channels.tx_delta {
466 txd.send(format!("\n\n[output truncated at {}]", max_output));
467 }
468 truncated = true;
469 let _ = child.kill().await;
470 }
471 }
472 let status = child
473 .wait()
474 .await
475 .map_err(|e| RuntimeError::Tool(e.to_string()))?;
476 for secret in &mut redactions {
478 secret.zeroize();
479 }
480 Ok::<_, RuntimeError>((status, full_output, truncated))
481 })
482 .await;
483
484 match result {
485 Ok(Ok((status, output, was_truncated))) => {
486 if status.success() || was_truncated {
487 Ok(output)
488 } else {
489 Err(RuntimeError::Tool(format!(
490 "Command failed (exit {}):\n{}",
491 status.code().unwrap_or(-1),
492 output
493 )))
494 }
495 }
496 Ok(Err(e)) => Err(RuntimeError::Tool(format!(
497 "Failed to execute command: {}",
498 e
499 ))),
500 Err(_) => Err(RuntimeError::Tool(format!(
501 "Command timed out after {}s",
502 timeout_secs
503 ))),
504 }
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[tokio::test]
513 async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
514 let before = bash_intermediary_snapshot();
515 let tool = BashTool;
516 let mut ctx = create_tool_context();
517 ctx.limits.max_tool_buffer = 64 * 1024;
518 let result = tool
519 .execute(
520 json!({
521 "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
522 "timeout": 30
523 }),
524 ctx,
525 )
526 .await
527 .unwrap();
528 assert!(result.contains("output truncated"));
529
530 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
540 let (mut produced, mut consumed, mut accepted, mut dropped);
541 loop {
542 let after = bash_intermediary_snapshot();
543 produced = after.produced_bytes - before.produced_bytes;
544 consumed = after.consumed_bytes - before.consumed_bytes;
545 accepted = after.accepted_bytes - before.accepted_bytes;
546 dropped = after.dropped_bytes - before.dropped_bytes;
547 let balanced =
548 produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
549 if balanced || std::time::Instant::now() >= deadline {
550 assert_eq!(
551 produced,
552 consumed + dropped,
553 "handoff bytes must be conserved at quiescence"
554 );
555 assert_eq!(after.retained_bytes, before.retained_bytes);
556 break;
557 }
558 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
559 }
560 assert!(accepted >= consumed);
561 assert!(produced >= 64 * 1024);
562 }
563
564 #[test]
565 fn detects_sudo_password_prompt_without_newline() {
566 assert_eq!(
567 detect_password_prompt("[sudo] password for me: "),
568 Some(PromptKind::Sudo)
569 );
570 }
571
572 #[test]
573 fn sanitizes_terminal_control_sequences_and_nuls() {
574 let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
575 assert_eq!(cleaned, "okdone");
576 }
577
578 use super::super::test_helpers::create_tool_context;
579 use crate::tools::Tool;
580 use serde_json::json;
581
582 #[test]
583 fn test_bash_tool_schema() {
584 let tool = BashTool;
585 assert_eq!(tool.name(), "bash");
586 assert!(!tool.description().is_empty());
587
588 let params = tool.parameters();
589 assert_eq!(params["type"], "object");
590 assert!(params["properties"].is_object());
591 assert!(params["required"].is_array());
592 }
593
594 #[tokio::test]
595 async fn test_bash_tool_execution() {
596 let tool = BashTool;
597
598 let ctx = create_tool_context();
600 let params = json!({
601 "command": "echo hello"
602 });
603
604 let result = tool.execute(params, ctx).await.unwrap();
605 assert!(result.contains("hello"));
606
607 let ctx = create_tool_context();
609 let params = json!({
610 "command": "sleep 1",
611 "timeout": 2
612 });
613
614 let result = tool.execute(params, ctx).await;
615 assert!(result.is_ok());
617
618 let ctx = create_tool_context();
620 let params = json!({
621 "command": "sleep 3",
622 "timeout": 1
623 });
624
625 let result = tool.execute(params, ctx).await;
626 assert!(result.is_err());
628 assert!(result.unwrap_err().to_string().contains("timed out"));
629 }
630
631 #[tokio::test]
632 async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
633 let tool = BashTool;
634 let mut ctx = create_tool_context();
635 ctx.limits.bash_max_timeout = 1;
636
637 let params = json!({
638 "command": "sleep 5; echo done",
639 "timeout": 10
640 });
641
642 let result = tool.execute(params, ctx).await;
643 assert!(
644 result.unwrap_err().to_string().contains("timed out"),
645 "requested timeout MUST be clamped by bash_max_timeout"
646 );
647 }
648
649 #[tokio::test]
650 async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
651 let tool = BashTool;
652 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
653 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
654 let channel = crate::tools::output::delta_channel();
655 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
656
657 let responder = tokio::spawn(async move {
658 let req = prompt_rx
659 .recv()
660 .await
661 .expect("bash should request a secret prompt");
662 assert!(
663 req.prompt.to_ascii_lowercase().contains("password"),
664 "prompt was {:?}",
665 req.prompt
666 );
667 req.response_tx.send(Some("swordfish".to_string())).unwrap();
668 });
669
670 let mut ctx = create_tool_context();
671 ctx.capabilities.secret_prompt = Some(prompt_handle);
672 ctx.channels.tx_delta = Some(delta_tx);
673 let params = json!({
674 "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
675 "timeout": 30
676 });
677
678 let result = tool.execute(params, ctx).await.unwrap();
679 responder.await.unwrap();
680 let mut streamed = String::new();
681 while let Some(delta) = delta_rx.try_drain() {
682 streamed.push_str(&delta);
683 }
684
685 assert!(result.contains("AUTH_OK"));
686 assert!(!result.contains("swordfish"));
687 assert!(!result.contains("[sudo] password"));
688 assert!(!streamed.contains("[sudo] password"));
689 }
690
691 #[test]
692 fn test_bash_wraps_sudo_to_force_stdin_prompt() {
693 let script = super::bash_script_with_secure_sudo("sudo id");
694
695 assert!(script.contains("sudo()"));
696 assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
697 assert!(script.ends_with("sudo id"));
698 }
699
700 #[cfg(unix)]
706 fn write_fake_sudo(dir: &std::path::Path) {
707 use std::os::unix::fs::PermissionsExt;
708 let sudo_path = dir.join("sudo");
709 std::fs::write(
710 &sudo_path,
711 r#"#!/bin/sh
712prompt=""
713while [ $# -gt 0 ]; do
714 case "$1" in
715 -S) shift ;;
716 -p) prompt="$2"; shift 2 ;;
717 -k) exit 0 ;;
718 *) break ;;
719 esac
720done
721printf '%s' "$prompt" >&2
722read -r _pw
723exit 1
724"#,
725 )
726 .expect("write fake sudo");
727 let mut perms = std::fs::metadata(&sudo_path)
728 .expect("stat fake sudo")
729 .permissions();
730 perms.set_mode(0o755);
731 std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
732 }
733
734 #[cfg(unix)]
747 #[tokio::test]
748 async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
749 let fake_bin = tempfile::tempdir().expect("tempdir");
750 write_fake_sudo(fake_bin.path());
751 let tool = BashTool;
752 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
753 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
754 let channel = crate::tools::output::delta_channel();
755 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
756
757 let responder = tokio::spawn(async move {
758 let req = prompt_rx
759 .recv()
760 .await
761 .expect("bash should request a secret prompt");
762 assert!(
763 req.prompt.contains("[sudo] password required"),
764 "prompt was {:?}",
765 req.prompt
766 );
767 req.response_tx
768 .send(Some("wrong-password-for-test".to_string()))
769 .unwrap();
770 });
771
772 let mut ctx = create_tool_context();
773 ctx.capabilities.secret_prompt = Some(prompt_handle);
774 ctx.channels.tx_delta = Some(delta_tx);
775 let params = json!({
776 "command": format!(
777 "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
778 fake_bin.path().display()
779 ),
780 "timeout": 30
781 });
782
783 let _ = tool.execute(params, ctx).await;
784 responder.await.unwrap();
785 let mut streamed = String::new();
786 while let Some(delta) = delta_rx.try_drain() {
787 streamed.push_str(&delta);
788 }
789
790 assert!(
791 !streamed.contains("[sudo] password required"),
792 "sudo password prompt leaked into deltas: {streamed:?}"
793 );
794 }
795
796 #[tokio::test]
797 async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
798 let tool = BashTool;
799 let channel = crate::tools::output::delta_channel();
800 let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
801 let mut ctx = create_tool_context();
802 ctx.channels.tx_delta = Some(delta_tx);
803 ctx.limits.max_tool_buffer = 256;
804
805 let params = json!({
806 "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
807 "timeout": 30
808 });
809
810 let result = tool.execute(params, ctx).await.unwrap();
811 let mut streamed = String::new();
812 while let Some(delta) = delta_rx.try_drain() {
813 streamed.push_str(&delta);
814 }
815
816 assert!(result.contains("[output truncated at 256]"));
817 assert!(!result.contains('\u{1b}'));
818 assert!(!result.contains('\0'));
819 assert!(!streamed.contains('\u{1b}'));
820 assert!(!streamed.contains('\0'));
821 assert!(
822 streamed.len() <= 2048,
823 "streamed deltas must be bounded, got {} bytes",
824 streamed.len()
825 );
826 }
827
828 #[tokio::test]
829 async fn test_bash_echoed_secret_is_redacted_from_output() {
830 let tool = BashTool;
831 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
832 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
833
834 let responder = tokio::spawn(async move {
835 let req = prompt_rx
836 .recv()
837 .await
838 .expect("bash should request a secret prompt");
839 req.response_tx.send(Some("swordfish".to_string())).unwrap();
840 });
841
842 let mut ctx = create_tool_context();
843 ctx.capabilities.secret_prompt = Some(prompt_handle);
844 let params = json!({
845 "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
846 "timeout": 30
847 });
848
849 let result = tool.execute(params, ctx).await.unwrap();
850 responder.await.unwrap();
851
852 assert!(result.contains("seen:[redacted]"));
853 assert!(!result.contains("swordfish"));
854 }
855
856 #[tokio::test]
857 async fn test_bash_sequential_password_prompts_are_each_handled() {
858 let tool = BashTool;
859 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
860 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
861
862 let responder = tokio::spawn(async move {
863 for value in ["first", "second"] {
864 let req = prompt_rx
865 .recv()
866 .await
867 .expect("bash should request each secret prompt");
868 assert!(req.prompt.to_ascii_lowercase().contains("password"));
869 req.response_tx.send(Some(value.to_string())).unwrap();
870 }
871 });
872
873 let mut ctx = create_tool_context();
874 ctx.capabilities.secret_prompt = Some(prompt_handle);
875 let params = json!({
876 "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
877 "timeout": 30
878 });
879
880 let result = tool.execute(params, ctx).await.unwrap();
881 responder.await.unwrap();
882
883 assert!(result.contains("done:[redacted]:[redacted]"));
884 assert!(!result.contains("first"));
885 assert!(!result.contains("second"));
886 }
887
888 #[tokio::test]
889 async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
890 let tool = BashTool;
891 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
892 let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
893
894 let responder = tokio::spawn(async move {
895 let req = prompt_rx
896 .recv()
897 .await
898 .expect("bash should request a secret prompt");
899 req.response_tx.send(None).unwrap();
900 });
901
902 let mut ctx = create_tool_context();
903 ctx.capabilities.secret_prompt = Some(prompt_handle);
904 let params = json!({
905 "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
906 "timeout": 30
907 });
908
909 let err = tool.execute(params, ctx).await.unwrap_err().to_string();
910 responder.await.unwrap();
911
912 assert!(err.contains("waiting for password"));
913 assert!(!err.contains("should-not-run"));
914 }
915
916 #[tokio::test]
917 async fn test_bash_binary_output_is_sanitized() {
918 let tool = BashTool;
919 let ctx = create_tool_context();
920 let params = json!({
921 "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
922 "timeout": 30
923 });
924
925 let result = tool.execute(params, ctx).await.unwrap();
926
927 assert!(result.contains("visible"));
928 assert!(!result.contains('\0'));
929 assert!(!result.contains('\u{1b}'));
930 }
931
932 #[tokio::test]
933 async fn test_bash_tool_timeout() {
934 let tool = BashTool;
935 let ctx = create_tool_context();
936
937 let params = json!({
938 "command": "sleep 10",
939 "timeout": 1
940 });
941
942 let result = tool.execute(params, ctx).await;
943
944 assert!(result.is_err());
946 let error = result.unwrap_err().to_string();
947 assert!(error.contains("timed out"));
948 }
949
950 #[tokio::test]
951 async fn test_bash_tool_failure() {
952 let tool = BashTool;
953 let ctx = create_tool_context();
954
955 let params = json!({
956 "command": "exit 1"
957 });
958
959 let result = tool.execute(params, ctx).await;
960
961 assert!(result.is_err());
963 let error = result.unwrap_err().to_string();
964 assert!(error.contains("failed") || error.contains("exit"));
965 }
966}