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/// Kills the command's whole process group unless the command finished on
224/// its own. `child.kill()` stops only the shell: everything the command
225/// started (a test runner's workers, `cmd &` jobs) used to keep running, and
226/// keep using memory, after a timeout or cancel (S348). The command leads its
227/// own group: `setsid` in `pre_exec`, and `systemd-run --scope` execs in
228/// place, so the pid is the group id. Dropped before the `Child`, so the
229/// leader is not reaped by Child drop before the kill. Explicit kill paths
230/// signal/disarm first; normal wait disarms synchronously on completion.
231struct KillGroupUnlessFinished {
232    pgid: Option<u32>,
233}
234
235impl KillGroupUnlessFinished {
236    fn new(pid: Option<u32>) -> Self {
237        Self { pgid: pid }
238    }
239
240    /// The command exited by itself: nothing to kill.
241    fn finished(&mut self) {
242        self.pgid = None;
243    }
244
245    /// Signal and disarm BEFORE any explicit kill/wait can reap the leader.
246    fn kill_now(&mut self) {
247        #[cfg(unix)]
248        if let Some(pgid) = self.pgid.take() {
249            // SAFETY: killpg(2) on our own child's process group.
250            unsafe {
251                libc::killpg(pgid as libc::pid_t, libc::SIGKILL);
252            }
253        }
254        #[cfg(not(unix))]
255        {
256            self.pgid = None;
257        }
258    }
259}
260
261impl Drop for KillGroupUnlessFinished {
262    fn drop(&mut self) {
263        self.kill_now();
264    }
265}
266
267/// Which shell backend the shared executor drives.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub(crate) enum ShellSpec {
270    Bash,
271    PowerShell,
272}
273
274impl ShellSpec {
275    /// Names the command's systemd scope (`systemctl --user list-units --type=scope`).
276    fn scope_description(self) -> &'static str {
277        match self {
278            ShellSpec::Bash => "synaps tool: bash",
279            ShellSpec::PowerShell => "synaps tool: powershell",
280        }
281    }
282}
283
284/// Shared execution core for bash + powershell: piped stdin/stdout/stderr,
285/// chunked streaming with byte-conservation accounting, secret-prompt
286/// interception, truncation, timeout. The only per-shell differences are
287/// which program is spawned and which args it takes.
288pub(crate) async fn run_shell_command(
289    script: &str,
290    spec: ShellSpec,
291    requested_timeout: Option<u64>,
292    ctx: ToolContext,
293) -> Result<String> {
294    {
295        let requested_timeout = requested_timeout.unwrap_or(ctx.limits.bash_timeout);
296        // H5: enforce bash_max_timeout cap — prevent DoS via prompt injection
297        // requesting unbounded timeouts (e.g. timeout:2592000 + infinite loop).
298        let timeout_secs = if ctx.limits.bash_max_timeout > 0 {
299            requested_timeout.min(ctx.limits.bash_max_timeout)
300        } else {
301            requested_timeout
302        };
303        let max_output = ctx.limits.max_tool_buffer;
304
305        let (program, args): (std::ffi::OsString, Vec<std::ffi::OsString>) = match spec {
306            ShellSpec::Bash => (bash_program(), vec!["-c".into(), script.into()]),
307            ShellSpec::PowerShell => (
308                powershell_program(),
309                vec!["-NoProfile".into(), "-Command".into(), script.into()],
310            ),
311        };
312        let effective_env = ctx.capabilities.env.as_ref()
313            .map(|env| env.iter().map(|(k, v)| (k.into(), v.into())).collect())
314            .unwrap_or_else(crate::process_scope::inherited_env);
315        let probe_env = effective_env.clone();
316        let probe_cwd = ctx.capabilities.cwd.clone();
317        // This worker can only launch a bounded throwaway probe. Abandoning
318        // this future can never launch the real command from the worker.
319        if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
320            return Err(RuntimeError::Tool("Command canceled before launch".into()));
321        }
322        let probe_task = tokio::task::spawn_blocking(move || {
323            crate::process_scope::launcher_for_effective_env(
324                &probe_env, probe_cwd.as_deref())
325        });
326        let launcher = if let Some(cancel) = &ctx.capabilities.launch_cancel {
327            tokio::select! {
328                biased;
329                _ = cancel.cancelled() => return Err(RuntimeError::Tool("Command canceled before launch".into())),
330                result = probe_task => result,
331            }
332        } else {
333            probe_task.await
334        }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
335        let (program, args) = match launcher {
336            Some(launcher) => launcher.wrap(spec.scope_description(),
337                crate::process_scope::resolve_program(program, &effective_env,
338                    ctx.capabilities.cwd.as_deref()).map_err(|e| RuntimeError::Tool(e.to_string()))?, args),
339            None => (program, args),
340        };
341        let mut cmd = tokio::process::Command::new(program);
342        if let Some(cwd) = ctx.capabilities.cwd.as_deref() {
343            cmd.current_dir(cwd);
344        }
345        cmd.env_clear().envs(effective_env);
346        cmd.args(&args)
347            .stdin(std::process::Stdio::piped())
348            .stdout(std::process::Stdio::piped())
349            .stderr(std::process::Stdio::piped())
350            .kill_on_drop(true);
351
352        // Start the child in a new session (setsid) so it has no controlling
353        // terminal. Programs that open /dev/tty directly (SSH fingerprint
354        // prompts, gpg pinentry, git credential helpers, pagers) will get
355        // ENXIO and fail with a readable error on stderr instead of hanging
356        // invisibly until timeout. Sudo is unaffected — we already force
357        // `-S` (stdin) via bash_script_with_secure_sudo().
358        #[cfg(unix)]
359        unsafe {
360            cmd.pre_exec(|| {
361                libc::setsid();
362                Ok(())
363            });
364        }
365
366        if ctx.capabilities.launch_cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
367            return Err(RuntimeError::Tool("Command canceled before launch".into()));
368        }
369        let mut child = cmd.spawn().map_err(|e| RuntimeError::Tool(e.to_string()))?;
370        // Armed until the command finishes on its own: a timeout, a cancel,
371        // the output cap or an abandoned password prompt kills the whole
372        // process group, not just the shell.
373        let mut group = KillGroupUnlessFinished::new(child.id());
374
375        let stdout = child
376            .stdout
377            .take()
378            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdout".to_string()))?;
379        let stderr = child
380            .stderr
381            .take()
382            .ok_or_else(|| RuntimeError::Tool("Failed to capture stderr".to_string()))?;
383        let stdin = child
384            .stdin
385            .take()
386            .ok_or_else(|| RuntimeError::Tool("Failed to capture stdin".to_string()))?;
387
388        let (tx_inter, mut rx_inter) =
389            tokio::sync::mpsc::channel::<(bool, String)>(BASH_INTERMEDIARY_CHANNEL_CAPACITY);
390
391        let tx_o = tx_inter.clone();
392        tokio::spawn(async move {
393            use tokio::io::AsyncReadExt;
394            let mut reader = stdout;
395            let mut buf = vec![0u8; READ_CHUNK_SIZE];
396            loop {
397                match reader.read(&mut buf).await {
398                    Ok(0) => break,
399                    Ok(n) => {
400                        let msg = sanitize_output(&buf[..n]);
401                        if !msg.is_empty() {
402                            use std::sync::atomic::Ordering;
403                            BASH_INTERMEDIARY_PRODUCED_BYTES
404                                .fetch_add(msg.len() as u64, Ordering::Relaxed);
405                            let len = msg.len();
406                            if tx_o.send((false, msg)).await.is_ok() {
407                                BASH_INTERMEDIARY_ACCEPTED_BYTES
408                                    .fetch_add(len as u64, Ordering::Relaxed);
409                            } else {
410                                BASH_INTERMEDIARY_DROPPED_BYTES
411                                    .fetch_add(len as u64, Ordering::Relaxed);
412                                break;
413                            }
414                        }
415                    }
416                    Err(_) => break,
417                }
418            }
419        });
420
421        let tx_e = tx_inter.clone();
422        tokio::spawn(async move {
423            use tokio::io::AsyncReadExt;
424            let mut reader = stderr;
425            let mut buf = vec![0u8; READ_CHUNK_SIZE];
426            loop {
427                match reader.read(&mut buf).await {
428                    Ok(0) => break,
429                    Ok(n) => {
430                        let msg = sanitize_output(&buf[..n]);
431                        if !msg.is_empty() {
432                            use std::sync::atomic::Ordering;
433                            BASH_INTERMEDIARY_PRODUCED_BYTES
434                                .fetch_add(msg.len() as u64, Ordering::Relaxed);
435                            let len = msg.len();
436                            if tx_e.send((true, msg)).await.is_ok() {
437                                BASH_INTERMEDIARY_ACCEPTED_BYTES
438                                    .fetch_add(len as u64, Ordering::Relaxed);
439                            } else {
440                                BASH_INTERMEDIARY_DROPPED_BYTES
441                                    .fetch_add(len as u64, Ordering::Relaxed);
442                                break;
443                            }
444                        }
445                    }
446                    Err(_) => break,
447                }
448            }
449        });
450
451        drop(tx_inter);
452
453        let result = tokio::time::timeout(tokio::time::Duration::from_secs(timeout_secs), async {
454            use tokio::io::AsyncWriteExt;
455
456            let mut stdin = stdin;
457            let mut full_output = String::new();
458            let mut stderr_tail = String::new();
459            let mut truncated = false;
460            let mut streamed_bytes = 0usize;
461            let mut redactions: Vec<String> = Vec::new();
462
463            while let Some((is_stderr, mut msg)) = rx_inter.recv().await {
464                BASH_INTERMEDIARY_CONSUMED_BYTES
465                    .fetch_add(msg.len() as u64, std::sync::atomic::Ordering::Relaxed);
466                if is_stderr {
467                    stderr_tail.push_str(&msg);
468                    if stderr_tail.len() > 512 {
469                        let keep_from = stderr_tail.len() - 512;
470                        if let Some((idx, _)) =
471                            stderr_tail.char_indices().find(|(i, _)| *i >= keep_from)
472                        {
473                            stderr_tail.drain(..idx);
474                        }
475                    }
476                    if let Some(kind) = detect_password_prompt(&stderr_tail) {
477                        let prompt_text = stderr_tail.trim().to_string();
478                        let secret = match &ctx.capabilities.secret_prompt {
479                            Some(prompt) => {
480                                prompt
481                                    .prompt(
482                                        match kind {
483                                            PromptKind::Sudo => {
484                                                "sudo password required".to_string()
485                                            }
486                                            PromptKind::Password => "password required".to_string(),
487                                        },
488                                        prompt_text.clone(),
489                                    )
490                                    .await
491                            }
492                            None => None,
493                        };
494                        match secret {
495                            Some(mut value) => {
496                                let secret_value = value.clone();
497                                if !secret_value.is_empty() {
498                                    redactions.push(secret_value);
499                                }
500                                value.push('\n');
501                                let write_result = stdin.write_all(value.as_bytes()).await;
502                                let flush_result = stdin.flush().await;
503                                // Zeroize the password from memory immediately after use
504                                value.zeroize();
505                                write_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
506                                flush_result.map_err(|e| RuntimeError::Tool(e.to_string()))?;
507                            }
508                            None => {
509                                group.kill_now();
510                                let _ = child.kill().await;
511                                return Err(RuntimeError::Tool(
512                                    "Command canceled while waiting for password".to_string(),
513                                ));
514                            }
515                        }
516                        let prompt_len = prompt_text.len();
517                        if prompt_len <= msg.len() {
518                            let keep_len = msg.len() - prompt_len;
519                            msg.truncate(keep_len);
520                        } else {
521                            msg.clear();
522                        }
523                        stderr_tail.clear();
524                    }
525                }
526
527                for secret in &redactions {
528                    if !secret.is_empty() {
529                        msg = msg.replace(secret, "[redacted]");
530                    }
531                }
532
533                if truncated {
534                    continue;
535                }
536
537                let added_all = append_bounded(&mut full_output, &msg, max_output);
538                if let Some(ref txd) = ctx.channels.tx_delta {
539                    if streamed_bytes < MAX_STREAMED_DELTA_BYTES {
540                        let remaining = MAX_STREAMED_DELTA_BYTES - streamed_bytes;
541                        let delta = if msg.len() <= remaining {
542                            msg.clone()
543                        } else {
544                            crate::truncate_str(&msg, remaining).to_string()
545                        };
546                        streamed_bytes += delta.len();
547                        if !delta.is_empty() {
548                            txd.send(delta);
549                        }
550                    }
551                }
552
553                if !added_all {
554                    full_output.push_str(&format!("\n\n[output truncated at {}]", max_output));
555                    if let Some(ref txd) = ctx.channels.tx_delta {
556                        txd.send(format!("\n\n[output truncated at {}]", max_output));
557                    }
558                    truncated = true;
559                    group.kill_now();
560                    let _ = child.kill().await;
561                }
562            }
563            let status = child.wait().await;
564            // wait errors may mean the leader was already reaped externally;
565            // never signal a potentially recycled process-group ID afterwards.
566            group.finished();
567            let status = status.map_err(|e| RuntimeError::Tool(e.to_string()))?;
568            // Finished on its own: leave anything it deliberately started in
569            // the background (`server &`) running, as before.
570            // Zeroize redactions (passwords) from memory now that command is done
571            for secret in &mut redactions {
572                secret.zeroize();
573            }
574            Ok::<_, RuntimeError>((status, full_output, truncated))
575        })
576        .await;
577
578        match result {
579            Ok(Ok((status, output, was_truncated))) => {
580                if status.success() || was_truncated {
581                    Ok(output)
582                } else {
583                    let mut notice = String::new();
584                    // T5: when a command fails and references a stripped secret,
585                    // append a notice (once per name per session).
586                    if !ctx.capabilities.env_stripped.is_empty() {
587                        let mut warned = ctx.capabilities.env_warned.lock().unwrap_or_else(|p| p.into_inner());
588                        for name in &ctx.capabilities.env_stripped {
589                            if warned.contains(name) {
590                                continue;
591                            }
592                            // Word-boundary match: covers $NAME, ${NAME},
593                            // ${NAME:?}, ${NAME:-x}, $env:NAME and NAME=…
594                            if references_env_name(script, name) {
595                                notice.push_str(&format!(
596                                    "\nnote: ${name} was stripped from the session env as a secret; \
597                                     reattach from a shell that has it \
598                                     (synaps --system … from that shell) \
599                                     — the daemon never receives credentials."
600                                ));
601                                warned.insert(name.clone());
602                            }
603                        }
604                    }
605                    Err(RuntimeError::Tool(format!(
606                        "Command failed (exit {}):\n{}{notice}",
607                        status.code().unwrap_or(-1),
608                        output
609                    )))
610                }
611            }
612            Ok(Err(e)) => Err(RuntimeError::Tool(format!(
613                "Failed to execute command: {}",
614                e
615            ))),
616            Err(_) => Err(RuntimeError::Tool(format!(
617                "Command timed out after {}s",
618                timeout_secs
619            ))),
620        }
621    }
622}
623
624/// Does `script` mention `name` as a whole identifier (not as a substring of
625/// a longer one)? Used by the T5 stripped-secret notice; a false positive
626/// costs one advisory line on an already-failing command, a false negative
627/// hides the reason the command failed — so err towards matching.
628fn references_env_name(script: &str, name: &str) -> bool {
629    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
630    let mut from = 0;
631    while let Some(pos) = script[from..].find(name) {
632        let start = from + pos;
633        let end = start + name.len();
634        let before_ok = !script[..start].chars().next_back().is_some_and(is_ident);
635        let after_ok = !script[end..].chars().next().is_some_and(is_ident);
636        if before_ok && after_ok {
637            return true;
638        }
639        from = end;
640    }
641    false
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn explicit_child_kills_signal_and_disarm_group_before_reaping() {
650        let source = include_str!("bash.rs");
651        let executor = source.split("pub(crate) async fn run_shell_command").nth(1).unwrap()
652            .split("#[cfg(test)]").next().unwrap();
653        let kills: Vec<_> = executor.match_indices("let _ = child.kill().await;").collect();
654        assert_eq!(kills.len(), 2, "audit every new explicit kill path");
655        for (offset, _) in kills {
656            assert!(executor[..offset].trim_end().ends_with("group.kill_now();"));
657        }
658        assert!(executor.contains("let status = child.wait().await;"));
659        let wait = executor.split("let status = child.wait().await;").nth(1).unwrap();
660        assert!(wait.find("group.finished();").unwrap() < wait.find("status.map_err").unwrap());
661        assert!(executor.find("let mut child = cmd.spawn()").unwrap()
662            < executor.find("let mut group = KillGroupUnlessFinished::new").unwrap());
663    }
664
665    #[tokio::test]
666    async fn canceled_launch_never_runs_payload() {
667        let dir = tempfile::tempdir().unwrap();
668        let marker = dir.path().join("must-not-exist");
669        let token = crate::CancellationToken::new();
670        token.cancel();
671        let mut ctx = create_tool_context();
672        ctx.capabilities.launch_cancel = Some(token);
673        let error = BashTool.execute(json!({"command": format!("touch '{}'", marker.display())}), ctx)
674            .await.unwrap_err().to_string();
675        assert!(error.contains("canceled before launch"), "{error}");
676        assert!(!marker.exists());
677    }
678
679    fn own_cgroup() -> String {
680        std::fs::read_to_string("/proc/self/cgroup")
681            .unwrap_or_default()
682            .trim()
683            .to_string()
684    }
685
686    /// The command runs in its own systemd scope when the machine supports
687    /// it, so an out-of-memory kill aimed at it cannot take the daemon's
688    /// cgroup down with it (S348). Without a reachable user manager (CI
689    /// containers), the command must still run, in this process's cgroup.
690    #[cfg(target_os = "linux")]
691    #[tokio::test]
692    async fn bash_command_gets_its_own_scope_when_available() {
693        if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") { return; }
694        let out = BashTool
695            .execute(
696                json!({ "command": "cat /proc/self/cgroup; echo pid=$$", "timeout": 30 }),
697                create_tool_context(),
698            )
699            .await
700            .unwrap();
701        let child_cgroup = out.lines().next().unwrap_or_default().trim().to_string();
702        assert!(child_cgroup.starts_with("0::/"), "cgroup v2 line: {out}");
703        if crate::process_scope::launcher_for_env(None).is_some() {
704            assert_ne!(
705                child_cgroup,
706                own_cgroup(),
707                "the command must leave our cgroup"
708            );
709            assert!(child_cgroup.ends_with(".scope"), "{child_cgroup}");
710            assert!(child_cgroup.contains("/app.slice/"), "{child_cgroup}");
711        } else {
712            assert_eq!(child_cgroup, own_cgroup(), "unwrapped fallback");
713        }
714    }
715
716    /// A session environment rebuilt without the user-bus variables cannot
717    /// reach systemd: the command starts unwrapped instead of failing.
718    #[cfg(target_os = "linux")]
719    #[tokio::test]
720    async fn bash_command_with_a_rebuilt_env_lacking_the_user_bus_runs_unwrapped() {
721        let mut ctx = create_tool_context();
722        ctx.capabilities.env = Some(vec![(
723            "PATH".to_string(),
724            std::env::var("PATH").unwrap_or_default(),
725        )]);
726        let out = BashTool
727            .execute(
728                json!({ "command": "cat /proc/self/cgroup", "timeout": 30 }),
729                ctx,
730            )
731            .await
732            .unwrap();
733        assert_eq!(out.lines().next().unwrap_or_default().trim(), own_cgroup());
734    }
735
736    fn processes_named(name: &str) -> Vec<String> {
737        let out = std::process::Command::new("pgrep")
738            .args(["-f", name])
739            .output()
740            .unwrap();
741        String::from_utf8_lossy(&out.stdout)
742            .split_whitespace()
743            .map(str::to_string)
744            .collect()
745    }
746
747    /// A timed-out command takes everything it started with it, not just the
748    /// shell (S348: a timed-out test run would leave all its workers running).
749    /// Each child is renamed to the marker (`exec -a`), so a survivor is seen.
750    #[cfg(target_os = "linux")]
751    #[tokio::test]
752    async fn bash_timeout_kills_the_whole_process_group() {
753        let marker = format!("synaps-timeout-child-{}", std::process::id());
754        let out = BashTool
755            .execute(
756                json!({
757                    "command": format!("(exec -a {marker} sleep 300) & (exec -a {marker} sleep 300); wait"),
758                    "timeout": 1
759                }),
760                create_tool_context(),
761            )
762            .await;
763        let text = match out {
764            Ok(s) => s,
765            Err(e) => e.to_string(),
766        };
767        assert!(text.contains("timed out"), "{text}");
768        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
769        while !processes_named(&marker).is_empty() && std::time::Instant::now() < deadline {
770            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
771        }
772        let left = processes_named(&marker);
773        if !left.is_empty() {
774            let _ = std::process::Command::new("pkill")
775                .args(["-f", &marker])
776                .status();
777        }
778        assert!(left.is_empty(), "children survived the timeout: {left:?}");
779    }
780
781    /// A command that exits by itself keeps what it deliberately started in
782    /// the background, as before: only abnormal endings kill the group.
783    #[cfg(target_os = "linux")]
784    #[tokio::test]
785    async fn bash_normal_exit_leaves_a_background_job_running() {
786        let marker = format!("synaps-background-job-{}", std::process::id());
787        let out = BashTool
788            .execute(
789                json!({
790                    "command": format!("(exec -a {marker} sleep 30) >/dev/null 2>&1 & echo started"),
791                    "timeout": 10
792                }),
793                create_tool_context(),
794            )
795            .await
796            .unwrap();
797        assert!(out.contains("started"), "{out}");
798        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
799        let alive = processes_named(&marker);
800        let _ = std::process::Command::new("pkill")
801            .args(["-f", &marker])
802            .status();
803        assert!(
804            !alive.is_empty(),
805            "a deliberate background job must survive a normal exit"
806        );
807    }
808
809    #[tokio::test]
810    async fn bash_intermediary_handoff_conserves_bytes_under_large_output() {
811        let before = bash_intermediary_snapshot();
812        let tool = BashTool;
813        let mut ctx = create_tool_context();
814        ctx.limits.max_tool_buffer = 64 * 1024;
815        let result = tool
816            .execute(
817                json!({
818                    "command": "python3 -c \"import sys; sys.stdout.write('x' * 1048576)\"",
819                    "timeout": 30
820                }),
821                ctx,
822            )
823            .await
824            .unwrap();
825        assert!(result.contains("output truncated"));
826
827        // The counters are PROCESS-GLOBAL: sibling tests running bash
828        // concurrently (--test-threads > 1) contribute mid-flight bytes to
829        // any instantaneous snapshot. Conservation is therefore asserted at
830        // quiescence: every completed relay balances produced == consumed +
831        // dropped and returns retained to baseline, so the delta window
832        // rebalances once in-flight relays finish. A REAL leak never
833        // rebalances — the bounded poll keeps the oracle strict while
834        // removing scheduling sensitivity (observed flake under the full
835        // workspace run at 8 threads).
836        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
837        let (mut produced, mut consumed, mut accepted, mut dropped);
838        loop {
839            let after = bash_intermediary_snapshot();
840            produced = after.produced_bytes - before.produced_bytes;
841            consumed = after.consumed_bytes - before.consumed_bytes;
842            accepted = after.accepted_bytes - before.accepted_bytes;
843            dropped = after.dropped_bytes - before.dropped_bytes;
844            let balanced =
845                produced == consumed + dropped && after.retained_bytes == before.retained_bytes;
846            if balanced || std::time::Instant::now() >= deadline {
847                assert_eq!(
848                    produced,
849                    consumed + dropped,
850                    "handoff bytes must be conserved at quiescence"
851                );
852                assert_eq!(after.retained_bytes, before.retained_bytes);
853                break;
854            }
855            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
856        }
857        assert!(accepted >= consumed);
858        assert!(produced >= 64 * 1024);
859    }
860
861    #[test]
862    fn detects_sudo_password_prompt_without_newline() {
863        assert_eq!(
864            detect_password_prompt("[sudo] password for me: "),
865            Some(PromptKind::Sudo)
866        );
867    }
868
869    #[test]
870    fn sanitizes_terminal_control_sequences_and_nuls() {
871        let cleaned = sanitize_output(b"ok\x1b[2J\x00done");
872        assert_eq!(cleaned, "okdone");
873    }
874
875    use super::super::test_helpers::create_tool_context;
876    use crate::tools::Tool;
877    use serde_json::json;
878
879    #[test]
880    fn test_bash_tool_schema() {
881        let tool = BashTool;
882        assert_eq!(tool.name(), "bash");
883        assert!(!tool.description().is_empty());
884
885        let params = tool.parameters();
886        assert_eq!(params["type"], "object");
887        assert!(params["properties"].is_object());
888        assert!(params["required"].is_array());
889    }
890
891    #[tokio::test]
892    async fn test_bash_tool_execution() {
893        let tool = BashTool;
894
895        // Test simple echo command
896        let ctx = create_tool_context();
897        let params = json!({
898            "command": "echo hello"
899        });
900
901        let result = tool.execute(params, ctx).await.unwrap();
902        assert!(result.contains("hello"));
903
904        // Test timeout parameter with quick command
905        let ctx = create_tool_context();
906        let params = json!({
907            "command": "sleep 1",
908            "timeout": 2
909        });
910
911        let result = tool.execute(params, ctx).await;
912        // Should succeed (1 second sleep with 2 second timeout)
913        assert!(result.is_ok());
914
915        // Test timeout with longer command
916        let ctx = create_tool_context();
917        let params = json!({
918            "command": "sleep 3",
919            "timeout": 1
920        });
921
922        let result = tool.execute(params, ctx).await;
923        // Should timeout
924        assert!(result.is_err());
925        assert!(result.unwrap_err().to_string().contains("timed out"));
926    }
927
928    #[tokio::test]
929    async fn test_bash_tool_requested_timeout_is_clamped_by_max_timeout() {
930        let tool = BashTool;
931        let mut ctx = create_tool_context();
932        ctx.limits.bash_max_timeout = 1;
933
934        let params = json!({
935            "command": "sleep 5; echo done",
936            "timeout": 10
937        });
938
939        let result = tool.execute(params, ctx).await;
940        assert!(
941            result.unwrap_err().to_string().contains("timed out"),
942            "requested timeout MUST be clamped by bash_max_timeout"
943        );
944    }
945
946    #[tokio::test]
947    async fn test_bash_fake_sudo_prompt_uses_secret_prompt_and_redacts_password() {
948        let tool = BashTool;
949        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
950        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
951        let channel = crate::tools::output::delta_channel();
952        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
953
954        let responder = tokio::spawn(async move {
955            let req = prompt_rx
956                .recv()
957                .await
958                .expect("bash should request a secret prompt");
959            assert!(
960                req.prompt.to_ascii_lowercase().contains("password"),
961                "prompt was {:?}",
962                req.prompt
963            );
964            req.response_tx.send(Some("swordfish".to_string())).unwrap();
965        });
966
967        let mut ctx = create_tool_context();
968        ctx.capabilities.secret_prompt = Some(prompt_handle);
969        ctx.channels.tx_delta = Some(delta_tx);
970        let params = json!({
971            "command": "printf '[sudo] password for testuser: ' >&2; read -r pw; if [ \"$pw\" = swordfish ]; then echo AUTH_OK; else echo AUTH_FAIL; fi",
972            "timeout": 30
973        });
974
975        let result = tool.execute(params, ctx).await.unwrap();
976        responder.await.unwrap();
977        let mut streamed = String::new();
978        while let Some(delta) = delta_rx.try_drain() {
979            streamed.push_str(&delta);
980        }
981
982        assert!(result.contains("AUTH_OK"));
983        assert!(!result.contains("swordfish"));
984        assert!(!result.contains("[sudo] password"));
985        assert!(!streamed.contains("[sudo] password"));
986    }
987
988    #[test]
989    fn test_bash_wraps_sudo_to_force_stdin_prompt() {
990        let script = super::bash_script_with_secure_sudo("sudo id");
991
992        assert!(script.contains("sudo()"));
993        assert!(script.contains("command sudo -S -p '[sudo] password required: '"));
994        assert!(script.ends_with("sudo id"));
995    }
996
997    /// Writes an executable stub `sudo` into `dir` that emulates the flags the
998    /// secure wrapper passes (`-S -p PROMPT`): `-k` exits silently like a real
999    /// timestamp reset, anything else writes PROMPT to stderr, consumes one
1000    /// stdin line, and fails. Lets the wrapper be tested without the system
1001    /// sudo, whose prompting behaviour is host-dependent.
1002    #[cfg(unix)]
1003    fn write_fake_sudo(dir: &std::path::Path) {
1004        use std::os::unix::fs::PermissionsExt;
1005        let sudo_path = dir.join("sudo");
1006        std::fs::write(
1007            &sudo_path,
1008            r#"#!/bin/sh
1009prompt=""
1010while [ $# -gt 0 ]; do
1011  case "$1" in
1012    -S) shift ;;
1013    -p) prompt="$2"; shift 2 ;;
1014    -k) exit 0 ;;
1015    *) break ;;
1016  esac
1017done
1018printf '%s' "$prompt" >&2
1019read -r _pw
1020exit 1
1021"#,
1022        )
1023        .expect("write fake sudo");
1024        let mut perms = std::fs::metadata(&sudo_path)
1025            .expect("stat fake sudo")
1026            .permissions();
1027        perms.set_mode(0o755);
1028        std::fs::set_permissions(&sudo_path, perms).expect("chmod fake sudo");
1029    }
1030
1031    /// The `sudo()` wrapper's prompt is intercepted by the secret-prompt path
1032    /// and never reaches the delta stream.
1033    ///
1034    /// Hermetic by construction: it drives a STUB sudo on PATH, not the host's.
1035    /// `command sudo` bypasses the shell function but still honours PATH, so
1036    /// the real wrapper path is exercised. PATH is exported inside the script
1037    /// rather than through `std::env::set_var` because these tests are not
1038    /// `#[serial]` and mutating process-global env would race them.
1039    ///
1040    /// This previously drove the system sudo and asserted that it prompts —
1041    /// false on any host with passwordless sudo, which includes every GitHub
1042    /// runner, so it could never pass in CI.
1043    #[cfg(unix)]
1044    #[tokio::test]
1045    async fn test_bash_sudo_function_prompt_is_intercepted_before_streaming() {
1046        let fake_bin = tempfile::tempdir().expect("tempdir");
1047        write_fake_sudo(fake_bin.path());
1048        let tool = BashTool;
1049        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1050        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1051        let channel = crate::tools::output::delta_channel();
1052        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1053
1054        let responder = tokio::spawn(async move {
1055            let req = prompt_rx
1056                .recv()
1057                .await
1058                .expect("bash should request a secret prompt");
1059            assert!(
1060                req.prompt.contains("[sudo] password required"),
1061                "prompt was {:?}",
1062                req.prompt
1063            );
1064            req.response_tx
1065                .send(Some("wrong-password-for-test".to_string()))
1066                .unwrap();
1067        });
1068
1069        let mut ctx = create_tool_context();
1070        ctx.capabilities.secret_prompt = Some(prompt_handle);
1071        ctx.channels.tx_delta = Some(delta_tx);
1072        let params = json!({
1073            "command": format!(
1074                "export PATH=\"{}:$PATH\"; sudo -k; sudo -v",
1075                fake_bin.path().display()
1076            ),
1077            "timeout": 30
1078        });
1079
1080        let _ = tool.execute(params, ctx).await;
1081        responder.await.unwrap();
1082        let mut streamed = String::new();
1083        while let Some(delta) = delta_rx.try_drain() {
1084            streamed.push_str(&delta);
1085        }
1086
1087        assert!(
1088            !streamed.contains("[sudo] password required"),
1089            "sudo password prompt leaked into deltas: {streamed:?}"
1090        );
1091    }
1092
1093    #[tokio::test]
1094    async fn test_bash_control_char_output_is_sanitized_and_bounded_in_deltas() {
1095        let tool = BashTool;
1096        let channel = crate::tools::output::delta_channel();
1097        let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
1098        let mut ctx = create_tool_context();
1099        ctx.channels.tx_delta = Some(delta_tx);
1100        ctx.limits.max_tool_buffer = 256;
1101
1102        let params = json!({
1103            "command": "python3 -c \"import sys; sys.stdout.buffer.write(b'clean\\x1b[2J\\x00' + b'A' * 2000); sys.stdout.flush()\"",
1104            "timeout": 30
1105        });
1106
1107        let result = tool.execute(params, ctx).await.unwrap();
1108        let mut streamed = String::new();
1109        while let Some(delta) = delta_rx.try_drain() {
1110            streamed.push_str(&delta);
1111        }
1112
1113        assert!(result.contains("[output truncated at 256]"));
1114        assert!(!result.contains('\u{1b}'));
1115        assert!(!result.contains('\0'));
1116        assert!(!streamed.contains('\u{1b}'));
1117        assert!(!streamed.contains('\0'));
1118        assert!(
1119            streamed.len() <= 2048,
1120            "streamed deltas must be bounded, got {} bytes",
1121            streamed.len()
1122        );
1123    }
1124
1125    #[tokio::test]
1126    async fn test_bash_echoed_secret_is_redacted_from_output() {
1127        let tool = BashTool;
1128        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1129        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1130
1131        let responder = tokio::spawn(async move {
1132            let req = prompt_rx
1133                .recv()
1134                .await
1135                .expect("bash should request a secret prompt");
1136            req.response_tx.send(Some("swordfish".to_string())).unwrap();
1137        });
1138
1139        let mut ctx = create_tool_context();
1140        ctx.capabilities.secret_prompt = Some(prompt_handle);
1141        let params = json!({
1142            "command": "printf 'Password: ' >&2; read -r pw; echo seen:$pw",
1143            "timeout": 30
1144        });
1145
1146        let result = tool.execute(params, ctx).await.unwrap();
1147        responder.await.unwrap();
1148
1149        assert!(result.contains("seen:[redacted]"));
1150        assert!(!result.contains("swordfish"));
1151    }
1152
1153    #[tokio::test]
1154    async fn test_bash_sequential_password_prompts_are_each_handled() {
1155        let tool = BashTool;
1156        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1157        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1158
1159        let responder = tokio::spawn(async move {
1160            for value in ["first", "second"] {
1161                let req = prompt_rx
1162                    .recv()
1163                    .await
1164                    .expect("bash should request each secret prompt");
1165                assert!(req.prompt.to_ascii_lowercase().contains("password"));
1166                req.response_tx.send(Some(value.to_string())).unwrap();
1167            }
1168        });
1169
1170        let mut ctx = create_tool_context();
1171        ctx.capabilities.secret_prompt = Some(prompt_handle);
1172        let params = json!({
1173            "command": "printf 'Password: ' >&2; read -r one; printf 'Password: ' >&2; read -r two; echo done:$one:$two",
1174            "timeout": 30
1175        });
1176
1177        let result = tool.execute(params, ctx).await.unwrap();
1178        responder.await.unwrap();
1179
1180        assert!(result.contains("done:[redacted]:[redacted]"));
1181        assert!(!result.contains("first"));
1182        assert!(!result.contains("second"));
1183    }
1184
1185    #[tokio::test]
1186    async fn test_bash_password_prompt_cancel_kills_command_without_leaking_partial_secret() {
1187        let tool = BashTool;
1188        let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
1189        let prompt_handle = crate::tools::SecretPromptHandle::new(prompt_tx);
1190
1191        let responder = tokio::spawn(async move {
1192            let req = prompt_rx
1193                .recv()
1194                .await
1195                .expect("bash should request a secret prompt");
1196            req.response_tx.send(None).unwrap();
1197        });
1198
1199        let mut ctx = create_tool_context();
1200        ctx.capabilities.secret_prompt = Some(prompt_handle);
1201        let params = json!({
1202            "command": "printf 'Password: ' >&2; read -r pw; echo should-not-run:$pw",
1203            "timeout": 30
1204        });
1205
1206        let err = tool.execute(params, ctx).await.unwrap_err().to_string();
1207        responder.await.unwrap();
1208
1209        assert!(err.contains("waiting for password"));
1210        assert!(!err.contains("should-not-run"));
1211    }
1212
1213    #[tokio::test]
1214    async fn test_bash_binary_output_is_sanitized() {
1215        let tool = BashTool;
1216        let ctx = create_tool_context();
1217        let params = json!({
1218            "command": "python3 -c \"import sys; sys.stdout.buffer.write(bytes(range(32)) + b'visible')\"",
1219            "timeout": 30
1220        });
1221
1222        let result = tool.execute(params, ctx).await.unwrap();
1223
1224        assert!(result.contains("visible"));
1225        assert!(!result.contains('\0'));
1226        assert!(!result.contains('\u{1b}'));
1227    }
1228
1229    #[tokio::test]
1230    async fn test_bash_tool_timeout() {
1231        let tool = BashTool;
1232        let ctx = create_tool_context();
1233
1234        let params = json!({
1235            "command": "sleep 10",
1236            "timeout": 1
1237        });
1238
1239        let result = tool.execute(params, ctx).await;
1240
1241        // Should timeout and return error
1242        assert!(result.is_err());
1243        let error = result.unwrap_err().to_string();
1244        assert!(error.contains("timed out"));
1245    }
1246
1247    #[tokio::test]
1248    async fn test_bash_tool_failure() {
1249        let tool = BashTool;
1250        let ctx = create_tool_context();
1251
1252        let params = json!({
1253            "command": "exit 1"
1254        });
1255
1256        let result = tool.execute(params, ctx).await;
1257
1258        // Should fail and return error
1259        assert!(result.is_err());
1260        let error = result.unwrap_err().to_string();
1261        assert!(error.contains("failed") || error.contains("exit"));
1262    }
1263
1264    #[cfg(unix)]
1265    #[tokio::test]
1266    async fn bash_honours_capability_cwd_when_set() {
1267        let tmp = tempfile::TempDir::new().unwrap();
1268        let expected = tmp.path().canonicalize().unwrap();
1269        let mut ctx = create_tool_context();
1270        ctx.capabilities.cwd = Some(tmp.path().to_path_buf());
1271        let out = BashTool
1272            .execute(json!({"command": "pwd -P"}), ctx)
1273            .await
1274            .unwrap();
1275        assert_eq!(out.trim(), expected.to_string_lossy());
1276    }
1277
1278    #[cfg(unix)]
1279    #[tokio::test]
1280    async fn bash_inherits_process_cwd_when_capability_cwd_is_none() {
1281        let expected = std::env::current_dir().unwrap().canonicalize().unwrap();
1282        let ctx = create_tool_context();
1283        assert!(ctx.capabilities.cwd.is_none());
1284        let out = BashTool
1285            .execute(json!({"command": "pwd -P"}), ctx)
1286            .await
1287            .unwrap();
1288        assert_eq!(out.trim(), expected.to_string_lossy());
1289    }
1290
1291    #[test]
1292    fn references_env_name_is_word_bounded() {
1293        assert!(references_env_name("echo ${GH_TOKEN:?}", "GH_TOKEN"));
1294        assert!(references_env_name("echo $GH_TOKEN", "GH_TOKEN"));
1295        assert!(references_env_name("GH_TOKEN=x gh auth", "GH_TOKEN"));
1296        assert!(references_env_name("$env:GH_TOKEN", "GH_TOKEN"));
1297        assert!(!references_env_name("echo $GH_TOKEN_FILE", "GH_TOKEN"));
1298        assert!(!references_env_name("echo $MY_GH_TOKEN", "GH_TOKEN"));
1299        assert!(!references_env_name("gh auth status", "GH_TOKEN"));
1300    }
1301
1302    /// T5 notice: non-zero exit + script references stripped var → notice.
1303    #[cfg(unix)]
1304    #[tokio::test]
1305    async fn bash_notice_on_stripped_secret_reference() {
1306        let warned = std::sync::Arc::new(std::sync::Mutex::new(
1307            std::collections::HashSet::<String>::new(),
1308        ));
1309        // --- case 1: non-zero exit + reference → notice
1310        let mut ctx = create_tool_context();
1311        ctx.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1312        ctx.capabilities.env_warned = warned.clone();
1313        let err = BashTool
1314            .execute(json!({"command": "echo ${GH_TOKEN:?} 2>&1; exit 1"}), ctx)
1315            .await
1316            .unwrap_err();
1317        let msg = format!("{err}");
1318        assert!(
1319            msg.contains("note: $GH_TOKEN was stripped"),
1320            "notice must name the var: {msg}"
1321        );
1322
1323        // --- case 2: zero exit → no notice
1324        let mut ctx2 = create_tool_context();
1325        ctx2.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1326        ctx2.capabilities.env_warned = warned.clone();
1327        let out = BashTool
1328            .execute(json!({"command": "echo $GH_TOKEN; exit 0"}), ctx2)
1329            .await
1330            .unwrap();
1331        assert!(
1332            !out.contains("note:"),
1333            "zero exit must not produce a notice: {out}"
1334        );
1335
1336        // --- case 3: second failure → no duplicate (same Arc)
1337        let mut ctx3 = create_tool_context();
1338        ctx3.capabilities.env_stripped = vec!["GH_TOKEN".into()];
1339        ctx3.capabilities.env_warned = warned.clone();
1340        let err2 = BashTool
1341            .execute(json!({"command": "echo ${GH_TOKEN}; exit 1"}), ctx3)
1342            .await
1343            .unwrap_err();
1344        let msg2 = format!("{err2}");
1345        assert!(
1346            !msg2.contains("note: $GH_TOKEN was stripped"),
1347            "dedup must suppress the second notice: {msg2}"
1348        );
1349    }
1350}