Skip to main content

synaps_cli/tools/
bash.rs

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    // sudo normally opens /dev/tty for password input, bypassing our piped
66    // stdin/stderr and corrupting the TUI. In the non-interactive bash tool,
67    // shadow simple `sudo ...` invocations with a shell function that forces
68    // sudo to read from stdin and write the prompt to stderr, where the secure
69    // prompt detector can intercept it before it reaches chat/model output.
70    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        // Start the child in a new session (setsid) so it has no controlling
120        // terminal. Programs that open /dev/tty directly (SSH fingerprint
121        // prompts, gpg pinentry, git credential helpers, pagers) will get
122        // ENXIO and fail with a readable error on stderr instead of hanging
123        // invisibly until timeout. Sudo is unaffected — we already force
124        // `-S` (stdin) via bash_script_with_secure_sudo().
125        #[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                                // Zeroize the password from memory immediately after use
226                                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            // Zeroize redactions (passwords) from memory now that command is done
287            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        // Test simple echo command
346        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        // Test timeout parameter with quick command
355        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        // Should succeed (1 second sleep with 2 second timeout)
363        assert!(result.is_ok());
364
365        // Test timeout with longer command
366        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        // Should timeout
374        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        // Should timeout and return error
603        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        // Should fail and return error
620        assert!(result.is_err());
621        let error = result.unwrap_err().to_string();
622        assert!(error.contains("failed") || error.contains("exit"));
623    }
624}