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