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