Skip to main content

agent_engine/tools/
bash.rs

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    // sudo normally opens /dev/tty for password input, bypassing our piped
63    // stdin/stderr and corrupting the TUI. In the non-interactive bash tool,
64    // shadow simple `sudo ...` invocations with a shell function that forces
65    // sudo to read from stdin and write the prompt to stderr, where the secure
66    // prompt detector can intercept it before it reaches chat/model output.
67    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        // Start the child in a new session (setsid) so it has no controlling
122        // terminal. Programs that open /dev/tty directly (SSH fingerprint
123        // prompts, gpg pinentry, git credential helpers, pagers) will get
124        // ENXIO and fail with a readable error on stderr instead of hanging
125        // invisibly until timeout. Sudo is unaffected — we already force
126        // `-S` (stdin) via bash_script_with_secure_sudo().
127        #[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                                // Zeroize the password from memory immediately after use
241                                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            // Zeroize redactions (passwords) from memory now that command is done
307            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        // Test simple echo command
377        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        // Test timeout parameter with quick command
386        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        // Should succeed (1 second sleep with 2 second timeout)
394        assert!(result.is_ok());
395
396        // Test timeout with longer command
397        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        // Should timeout
405        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        // Should timeout and return error
669        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        // Should fail and return error
686        assert!(result.is_err());
687        let error = result.unwrap_err().to_string();
688        assert!(error.contains("failed") || error.contains("exit"));
689    }
690}