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(&script, ShellSpec::Bash, params["timeout"].as_u64(), ctx).await
220    }
221}
222
223/// Which shell backend the shared executor drives.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub(crate) enum ShellSpec {
226    Bash,
227    PowerShell,
228}
229
230/// Shared execution core for bash + powershell: piped stdin/stdout/stderr,
231/// chunked streaming with byte-conservation accounting, secret-prompt
232/// interception, truncation, timeout. The only per-shell differences are
233/// which program is spawned and which args it takes.
234pub(crate) async fn run_shell_command(
235    script: &str,
236    spec: ShellSpec,
237    requested_timeout: Option<u64>,
238    ctx: ToolContext,
239) -> Result<String> {
240    {
241        let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
242        // H5: enforce bash_max_timeout cap — prevent DoS via prompt injection
243        // requesting unbounded timeouts (e.g. timeout:2592000 + infinite loop).
244        let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
245            requested_timeout.min(ctx.limits.bash_max_timeout)
246        } else {
247            requested_timeout
248        };
249        let max_output = ctx.limits.max_tool_buffer;
250
251        let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
252            ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
253            ShellSpec::PowerShell => (
254                powershell_program(),
255                vec!["-NoProfile".into(), "-Command".into(), script.into()],
256            ),
257        };
258        let mut cmd = tokio::process::Command::new(program);
259        if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
260            cmd.current_dir(cwd);
261        }
262        if let Some(env) = &ctx.capabilities.env {
263            cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
264        }
265        cmd.args(&args)
266            .stdin(std::process::Stdio::piped())
267            .stdout(std::process::Stdio::piped())
268            .stderr(std::process::Stdio::piped())
269            .kill_on_drop(true);
270
271        // Start the child in a new session (setsid) so it has no controlling
272        // terminal. Programs that open /dev/tty directly (SSH fingerprint
273        // prompts, gpg pinentry, git credential helpers, pagers) will get
274        // ENXIO and fail with a readable error on stderr instead of hanging
275        // invisibly until timeout. Sudo is unaffected — we already force
276        // `-S` (stdin) via bash_script_with_secure_sudo().
277        #[cfg(unix)]
278        unsafe {
279            cmd.pre_exec(|| {
280                libc::setsid();
281                Ok(())
282            });
283        }
284
285        let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
286
287        let stdout = child
288            .stdout
289            .take()
290            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
291        let stderr = child
292            .stderr
293            .take()
294            .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
295        let stdin = child
296            .stdin
297            .take()
298            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
299
300        let (tx_inter, mut rx_inter) =
301            tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
302
303        let tx_o = tx_inter.clone();
304        tokio::spawn(async move {
305            use tokio::io::AsyncReadExt;
306            let mut reader = stdout;
307            let mut buf = vec![0u8; READ_CHUNK_SIZE];
308            loop {
309                match reader.read(&mut buf).await {
310                    Ok(0) => break,
311                    Ok(n) => {
312                        let msg = sanitize_output(&buf[..n]);
313                        if !msg.is_empty() {
314                            use std::sync::atomic::Ordering;
315                            BASH_INTERMEDIARY_PRODUCED_BYTES
316                                .fetch_add(msg.len() as u64, Ordering::Relaxed);
317                            let len = msg.len();
318                            if tx_o.send((false, msg)).await.is_ok() {
319                                BASH_INTERMEDIARY_ACCEPTED_BYTES
320                                    .fetch_add(len as u64, Ordering::Relaxed);
321                            } else {
322                                BASH_INTERMEDIARY_DROPPED_BYTES
323                                    .fetch_add(len as u64, Ordering::Relaxed);
324                                break;
325                            }
326                        }
327                    }
328                    Err(_) => break,
329                }
330            }
331        });
332
333        let tx_e = tx_inter.clone();
334        tokio::spawn(async move {
335            use tokio::io::AsyncReadExt;
336            let mut reader = stderr;
337            let mut buf = vec![0u8; READ_CHUNK_SIZE];
338            loop {
339                match reader.read(&mut buf).await {
340                    Ok(0) => break,
341                    Ok(n) => {
342                        let msg = sanitize_output(&buf[..n]);
343                        if !msg.is_empty() {
344                            use std::sync::atomic::Ordering;
345                            BASH_INTERMEDIARY_PRODUCED_BYTES
346                                .fetch_add(msg.len() as u64, Ordering::Relaxed);
347                            let len = msg.len();
348                            if tx_e.send((true, msg)).await.is_ok() {
349                                BASH_INTERMEDIARY_ACCEPTED_BYTES
350                                    .fetch_add(len as u64, Ordering::Relaxed);
351                            } else {
352                                BASH_INTERMEDIARY_DROPPED_BYTES
353                                    .fetch_add(len as u64, Ordering::Relaxed);
354                                break;
355                            }
356                        }
357                    }
358                    Err(_) => break,
359                }
360            }
361        });
362
363        drop(tx_inter);
364
365        let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
366            use tokio::io::AsyncWriteExt;
367
368            let mut stdin = stdin;
369            let mut full_output = String::new();
370            let mut stderr_tail = String::new();
371            let mut truncated = false;
372            let mut streamed_bytes = 0usize;
373            let mut redactions: Vec<String> = Vec::new();
374
375            while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
376                BASH_INTERMEDIARY_CONSUMED_BYTES
377                    .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
378                if is_stderr {
379                    stderr_tail.push_str(&msg);
380                    if stderr_tail.len() > 512 {
381                        let keep_from = stderr_tail.len() - 512;
382                        if let Some((idx, _)) =
383                            stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
384                        {
385                            stderr_tail.drain(..idx);
386                        }
387                    }
388                    if let Some(kind) = detect_password_prompt(&stderr_tail) {
389                        let prompt_text = stderr_tail.trim().to_string();
390                        let secret = match &ctx.capabilities.secret_prompt {
391                            Some(prompt) => {
392                                prompt
393                                    .prompt(
394                                        match kind {
395                                            PromptKind::Sudo => {
396                                                "sudo password required".to_string()
397                                            }
398                                            PromptKind::Password => "password required".to_string(),
399                                        },
400                                        prompt_text.clone(),
401                                    )
402                                    .await
403                            }
404                            None => None,
405                        };
406                        match secret {
407                            Some(mut value) => {
408                                let secret_value = value.clone();
409                                if !secret_value.is_empty() {
410                                    redactions.push(secret_value);
411                                }
412                                value.push('\n');
413                                let write_result = stdin.write_all(value.as_bytes()).await;
414                                let flush_result = stdin.flush().await;
415                                // Zeroize the password from memory immediately after use
416                                value.zeroize();
417                                write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
418                                flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
419                            }
420                            None => {
421                                let _ = child.kill().await;
422                                return Err(RuntimeError::Tool(
423                                    "Command canceled while waiting for password".to_string(),
424                                ));
425                            }
426                        }
427                        let prompt_len = prompt_text.len();
428                        if prompt_len <= msg.len() {
429                            let keep_len = msg.len() - prompt_len;
430                            msg.truncate(keep_len);
431                        } else {
432                            msg.clear();
433                        }
434                        stderr_tail.clear();
435                    }
436                }
437
438                for secret in &redactions {
439                    if !secret.is_empty() {
440                        msg = msg.replace(secret, "[redacted]");
441                    }
442                }
443
444                if truncated {
445                    continue;
446                }
447
448                let added_all = append_bounded(&mut full_output, &msg, max_output);
449                if let Some(ref txd) = ctx.channels.tx_delta {
450                    if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
451                        let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
452                        let delta = if msg.len() <= remaining {
453                            msg.clone()
454                        } else {
455                            crate::truncate_str(&msg, remaining).to_string()
456                        };
457                        streamed_bytes += delta.len();
458                        if !delta.is_empty() {
459                            txd.send(delta);
460                        }
461                    }
462                }
463
464                if !added_all {
465                    full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
466                    if let Some(ref txd) = ctx.channels.tx_delta {
467                        txd.send(format!("\n\n[output truncated at {}]", max_output));
468                    }
469                    truncated = true;
470                    let _ = child.kill().await;
471                }
472            }
473            let status = child
474                .wait()
475                .await
476                .map_err(|e| RuntimeError::Tool(e.to_string()))?;
477            // Zeroize redactions (passwords) from memory now that command is done
478            for secret in &mut redactions {
479                secret.zeroize();
480            }
481            Ok::<_, RuntimeError>((status, full_output, truncated))
482        })
483        .await;
484
485        match result {
486            Ok(Ok((status, output, was_truncated))) => {
487                if status.success() || was_truncated {
488                    Ok(output)
489                } else {
490                    let mut notice = String::new();
491                    // T5: when a command fails and references a stripped secret,
492                    // append a notice (once per name per session).
493                    if !ctx.capabilities.env_stripped.is_empty() {
494                        let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
495                        for name in &ctx.capabilities.env_stripped {
496                            if warned.contains(name) {
497                                continue;
498                            }
499                            // Word-boundary match: covers $NAME, ${NAME},
500                            // ${NAME:?}, ${NAME:-x}, $env:NAME and NAME=…
501                            if references_env_name(script, name) {
502                                notice.push_str(&format!(
503                                    "\nnote: ${name} was stripped from the session env as a secret; \
504                                     reattach from a shell that has it \
505                                     (synaps --system … from that shell) \
506                                     — the daemon never receives credentials."
507                                ));
508                                warned.insert(name.clone());
509                            }
510                        }
511                    }
512                    Err(RuntimeError::Tool(format!(
513                        "Command failed (exit {}):\n{}{notice}",
514                        status.code().unwrap_or(-1),
515                        output
516                    )))
517                }
518            }
519            Ok(Err(e)) => Err(RuntimeError::Tool(format!(
520                "Failed to execute command: {}",
521                e
522            ))),
523            Err(_) => Err(RuntimeError::Tool(format!(
524                "Command timed out after {}s",
525                timeout_secs
526            ))),
527        }
528    }
529}
530
531/// Does `script` mention `name` as a whole identifier (not as a substring of
532/// a longer one)? Used by the T5 stripped-secret notice; a false positive
533/// costs one advisory line on an already-failing command, a false negative
534/// hides the reason the command failed — so err towards matching.
535fn references_env_name(script: &str, name: &str) -> bool {
536    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
537    let mut from = 0;
538    while let Some(pos) = script[from..].find(name) {
539        let start = from + pos;
540        let end = start + name.len();
541        let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
542        let after_ok = !script[end..].chars().next().is_some_and(is_ident);
543        if before_ok && after_ok {
544            return true;
545        }
546        from = end;
547    }
548    false
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[tokio::test]
556    async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
557        let before = bash_intermediary_snapshot();
558        let tool = BashTool;
559        let mut ctx = create_tool_context();
560        ctx.limits.max_tool_buffer = 64 * 1024;
561        let result = tool
562            .execute(
563                json!({
564                    "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
565                    "timeout": 30
566                }),
567                ctx,
568            )
569            .await
570            .unwrap();
571        assert!(result.contains("output truncated"));
572
573        // The counters are PROCESS-GLOBAL: sibling tests running bash
574        // concurrently (--test-threads > 1) contribute mid-flight bytes to
575        // any instantaneous snapshot. Conservation is therefore asserted at
576        // quiescence: every completed relay balances produced == consumed +
577        // dropped and returns retained to baseline, so the delta window
578        // rebalances once in-flight relays finish. A REAL leak never
579        // rebalances — the bounded poll keeps the oracle strict while
580        // removing scheduling sensitivity (observed flake under the full
581        // workspace run at 8 threads).
582        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
583        let (mut produced, mut consumed, mut accepted, mut dropped);
584        loop {
585            let after = bash_intermediary_snapshot();
586            produced = after.produced_bytes - before.produced_bytes;
587            consumed = after.consumed_bytes - before.consumed_bytes;
588            accepted = after.accepted_bytes - before.accepted_bytes;
589            dropped = after.dropped_bytes - before.dropped_bytes;
590            let balanced =
591                produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
592            if balanced || std::time::Instant::now() >= deadline {
593                assert_eq!(
594                    produced,
595                    consumed + dropped,
596                    "handoff bytes must be conserved at quiescence"
597                );
598                assert_eq!(after.retained_bytes, before.retained_bytes);
599                break;
600            }
601            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
602        }
603        assert!(accepted >= consumed);
604        assert!(produced >= 64 * 1024);
605    }
606
607    #[test]
608    fn detects_sudo_password_prompt_without_newline() {
609        assert_eq!(
610            detect_password_prompt("[sudo] password for me: "),
611            Some(PromptKind::Sudo)
612        );
613    }
614
615    #[test]
616    fn sanitizes_terminal_control_sequences_and_nuls() {
617        let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
618        assert_eq!(cleaned, "okdone");
619    }
620
621    use super::super::test_helpers::create_tool_context;
622    use crate::tools::Tool;
623    use serde_json::json;
624
625    #[test]
626    fn test_bash_tool_schema() {
627        let tool = BashTool;
628        assert_eq!(tool.name(), "bash");
629        assert!(!tool.description().is_empty());
630
631        let params = tool.parameters();
632        assert_eq!(params["type"], "object");
633        assert!(params["properties"].is_object());
634        assert!(params["required"].is_array());
635    }
636
637    #[tokio::test]
638    async fn test_bash_tool_execution() {
639        let tool = BashTool;
640
641        // Test simple echo command
642        let ctx = create_tool_context();
643        let params = json!({
644            "command": "echo hello"
645        });
646
647        let result = tool.execute(params, ctx).await.unwrap();
648        assert!(result.contains("hello"));
649
650        // Test timeout parameter with quick command
651        let ctx = create_tool_context();
652        let params = json!({
653            "command": "sleep 1",
654            "timeout": 2
655        });
656
657        let result = tool.execute(params, ctx).await;
658        // Should succeed (1 second sleep with 2 second timeout)
659        assert!(result.is_ok());
660
661        // Test timeout with longer command
662        let ctx = create_tool_context();
663        let params = json!({
664            "command": "sleep 3",
665            "timeout": 1
666        });
667
668        let result = tool.execute(params, ctx).await;
669        // Should timeout
670        assert!(result.is_err());
671        assert!(result.unwrap_err().to_string().contains("timed out"));
672    }
673
674    #[tokio::test]
675    async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
676        let tool = BashTool;
677        let mut ctx = create_tool_context();
678        ctx.limits.bash_max_timeout = 1;
679
680        let params = json!({
681            "command": "sleep 5; echo done",
682            "timeout": 10
683        });
684
685        let result = tool.execute(params, ctx).await;
686        assert!(
687            result.unwrap_err().to_string().contains("timed out"),
688            "requested timeout MUST be clamped by bash_max_timeout"
689        );
690    }
691
692    #[tokio::test]
693    async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
694        let tool = BashTool;
695        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
696        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
697        let channel = crate::tools::output::delta_channel();
698        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
699
700        let responder = tokio::spawn(async move {
701            let req = prompt_rx
702                .recv()
703                .await
704                .expect("bash should request a secret prompt");
705            assert!(
706                req.prompt.to_ascii_lowercase().contains("password"),
707                "prompt was {:?}",
708                req.prompt
709            );
710            req.response_tx.send(Some("swordfish".to_string())).unwrap();
711        });
712
713        let mut ctx = create_tool_context();
714        ctx.capabilities.secret_prompt = Some(prompt_handle);
715        ctx.channels.tx_delta = Some(delta_tx);
716        let params = json!({
717            "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
718            "timeout": 30
719        });
720
721        let result = tool.execute(params, ctx).await.unwrap();
722        responder.await.unwrap();
723        let mut streamed = String::new();
724        while let Some(delta) = delta_rx.try_drain() {
725            streamed.push_str(&delta);
726        }
727
728        assert!(result.contains("AUTH_OK"));
729        assert!(!result.contains("swordfish"));
730        assert!(!result.contains("[sudo] password"));
731        assert!(!streamed.contains("[sudo] password"));
732    }
733
734    #[test]
735    fn test_bash_wraps_sudo_to_force_stdin_prompt() {
736        let script = super::bash_script_with_secure_sudo("sudo id");
737
738        assert!(script.contains("sudo()"));
739        assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
740        assert!(script.ends_with("sudo id"));
741    }
742
743    /// Writes an executable stub `sudo` into `dir` that emulates the flags the
744    /// secure wrapper passes (`-S -p PROMPT`): `-k` exits silently like a real
745    /// timestamp reset, anything else writes PROMPT to stderr, consumes one
746    /// stdin line, and fails. Lets the wrapper be tested without the system
747    /// sudo, whose prompting behaviour is host-dependent.
748    #[cfg(unix)]
749    fn write_fake_sudo(dir: &std::path::Path) {
750        use std::os::unix::fs::PermissionsExt;
751        let sudo_path = dir.join("sudo");
752        std::fs::write(
753            &sudo_path,
754            r#"#!/bin/sh
755prompt=""
756while [ $# -gt 0 ]; do
757  case "$1" in
758    -S) shift ;;
759    -p) prompt="$2"; shift 2 ;;
760    -k) exit 0 ;;
761    *) break ;;
762  esac
763done
764printf '%s' "$prompt" >&2
765read -r _pw
766exit 1
767"#,
768        )
769        .expect("write fake sudo");
770        let mut perms = std::fs::metadata(&sudo_path)
771            .expect("stat fake sudo")
772            .permissions();
773        perms.set_mode(0o755);
774        std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
775    }
776
777    /// The `sudo()` wrapper's prompt is intercepted by the secret-prompt path
778    /// and never reaches the delta stream.
779    ///
780    /// Hermetic by construction: it drives a STUB sudo on PATH, not the host's.
781    /// `command sudo` bypasses the shell function but still honours PATH, so
782    /// the real wrapper path is exercised. PATH is exported inside the script
783    /// rather than through `std::env::set_var` because these tests are not
784    /// `#[serial]` and mutating process-global env would race them.
785    ///
786    /// This previously drove the system sudo and asserted that it prompts —
787    /// false on any host with passwordless sudo, which includes every GitHub
788    /// runner, so it could never pass in CI.
789    #[cfg(unix)]
790    #[tokio::test]
791    async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
792        let fake_bin = tempfile::tempdir().expect("tempdir");
793        write_fake_sudo(fake_bin.path());
794        let tool = BashTool;
795        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
796        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
797        let channel = crate::tools::output::delta_channel();
798        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
799
800        let responder = tokio::spawn(async move {
801            let req = prompt_rx
802                .recv()
803                .await
804                .expect("bash should request a secret prompt");
805            assert!(
806                req.prompt.contains("[sudo] password required"),
807                "prompt was {:?}",
808                req.prompt
809            );
810            req.response_tx
811                .send(Some("wrong-password-for-test".to_string()))
812                .unwrap();
813        });
814
815        let mut ctx = create_tool_context();
816        ctx.capabilities.secret_prompt = Some(prompt_handle);
817        ctx.channels.tx_delta = Some(delta_tx);
818        let params = json!({
819            "command": format!(
820                "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
821                fake_bin.path().display()
822            ),
823            "timeout": 30
824        });
825
826        let _ = tool.execute(params, ctx).await;
827        responder.await.unwrap();
828        let mut streamed = String::new();
829        while let Some(delta) = delta_rx.try_drain() {
830            streamed.push_str(&delta);
831        }
832
833        assert!(
834            !streamed.contains("[sudo] password required"),
835            "sudo password prompt leaked into deltas: {streamed:?}"
836        );
837    }
838
839    #[tokio::test]
840    async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
841        let tool = BashTool;
842        let channel = crate::tools::output::delta_channel();
843        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
844        let mut ctx = create_tool_context();
845        ctx.channels.tx_delta = Some(delta_tx);
846        ctx.limits.max_tool_buffer = 256;
847
848        let params = json!({
849            "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
850            "timeout": 30
851        });
852
853        let result = tool.execute(params, ctx).await.unwrap();
854        let mut streamed = String::new();
855        while let Some(delta) = delta_rx.try_drain() {
856            streamed.push_str(&delta);
857        }
858
859        assert!(result.contains("[output truncated at 256]"));
860        assert!(!result.contains('\u{1b}'));
861        assert!(!result.contains('\0'));
862        assert!(!streamed.contains('\u{1b}'));
863        assert!(!streamed.contains('\0'));
864        assert!(
865            streamed.len() <= 2048,
866            "streamed deltas must be bounded, got {} bytes",
867            streamed.len()
868        );
869    }
870
871    #[tokio::test]
872    async fn test_bash_echoed_secret_is_redacted_from_output() {
873        let tool = BashTool;
874        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
875        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
876
877        let responder = tokio::spawn(async move {
878            let req = prompt_rx
879                .recv()
880                .await
881                .expect("bash should request a secret prompt");
882            req.response_tx.send(Some("swordfish".to_string())).unwrap();
883        });
884
885        let mut ctx = create_tool_context();
886        ctx.capabilities.secret_prompt = Some(prompt_handle);
887        let params = json!({
888            "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
889            "timeout": 30
890        });
891
892        let result = tool.execute(params, ctx).await.unwrap();
893        responder.await.unwrap();
894
895        assert!(result.contains("seen:[redacted]"));
896        assert!(!result.contains("swordfish"));
897    }
898
899    #[tokio::test]
900    async fn test_bash_sequential_password_prompts_are_each_handled() {
901        let tool = BashTool;
902        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
903        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
904
905        let responder = tokio::spawn(async move {
906            for value in ["first", "second"] {
907                let req = prompt_rx
908                    .recv()
909                    .await
910                    .expect("bash should request each secret prompt");
911                assert!(req.prompt.to_ascii_lowercase().contains("password"));
912                req.response_tx.send(Some(value.to_string())).unwrap();
913            }
914        });
915
916        let mut ctx = create_tool_context();
917        ctx.capabilities.secret_prompt = Some(prompt_handle);
918        let params = json!({
919            "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
920            "timeout": 30
921        });
922
923        let result = tool.execute(params, ctx).await.unwrap();
924        responder.await.unwrap();
925
926        assert!(result.contains("done:[redacted]:[redacted]"));
927        assert!(!result.contains("first"));
928        assert!(!result.contains("second"));
929    }
930
931    #[tokio::test]
932    async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
933        let tool = BashTool;
934        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
935        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
936
937        let responder = tokio::spawn(async move {
938            let req = prompt_rx
939                .recv()
940                .await
941                .expect("bash should request a secret prompt");
942            req.response_tx.send(None).unwrap();
943        });
944
945        let mut ctx = create_tool_context();
946        ctx.capabilities.secret_prompt = Some(prompt_handle);
947        let params = json!({
948            "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
949            "timeout": 30
950        });
951
952        let err = tool.execute(params, ctx).await.unwrap_err().to_string();
953        responder.await.unwrap();
954
955        assert!(err.contains("waiting for password"));
956        assert!(!err.contains("should-not-run"));
957    }
958
959    #[tokio::test]
960    async fn test_bash_binary_output_is_sanitized() {
961        let tool = BashTool;
962        let ctx = create_tool_context();
963        let params = json!({
964            "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
965            "timeout": 30
966        });
967
968        let result = tool.execute(params, ctx).await.unwrap();
969
970        assert!(result.contains("visible"));
971        assert!(!result.contains('\0'));
972        assert!(!result.contains('\u{1b}'));
973    }
974
975    #[tokio::test]
976    async fn test_bash_tool_timeout() {
977        let tool = BashTool;
978        let ctx = create_tool_context();
979
980        let params = json!({
981            "command": "sleep 10",
982            "timeout": 1
983        });
984
985        let result = tool.execute(params, ctx).await;
986
987        // Should timeout and return error
988        assert!(result.is_err());
989        let error = result.unwrap_err().to_string();
990        assert!(error.contains("timed out"));
991    }
992
993    #[tokio::test]
994    async fn test_bash_tool_failure() {
995        let tool = BashTool;
996        let ctx = create_tool_context();
997
998        let params = json!({
999            "command": "exit 1"
1000        });
1001
1002        let result = tool.execute(params, ctx).await;
1003
1004        // Should fail and return error
1005        assert!(result.is_err());
1006        let error = result.unwrap_err().to_string();
1007        assert!(error.contains("failed") || error.contains("exit"));
1008    }
1009
1010    #[cfg(unix)]
1011    #[tokio::test]
1012    async fn bash_honours_capability_cwd_when_set() {
1013        let tmp = tempfile::TempDir::new().unwrap();
1014        let expected = tmp.path().canonicalize().unwrap();
1015        let mut ctx = create_tool_context();
1016        ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1017        let out = BashTool
1018            .execute(json!({"command": "pwd -P"}), ctx)
1019            .await
1020            .unwrap();
1021        assert_eq!(out.trim(), expected.to_string_lossy());
1022    }
1023
1024    #[cfg(unix)]
1025    #[tokio::test]
1026    async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1027        let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1028        let ctx = create_tool_context();
1029        assert!(ctx.capabilities.cwd.is_none());
1030        let out = BashTool
1031            .execute(json!({"command": "pwd -P"}), ctx)
1032            .await
1033            .unwrap();
1034        assert_eq!(out.trim(), expected.to_string_lossy());
1035    }
1036
1037    #[test]
1038    fn references_env_name_is_word_bounded() {
1039        assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1040        assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1041        assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1042        assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1043        assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1044        assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1045        assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1046    }
1047
1048    /// T5 notice: non-zero exit + script references stripped var → notice.
1049    #[cfg(unix)]
1050    #[tokio::test]
1051    async fn bash_notice_on_stripped_secret_reference() {
1052        let warned = std::sync::Arc::new(std::sync::Mutex::new(
1053            std::collections::HashSet::<String>::new(),
1054        ));
1055        // --- case 1: non-zero exit + reference → notice
1056        let mut ctx = create_tool_context();
1057        ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1058        ctx.capabilities.env_warned = warned.clone();
1059        let err = BashTool
1060            .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1061            .await
1062            .unwrap_err();
1063        let msg = format!("{err}");
1064        assert!(
1065            msg.contains("note: $GH_TOKEN was stripped"),
1066            "notice must name the var: {msg}"
1067        );
1068
1069        // --- case 2: zero exit → no notice
1070        let mut ctx2 = create_tool_context();
1071        ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1072        ctx2.capabilities.env_warned = warned.clone();
1073        let out = BashTool
1074            .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1075            .await
1076            .unwrap();
1077        assert!(
1078            !out.contains("note:"),
1079            "zero exit must not produce a notice: {out}"
1080        );
1081
1082        // --- case 3: second failure → no duplicate (same Arc)
1083        let mut ctx3 = create_tool_context();
1084        ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1085        ctx3.capabilities.env_warned = warned.clone();
1086        let err2 = BashTool
1087            .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1088            .await
1089            .unwrap_err();
1090        let msg2 = format!("{err2}");
1091        assert!(
1092            !msg2.contains("note: $GH_TOKEN was stripped"),
1093            "dedup must suppress the second notice: {msg2}"
1094        );
1095    }
1096}