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
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_script_with_secure_sudo(command: &str) -> String {
96    // sudo normally opens /dev/tty for password input, bypassing our piped
97    // stdin/stderr and corrupting the TUI. In the non-interactive bash tool,
98    // shadow simple `sudo ...` invocations with a shell function that forces
99    // sudo to read from stdin and write the prompt to stderr, where the secure
100    // prompt detector can intercept it before it reaches chat/model output.
101    format!(
102        r#"sudo() {{
103    command sudo -S -p '[sudo] password required: ' "$@"
104}}
105{command}"#
106    )
107}
108
109#[async_trait::async_trait]
110impl Tool for BashTool {
111    fn origin(&self) -> crate::tools::ToolOrigin {
112        crate::tools::ToolOrigin::Builtin
113    }
114
115    /// Explicitly NonIdempotent (Task 24): arbitrary shell side effects —
116    /// serialized execution, no concurrency key.
117    fn effect(&self) -> crate::tools::catalog::ToolEffect {
118        crate::tools::catalog::ToolEffect::NonIdempotent
119    }
120
121    fn name(&self) -> &str {
122        "bash"
123    }
124
125    fn description(&self) -> &str {
126        "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."
127    }
128
129    fn parameters(&self) -> Value {
130        json!({
131            "type": "object",
132            "properties": {
133                "command": {
134                    "type": "string",
135                    "description": "The bash command to execute"
136                },
137                "timeout": {
138                    "type": "integer",
139                    "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
140                }
141            },
142            "required": ["command"]
143        })
144    }
145
146    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
147        let command = params["command"]
148            .as_str()
149            .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
150
151        let requested_timeout = params["timeout"]
152            .as_u64()
153            .unwrap_or(ctx.limits.bash_timeout);
154        // H5: enforce bash_max_timeout cap — prevent DoS via prompt injection
155        // requesting unbounded timeouts (e.g. timeout:2592000 + infinite loop).
156        let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
157            requested_timeout.min(ctx.limits.bash_max_timeout)
158        } else {
159            requested_timeout
160        };
161        let max_output = ctx.limits.max_tool_buffer;
162
163        let script = bash_script_with_secure_sudo(command);
164        let mut cmd = tokio::process::Command::new("bash");
165        cmd.arg("-c")
166            .arg(&script)
167            .stdin(std::process::Stdio::piped())
168            .stdout(std::process::Stdio::piped())
169            .stderr(std::process::Stdio::piped())
170            .kill_on_drop(true);
171
172        // Start the child in a new session (setsid) so it has no controlling
173        // terminal. Programs that open /dev/tty directly (SSH fingerprint
174        // prompts, gpg pinentry, git credential helpers, pagers) will get
175        // ENXIO and fail with a readable error on stderr instead of hanging
176        // invisibly until timeout. Sudo is unaffected — we already force
177        // `-S` (stdin) via bash_script_with_secure_sudo().
178        #[cfg(unix)]
179        unsafe {
180            cmd.pre_exec(|| {
181                libc::setsid();
182                Ok(())
183            });
184        }
185
186        let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
187
188        let stdout = child
189            .stdout
190            .take()
191            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
192        let stderr = child
193            .stderr
194            .take()
195            .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
196        let stdin = child
197            .stdin
198            .take()
199            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
200
201        let (tx_inter, mut rx_inter) =
202            tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
203
204        let tx_o = tx_inter.clone();
205        tokio::spawn(async move {
206            use tokio::io::AsyncReadExt;
207            let mut reader = stdout;
208            let mut buf = vec![0u8; READ_CHUNK_SIZE];
209            loop {
210                match reader.read(&mut buf).await {
211                    Ok(0) => break,
212                    Ok(n) => {
213                        let msg = sanitize_output(&buf[..n]);
214                        if !msg.is_empty() {
215                            use std::sync::atomic::Ordering;
216                            BASH_INTERMEDIARY_PRODUCED_BYTES
217                                .fetch_add(msg.len() as u64, Ordering::Relaxed);
218                            let len = msg.len();
219                            if tx_o.send((false, msg)).await.is_ok() {
220                                BASH_INTERMEDIARY_ACCEPTED_BYTES
221                                    .fetch_add(len as u64, Ordering::Relaxed);
222                            } else {
223                                BASH_INTERMEDIARY_DROPPED_BYTES
224                                    .fetch_add(len as u64, Ordering::Relaxed);
225                                break;
226                            }
227                        }
228                    }
229                    Err(_) => break,
230                }
231            }
232        });
233
234        let tx_e = tx_inter.clone();
235        tokio::spawn(async move {
236            use tokio::io::AsyncReadExt;
237            let mut reader = stderr;
238            let mut buf = vec![0u8; READ_CHUNK_SIZE];
239            loop {
240                match reader.read(&mut buf).await {
241                    Ok(0) => break,
242                    Ok(n) => {
243                        let msg = sanitize_output(&buf[..n]);
244                        if !msg.is_empty() {
245                            use std::sync::atomic::Ordering;
246                            BASH_INTERMEDIARY_PRODUCED_BYTES
247                                .fetch_add(msg.len() as u64, Ordering::Relaxed);
248                            let len = msg.len();
249                            if tx_e.send((true, msg)).await.is_ok() {
250                                BASH_INTERMEDIARY_ACCEPTED_BYTES
251                                    .fetch_add(len as u64, Ordering::Relaxed);
252                            } else {
253                                BASH_INTERMEDIARY_DROPPED_BYTES
254                                    .fetch_add(len as u64, Ordering::Relaxed);
255                                break;
256                            }
257                        }
258                    }
259                    Err(_) => break,
260                }
261            }
262        });
263
264        drop(tx_inter);
265
266        let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
267            use tokio::io::AsyncWriteExt;
268
269            let mut stdin = stdin;
270            let mut full_output = String::new();
271            let mut stderr_tail = String::new();
272            let mut truncated = false;
273            let mut streamed_bytes = 0usize;
274            let mut redactions: Vec<String> = Vec::new();
275
276            while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
277                BASH_INTERMEDIARY_CONSUMED_BYTES
278                    .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
279                if is_stderr {
280                    stderr_tail.push_str(&msg);
281                    if stderr_tail.len() > 512 {
282                        let keep_from = stderr_tail.len() - 512;
283                        if let Some((idx, _)) =
284                            stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
285                        {
286                            stderr_tail.drain(..idx);
287                        }
288                    }
289                    if let Some(kind) = detect_password_prompt(&stderr_tail) {
290                        let prompt_text = stderr_tail.trim().to_string();
291                        let secret = match &ctx.capabilities.secret_prompt {
292                            Some(prompt) => {
293                                prompt
294                                    .prompt(
295                                        match kind {
296                                            PromptKind::Sudo => {
297                                                "sudo password required".to_string()
298                                            }
299                                            PromptKind::Password => "password required".to_string(),
300                                        },
301                                        prompt_text.clone(),
302                                    )
303                                    .await
304                            }
305                            None => None,
306                        };
307                        match secret {
308                            Some(mut value) => {
309                                let secret_value = value.clone();
310                                if !secret_value.is_empty() {
311                                    redactions.push(secret_value);
312                                }
313                                value.push('\n');
314                                let write_result = stdin.write_all(value.as_bytes()).await;
315                                let flush_result = stdin.flush().await;
316                                // Zeroize the password from memory immediately after use
317                                value.zeroize();
318                                write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
319                                flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
320                            }
321                            None => {
322                                let _ = child.kill().await;
323                                return Err(RuntimeError::Tool(
324                                    "Command canceled while waiting for password".to_string(),
325                                ));
326                            }
327                        }
328                        let prompt_len = prompt_text.len();
329                        if prompt_len <= msg.len() {
330                            let keep_len = msg.len() - prompt_len;
331                            msg.truncate(keep_len);
332                        } else {
333                            msg.clear();
334                        }
335                        stderr_tail.clear();
336                    }
337                }
338
339                for secret in &redactions {
340                    if !secret.is_empty() {
341                        msg = msg.replace(secret, "[redacted]");
342                    }
343                }
344
345                if truncated {
346                    continue;
347                }
348
349                let added_all = append_bounded(&mut full_output, &msg, max_output);
350                if let Some(ref txd) = ctx.channels.tx_delta {
351                    if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
352                        let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
353                        let delta = if msg.len() <= remaining {
354                            msg.clone()
355                        } else {
356                            crate::truncate_str(&msg, remaining).to_string()
357                        };
358                        streamed_bytes += delta.len();
359                        if !delta.is_empty() {
360                            let _ = txd.send(delta);
361                        }
362                    }
363                }
364
365                if !added_all {
366                    full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
367                    if let Some(ref txd) = ctx.channels.tx_delta {
368                        let _ = txd.send(format!("\n\n[output truncated at {}]", max_output));
369                    }
370                    truncated = true;
371                    let _ = child.kill().await;
372                }
373            }
374            let status = child
375                .wait()
376                .await
377                .map_err(|e| RuntimeError::Tool(e.to_string()))?;
378            // Zeroize redactions (passwords) from memory now that command is done
379            for secret in &mut redactions {
380                secret.zeroize();
381            }
382            Ok::<_, RuntimeError>((status, full_output, truncated))
383        })
384        .await;
385
386        match result {
387            Ok(Ok((status, output, was_truncated))) => {
388                if status.success() || was_truncated {
389                    Ok(output)
390                } else {
391                    Err(RuntimeError::Tool(format!(
392                        "Command failed (exit {}):\n{}",
393                        status.code().unwrap_or(-1),
394                        output
395                    )))
396                }
397            }
398            Ok(Err(e)) => Err(RuntimeError::Tool(format!(
399                "Failed to execute command: {}",
400                e
401            ))),
402            Err(_) => Err(RuntimeError::Tool(format!(
403                "Command timed out after {}s",
404                timeout_secs
405            ))),
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[tokio::test]
415    async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
416        let before = bash_intermediary_snapshot();
417        let tool = BashTool;
418        let mut ctx = create_tool_context();
419        ctx.limits.max_tool_buffer = 64 * 1024;
420        let result = tool
421            .execute(
422                json!({
423                    "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
424                    "timeout": 30
425                }),
426                ctx,
427            )
428            .await
429            .unwrap();
430        assert!(result.contains("output truncated"));
431
432        // The counters are PROCESS-GLOBAL: sibling tests running bash
433        // concurrently (--test-threads > 1) contribute mid-flight bytes to
434        // any instantaneous snapshot. Conservation is therefore asserted at
435        // quiescence: every completed relay balances produced == consumed +
436        // dropped and returns retained to baseline, so the delta window
437        // rebalances once in-flight relays finish. A REAL leak never
438        // rebalances — the bounded poll keeps the oracle strict while
439        // removing scheduling sensitivity (observed flake under the full
440        // workspace run at 8 threads).
441        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
442        let (mut produced, mut consumed, mut accepted, mut dropped);
443        loop {
444            let after = bash_intermediary_snapshot();
445            produced = after.produced_bytes - before.produced_bytes;
446            consumed = after.consumed_bytes - before.consumed_bytes;
447            accepted = after.accepted_bytes - before.accepted_bytes;
448            dropped = after.dropped_bytes - before.dropped_bytes;
449            let balanced =
450                produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
451            if balanced || std::time::Instant::now() >= deadline {
452                assert_eq!(
453                    produced,
454                    consumed + dropped,
455                    "handoff bytes must be conserved at quiescence"
456                );
457                assert_eq!(after.retained_bytes, before.retained_bytes);
458                break;
459            }
460            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
461        }
462        assert!(accepted >= consumed);
463        assert!(produced >= 64 * 1024);
464    }
465
466    #[test]
467    fn detects_sudo_password_prompt_without_newline() {
468        assert_eq!(
469            detect_password_prompt("[sudo] password for me: "),
470            Some(PromptKind::Sudo)
471        );
472    }
473
474    #[test]
475    fn sanitizes_terminal_control_sequences_and_nuls() {
476        let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
477        assert_eq!(cleaned, "okdone");
478    }
479
480    use super::super::test_helpers::create_tool_context;
481    use crate::tools::Tool;
482    use serde_json::json;
483
484    #[test]
485    fn test_bash_tool_schema() {
486        let tool = BashTool;
487        assert_eq!(tool.name(), "bash");
488        assert!(!tool.description().is_empty());
489
490        let params = tool.parameters();
491        assert_eq!(params["type"], "object");
492        assert!(params["properties"].is_object());
493        assert!(params["required"].is_array());
494    }
495
496    #[tokio::test]
497    async fn test_bash_tool_execution() {
498        let tool = BashTool;
499
500        // Test simple echo command
501        let ctx = create_tool_context();
502        let params = json!({
503            "command": "echo hello"
504        });
505
506        let result = tool.execute(params, ctx).await.unwrap();
507        assert!(result.contains("hello"));
508
509        // Test timeout parameter with quick command
510        let ctx = create_tool_context();
511        let params = json!({
512            "command": "sleep 1",
513            "timeout": 2
514        });
515
516        let result = tool.execute(params, ctx).await;
517        // Should succeed (1 second sleep with 2 second timeout)
518        assert!(result.is_ok());
519
520        // Test timeout with longer command
521        let ctx = create_tool_context();
522        let params = json!({
523            "command": "sleep 3",
524            "timeout": 1
525        });
526
527        let result = tool.execute(params, ctx).await;
528        // Should timeout
529        assert!(result.is_err());
530        assert!(result.unwrap_err().to_string().contains("timed out"));
531    }
532
533    #[tokio::test]
534    async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
535        let tool = BashTool;
536        let mut ctx = create_tool_context();
537        ctx.limits.bash_max_timeout = 1;
538
539        let params = json!({
540            "command": "sleep 5; echo done",
541            "timeout": 10
542        });
543
544        let result = tool.execute(params, ctx).await;
545        assert!(
546            result.unwrap_err().to_string().contains("timed out"),
547            "requested timeout MUST be clamped by bash_max_timeout"
548        );
549    }
550
551    #[tokio::test]
552    async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
553        let tool = BashTool;
554        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
555        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
556        let channel = crate::tools::output::delta_channel();
557        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
558
559        let responder = tokio::spawn(async move {
560            let req = prompt_rx
561                .recv()
562                .await
563                .expect("bash should request a secret prompt");
564            assert!(
565                req.prompt.to_ascii_lowercase().contains("password"),
566                "prompt was {:?}",
567                req.prompt
568            );
569            req.response_tx.send(Some("swordfish".to_string())).unwrap();
570        });
571
572        let mut ctx = create_tool_context();
573        ctx.capabilities.secret_prompt = Some(prompt_handle);
574        ctx.channels.tx_delta = Some(delta_tx);
575        let params = json!({
576            "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
577            "timeout": 30
578        });
579
580        let result = tool.execute(params, ctx).await.unwrap();
581        responder.await.unwrap();
582        let mut streamed = String::new();
583        while let Some(delta) = delta_rx.try_drain() {
584            streamed.push_str(&delta);
585        }
586
587        assert!(result.contains("AUTH_OK"));
588        assert!(!result.contains("swordfish"));
589        assert!(!result.contains("[sudo] password"));
590        assert!(!streamed.contains("[sudo] password"));
591    }
592
593    #[test]
594    fn test_bash_wraps_sudo_to_force_stdin_prompt() {
595        let script = super::bash_script_with_secure_sudo("sudo id");
596
597        assert!(script.contains("sudo()"));
598        assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
599        assert!(script.ends_with("sudo id"));
600    }
601
602    /// Writes an executable stub `sudo` into `dir` that emulates the flags the
603    /// secure wrapper passes (`-S -p PROMPT`): `-k` exits silently like a real
604    /// timestamp reset, anything else writes PROMPT to stderr, consumes one
605    /// stdin line, and fails. Lets the wrapper be tested without the system
606    /// sudo, whose prompting behaviour is host-dependent.
607    #[cfg(unix)]
608    fn write_fake_sudo(dir: &std::path::Path) {
609        use std::os::unix::fs::PermissionsExt;
610        let sudo_path = dir.join("sudo");
611        std::fs::write(
612            &sudo_path,
613            r#"#!/bin/sh
614prompt=""
615while [ $# -gt 0 ]; do
616  case "$1" in
617    -S) shift ;;
618    -p) prompt="$2"; shift 2 ;;
619    -k) exit 0 ;;
620    *) break ;;
621  esac
622done
623printf '%s' "$prompt" >&2
624read -r _pw
625exit 1
626"#,
627        )
628        .expect("write fake sudo");
629        let mut perms = std::fs::metadata(&sudo_path)
630            .expect("stat fake sudo")
631            .permissions();
632        perms.set_mode(0o755);
633        std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
634    }
635
636    /// The `sudo()` wrapper's prompt is intercepted by the secret-prompt path
637    /// and never reaches the delta stream.
638    ///
639    /// Hermetic by construction: it drives a STUB sudo on PATH, not the host's.
640    /// `command sudo` bypasses the shell function but still honours PATH, so
641    /// the real wrapper path is exercised. PATH is exported inside the script
642    /// rather than through `std::env::set_var` because these tests are not
643    /// `#[serial]` and mutating process-global env would race them.
644    ///
645    /// This previously drove the system sudo and asserted that it prompts —
646    /// false on any host with passwordless sudo, which includes every GitHub
647    /// runner, so it could never pass in CI.
648    #[cfg(unix)]
649    #[tokio::test]
650    async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
651        let fake_bin = tempfile::tempdir().expect("tempdir");
652        write_fake_sudo(fake_bin.path());
653        let tool = BashTool;
654        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
655        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
656        let channel = crate::tools::output::delta_channel();
657        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
658
659        let responder = tokio::spawn(async move {
660            let req = prompt_rx
661                .recv()
662                .await
663                .expect("bash should request a secret prompt");
664            assert!(
665                req.prompt.contains("[sudo] password required"),
666                "prompt was {:?}",
667                req.prompt
668            );
669            req.response_tx
670                .send(Some("wrong-password-for-test".to_string()))
671                .unwrap();
672        });
673
674        let mut ctx = create_tool_context();
675        ctx.capabilities.secret_prompt = Some(prompt_handle);
676        ctx.channels.tx_delta = Some(delta_tx);
677        let params = json!({
678            "command": format!(
679                "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
680                fake_bin.path().display()
681            ),
682            "timeout": 30
683        });
684
685        let _ = tool.execute(params, ctx).await;
686        responder.await.unwrap();
687        let mut streamed = String::new();
688        while let Some(delta) = delta_rx.try_drain() {
689            streamed.push_str(&delta);
690        }
691
692        assert!(
693            !streamed.contains("[sudo] password required"),
694            "sudo password prompt leaked into deltas: {streamed:?}"
695        );
696    }
697
698    #[tokio::test]
699    async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
700        let tool = BashTool;
701        let channel = crate::tools::output::delta_channel();
702        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
703        let mut ctx = create_tool_context();
704        ctx.channels.tx_delta = Some(delta_tx);
705        ctx.limits.max_tool_buffer = 256;
706
707        let params = json!({
708            "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
709            "timeout": 30
710        });
711
712        let result = tool.execute(params, ctx).await.unwrap();
713        let mut streamed = String::new();
714        while let Some(delta) = delta_rx.try_drain() {
715            streamed.push_str(&delta);
716        }
717
718        assert!(result.contains("[output truncated at 256]"));
719        assert!(!result.contains('\u{1b}'));
720        assert!(!result.contains('\0'));
721        assert!(!streamed.contains('\u{1b}'));
722        assert!(!streamed.contains('\0'));
723        assert!(
724            streamed.len() <= 2048,
725            "streamed deltas must be bounded, got {} bytes",
726            streamed.len()
727        );
728    }
729
730    #[tokio::test]
731    async fn test_bash_echoed_secret_is_redacted_from_output() {
732        let tool = BashTool;
733        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
734        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
735
736        let responder = tokio::spawn(async move {
737            let req = prompt_rx
738                .recv()
739                .await
740                .expect("bash should request a secret prompt");
741            req.response_tx.send(Some("swordfish".to_string())).unwrap();
742        });
743
744        let mut ctx = create_tool_context();
745        ctx.capabilities.secret_prompt = Some(prompt_handle);
746        let params = json!({
747            "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
748            "timeout": 30
749        });
750
751        let result = tool.execute(params, ctx).await.unwrap();
752        responder.await.unwrap();
753
754        assert!(result.contains("seen:[redacted]"));
755        assert!(!result.contains("swordfish"));
756    }
757
758    #[tokio::test]
759    async fn test_bash_sequential_password_prompts_are_each_handled() {
760        let tool = BashTool;
761        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
762        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
763
764        let responder = tokio::spawn(async move {
765            for value in ["first", "second"] {
766                let req = prompt_rx
767                    .recv()
768                    .await
769                    .expect("bash should request each secret prompt");
770                assert!(req.prompt.to_ascii_lowercase().contains("password"));
771                req.response_tx.send(Some(value.to_string())).unwrap();
772            }
773        });
774
775        let mut ctx = create_tool_context();
776        ctx.capabilities.secret_prompt = Some(prompt_handle);
777        let params = json!({
778            "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
779            "timeout": 30
780        });
781
782        let result = tool.execute(params, ctx).await.unwrap();
783        responder.await.unwrap();
784
785        assert!(result.contains("done:[redacted]:[redacted]"));
786        assert!(!result.contains("first"));
787        assert!(!result.contains("second"));
788    }
789
790    #[tokio::test]
791    async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
792        let tool = BashTool;
793        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
794        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
795
796        let responder = tokio::spawn(async move {
797            let req = prompt_rx
798                .recv()
799                .await
800                .expect("bash should request a secret prompt");
801            req.response_tx.send(None).unwrap();
802        });
803
804        let mut ctx = create_tool_context();
805        ctx.capabilities.secret_prompt = Some(prompt_handle);
806        let params = json!({
807            "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
808            "timeout": 30
809        });
810
811        let err = tool.execute(params, ctx).await.unwrap_err().to_string();
812        responder.await.unwrap();
813
814        assert!(err.contains("waiting for password"));
815        assert!(!err.contains("should-not-run"));
816    }
817
818    #[tokio::test]
819    async fn test_bash_binary_output_is_sanitized() {
820        let tool = BashTool;
821        let ctx = create_tool_context();
822        let params = json!({
823            "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
824            "timeout": 30
825        });
826
827        let result = tool.execute(params, ctx).await.unwrap();
828
829        assert!(result.contains("visible"));
830        assert!(!result.contains('\0'));
831        assert!(!result.contains('\u{1b}'));
832    }
833
834    #[tokio::test]
835    async fn test_bash_tool_timeout() {
836        let tool = BashTool;
837        let ctx = create_tool_context();
838
839        let params = json!({
840            "command": "sleep 10",
841            "timeout": 1
842        });
843
844        let result = tool.execute(params, ctx).await;
845
846        // Should timeout and return error
847        assert!(result.is_err());
848        let error = result.unwrap_err().to_string();
849        assert!(error.contains("timed out"));
850    }
851
852    #[tokio::test]
853    async fn test_bash_tool_failure() {
854        let tool = BashTool;
855        let ctx = create_tool_context();
856
857        let params = json!({
858            "command": "exit 1"
859        });
860
861        let result = tool.execute(params, ctx).await;
862
863        // Should fail and return error
864        assert!(result.is_err());
865        let error = result.unwrap_err().to_string();
866        assert!(error.contains("failed") || error.contains("exit"));
867    }
868}