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