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: &str) -> String {
52    let stripped = strip_ansi(input);
53    stripped
54        .chars()
55        .filter(|ch| {
56            *ch == '\n' || *ch == '\r' || *ch == '\t' || (!ch.is_control() && *ch != '\u{7f}')
57        })
58        .collect()
59}
60
61/// Read one child stream in `READ_CHUNK_SIZE` chunks and forward the
62/// sanitized text to the bash executor, tagged stdout/stderr. A character
63/// split between two reads is held back and decoded whole (#286); only an
64/// incomplete sequence (at most 3 bytes) waits, so prompts without a
65/// trailing newline are still forwarded as soon as they are read.
66async fn forward_output<R>(
67    mut reader: R,
68    is_stderr: bool,
69    tx: tokio::sync::mpsc::Sender<(bool, String)>,
70) where
71    R: tokio::io::AsyncRead + Unpin,
72{
73    use std::sync::atomic::Ordering;
74    use tokio::io::AsyncReadExt;
75    let mut buf = vec![0u8; READ_CHUNK_SIZE];
76    let mut decoder = super::utf8_stream::Utf8StreamDecoder::new();
77    loop {
78        let (text, eof) = match reader.read(&mut buf).await {
79            Ok(0) | Err(_) => (decoder.finish(), true),
80            Ok(n) => (decoder.decode(&buf[..n]), false),
81        };
82        let msg = sanitize_output(&text);
83        if !msg.is_empty() {
84            BASH_INTERMEDIARY_PRODUCED_BYTES.fetch_add(msg.len() as u64, Ordering::Relaxed);
85            let len = msg.len();
86            if tx.send((is_stderr, msg)).await.is_ok() {
87                BASH_INTERMEDIARY_ACCEPTED_BYTES.fetch_add(len as u64, Ordering::Relaxed);
88            } else {
89                BASH_INTERMEDIARY_DROPPED_BYTES.fetch_add(len as u64, Ordering::Relaxed);
90                break;
91            }
92        }
93        if eof {
94            break;
95        }
96    }
97}
98
99fn detect_password_prompt(text: &str) -> Option<PromptKind> {
100    let lower = text.to_ascii_lowercase();
101    let has_password = lower.contains("password");
102    if !has_password {
103        return None;
104    }
105    if lower.contains("[sudo]") || lower.contains("sudo") {
106        Some(PromptKind::Sudo)
107    } else if lower.trim_end().ends_with(':') || lower.contains("password:") {
108        Some(PromptKind::Password)
109    } else {
110        None
111    }
112}
113
114fn append_bounded(output: &mut String, text: &str, max_output: usize) -> bool {
115    if output.len() >= max_output {
116        return false;
117    }
118    let remaining = max_output - output.len();
119    if text.len() <= remaining {
120        output.push_str(text);
121        true
122    } else {
123        let mut end = remaining;
124        while end > 0 && !text.is_char_boundary(end) {
125            end -= 1;
126        }
127        output.push_str(&text[..end]);
128        false
129    }
130}
131
132/// Resolve the bash executable for the bash tool.
133///
134/// Unix: `bash` from PATH, as always. Windows: bash is usually NOT on PATH —
135/// prefer an explicit PATH hit, then Git Bash's known install locations, then
136/// WSL's `bash.exe` (runs inside the default distro) as a last resort.
137pub(crate) fn bash_program() -> std::ffi::OsString {
138    #[cfg(unix)]
139    {
140        "bash".into()
141    }
142    #[cfg(windows)]
143    {
144        let on_path = std::env::var_os("PATH")
145            .map(|p| std::env::split_paths(&p).any(|d| d.join("bash.exe").is_file()))
146            .unwrap_or(false);
147        if on_path {
148            return "bash".into();
149        }
150        for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
151            if let Some(pf) = std::env::var_os(var) {
152                let candidate = std::path::Path::new(&pf).join(r"Git\bin\bash.exe");
153                if candidate.is_file() {
154                    return candidate.into_os_string();
155                }
156            }
157        }
158        let wsl = std::path::Path::new(r"C:\Windows\System32\bash.exe");
159        if wsl.is_file() {
160            return wsl.as_os_str().to_os_string();
161        }
162        // Nothing found — let spawn fail with a readable NotFound error.
163        "bash".into()
164    }
165}
166
167pub(crate) fn bash_script_with_secure_sudo(command: &str) -> String {
168    // sudo normally opens /dev/tty for password input, bypassing our piped
169    // stdin/stderr and corrupting the TUI. In the non-interactive bash tool,
170    // shadow simple `sudo ...` invocations with a shell function that forces
171    // sudo to read from stdin and write the prompt to stderr, where the secure
172    // prompt detector can intercept it before it reaches chat/model output.
173    format!(
174        r#"sudo() {{
175    command sudo -S -p '[sudo] password required: ' "$@"
176}}
177{command}"#
178    )
179}
180
181/// Resolve the PowerShell executable. Prefers pwsh (PowerShell 7+, the
182/// cross-platform build with sane defaults) and falls back to the
183/// Windows-bundled powershell.exe (5.1).
184#[cfg(windows)]
185pub(crate) fn powershell_program() -> std::ffi::OsString {
186    let on_path = |exe: &str| {
187        std::env::var_os("PATH")
188            .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file()))
189            .unwrap_or(false)
190    };
191    if on_path("pwsh.exe") {
192        return "pwsh".into();
193    }
194    for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
195        if let Some(pf) = std::env::var_os(var) {
196            let candidate = std::path::Path::new(&pf).join(r"PowerShell\7\pwsh.exe");
197            if candidate.is_file() {
198                return candidate.into_os_string();
199            }
200        }
201    }
202    // powershell.exe is always present on Windows (System32).
203    "powershell".into()
204}
205
206/// Unix stub — the powershell tool is only registered on Windows, so this is
207/// never called there; it exists so cross-compilation `cargo check` works.
208#[cfg(not(windows))]
209pub(crate) fn powershell_program() -> std::ffi::OsString {
210    "pwsh".into()
211}
212
213#[async_trait::async_trait]
214impl Tool for BashTool {
215    fn origin(&self) -> crate::tools::ToolOrigin {
216        crate::tools::ToolOrigin::Builtin
217    }
218
219    /// Explicitly NonIdempotent (Task 24): arbitrary shell side effects —
220    /// serialized execution, no concurrency key.
221    fn effect(&self) -> crate::tools::catalog::ToolEffect {
222        crate::tools::catalog::ToolEffect::NonIdempotent
223    }
224
225    fn name(&self) -> &str {
226        "bash"
227    }
228
229    fn description(&self) -> &str {
230        "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."
231    }
232
233    fn parameters(&self) -> Value {
234        json!({
235            "type": "object",
236            "properties": {
237                "command": {
238                    "type": "string",
239                    "description": "The bash command to execute"
240                },
241                "timeout": {
242                    "type": "integer",
243                    "description": "Timeout in seconds (default: 30). Use a larger value for long-running commands."
244                }
245            },
246            "required": ["command"]
247        })
248    }
249
250    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
251        let command = params["command"]
252            .as_str()
253            .ok_or_else(|| RuntimeError::Tool("Missing command parameter".to_string()))?;
254
255        let script = bash_script_with_secure_sudo(command);
256        run_shell_command(&script, ShellSpec::Bash, params["timeout"].as_u64(), ctx).await
257    }
258}
259
260/// Kills the command's whole process group unless the command finished on
261/// its own. `child.kill()` stops only the shell: everything the command
262/// started (a test runner's workers, `cmd &` jobs) used to keep running, and
263/// keep using memory, after a timeout or cancel (S348). The command leads its
264/// own group: `setsid` in `pre_exec`, and `systemd-run --scope` execs in
265/// place, so the pid is the group id. Dropped before the `Child`, so the
266/// leader is not reaped by Child drop before the kill. Explicit kill paths
267/// signal/disarm first; normal wait disarms synchronously on completion.
268struct KillGroupUnlessFinished {
269    pgid: Option<u32>,
270}
271
272impl KillGroupUnlessFinished {
273    fn new(pid: Option<u32>) -> Self {
274        Self { pgid: pid }
275    }
276
277    /// The command exited by itself: nothing to kill.
278    fn finished(&mut self) {
279        self.pgid = None;
280    }
281
282    /// Signal and disarm BEFORE any explicit kill/wait can reap the leader.
283    fn kill_now(&mut self) {
284        #[cfg(unix)]
285        if let Some(pgid) = self.pgid.take() {
286            // SAFETY: killpg(2) on our own child's process group.
287            unsafe {
288                libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
289            }
290        }
291        #[cfg(not(unix))]
292        {
293            self.pgid = None;
294        }
295    }
296}
297
298impl Drop for KillGroupUnlessFinished {
299    fn drop(&mut self) {
300        self.kill_now();
301    }
302}
303
304/// Which shell backend the shared executor drives.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub(crate) enum ShellSpec {
307    Bash,
308    PowerShell,
309}
310
311impl ShellSpec {
312    /// Names the command's systemd scope (`systemctl --user list-units --type=scope`).
313    fn scope_description(self) -> &'static str {
314        match self {
315            ShellSpec::Bash => "synaps tool: bash",
316            ShellSpec::PowerShell => "synaps tool: powershell",
317        }
318    }
319}
320
321/// Shared execution core for bash + powershell: piped stdin/stdout/stderr,
322/// chunked streaming with byte-conservation accounting, secret-prompt
323/// interception, truncation, timeout. The only per-shell differences are
324/// which program is spawned and which args it takes.
325pub(crate) async fn run_shell_command(
326    script: &str,
327    spec: ShellSpec,
328    requested_timeout: Option<u64>,
329    ctx: ToolContext,
330) -> Result<String> {
331    {
332        let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
333        // H5: enforce bash_max_timeout cap — prevent DoS via prompt injection
334        // requesting unbounded timeouts (e.g. timeout:2592000 + infinite loop).
335        let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
336            requested_timeout.min(ctx.limits.bash_max_timeout)
337        } else {
338            requested_timeout
339        };
340        let max_output = ctx.limits.max_tool_buffer;
341
342        let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
343            ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
344            ShellSpec::PowerShell => (
345                powershell_program(),
346                vec!["-NoProfile".into(), "-Command".into(), script.into()],
347            ),
348        };
349        let effective_env = ctx.capabilities.env.as_ref()
350            .map(|env| env.iter().map(|(k, v)| (k.into(), v.into())).collect())
351            .unwrap_or_else(crate::process_scope::inherited_env);
352        let probe_env = effective_env.clone();
353        let probe_cwd = ctx.capabilities.cwd.clone();
354        // This worker can only launch a bounded throwaway probe. Abandoning
355        // this future can never launch the real command from the worker.
356        if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
357            return Err(RuntimeError::Tool("Command canceled before launch".into()));
358        }
359        let probe_task = tokio::task::spawn_blocking(move || {
360            crate::process_scope::launcher_for_effective_env(
361                &probe_env, probe_cwd.as_deref())
362        });
363        let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
364            tokio::select! {
365                biased;
366                _ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
367                result = probe_task => result,
368            }
369        } else {
370            probe_task.await
371        }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
372        let (program, args) = match launcher {
373            Some(launcher) => launcher.wrap(spec.scope_description(),
374                crate::process_scope::resolve_program(program, &effective_env,
375                    ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
376            None => (program, args),
377        };
378        let mut cmd = tokio::process::Command::new(program);
379        if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
380            cmd.current_dir(cwd);
381        }
382        cmd.env_clear().envs(effective_env);
383        cmd.args(&args)
384            .stdin(std::process::Stdio::piped())
385            .stdout(std::process::Stdio::piped())
386            .stderr(std::process::Stdio::piped())
387            .kill_on_drop(true);
388
389        // Start the child in a new session (setsid) so it has no controlling
390        // terminal. Programs that open /dev/tty directly (SSH fingerprint
391        // prompts, gpg pinentry, git credential helpers, pagers) will get
392        // ENXIO and fail with a readable error on stderr instead of hanging
393        // invisibly until timeout. Sudo is unaffected — we already force
394        // `-S` (stdin) via bash_script_with_secure_sudo().
395        #[cfg(unix)]
396        unsafe {
397            cmd.pre_exec(|| {
398                libc::setsid();
399                Ok(())
400            });
401        }
402
403        if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
404            return Err(RuntimeError::Tool("Command canceled before launch".into()));
405        }
406        let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
407        // Armed until the command finishes on its own: a timeout, a cancel,
408        // the output cap or an abandoned password prompt kills the whole
409        // process group, not just the shell.
410        let mut group = KillGroupUnlessFinished::new(child.id());
411
412        let stdout = child
413            .stdout
414            .take()
415            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
416        let stderr = child
417            .stderr
418            .take()
419            .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
420        let stdin = child
421            .stdin
422            .take()
423            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
424
425        let (tx_inter, mut rx_inter) =
426            tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
427
428        tokio::spawn(forward_output(stdout, false, tx_inter.clone()));
429        tokio::spawn(forward_output(stderr, true, tx_inter.clone()));
430
431        drop(tx_inter);
432
433        let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
434            use tokio::io::AsyncWriteExt;
435
436            let mut stdin = stdin;
437            let mut full_output = String::new();
438            let mut stderr_tail = String::new();
439            let mut truncated = false;
440            let mut streamed_bytes = 0usize;
441            let mut redactions: Vec<String> = Vec::new();
442
443            while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
444                BASH_INTERMEDIARY_CONSUMED_BYTES
445                    .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
446                if is_stderr {
447                    stderr_tail.push_str(&msg);
448                    if stderr_tail.len() > 512 {
449                        let keep_from = stderr_tail.len() - 512;
450                        if let Some((idx, _)) =
451                            stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
452                        {
453                            stderr_tail.drain(..idx);
454                        }
455                    }
456                    if let Some(kind) = detect_password_prompt(&stderr_tail) {
457                        let prompt_text = stderr_tail.trim().to_string();
458                        let secret = match &ctx.capabilities.secret_prompt {
459                            Some(prompt) => {
460                                prompt
461                                    .prompt(
462                                        match kind {
463                                            PromptKind::Sudo => {
464                                                "sudo password required".to_string()
465                                            }
466                                            PromptKind::Password => "password required".to_string(),
467                                        },
468                                        prompt_text.clone(),
469                                    )
470                                    .await
471                            }
472                            None => None,
473                        };
474                        match secret {
475                            Some(mut value) => {
476                                let secret_value = value.clone();
477                                if !secret_value.is_empty() {
478                                    redactions.push(secret_value);
479                                }
480                                value.push('\n');
481                                let write_result = stdin.write_all(value.as_bytes()).await;
482                                let flush_result = stdin.flush().await;
483                                // Zeroize the password from memory immediately after use
484                                value.zeroize();
485                                write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
486                                flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
487                            }
488                            None => {
489                                group.kill_now();
490                                let _ = child.kill().await;
491                                return Err(RuntimeError::Tool(
492                                    "Command canceled while waiting for password".to_string(),
493                                ));
494                            }
495                        }
496                        let prompt_len = prompt_text.len();
497                        if prompt_len <= msg.len() {
498                            let keep_len = msg.len() - prompt_len;
499                            msg.truncate(keep_len);
500                        } else {
501                            msg.clear();
502                        }
503                        stderr_tail.clear();
504                    }
505                }
506
507                for secret in &redactions {
508                    if !secret.is_empty() {
509                        msg = msg.replace(secret, "[redacted]");
510                    }
511                }
512
513                if truncated {
514                    continue;
515                }
516
517                let added_all = append_bounded(&mut full_output, &msg, max_output);
518                if let Some(ref txd) = ctx.channels.tx_delta {
519                    if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
520                        let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
521                        let delta = if msg.len() <= remaining {
522                            msg.clone()
523                        } else {
524                            crate::truncate_str(&msg, remaining).to_string()
525                        };
526                        streamed_bytes += delta.len();
527                        if !delta.is_empty() {
528                            txd.send(delta);
529                        }
530                    }
531                }
532
533                if !added_all {
534                    full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
535                    if let Some(ref txd) = ctx.channels.tx_delta {
536                        txd.send(format!("\n\n[output truncated at {}]", max_output));
537                    }
538                    truncated = true;
539                    group.kill_now();
540                    let _ = child.kill().await;
541                }
542            }
543            let status = child.wait().await;
544            // wait errors may mean the leader was already reaped externally;
545            // never signal a potentially recycled process-group ID afterwards.
546            group.finished();
547            let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
548            // Finished on its own: leave anything it deliberately started in
549            // the background (`server &`) running, as before.
550            // Zeroize redactions (passwords) from memory now that command is done
551            for secret in &mut redactions {
552                secret.zeroize();
553            }
554            Ok::<_, RuntimeError>((status, full_output, truncated))
555        })
556        .await;
557
558        match result {
559            Ok(Ok((status, output, was_truncated))) => {
560                if status.success() || was_truncated {
561                    Ok(output)
562                } else {
563                    let mut notice = String::new();
564                    // T5: when a command fails and references a stripped secret,
565                    // append a notice (once per name per session).
566                    if !ctx.capabilities.env_stripped.is_empty() {
567                        let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
568                        for name in &ctx.capabilities.env_stripped {
569                            if warned.contains(name) {
570                                continue;
571                            }
572                            // Word-boundary match: covers $NAME, ${NAME},
573                            // ${NAME:?}, ${NAME:-x}, $env:NAME and NAME=…
574                            if references_env_name(script, name) {
575                                notice.push_str(&format!(
576                                    "\nnote: ${name} was stripped from the session env as a secret; \
577                                     reattach from a shell that has it \
578                                     (synaps --system … from that shell) \
579                                     — the daemon never receives credentials."
580                                ));
581                                warned.insert(name.clone());
582                            }
583                        }
584                    }
585                    Err(RuntimeError::Tool(format!(
586                        "Command failed (exit {}):\n{}{notice}",
587                        status.code().unwrap_or(-1),
588                        output
589                    )))
590                }
591            }
592            Ok(Err(e)) => Err(RuntimeError::Tool(format!(
593                "Failed to execute command: {}",
594                e
595            ))),
596            Err(_) => Err(RuntimeError::Tool(format!(
597                "Command timed out after {}s",
598                timeout_secs
599            ))),
600        }
601    }
602}
603
604/// Does `script` mention `name` as a whole identifier (not as a substring of
605/// a longer one)? Used by the T5 stripped-secret notice; a false positive
606/// costs one advisory line on an already-failing command, a false negative
607/// hides the reason the command failed — so err towards matching.
608fn references_env_name(script: &str, name: &str) -> bool {
609    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
610    let mut from = 0;
611    while let Some(pos) = script[from..].find(name) {
612        let start = from + pos;
613        let end = start + name.len();
614        let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
615        let after_ok = !script[end..].chars().next().is_some_and(is_ident);
616        if before_ok && after_ok {
617            return true;
618        }
619        from = end;
620    }
621    false
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
630        let source = include_str!("bash.rs");
631        let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
632            .split("#[cfg(test)]").next().unwrap();
633        let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
634        assert_eq!(kills.len(), 2, "audit every new explicit kill path");
635        for (offset, _) in kills {
636            assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
637        }
638        assert!(executor.contains("let status = child.wait().await;"));
639        let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
640        assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
641        assert!(executor.find("let mut child = cmd.spawn()").unwrap()
642            < executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
643    }
644
645    #[tokio::test]
646    async fn canceled_launch_never_runs_payload() {
647        let dir = tempfile::tempdir().unwrap();
648        let marker = dir.path().join("must-not-exist");
649        let token = crate::CancellationToken::new();
650        token.cancel();
651        let mut ctx = create_tool_context();
652        ctx.capabilities.launch_cancel = Some(token);
653        let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
654            .await.unwrap_err().to_string();
655        assert!(error.contains("canceled before launch"), "{error}");
656        assert!(!marker.exists());
657    }
658
659    fn own_cgroup() -> String {
660        std::fs::read_to_string("/proc/self/cgroup")
661            .unwrap_or_default()
662            .trim()
663            .to_string()
664    }
665
666    /// The command runs in its own systemd scope when the machine supports
667    /// it, so an out-of-memory kill aimed at it cannot take the daemon's
668    /// cgroup down with it (S348). Without a reachable user manager (CI
669    /// containers), the command must still run, in this process's cgroup.
670    #[cfg(target_os = "linux")]
671    #[tokio::test]
672    async fn bash_command_gets_its_own_scope_when_available() {
673        if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
674        let out = BashTool
675            .execute(
676                json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
677                create_tool_context(),
678            )
679            .await
680            .unwrap();
681        let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
682        assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
683        if crate::process_scope::launcher_for_env(None).is_some() {
684            assert_ne!(
685                child_cgroup,
686                own_cgroup(),
687                "the command must leave our cgroup"
688            );
689            assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
690            assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
691        } else {
692            assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
693        }
694    }
695
696    /// A session environment rebuilt without the user-bus variables cannot
697    /// reach systemd: the command starts unwrapped instead of failing.
698    #[cfg(target_os = "linux")]
699    #[tokio::test]
700    async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
701        let mut ctx = create_tool_context();
702        ctx.capabilities.env = Some(vec![(
703            "PATH".to_string(),
704            std::env::var("PATH").unwrap_or_default(),
705        )]);
706        let out = BashTool
707            .execute(
708                json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
709                ctx,
710            )
711            .await
712            .unwrap();
713        assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
714    }
715
716    fn processes_named(name: &str) -> Vec<String> {
717        let out = std::process::Command::new("pgrep")
718            .args(["-f", name])
719            .output()
720            .unwrap();
721        String::from_utf8_lossy(&out.stdout)
722            .split_whitespace()
723            .map(str::to_string)
724            .collect()
725    }
726
727    /// A timed-out command takes everything it started with it, not just the
728    /// shell (S348: a timed-out test run would leave all its workers running).
729    /// Each child is renamed to the marker (`exec -a`), so a survivor is seen.
730    #[cfg(target_os = "linux")]
731    #[tokio::test]
732    async fn bash_timeout_kills_the_whole_process_group() {
733        let marker = format!("synaps-timeout-child-{}", std::process::id());
734        let out = BashTool
735            .execute(
736                json!({
737                    "command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
738                    "timeout": 1
739                }),
740                create_tool_context(),
741            )
742            .await;
743        let text = match out {
744            Ok(s) => s,
745            Err(e) => e.to_string(),
746        };
747        assert!(text.contains("timed out"), "{text}");
748        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
749        while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
750            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
751        }
752        let left = processes_named(&marker);
753        if !left.is_empty() {
754            let _ = std::process::Command::new("pkill")
755                .args(["-f", &marker])
756                .status();
757        }
758        assert!(left.is_empty(), "children survived the timeout: {left:?}");
759    }
760
761    /// A command that exits by itself keeps what it deliberately started in
762    /// the background, as before: only abnormal endings kill the group.
763    #[cfg(target_os = "linux")]
764    #[tokio::test]
765    async fn bash_normal_exit_leaves_a_background_job_running() {
766        let marker = format!("synaps-background-job-{}", std::process::id());
767        let out = BashTool
768            .execute(
769                json!({
770                    "command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
771                    "timeout": 10
772                }),
773                create_tool_context(),
774            )
775            .await
776            .unwrap();
777        assert!(out.contains("started"), "{out}");
778        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
779        let alive = processes_named(&marker);
780        let _ = std::process::Command::new("pkill")
781            .args(["-f", &marker])
782            .status();
783        assert!(
784            !alive.is_empty(),
785            "a deliberate background job must survive a normal exit"
786        );
787    }
788
789    #[tokio::test]
790    async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
791        let before = bash_intermediary_snapshot();
792        let tool = BashTool;
793        let mut ctx = create_tool_context();
794        ctx.limits.max_tool_buffer = 64 * 1024;
795        let result = tool
796            .execute(
797                json!({
798                    "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
799                    "timeout": 30
800                }),
801                ctx,
802            )
803            .await
804            .unwrap();
805        assert!(result.contains("output truncated"));
806
807        // The counters are PROCESS-GLOBAL: sibling tests running bash
808        // concurrently (--test-threads > 1) contribute mid-flight bytes to
809        // any instantaneous snapshot. Conservation is therefore asserted at
810        // quiescence: every completed relay balances produced == consumed +
811        // dropped and returns retained to baseline, so the delta window
812        // rebalances once in-flight relays finish. A REAL leak never
813        // rebalances — the bounded poll keeps the oracle strict while
814        // removing scheduling sensitivity (observed flake under the full
815        // workspace run at 8 threads).
816        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
817        let (mut produced, mut consumed, mut accepted, mut dropped);
818        loop {
819            let after = bash_intermediary_snapshot();
820            produced = after.produced_bytes - before.produced_bytes;
821            consumed = after.consumed_bytes - before.consumed_bytes;
822            accepted = after.accepted_bytes - before.accepted_bytes;
823            dropped = after.dropped_bytes - before.dropped_bytes;
824            let balanced =
825                produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
826            if balanced || std::time::Instant::now() >= deadline {
827                assert_eq!(
828                    produced,
829                    consumed + dropped,
830                    "handoff bytes must be conserved at quiescence"
831                );
832                assert_eq!(after.retained_bytes, before.retained_bytes);
833                break;
834            }
835            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
836        }
837        assert!(accepted >= consumed);
838        assert!(produced >= 64 * 1024);
839    }
840
841    #[test]
842    fn detects_sudo_password_prompt_without_newline() {
843        assert_eq!(
844            detect_password_prompt("[sudo] password for me: "),
845            Some(PromptKind::Sudo)
846        );
847    }
848
849    #[test]
850    fn sanitizes_terminal_control_sequences_and_nuls() {
851        let cleaned = sanitize_output("ok\x1b[2J\x00done");
852        assert_eq!(cleaned, "okdone");
853    }
854
855    use super::super::test_helpers::create_tool_context;
856    use crate::tools::Tool;
857    use serde_json::json;
858
859    #[test]
860    fn test_bash_tool_schema() {
861        let tool = BashTool;
862        assert_eq!(tool.name(), "bash");
863        assert!(!tool.description().is_empty());
864
865        let params = tool.parameters();
866        assert_eq!(params["type"], "object");
867        assert!(params["properties"].is_object());
868        assert!(params["required"].is_array());
869    }
870
871    #[tokio::test]
872    async fn test_bash_tool_execution() {
873        let tool = BashTool;
874
875        // Test simple echo command
876        let ctx = create_tool_context();
877        let params = json!({
878            "command": "echo hello"
879        });
880
881        let result = tool.execute(params, ctx).await.unwrap();
882        assert!(result.contains("hello"));
883
884        // Test timeout parameter with quick command
885        let ctx = create_tool_context();
886        let params = json!({
887            "command": "sleep 1",
888            "timeout": 2
889        });
890
891        let result = tool.execute(params, ctx).await;
892        // Should succeed (1 second sleep with 2 second timeout)
893        assert!(result.is_ok());
894
895        // Test timeout with longer command
896        let ctx = create_tool_context();
897        let params = json!({
898            "command": "sleep 3",
899            "timeout": 1
900        });
901
902        let result = tool.execute(params, ctx).await;
903        // Should timeout
904        assert!(result.is_err());
905        assert!(result.unwrap_err().to_string().contains("timed out"));
906    }
907
908    #[tokio::test]
909    async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
910        let tool = BashTool;
911        let mut ctx = create_tool_context();
912        ctx.limits.bash_max_timeout = 1;
913
914        let params = json!({
915            "command": "sleep 5; echo done",
916            "timeout": 10
917        });
918
919        let result = tool.execute(params, ctx).await;
920        assert!(
921            result.unwrap_err().to_string().contains("timed out"),
922            "requested timeout MUST be clamped by bash_max_timeout"
923        );
924    }
925
926    #[tokio::test]
927    async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
928        let tool = BashTool;
929        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
930        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
931        let channel = crate::tools::output::delta_channel();
932        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
933
934        let responder = tokio::spawn(async move {
935            let req = prompt_rx
936                .recv()
937                .await
938                .expect("bash should request a secret prompt");
939            assert!(
940                req.prompt.to_ascii_lowercase().contains("password"),
941                "prompt was {:?}",
942                req.prompt
943            );
944            req.response_tx.send(Some("swordfish".to_string())).unwrap();
945        });
946
947        let mut ctx = create_tool_context();
948        ctx.capabilities.secret_prompt = Some(prompt_handle);
949        ctx.channels.tx_delta = Some(delta_tx);
950        let params = json!({
951            "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
952            "timeout": 30
953        });
954
955        let result = tool.execute(params, ctx).await.unwrap();
956        responder.await.unwrap();
957        let mut streamed = String::new();
958        while let Some(delta) = delta_rx.try_drain() {
959            streamed.push_str(&delta);
960        }
961
962        assert!(result.contains("AUTH_OK"));
963        assert!(!result.contains("swordfish"));
964        assert!(!result.contains("[sudo] password"));
965        assert!(!streamed.contains("[sudo] password"));
966    }
967
968    #[test]
969    fn test_bash_wraps_sudo_to_force_stdin_prompt() {
970        let script = super::bash_script_with_secure_sudo("sudo id");
971
972        assert!(script.contains("sudo()"));
973        assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
974        assert!(script.ends_with("sudo id"));
975    }
976
977    /// Writes an executable stub `sudo` into `dir` that emulates the flags the
978    /// secure wrapper passes (`-S -p PROMPT`): `-k` exits silently like a real
979    /// timestamp reset, anything else writes PROMPT to stderr, consumes one
980    /// stdin line, and fails. Lets the wrapper be tested without the system
981    /// sudo, whose prompting behaviour is host-dependent.
982    #[cfg(unix)]
983    fn write_fake_sudo(dir: &std::path::Path) {
984        use std::os::unix::fs::PermissionsExt;
985        let sudo_path = dir.join("sudo");
986        std::fs::write(
987            &sudo_path,
988            r#"#!/bin/sh
989prompt=""
990while [ $# -gt 0 ]; do
991  case "$1" in
992    -S) shift ;;
993    -p) prompt="$2"; shift 2 ;;
994    -k) exit 0 ;;
995    *) break ;;
996  esac
997done
998printf '%s' "$prompt" >&2
999read -r _pw
1000exit 1
1001"#,
1002        )
1003        .expect("write fake sudo");
1004        let mut perms = std::fs::metadata(&sudo_path)
1005            .expect("stat fake sudo")
1006            .permissions();
1007        perms.set_mode(0o755);
1008        std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
1009    }
1010
1011    /// The `sudo()` wrapper's prompt is intercepted by the secret-prompt path
1012    /// and never reaches the delta stream.
1013    ///
1014    /// Hermetic by construction: it drives a STUB sudo on PATH, not the host's.
1015    /// `command sudo` bypasses the shell function but still honours PATH, so
1016    /// the real wrapper path is exercised. PATH is exported inside the script
1017    /// rather than through `std::env::set_var` because these tests are not
1018    /// `#[serial]` and mutating process-global env would race them.
1019    ///
1020    /// This previously drove the system sudo and asserted that it prompts —
1021    /// false on any host with passwordless sudo, which includes every GitHub
1022    /// runner, so it could never pass in CI.
1023    #[cfg(unix)]
1024    #[tokio::test]
1025    async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
1026        let fake_bin = tempfile::tempdir().expect("tempdir");
1027        write_fake_sudo(fake_bin.path());
1028        let tool = BashTool;
1029        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1030        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1031        let channel = crate::tools::output::delta_channel();
1032        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1033
1034        let responder = tokio::spawn(async move {
1035            let req = prompt_rx
1036                .recv()
1037                .await
1038                .expect("bash should request a secret prompt");
1039            assert!(
1040                req.prompt.contains("[sudo] password required"),
1041                "prompt was {:?}",
1042                req.prompt
1043            );
1044            req.response_tx
1045                .send(Some("wrong-password-for-test".to_string()))
1046                .unwrap();
1047        });
1048
1049        let mut ctx = create_tool_context();
1050        ctx.capabilities.secret_prompt = Some(prompt_handle);
1051        ctx.channels.tx_delta = Some(delta_tx);
1052        let params = json!({
1053            "command": format!(
1054                "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
1055                fake_bin.path().display()
1056            ),
1057            "timeout": 30
1058        });
1059
1060        let _ = tool.execute(params, ctx).await;
1061        responder.await.unwrap();
1062        let mut streamed = String::new();
1063        while let Some(delta) = delta_rx.try_drain() {
1064            streamed.push_str(&delta);
1065        }
1066
1067        assert!(
1068            !streamed.contains("[sudo] password required"),
1069            "sudo password prompt leaked into deltas: {streamed:?}"
1070        );
1071    }
1072
1073    #[tokio::test]
1074    async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
1075        let tool = BashTool;
1076        let channel = crate::tools::output::delta_channel();
1077        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1078        let mut ctx = create_tool_context();
1079        ctx.channels.tx_delta = Some(delta_tx);
1080        ctx.limits.max_tool_buffer = 256;
1081
1082        let params = json!({
1083            "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
1084            "timeout": 30
1085        });
1086
1087        let result = tool.execute(params, ctx).await.unwrap();
1088        let mut streamed = String::new();
1089        while let Some(delta) = delta_rx.try_drain() {
1090            streamed.push_str(&delta);
1091        }
1092
1093        assert!(result.contains("[output truncated at 256]"));
1094        assert!(!result.contains('\u{1b}'));
1095        assert!(!result.contains('\0'));
1096        assert!(!streamed.contains('\u{1b}'));
1097        assert!(!streamed.contains('\0'));
1098        assert!(
1099            streamed.len() <= 2048,
1100            "streamed deltas must be bounded, got {} bytes",
1101            streamed.len()
1102        );
1103    }
1104
1105    #[tokio::test]
1106    async fn test_bash_echoed_secret_is_redacted_from_output() {
1107        let tool = BashTool;
1108        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1109        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1110
1111        let responder = tokio::spawn(async move {
1112            let req = prompt_rx
1113                .recv()
1114                .await
1115                .expect("bash should request a secret prompt");
1116            req.response_tx.send(Some("swordfish".to_string())).unwrap();
1117        });
1118
1119        let mut ctx = create_tool_context();
1120        ctx.capabilities.secret_prompt = Some(prompt_handle);
1121        let params = json!({
1122            "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
1123            "timeout": 30
1124        });
1125
1126        let result = tool.execute(params, ctx).await.unwrap();
1127        responder.await.unwrap();
1128
1129        assert!(result.contains("seen:[redacted]"));
1130        assert!(!result.contains("swordfish"));
1131    }
1132
1133    #[tokio::test]
1134    async fn test_bash_sequential_password_prompts_are_each_handled() {
1135        let tool = BashTool;
1136        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1137        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1138
1139        let responder = tokio::spawn(async move {
1140            for value in ["first", "second"] {
1141                let req = prompt_rx
1142                    .recv()
1143                    .await
1144                    .expect("bash should request each secret prompt");
1145                assert!(req.prompt.to_ascii_lowercase().contains("password"));
1146                req.response_tx.send(Some(value.to_string())).unwrap();
1147            }
1148        });
1149
1150        let mut ctx = create_tool_context();
1151        ctx.capabilities.secret_prompt = Some(prompt_handle);
1152        let params = json!({
1153            "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
1154            "timeout": 30
1155        });
1156
1157        let result = tool.execute(params, ctx).await.unwrap();
1158        responder.await.unwrap();
1159
1160        assert!(result.contains("done:[redacted]:[redacted]"));
1161        assert!(!result.contains("first"));
1162        assert!(!result.contains("second"));
1163    }
1164
1165    #[tokio::test]
1166    async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
1167        let tool = BashTool;
1168        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1169        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1170
1171        let responder = tokio::spawn(async move {
1172            let req = prompt_rx
1173                .recv()
1174                .await
1175                .expect("bash should request a secret prompt");
1176            req.response_tx.send(None).unwrap();
1177        });
1178
1179        let mut ctx = create_tool_context();
1180        ctx.capabilities.secret_prompt = Some(prompt_handle);
1181        let params = json!({
1182            "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
1183            "timeout": 30
1184        });
1185
1186        let err = tool.execute(params, ctx).await.unwrap_err().to_string();
1187        responder.await.unwrap();
1188
1189        assert!(err.contains("waiting for password"));
1190        assert!(!err.contains("should-not-run"));
1191    }
1192
1193    #[tokio::test]
1194    async fn test_bash_binary_output_is_sanitized() {
1195        let tool = BashTool;
1196        let ctx = create_tool_context();
1197        let params = json!({
1198            "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
1199            "timeout": 30
1200        });
1201
1202        let result = tool.execute(params, ctx).await.unwrap();
1203
1204        assert!(result.contains("visible"));
1205        assert!(!result.contains('\0'));
1206        assert!(!result.contains('\u{1b}'));
1207    }
1208
1209    #[tokio::test]
1210    async fn test_bash_tool_timeout() {
1211        let tool = BashTool;
1212        let ctx = create_tool_context();
1213
1214        let params = json!({
1215            "command": "sleep 10",
1216            "timeout": 1
1217        });
1218
1219        let result = tool.execute(params, ctx).await;
1220
1221        // Should timeout and return error
1222        assert!(result.is_err());
1223        let error = result.unwrap_err().to_string();
1224        assert!(error.contains("timed out"));
1225    }
1226
1227    #[tokio::test]
1228    async fn test_bash_tool_failure() {
1229        let tool = BashTool;
1230        let ctx = create_tool_context();
1231
1232        let params = json!({
1233            "command": "exit 1"
1234        });
1235
1236        let result = tool.execute(params, ctx).await;
1237
1238        // Should fail and return error
1239        assert!(result.is_err());
1240        let error = result.unwrap_err().to_string();
1241        assert!(error.contains("failed") || error.contains("exit"));
1242    }
1243
1244    #[cfg(unix)]
1245    #[tokio::test]
1246    async fn bash_honours_capability_cwd_when_set() {
1247        let tmp = tempfile::TempDir::new().unwrap();
1248        let expected = tmp.path().canonicalize().unwrap();
1249        let mut ctx = create_tool_context();
1250        ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1251        let out = BashTool
1252            .execute(json!({"command": "pwd -P"}), ctx)
1253            .await
1254            .unwrap();
1255        assert_eq!(out.trim(), expected.to_string_lossy());
1256    }
1257
1258    #[cfg(unix)]
1259    #[tokio::test]
1260    async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1261        let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1262        let ctx = create_tool_context();
1263        assert!(ctx.capabilities.cwd.is_none());
1264        let out = BashTool
1265            .execute(json!({"command": "pwd -P"}), ctx)
1266            .await
1267            .unwrap();
1268        assert_eq!(out.trim(), expected.to_string_lossy());
1269    }
1270
1271    #[test]
1272    fn references_env_name_is_word_bounded() {
1273        assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1274        assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1275        assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1276        assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1277        assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1278        assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1279        assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1280    }
1281
1282    /// T5 notice: non-zero exit + script references stripped var → notice.
1283    #[cfg(unix)]
1284    #[tokio::test]
1285    async fn bash_notice_on_stripped_secret_reference() {
1286        let warned = std::sync::Arc::new(std::sync::Mutex::new(
1287            std::collections::HashSet::<String>::new(),
1288        ));
1289        // --- case 1: non-zero exit + reference → notice
1290        let mut ctx = create_tool_context();
1291        ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1292        ctx.capabilities.env_warned = warned.clone();
1293        let err = BashTool
1294            .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1295            .await
1296            .unwrap_err();
1297        let msg = format!("{err}");
1298        assert!(
1299            msg.contains("note: $GH_TOKEN was stripped"),
1300            "notice must name the var: {msg}"
1301        );
1302
1303        // --- case 2: zero exit → no notice
1304        let mut ctx2 = create_tool_context();
1305        ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1306        ctx2.capabilities.env_warned = warned.clone();
1307        let out = BashTool
1308            .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1309            .await
1310            .unwrap();
1311        assert!(
1312            !out.contains("note:"),
1313            "zero exit must not produce a notice: {out}"
1314        );
1315
1316        // --- case 3: second failure → no duplicate (same Arc)
1317        let mut ctx3 = create_tool_context();
1318        ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1319        ctx3.capabilities.env_warned = warned.clone();
1320        let err2 = BashTool
1321            .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1322            .await
1323            .unwrap_err();
1324        let msg2 = format!("{err2}");
1325        assert!(
1326            !msg2.contains("note: $GH_TOKEN was stripped"),
1327            "dedup must suppress the second notice: {msg2}"
1328        );
1329    }
1330}
1331
1332/// Issue #286: a multi-byte character split across two reads must come back
1333/// whole, not as U+FFFD.
1334#[cfg(test)]
1335mod utf8_chunk_tests {
1336    use super::*;
1337    use crate::tools::Tool;
1338    use serde_json::json;
1339    use std::collections::VecDeque;
1340    use std::pin::Pin;
1341    use std::task::{Context, Poll};
1342
1343    /// Yields exactly the given chunks, one per `read`, then EOF.
1344    struct ChunkedReader(VecDeque<Vec<u8>>);
1345
1346    impl tokio::io::AsyncRead for ChunkedReader {
1347        fn poll_read(
1348            mut self: Pin<&mut Self>,
1349            _cx: &mut Context<'_>,
1350            buf: &mut tokio::io::ReadBuf<'_>,
1351        ) -> Poll<std::io::Result<()>> {
1352            if let Some(chunk) = self.0.pop_front() {
1353                assert!(chunk.len() <= buf.remaining(), "test chunk larger than read buffer");
1354                buf.put_slice(&chunk);
1355            }
1356            Poll::Ready(Ok(()))
1357        }
1358    }
1359
1360    /// `forward_output` feeds the process-global handoff counters; count what
1361    /// the test consumes too, or the conservation test running alongside
1362    /// sees bytes produced that nobody consumed.
1363    fn consumed(msg: &str) {
1364        BASH_INTERMEDIARY_CONSUMED_BYTES
1365            .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
1366    }
1367
1368    async fn forward_chunks(chunks: Vec<Vec<u8>>, is_stderr: bool) -> String {
1369        let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1370        forward_output(ChunkedReader(chunks.into()), is_stderr, tx).await;
1371        let mut out = String::new();
1372        while let Some((stderr, msg)) = rx.recv().await {
1373            consumed(&msg);
1374            assert_eq!(stderr, is_stderr);
1375            out.push_str(&msg);
1376        }
1377        out
1378    }
1379
1380    #[tokio::test]
1381    async fn characters_split_at_every_offset_survive_both_streams() {
1382        for ch in ['é', '▀', '😀'] {
1383            let text = format!("a{ch}b{ch}{ch}c");
1384            let bytes = text.as_bytes();
1385            for split in 1..bytes.len() {
1386                for is_stderr in [false, true] {
1387                    let out = forward_chunks(
1388                        vec![bytes[..split].to_vec(), bytes[split..].to_vec()],
1389                        is_stderr,
1390                    )
1391                    .await;
1392                    assert!(!out.contains('\u{FFFD}'), "{ch:?} split at {split}: {out:?}");
1393                    assert_eq!(out.as_bytes(), bytes, "{ch:?} split at {split}");
1394                }
1395            }
1396        }
1397    }
1398
1399    #[tokio::test]
1400    async fn one_byte_reads_reassemble_every_character() {
1401        let text = "é▀😀 mixed ascii ✓ 日本語";
1402        let chunks = text.bytes().map(|b| vec![b]).collect();
1403        assert_eq!(forward_chunks(chunks, false).await, text);
1404    }
1405
1406    #[tokio::test]
1407    async fn genuinely_invalid_bytes_still_become_replacement_characters() {
1408        assert_eq!(forward_chunks(vec![b"ok\xffok".to_vec()], false).await, "ok\u{FFFD}ok");
1409        // A lead byte followed by a non-continuation byte in the next read.
1410        assert_eq!(
1411            forward_chunks(vec![b"a\xe2".to_vec(), b"Xb".to_vec()], false).await,
1412            "a\u{FFFD}Xb"
1413        );
1414        // A stray continuation byte at the start of a read.
1415        assert_eq!(
1416            forward_chunks(vec![b"a".to_vec(), b"\x80b".to_vec()], false).await,
1417            "a\u{FFFD}b"
1418        );
1419    }
1420
1421    #[tokio::test]
1422    async fn incomplete_sequence_at_eof_is_flushed_not_dropped() {
1423        assert_eq!(forward_chunks(vec![b"ab\xe2\x96".to_vec()], false).await, "ab\u{FFFD}");
1424        assert_eq!(
1425            forward_chunks(vec![b"ab".to_vec(), b"\xf0\x9f".to_vec()], true).await,
1426            "ab\u{FFFD}"
1427        );
1428    }
1429
1430    /// A password prompt has no trailing newline; it must be forwarded as soon
1431    /// as it is read, not held back waiting for more output or EOF.
1432    #[tokio::test]
1433    async fn prompt_without_newline_is_forwarded_before_eof() {
1434        let (mut writer, reader) = tokio::io::duplex(4096);
1435        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1436        let task = tokio::spawn(forward_output(reader, true, tx));
1437        use tokio::io::AsyncWriteExt;
1438        writer.write_all("[sudo] password for x: ".as_bytes()).await.unwrap();
1439        let (is_stderr, msg) =
1440            tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1441                .await
1442                .expect("prompt must arrive without EOF")
1443                .unwrap();
1444        consumed(&msg);
1445        assert!(is_stderr);
1446        assert_eq!(detect_password_prompt(&msg), Some(PromptKind::Sudo), "{msg:?}");
1447        drop(writer);
1448        task.await.unwrap();
1449    }
1450
1451    /// Through the real tool: more than 1 KiB of 3-byte characters on both
1452    /// streams. 1024 is not a multiple of 3, so the reads split characters.
1453    #[tokio::test]
1454    async fn bash_tool_output_over_1kib_keeps_multibyte_characters_whole() {
1455        let channel = crate::tools::output::delta_channel();
1456        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1457        let mut ctx = super::super::test_helpers::create_tool_context();
1458        ctx.channels.tx_delta = Some(delta_tx);
1459        let result = BashTool
1460            .execute(json!({"command": "printf '▀%.0s' {1..2000}", "timeout": 30}), ctx)
1461            .await
1462            .unwrap();
1463        let mut streamed = String::new();
1464        while let Some(delta) = delta_rx.try_drain() {
1465            streamed.push_str(&delta);
1466        }
1467        let expected = "▀".repeat(2000);
1468        assert!(!result.contains('\u{FFFD}'), "result has U+FFFD");
1469        assert_eq!(result, expected);
1470        assert!(!streamed.contains('\u{FFFD}'), "deltas have U+FFFD");
1471        assert!(expected.starts_with(&streamed) && !streamed.is_empty());
1472
1473        let ctx = super::super::test_helpers::create_tool_context();
1474        let err = BashTool
1475            .execute(
1476                json!({"command": "python3 -c \"import sys; sys.stderr.write(('▀'*119+'\\n')*45)\"; exit 3", "timeout": 30}),
1477                ctx,
1478            )
1479            .await
1480            .unwrap_err()
1481            .to_string();
1482        assert!(!err.contains('\u{FFFD}'), "stderr has U+FFFD: {err}");
1483        assert!(err.contains(&("▀".repeat(119) + "\n").repeat(45)), "{err}");
1484    }
1485}