Skip to main content

oxicode_agent/tools/
bash.rs

1/// Bash tool - execute shell commands
2/// Features:
3/// - Timeout support with process group kill
4/// - Working directory (cwd) parameter
5/// - Environment variables support
6/// - Duration timing reporting
7/// - Output truncation (2000 lines / 50KB defaults via truncate module)
8/// - Separate stdout/stderr capture combined at end
9/// - Process tree kill on abort/cancel via signal
10use super::truncate::{self, TruncationOptions, TruncationResult};
11use super::{AgentTool, AgentToolResult, ProgressCallback, ToolContext, ToolError};
12use async_trait::async_trait;
13use serde_json::{Value, json};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tokio::io::AsyncReadExt;
18use tokio::process::Command;
19use tokio::sync::oneshot;
20
21/// Environment variables that are blocked from injection via the LLM.
22/// These can be used for privilege escalation, library injection, or path manipulation.
23const BLOCKED_ENV_VARS: &[&str] = &[
24    "LD_PRELOAD",
25    "LD_LIBRARY_PATH",
26    "DYLD_INSERT_LIBRARIES",
27    "DYLD_LIBRARY_PATH",
28    "DYLD_FRAMEWORK_PATH",
29    "PATH",
30    "HOME",
31    "IFS",
32    "SHELL",
33    "USER",
34    "LOGNAME",
35    "PYTHONPATH",
36    "NODE_PATH",
37    "RUBYLIB",
38    "PERL5LIB",
39    "CLASSPATH",
40    "JAVA_TOOL_OPTIONS",
41    "MallocNanoZone",
42    "MallocSpaceEfficient",
43];
44
45/// Check if a command contains dangerous patterns.
46/// Returns a warning string if dangerous patterns are detected, or None if safe.
47/// This does NOT block execution - it only emits a warning.
48fn is_dangerous_command(command: &str) -> Option<String> {
49    let cmd_lower = command.to_lowercase();
50    let mut warnings: Vec<String> = Vec::new();
51
52    // Pipe to shell
53    if cmd_lower.contains("| sh") || cmd_lower.contains("| bash") || cmd_lower.contains("| zsh") {
54        warnings.push("pipe to shell".to_string());
55    }
56
57    // Sensitive file access via command substitution
58    if command.contains("/etc/passwd") || command.contains("/etc/shadow") {
59        warnings.push("access to sensitive authentication files".to_string());
60    }
61    if command.contains("id_rsa") || command.contains("id_ed25519") || command.contains(".ssh/") {
62        warnings.push("access to SSH private keys/directory".to_string());
63    }
64
65    // Network exfiltration patterns
66    if (cmd_lower.contains("curl") || cmd_lower.contains("wget")) && cmd_lower.contains("| nc") {
67        warnings.push("possible network exfiltration (pipe to netcat)".to_string());
68    }
69    if command.contains("/dev/tcp/") || command.contains("/dev/udp/") {
70        warnings.push("possible network exfiltration via /dev/tcp|udp".to_string());
71    }
72
73    // Privilege escalation
74    if cmd_lower.starts_with("sudo ")
75        || cmd_lower.contains("\nsudo ")
76        || cmd_lower.contains("&&sudo ")
77    {
78        warnings.push("sudo detected (privilege escalation)".to_string());
79    }
80    if cmd_lower.contains("su -") || cmd_lower.contains("su root") {
81        warnings.push("user switch to privileged account".to_string());
82    }
83
84    // Fork bomb patterns
85    if cmd_lower.contains(":(){ :|:& };") || cmd_lower.contains("fork bomb") {
86        warnings.push("fork bomb pattern detected".to_string());
87    }
88    // Also detect the common `:(){ :|:& };:` pattern (without spaces)
89    if command.contains(":(){") && command.contains(":|:&") {
90        warnings.push("fork bomb pattern detected".to_string());
91    }
92
93    // Write to system directories
94    let system_write_patterns: &[(&str, &str)] = &[
95        ("> /etc/", "/etc/"),
96        (">> /etc/", "/etc/"),
97        ("> /boot/", "/boot/"),
98        (">> /boot/", "/boot/"),
99        ("> /sys/", "/sys/"),
100        (">> /sys/", "/sys/"),
101        ("> /proc/", "/proc/"),
102        (">> /proc/", "/proc/"),
103    ];
104    for (pattern, dir) in system_write_patterns {
105        if cmd_lower.contains(pattern) {
106            warnings.push(format!("write to system directory {}", dir));
107            break;
108        }
109    }
110
111    if warnings.is_empty() {
112        None
113    } else {
114        Some(format!(
115            "⚠️  SECURITY WARNING: {}",
116            warnings
117                .iter()
118                .map(|s| s.as_str())
119                .collect::<Vec<_>>()
120                .join(", ")
121        ))
122    }
123}
124
125/// Validate that a working directory is within the allowed workspace.
126/// Returns an error message if the path is invalid/escapes, or the resolved path on success.
127fn validate_cwd(dir: &str, workspace: Option<&Path>) -> Result<PathBuf, String> {
128    let path = Path::new(dir);
129
130    // Reject path traversal
131    if path.components().any(|c| c.as_os_str() == "..") {
132        return Err("Path traversal (..) not allowed in working directory".to_string());
133    }
134
135    if !path.exists() {
136        return Err(format!("Working directory does not exist: {}", dir));
137    }
138
139    // If we have a workspace root, validate the cwd is within it
140    if let Some(workspace_root) = workspace {
141        // Canonicalize both paths to resolve symlinks and normalize
142        let canonical_cwd = path
143            .canonicalize()
144            .map_err(|e| format!("Failed to resolve working directory: {}", e))?;
145        let canonical_workspace = workspace_root
146            .canonicalize()
147            .map_err(|e| format!("Failed to resolve workspace directory: {}", e))?;
148
149        if !canonical_cwd.starts_with(&canonical_workspace) {
150            return Err(format!(
151                "Working directory '{}' is outside the allowed workspace '{}'",
152                canonical_cwd.display(),
153                canonical_workspace.display()
154            ));
155        }
156
157        return Ok(canonical_cwd);
158    }
159
160    // No workspace constraint - just return the original path
161    Ok(path.to_path_buf())
162}
163
164// ── F-9: PTY-backed bash execution preserves ANSI SGR, drops other CSI/OSC ─
165
166/// Outcome of a PTY-backed command run. The output is filtered to keep SGR
167/// color escapes (`ESC [ ... m`) and drop cursor-motion / screen-control
168/// sequences so the bytes are safe to embed in the agent transcript.
169#[cfg(unix)]
170#[derive(Debug, Clone)]
171pub struct PtyOutcome {
172    /// Filtered PTY output (SGR kept, other CSI / OSC dropped).
173    pub output: String,
174    /// Exit status of the child, if available (None for signal kills).
175    pub exit_code: Option<i32>,
176}
177
178/// Filter PTY bytes: keep SGR (`ESC [ ... m`) sequences, drop all other CSI
179/// (`ESC [ ... <letter>`) and OSC (`ESC ] ... BEL|ST`) escapes. Non-control
180/// bytes pass through unchanged.
181///
182/// Downstream renderers (TUI transcript, logs) treat `ESC [ 31m` red text as
183/// meaningful, but `ESC [ 2J` clear-screen and `ESC [ H` cursor-home are noise
184/// that would corrupt transcript diffs.
185#[cfg(unix)]
186fn ansi_filter(input: &str) -> String {
187    let bytes = input.as_bytes();
188    let mut out = Vec::with_capacity(bytes.len());
189    let mut i = 0;
190    while i < bytes.len() {
191        let b = bytes[i];
192        if b != 0x1b {
193            out.push(b);
194            i += 1;
195            continue;
196        }
197        if i + 1 >= bytes.len() {
198            out.push(b);
199            i += 1;
200            continue;
201        }
202        match bytes[i + 1] {
203            b'[' => {
204                // CSI: ESC [ <params> <final byte 0x40..=0x7e>
205                let mut j = i + 2;
206                while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
207                    j += 1;
208                }
209                if j < bytes.len() {
210                    if bytes[j] == b'm' {
211                        out.extend_from_slice(&bytes[i..=j]);
212                    }
213                    i = j + 1;
214                } else {
215                    i += 1;
216                }
217            }
218            b']' => {
219                // OSC: ESC ] <payload> terminated by BEL (0x07) or ST (ESC \).
220                let mut j = i + 2;
221                while j < bytes.len() {
222                    if bytes[j] == 0x07 {
223                        j += 1;
224                        break;
225                    }
226                    if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
227                        j += 2;
228                        break;
229                    }
230                    j += 1;
231                }
232                i = j;
233            }
234            _ => {
235                // ESC followed by something we don't recognize. Drop the
236                // ESC; the next byte is kept verbatim.
237                i += 1;
238            }
239        }
240    }
241    String::from_utf8_lossy(&out).into_owned()
242}
243
244/// Run a command inside a real PTY (portable-pty). Output is merged (stderr
245/// is folded into stdout via `exec 2>&1`) and filtered. Returns the filtered
246/// output and the child's exit code.
247///
248/// Low-level helper used by the bash tool when `OXICODE_BASH_PTY=1` is set.
249/// Does NOT enforce `BLOCKED_ENV_VARS` or workspace CWD policy — those
250/// concerns live in the calling tool.
251#[cfg(unix)]
252pub async fn run_in_pty(
253    cmd: &str,
254    cwd: &Path,
255    timeout: Duration,
256    abort: oneshot::Receiver<()>,
257) -> Result<PtyOutcome, ToolError> {
258    use portable_pty::{CommandBuilder, PtySize, native_pty_system};
259
260    let pty_system = native_pty_system();
261    let pair = pty_system
262        .openpty(PtySize {
263            rows: 24,
264            cols: 80,
265            pixel_width: 0,
266            pixel_height: 0,
267        })
268        .map_err(|e| format!("failed to open PTY: {e}"))?;
269
270    // Single bash invocation: merge stderr into stdout inside the shell,
271    // then run the user's command. This avoids juggling two PTY readers.
272    // portable-pty already calls setsid() in the spawned child, so the
273    // bash process becomes its own session / process-group leader —
274    // signalling the whole group (negative pid) reaches bash AND any
275    // descendants, leaving no orphans holding the PTY slave fd open
276    // (which would hang read_thread.join()).
277    let wrapped = format!("exec 2>&1; {cmd}");
278    let mut builder = CommandBuilder::new("bash");
279    builder.arg("-c");
280    builder.arg(&wrapped);
281    builder.cwd(cwd);
282
283    let mut child = pair
284        .slave
285        .spawn_command(builder)
286        .map_err(|e| format!("failed to spawn PTY child: {e}"))?;
287
288    // Clone a kill handle so the timeout / abort path can stop the child
289    // without owning the Child.
290    let mut killer = child.clone_killer();
291    let pid = child.process_id();
292
293    let mut reader = pair
294        .master
295        .try_clone_reader()
296        .map_err(|e| format!("failed to clone PTY reader: {e}"))?;
297    drop(pair.slave);
298
299    // Reader thread: blocking std::io::Read until EOF or error.
300    let (read_tx, read_rx) = std::sync::mpsc::channel::<String>();
301    let read_thread = std::thread::spawn(move || {
302        let mut buf = String::new();
303        let mut chunk = [0u8; 4096];
304        loop {
305            match reader.read(&mut chunk) {
306                Ok(0) => break,
307                Ok(n) => buf.push_str(&String::from_utf8_lossy(&chunk[..n])),
308                Err(e) => {
309                    if e.kind() == std::io::ErrorKind::Interrupted {
310                        continue;
311                    }
312                    // EIO on Linux when the slave side closes — treat as EOF.
313                    break;
314                }
315            }
316        }
317        let _ = read_tx.send(buf);
318    });
319
320    // Wait thread: blocking wait for the child.
321    let wait_thread = std::thread::spawn(move || child.wait());
322
323    // Bridge the oneshot abort signal into a polled AtomicBool so we can
324    // race it against the wait timeout without spawning a tokio task. The
325    // bridge thread blocks on `try_recv` until either the sender fires
326    // or the channel is dropped — whichever comes first.
327    let abort_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
328    {
329        let abort_flag = abort_flag.clone();
330        std::thread::spawn(move || {
331            let mut abort = abort;
332            loop {
333                match abort.try_recv() {
334                    Ok(_) => {
335                        abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
336                        return;
337                    }
338                    Err(oneshot::error::TryRecvError::Closed) => return,
339                    Err(oneshot::error::TryRecvError::Empty) => {
340                        std::thread::sleep(Duration::from_millis(20));
341                    }
342                }
343            }
344        });
345    }
346
347    let timeout_at = std::time::Instant::now() + timeout;
348    let wait_handle = wait_thread;
349    let (status_opt, timed_out, aborted): (Option<portable_pty::ExitStatus>, bool, bool) = loop {
350        if wait_handle.is_finished() {
351            let join_result = wait_handle.join();
352            let status = match join_result {
353                Ok(Ok(s)) => Some(s),
354                _ => None,
355            };
356            break (status, false, false);
357        }
358        if std::time::Instant::now() >= timeout_at {
359            pty_kill_process_group(pid, &mut killer);
360            break (None, true, false);
361        }
362        if abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
363            pty_kill_process_group(pid, &mut killer);
364            break (None, true, true);
365        }
366        std::thread::sleep(Duration::from_millis(10));
367    };
368
369    // Drain any partial output, then join the reader thread with a bounded
370    // timeout. Killing the process group closes the slave fd, so the
371    // reader's blocking read returns EOF and the thread terminates — but
372    // cap the join wait so we never hang the agent loop if something is
373    // wrong.
374    let raw = read_rx
375        .recv_timeout(Duration::from_millis(500))
376        .unwrap_or_default();
377    let join_deadline = std::time::Instant::now() + Duration::from_secs(2);
378    while !read_thread.is_finished() && std::time::Instant::now() < join_deadline {
379        std::thread::sleep(Duration::from_millis(20));
380    }
381    // If the reader thread is still alive after 2s, leak it rather than
382    // hanging the agent loop. The OS reclaims it when the group is torn
383    // down.
384    drop(read_thread);
385
386    if aborted {
387        let filtered = ansi_filter(&raw);
388        return Err(format!("Command aborted; partial output:\n{filtered}"));
389    }
390    if timed_out {
391        let filtered = ansi_filter(&raw);
392        return Err(format!(
393            "Command timed out after {} seconds; partial output:\n{filtered}",
394            timeout.as_secs(),
395        ));
396    }
397
398    let filtered = ansi_filter(&raw);
399    Ok(PtyOutcome {
400        output: filtered,
401        exit_code: status_opt.map(|s| s.exit_code() as i32),
402    })
403}
404
405/// Send SIGKILL to the bash process group (negative pid → group) when a
406/// pid is available, falling back to the portable-pty kill trait. Used by
407/// both timeout and abort paths.
408#[cfg(unix)]
409fn pty_kill_process_group(
410    pid: Option<u32>,
411    killer: &mut Box<dyn portable_pty::ChildKiller + Send + Sync>,
412) {
413    if let Some(pid) = pid {
414        // SAFETY: pid is a live owned child process at the time of the call.
415        // portable-pty calls setsid() in the spawned child, so a negative
416        // pid targets bash + its descendants (the process group).
417        unsafe {
418            libc::kill(-(pid as i32), libc::SIGKILL);
419        }
420    } else {
421        let _ = killer.kill();
422    }
423}
424
425/// Default timeout in seconds
426const DEFAULT_TIMEOUT_SECS: u64 = 120;
427
428/// BashTool.
429pub struct BashTool {
430    root_dir: Option<PathBuf>,
431    progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
432}
433
434impl BashTool {
435    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
436    pub fn new() -> Self {
437        Self {
438            root_dir: None,
439            progress_callback: Arc::new(std::sync::Mutex::new(None)),
440        }
441    }
442
443    /// Create with a specific working directory (overrides ToolContext).
444    pub fn with_cwd(cwd: PathBuf) -> Self {
445        Self {
446            root_dir: Some(cwd),
447            progress_callback: Arc::new(std::sync::Mutex::new(None)),
448        }
449    }
450
451    /// Format a duration for human-readable display
452    fn format_duration(duration: Duration) -> String {
453        let secs = duration.as_secs();
454        let millis = duration.subsec_millis();
455        if secs >= 60 {
456            let mins = secs / 60;
457            let remain_secs = secs % 60;
458            format!(
459                "{}m {:.1}s",
460                mins,
461                remain_secs as f64 + millis as f64 / 1000.0
462            )
463        } else {
464            format!("{:.1}s", secs as f64 + millis as f64 / 1000.0)
465        }
466    }
467
468    /// Build the output string with optional truncation notice and timing.
469    fn build_output(
470        truncation: &TruncationResult,
471        elapsed: Duration,
472        exit_code: Option<i32>,
473    ) -> String {
474        let mut output = truncation.content.clone();
475
476        // Append truncation notice if output was truncated
477        if truncation.truncated {
478            let notice = match truncation.truncated_by {
479                truncate::TruncatedBy::Lines => format!(
480                    "\n\n[Truncated: showing {} of {} lines. {} bytes remaining]",
481                    truncation.output_lines,
482                    truncation.total_lines,
483                    truncate::format_bytes(
484                        truncation
485                            .total_bytes
486                            .saturating_sub(truncation.output_bytes)
487                    )
488                ),
489                truncate::TruncatedBy::Bytes => format!(
490                    "\n\n[Truncated: {} lines shown ({} byte limit). Total was {} lines, {}]",
491                    truncation.output_lines,
492                    truncate::format_bytes(truncate::DEFAULT_MAX_BYTES),
493                    truncation.total_lines,
494                    truncate::format_bytes(truncation.total_bytes)
495                ),
496                truncate::TruncatedBy::None => String::new(),
497            };
498            output.push_str(&notice);
499        }
500
501        // Append exit code for non-zero
502        if let Some(code) = exit_code
503            && code != 0
504        {
505            output.push_str(&format!("\n\nCommand exited with code {}", code));
506        }
507
508        // Append timing
509        output.push_str(&format!("\n\nTook {}", Self::format_duration(elapsed)));
510
511        output
512    }
513
514    /// Wait for a child process with timeout and optional abort signal.
515    async fn wait_with_timeout_and_signal(
516        child: &mut tokio::process::Child,
517        timeout: u64,
518        signal: &mut Option<oneshot::Receiver<()>>,
519    ) -> Result<std::process::ExitStatus, String> {
520        let timeout_duration = Duration::from_secs(timeout);
521
522        tokio::select! {
523            status = child.wait() => {
524                status.map_err(|e| format!("Failed to wait for process: {}", e))
525            }
526            _ = tokio::time::sleep(timeout_duration) => {
527                Self::kill_process_group(child).await;
528                Err(format!("Command timed out after {} seconds", timeout))
529            }
530            _ = async {
531                match signal {
532                    Some(rx) => { let _ = rx.await; }
533                    None => std::future::pending::<()>().await,
534                }
535            } => {
536                Self::kill_process_group(child).await;
537                Err("Command aborted".to_string())
538            }
539        }
540    }
541
542    /// Build the shell command with working directory and environment variables.
543    fn build_shell_command(
544        command: &str,
545        work_dir: &Option<String>,
546        env: Option<&serde_json::Map<String, Value>>,
547    ) -> Command {
548        let mut cmd = Command::new("sh");
549        cmd.arg("-c")
550            .arg(command)
551            .stdout(std::process::Stdio::piped())
552            .stderr(std::process::Stdio::piped())
553            .process_group(0);
554
555        if let Some(dir) = work_dir {
556            cmd.current_dir(dir);
557        }
558
559        if let Some(env_map) = env {
560            for (key, val) in env_map {
561                if BLOCKED_ENV_VARS
562                    .iter()
563                    .any(|blocked| blocked.eq_ignore_ascii_case(key))
564                {
565                    continue;
566                }
567                if let Some(val_str) = val.as_str() {
568                    cmd.env(key, val_str);
569                }
570            }
571        }
572
573        cmd
574    }
575
576    /// Kill a process group (Unix) or fall back to child.kill().
577    async fn kill_process_group(child: &mut tokio::process::Child) {
578        #[cfg(unix)]
579        {
580            if let Some(pid) = child.id() {
581                let pgid = -(pid as i32);
582                // SAFETY: libc::kill sends SIGKILL to the process group. The negative PID
583                // targets the entire group (shell + child processes). PID comes from
584                // child.id() which is a valid running process owned by this process.
585                unsafe {
586                    libc::kill(pgid, libc::SIGKILL);
587                }
588            }
589        }
590        let _ = child.kill().await;
591        let _ = child.wait().await;
592    }
593
594    /// Format error output for timeout/abort cases.
595    fn format_error_output(
596        stdout_str: &str,
597        stderr_str: &str,
598        error_msg: &str,
599        elapsed: Duration,
600    ) -> String {
601        let mut output = String::new();
602        if !stdout_str.is_empty() {
603            output.push_str(stdout_str);
604        }
605        if !stderr_str.is_empty() {
606            if !output.is_empty() {
607                output.push('\n');
608            }
609            output.push_str(stderr_str);
610        }
611
612        if !output.is_empty() {
613            let truncation = truncate::truncate_head(&output, &TruncationOptions::default());
614            output = truncation.content;
615        }
616
617        output.push_str(&format!("\n\n{}", error_msg));
618        output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
619        output
620    }
621
622    /// Execute a command using tokio::process::Command with full feature support.
623    async fn run_command(
624        root_dir: &Path,
625        command: &str,
626        cwd: Option<&str>,
627        env: Option<&serde_json::Map<String, Value>>,
628        timeout_secs: Option<u64>,
629        progress_cb: &Option<ProgressCallback>,
630        mut signal: Option<oneshot::Receiver<()>>,
631    ) -> Result<AgentToolResult, ToolError> {
632        if let Some(cb) = progress_cb {
633            cb(format!("Executing: {}", command));
634        }
635
636        let timeout = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
637        let start = Instant::now();
638
639        // Resolve working directory
640        let work_dir = match cwd {
641            Some(dir) if !dir.is_empty() => {
642                let validated = validate_cwd(dir, Some(root_dir))?;
643                Some(validated.to_string_lossy().to_string())
644            }
645            _ => Some(root_dir.to_string_lossy().to_string()),
646        };
647
648        // Build the command
649        let mut cmd = Self::build_shell_command(command, &work_dir, env);
650
651        // Spawn the child process
652        let mut child = cmd
653            .spawn()
654            .map_err(|e| format!("Failed to spawn command: {}", e))?;
655
656        // Take stdout and stderr handles for separate capture
657        let mut stdout_pipe = child
658            .stdout
659            .take()
660            .ok_or_else(|| "Failed to capture stdout".to_string())?;
661        let mut stderr_pipe = child
662            .stderr
663            .take()
664            .ok_or_else(|| "Failed to capture stderr".to_string())?;
665
666        // Read stdout and stderr concurrently
667        let stdout_handle = tokio::spawn(async move {
668            let mut buf = Vec::new();
669            let _ = stdout_pipe.read_to_end(&mut buf).await;
670            buf
671        });
672        let stderr_handle = tokio::spawn(async move {
673            let mut buf = Vec::new();
674            let _ = stderr_pipe.read_to_end(&mut buf).await;
675            buf
676        });
677
678        // Wait for the process with timeout and signal handling
679        let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
680
681        let elapsed = start.elapsed();
682
683        // Collect stdout and stderr
684        let stdout_bytes = stdout_handle.await.unwrap_or_default();
685        let stderr_bytes = stderr_handle.await.unwrap_or_default();
686
687        let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
688        let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
689
690        if let Some(cb) = progress_cb {
691            cb(format!(
692                "Process completed in {}",
693                Self::format_duration(elapsed)
694            ));
695        }
696
697        match result {
698            Ok(status) => {
699                let exit_code = status.code();
700                if let Some(code) = exit_code
701                    && let Some(cb) = progress_cb
702                {
703                    cb(format!("Process exited with code {}", code));
704                }
705                let combined = if stderr_str.is_empty() {
706                    stdout_str.clone()
707                } else if stdout_str.is_empty() {
708                    stderr_str.clone()
709                } else {
710                    format!("{}\n{}", stdout_str, stderr_str)
711                };
712
713                let security_warning = is_dangerous_command(command);
714
715                let truncation = truncate::truncate_head(
716                    if combined.is_empty() {
717                        "(no output)"
718                    } else {
719                        &combined
720                    },
721                    &TruncationOptions::default(),
722                );
723
724                let mut output = Self::build_output(&truncation, elapsed, exit_code);
725
726                if let Some(ref warning) = security_warning {
727                    output.push_str(&format!("\n{}", warning));
728                }
729
730                if status.success() {
731                    Ok(AgentToolResult::success(output))
732                } else {
733                    Ok(AgentToolResult::error(output))
734                }
735            }
736            Err(e) => {
737                let output = Self::format_error_output(&stdout_str, &stderr_str, &e, elapsed);
738                Ok(AgentToolResult::error(output))
739            }
740        }
741    }
742}
743
744impl Default for BashTool {
745    fn default() -> Self {
746        Self::new()
747    }
748}
749
750#[async_trait]
751impl AgentTool for BashTool {
752    fn name(&self) -> &str {
753        "bash"
754    }
755
756    fn label(&self) -> &str {
757        "Bash"
758    }
759
760    fn essential(&self) -> bool {
761        true
762    }
763    fn description(&self) -> &str {
764        "Execute a bash command in a shell. Returns stdout and stderr. \
765         Output is truncated to 2000 lines or 50KB (whichever is hit first). \
766         Set timeout to limit execution time."
767    }
768
769    fn parameters_schema(&self) -> Value {
770        json!({
771            "type": "object",
772            "properties": {
773                "command": {
774                    "type": "string",
775                    "description": "The bash command to execute"
776                },
777                "timeout": {
778                    "type": "integer",
779                    "description": "Timeout in seconds (default: 120)",
780                    "default": 120
781                },
782                "cwd": {
783                    "type": "string",
784                    "description": "Working directory for the command (optional)"
785                },
786                "env": {
787                    "type": "object",
788                    "description": "Environment variables as key-value pairs (optional)",
789                    "additionalProperties": {
790                        "type": "string"
791                    }
792                }
793            },
794            "required": ["command"]
795        })
796    }
797
798    async fn execute(
799        &self,
800        _tool_call_id: &str,
801        params: Value,
802        signal: Option<oneshot::Receiver<()>>,
803        ctx: &ToolContext,
804    ) -> Result<AgentToolResult, ToolError> {
805        let command = params
806            .get("command")
807            .and_then(|v: &Value| v.as_str())
808            .ok_or_else(|| "Missing required parameter: command".to_string())?;
809
810        // F-10 (audit 2026-06-21): in strict mode (OXICODE_STRICT_BASH=1) refuse
811        // commands that match `is_dangerous_command` patterns outright
812        // instead of merely appending a warning after the fact. Without
813        // strict mode, the original "warn after execution" behavior is
814        // preserved (so existing users see no change). Strict mode is
815        // opt-in because some legitimate operations match the heuristic
816        // (e.g. `cat /etc/passwd` is sometimes needed to inspect user
817        // identity); making it opt-in preserves backward compatibility
818        // while giving security-conscious deployments a hard block.
819        // F-10 (audit 2026-06-21): collapsed `if` so the two predicates
820        // (`OXICODE_STRICT_BASH=1` AND a dangerous-pattern match) live on a
821        // single line — clippy::collapsible_if flagged the original form.
822        if std::env::var_os("OXICODE_STRICT_BASH").as_deref() == Some(std::ffi::OsStr::new("1"))
823            && let Some(reason) = is_dangerous_command(command)
824        {
825            return Err(format!(
826                "OXICODE_STRICT_BASH=1 blocked dangerous command: {reason}"
827            ));
828        }
829
830        let cwd = params.get("cwd").and_then(|v: &Value| v.as_str());
831        let timeout = params.get("timeout").and_then(|v: &Value| v.as_u64());
832        let env = params.get("env").and_then(|v: &Value| v.as_object());
833
834        // SAFETY: a poisoned lock means the previous holder panicked while
835        // holding it — a real bug that must surface, not be swallowed. The
836        // tool fails closed rather than proceeding with inconsistent state.
837        #[allow(clippy::expect_used)]
838        let progress_cb = self
839            .progress_callback
840            .lock()
841            .expect("progress callback lock poisoned")
842            .clone();
843
844        // Use root_dir if set, else ctx.root()
845        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
846
847        // F-9 (audit 2026-08-24): PTY-backed bash execution preserves ANSI
848        // SGR color sequences in command output. Off by default; opt-in via
849        // `OXICODE_BASH_PTY=1` (the matching `bash_pty` setting in
850        // oxicode-cli is informational — the agent tool cannot see cli
851        // settings today; the env var is the only live override).
852        //
853        // Stderr is merged into stdout inside the PTY command, so the
854        // existing combined-output / truncation / timing pipeline still
855        // works unchanged.
856        #[cfg(unix)]
857        if std::env::var_os("OXICODE_BASH_PTY").as_deref() == Some(std::ffi::OsStr::new("1")) {
858            let work_dir = match cwd {
859                Some(dir) if !dir.is_empty() => validate_cwd(dir, Some(root))?,
860                _ => root.to_path_buf(),
861            };
862            let timeout_secs = timeout.unwrap_or(DEFAULT_TIMEOUT_SECS);
863            let start = Instant::now();
864            if let Some(cb) = &progress_cb {
865                cb(format!("Executing (pty): {}", command));
866            }
867            // Convert optional abort into a concrete oneshot::Receiver<()>.
868            // If the caller didn't provide one, create a fresh channel and
869            // immediately drop the sender so the receiver reports Closed
870            // (treated as "never aborts" by the bridge thread).
871            let abort_rx = match signal {
872                Some(rx) => rx,
873                None => {
874                    let (tx, rx) = oneshot::channel();
875                    drop(tx);
876                    rx
877                }
878            };
879            let outcome = run_in_pty(
880                command,
881                &work_dir,
882                Duration::from_secs(timeout_secs),
883                abort_rx,
884            )
885            .await;
886            let elapsed = start.elapsed();
887            if let Some(cb) = &progress_cb {
888                cb(format!(
889                    "Process (pty) completed in {}",
890                    Self::format_duration(elapsed)
891                ));
892            }
893            return match outcome {
894                Ok(o) => {
895                    let combined = if o.output.is_empty() {
896                        "(no output)".to_string()
897                    } else {
898                        o.output
899                    };
900                    let truncation =
901                        truncate::truncate_head(&combined, &TruncationOptions::default());
902                    let mut output = Self::build_output(&truncation, elapsed, o.exit_code);
903                    if let Some(reason) = is_dangerous_command(command) {
904                        output.push_str(&format!("\n{}", reason));
905                    }
906                    if o.exit_code == Some(0) {
907                        Ok(AgentToolResult::success(output))
908                    } else {
909                        Ok(AgentToolResult::error(output))
910                    }
911                }
912                Err(e) => {
913                    let mut output = format!("\n\n{}", e);
914                    output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
915                    Ok(AgentToolResult::error(output))
916                }
917            };
918        }
919
920        Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
921    }
922
923    fn on_progress(&self, callback: ProgressCallback) {
924        let cb = self.progress_callback.clone();
925        // SAFETY: a poisoned lock means the previous holder panicked while
926        // holding it — a real bug that must surface, not be swallowed.
927        #[allow(clippy::expect_used)]
928        let mut guard = cb.lock().expect("progress callback lock poisoned");
929        *guard = Some(callback);
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    fn make_params(command: &str) -> Value {
938        json!({ "command": command })
939    }
940
941    fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
942        json!({ "command": command, "timeout": timeout })
943    }
944
945    fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
946        json!({ "command": command, "cwd": cwd })
947    }
948
949    fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
950        json!({ "command": command, "env": env })
951    }
952
953    #[tokio::test]
954    async fn test_simple_command() {
955        let tool = BashTool::new();
956        let result = tool
957            .execute(
958                "test-1",
959                make_params("echo hello"),
960                None,
961                &ToolContext::default(),
962            )
963            .await
964            .unwrap();
965        assert!(result.success);
966        assert!(result.output.contains("hello"));
967    }
968
969    #[tokio::test]
970    async fn test_command_with_args() {
971        let tool = BashTool::new();
972        let result = tool
973            .execute(
974                "test-2",
975                make_params("echo hello world"),
976                None,
977                &ToolContext::default(),
978            )
979            .await
980            .unwrap();
981        assert!(result.success);
982        assert!(result.output.contains("hello world"));
983    }
984
985    #[tokio::test]
986    async fn test_failed_command() {
987        let tool = BashTool::new();
988        let result = tool
989            .execute(
990                "test-3",
991                make_params("exit 1"),
992                None,
993                &ToolContext::default(),
994            )
995            .await
996            .unwrap();
997        assert!(!result.success);
998        assert!(result.output.contains("exited with code 1"));
999    }
1000
1001    #[tokio::test]
1002    async fn test_missing_command_param() {
1003        let tool = BashTool::new();
1004        let result = tool
1005            .execute("test-4", json!({}), None, &ToolContext::default())
1006            .await;
1007        assert!(result.is_err());
1008        assert!(
1009            result
1010                .unwrap_err()
1011                .contains("Missing required parameter: command")
1012        );
1013    }
1014
1015    #[tokio::test]
1016    async fn test_no_output() {
1017        let tool = BashTool::new();
1018        let result = tool
1019            .execute("test-5", make_params("true"), None, &ToolContext::default())
1020            .await
1021            .unwrap();
1022        assert!(result.success);
1023        assert!(result.output.contains("(no output)"));
1024    }
1025
1026    #[tokio::test]
1027    async fn test_stderr_capture() {
1028        let tool = BashTool::new();
1029        let result = tool
1030            .execute(
1031                "test-6",
1032                make_params("echo error_msg >&2"),
1033                None,
1034                &ToolContext::default(),
1035            )
1036            .await
1037            .unwrap();
1038        assert!(result.success);
1039        assert!(result.output.contains("error_msg"));
1040    }
1041
1042    #[tokio::test]
1043    async fn test_timeout_kills_process() {
1044        let tool = BashTool::new();
1045        let result = tool
1046            .execute(
1047                "test-7",
1048                make_params_with_timeout("sleep 300", 1),
1049                None,
1050                &ToolContext::default(),
1051            )
1052            .await
1053            .unwrap();
1054        assert!(!result.success);
1055        assert!(result.output.contains("timed out"));
1056    }
1057
1058    #[tokio::test]
1059    async fn test_timeout_default() {
1060        // Verify default timeout is 120 seconds by checking the parameter schema
1061        let tool = BashTool::new();
1062        let schema = tool.parameters_schema();
1063        assert_eq!(schema["properties"]["timeout"]["default"], 120);
1064    }
1065
1066    #[tokio::test]
1067    async fn test_working_directory() {
1068        let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
1069        let result = tool
1070            .execute(
1071                "test-8",
1072                make_params_with_cwd("pwd", "/tmp"),
1073                None,
1074                &ToolContext::default(),
1075            )
1076            .await
1077            .unwrap();
1078        assert!(result.success);
1079        assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
1080    }
1081
1082    #[tokio::test]
1083    async fn test_working_directory_nonexistent() {
1084        let tool = BashTool::new();
1085        let result = tool
1086            .execute(
1087                "test-9",
1088                make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
1089                None,
1090                &ToolContext::default(),
1091            )
1092            .await;
1093        assert!(result.is_err());
1094        assert!(result.unwrap_err().contains("does not exist"));
1095    }
1096
1097    #[tokio::test]
1098    async fn test_working_directory_traversal() {
1099        let tool = BashTool::new();
1100        let result = tool
1101            .execute(
1102                "test-10",
1103                make_params_with_cwd("echo hi", "/tmp/../etc"),
1104                None,
1105                &ToolContext::default(),
1106            )
1107            .await;
1108        assert!(result.is_err());
1109        assert!(result.unwrap_err().contains("Path traversal"));
1110    }
1111
1112    #[tokio::test]
1113    async fn test_env_variables() {
1114        let tool = BashTool::new();
1115        let result = tool
1116            .execute(
1117                "test-11",
1118                make_params_with_env(
1119                    "echo $OXICODE_TEST_VAR",
1120                    json!({ "OXICODE_TEST_VAR": "hello_from_env" }),
1121                ),
1122                None,
1123                &ToolContext::default(),
1124            )
1125            .await
1126            .unwrap();
1127        assert!(result.success);
1128        assert!(result.output.contains("hello_from_env"));
1129    }
1130
1131    #[tokio::test]
1132    async fn test_env_variables_multiple() {
1133        let tool = BashTool::new();
1134        let result = tool
1135            .execute(
1136                "test-12",
1137                make_params_with_env(
1138                    "echo $OXICODE_A $OXICODE_B",
1139                    json!({ "OXICODE_A": "first", "OXICODE_B": "second" }),
1140                ),
1141                None,
1142                &ToolContext::default(),
1143            )
1144            .await
1145            .unwrap();
1146        assert!(result.success);
1147        assert!(result.output.contains("first second"));
1148    }
1149
1150    #[tokio::test]
1151    async fn test_duration_timing() {
1152        let tool = BashTool::new();
1153        let result = tool
1154            .execute(
1155                "test-13",
1156                make_params("sleep 0.1 && echo done"),
1157                None,
1158                &ToolContext::default(),
1159            )
1160            .await
1161            .unwrap();
1162        assert!(result.success);
1163        assert!(result.output.contains("Took "));
1164        assert!(result.output.contains("s")); // Should contain seconds
1165    }
1166
1167    #[tokio::test]
1168    async fn test_combined_stdout_stderr() {
1169        let tool = BashTool::new();
1170        let result = tool
1171            .execute(
1172                "test",
1173                make_params("echo stdout_msg; echo stderr_msg >&2"),
1174                None,
1175                &ToolContext::default(),
1176            )
1177            .await
1178            .unwrap();
1179        assert!(result.success);
1180        assert!(result.output.contains("stdout_msg"));
1181        assert!(result.output.contains("stderr_msg"));
1182    }
1183
1184    #[tokio::test]
1185    async fn test_output_truncation() {
1186        let tool = BashTool::new();
1187        // Generate more than 2000 lines to trigger truncation
1188        let result = tool
1189            .execute(
1190                "test-15",
1191                make_params("seq 1 3000"),
1192                None,
1193                &ToolContext::default(),
1194            )
1195            .await
1196            .unwrap();
1197        assert!(result.success);
1198        assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
1199    }
1200
1201    #[tokio::test]
1202    async fn test_signal_aborts_process() {
1203        let tool = BashTool::new();
1204        let (tx, rx) = oneshot::channel();
1205
1206        // Spawn a task that will send the abort signal after a short delay
1207        tokio::spawn(async move {
1208            tokio::time::sleep(Duration::from_millis(100)).await;
1209            let _ = tx.send(());
1210        });
1211
1212        let result = tool
1213            .execute(
1214                "test-16",
1215                make_params("sleep 300"),
1216                Some(rx),
1217                &ToolContext::default(),
1218            )
1219            .await
1220            .unwrap();
1221        assert!(!result.success);
1222        assert!(result.output.contains("aborted"));
1223    }
1224
1225    #[tokio::test]
1226    async fn test_parameters_schema() {
1227        let tool = BashTool::new();
1228        let schema = tool.parameters_schema();
1229
1230        // Check required fields
1231        let required = schema["required"].as_array().unwrap();
1232        assert!(required.iter().any(|r| r.as_str() == Some("command")));
1233
1234        // Check all expected properties exist
1235        let props = schema["properties"].as_object().unwrap();
1236        assert!(props.contains_key("command"));
1237        assert!(props.contains_key("timeout"));
1238        assert!(props.contains_key("cwd"));
1239        assert!(props.contains_key("env"));
1240
1241        // Check types
1242        assert_eq!(props["command"]["type"], "string");
1243        assert_eq!(props["timeout"]["type"], "integer");
1244        assert_eq!(props["cwd"]["type"], "string");
1245        assert_eq!(props["env"]["type"], "object");
1246    }
1247
1248    #[tokio::test]
1249    async fn test_multiline_output() {
1250        let tool = BashTool::new();
1251        let result = tool
1252            .execute(
1253                "test",
1254                make_params("echo line1 && echo line2 && echo line3"),
1255                None,
1256                &ToolContext::default(),
1257            )
1258            .await
1259            .unwrap();
1260        assert!(result.success);
1261        assert!(result.output.contains("line1"));
1262        assert!(result.output.contains("line2"));
1263        assert!(result.output.contains("line3"));
1264    }
1265
1266    #[tokio::test]
1267    async fn test_format_duration() {
1268        assert_eq!(
1269            BashTool::format_duration(Duration::from_millis(500)),
1270            "0.5s"
1271        );
1272        assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
1273        assert_eq!(
1274            BashTool::format_duration(Duration::from_secs(65)),
1275            "1m 5.0s"
1276        );
1277        assert_eq!(
1278            BashTool::format_duration(Duration::from_secs(120)),
1279            "2m 0.0s"
1280        );
1281    }
1282
1283    // ── F-10 regression: OXICODE_STRICT_BASH=1 blocks dangerous commands ─────
1284
1285    /// With `OXICODE_STRICT_BASH=1`, a command matching `is_dangerous_command`
1286    /// must be refused BEFORE `sh -c` runs (audit finding F-10). Without
1287    /// the gate, the agent receives an `AgentToolResult::success` with a
1288    /// trailing warning — i.e. the dangerous command ran and only the
1289    /// post-hoc warning tried to discourage it.
1290    #[tokio::test]
1291    async fn test_strict_bash_blocks_pipe_to_shell() {
1292        // SAFETY: env mutation under test (Rust 2024 makes this unsafe);
1293        // acceptable for a single isolated #[tokio::test].
1294        unsafe {
1295            std::env::set_var("OXICODE_STRICT_BASH", "1");
1296        }
1297        let tool = BashTool::new();
1298        let result = tool
1299            .execute(
1300                "test-strict",
1301                make_params("echo hi | sh"),
1302                None,
1303                &ToolContext::default(),
1304            )
1305            .await;
1306        unsafe {
1307            std::env::remove_var("OXICODE_STRICT_BASH");
1308        }
1309        // The command should be refused — `Err(...)` with the audit reason.
1310        let err = result.expect_err("strict mode must refuse `| sh` commands");
1311        assert!(
1312            err.contains("OXICODE_STRICT_BASH") && err.contains("pipe to shell"),
1313            "unexpected error: {err}"
1314        );
1315    }
1316
1317    /// Without `OXICODE_STRICT_BASH`, the legacy "warn after execution"
1318    /// behavior is preserved (backward compatible).
1319    #[tokio::test]
1320    async fn test_strict_bash_off_preserves_warning_behavior() {
1321        // Ensure strict mode is off.
1322        unsafe {
1323            std::env::remove_var("OXICODE_STRICT_BASH");
1324        }
1325        let tool = BashTool::new();
1326        let result = tool
1327            .execute(
1328                "test-lenient",
1329                make_params("echo hi"),
1330                None,
1331                &ToolContext::default(),
1332            )
1333            .await;
1334        let r = result.expect("non-dangerous command must succeed when strict is off");
1335        assert!(r.success, "echo hi must succeed: {}", r.output);
1336        assert!(!r.output.contains("OXICODE_STRICT_BASH"));
1337    }
1338
1339    // ── F-9: PTY-backed bash execution preserves ANSI SGR, drop CSI/OSC ─
1340
1341    /// Run a command through a real PTY and assert that the SGR color sequence
1342    /// `\x1b[31m` survives the capture. Pipe-based execution strips these bytes;
1343    /// the PTY path is the only way to keep colorized output for downstream
1344    /// renderers that respect SGR.
1345    #[cfg(unix)]
1346    #[tokio::test]
1347    async fn pty_preserves_color_codes() {
1348        let cwd = std::env::current_dir().expect("current_dir");
1349        let (_tx, rx) = oneshot::channel::<()>();
1350        let outcome = run_in_pty(
1351            "printf '\\x1b[31mred\\x1b[0m'",
1352            &cwd,
1353            Duration::from_secs(10),
1354            rx,
1355        )
1356        .await
1357        .expect("run_in_pty");
1358        assert!(
1359            outcome.output.contains("\x1b[31m"),
1360            "PTY output must preserve the \\x1b[31m SGR escape; got: {:?}",
1361            outcome.output
1362        );
1363        assert!(
1364            outcome.output.contains("red"),
1365            "PTY output must contain the printed text; got: {:?}",
1366            outcome.output
1367        );
1368    }
1369
1370    /// Run a command that emits a CSI screen-clear escape and assert that the
1371    /// filter strips the control sequence while preserving the text. The
1372    /// filter keeps SGR (`ESC [ ... m`) and drops other CSI / OSC sequences
1373    /// so that cursor motion and screen-control bytes don't leak into the
1374    /// agent transcript.
1375    #[cfg(unix)]
1376    #[tokio::test]
1377    async fn pty_strips_cursor_motion() {
1378        let cwd = std::env::current_dir().expect("current_dir");
1379        let (_tx, rx) = oneshot::channel::<()>();
1380        let outcome = run_in_pty(
1381            "printf '\\x1b[2Jhello\\x1b[H'",
1382            &cwd,
1383            Duration::from_secs(10),
1384            rx,
1385        )
1386        .await
1387        .expect("run_in_pty");
1388        assert!(
1389            !outcome.output.contains("\x1b[2J"),
1390            "PTY output must drop the screen-clear CSI; got: {:?}",
1391            outcome.output
1392        );
1393        assert!(
1394            !outcome.output.contains("\x1b[H"),
1395            "PTY output must drop the cursor-home CSI; got: {:?}",
1396            outcome.output
1397        );
1398        assert!(
1399            outcome.output.contains("hello"),
1400            "PTY output must contain the printed text; got: {:?}",
1401            outcome.output
1402        );
1403    }
1404
1405    // ── F-9 round 2: process-group kill on timeout ─────────────────
1406
1407    /// Assert that run_in_pty respects a deadline. When the user command
1408    /// outlives the deadline the function returns Err(...) AND returns
1409    /// promptly — a hung child holding the PTY slave fd would hang
1410    /// read_thread.join() indefinitely.
1411    #[cfg(unix)]
1412    #[tokio::test]
1413    async fn pty_timeout_kills_process_group_promptly() {
1414        let cwd = std::env::current_dir().expect("current_dir");
1415        let (_tx, rx) = oneshot::channel::<()>();
1416        let start = std::time::Instant::now();
1417        let result = run_in_pty("sleep 30", &cwd, Duration::from_millis(200), rx).await;
1418        let elapsed = start.elapsed();
1419        let err = result.expect_err("sleep 30 must time out within 200ms budget");
1420        // Bounded: must return within 5 seconds even if the process group
1421        // kill fails for any reason.
1422        assert!(
1423            elapsed < Duration::from_secs(5),
1424            "run_in_pty took {elapsed:?} after timeout — likely a hang on join"
1425        );
1426        assert!(
1427            err.contains("timed out") || err.contains("Timeout"),
1428            "error must mention the timeout: {err}"
1429        );
1430    }
1431
1432    /// Assert the abort signal path tears the PTY child down. The abort
1433    /// signal fires immediately, the PTY group is killed, run_in_pty
1434    /// returns Err(...) promptly with an `aborted` message.
1435    #[cfg(unix)]
1436    #[tokio::test]
1437    async fn pty_abort_signal_tears_down_promptly() {
1438        let cwd = std::env::current_dir().expect("current_dir");
1439        let (tx, rx) = oneshot::channel::<()>();
1440        let start = std::time::Instant::now();
1441        // Fire the abort on a std::thread (not tokio::spawn) — run_in_pty
1442        // is async but its body is purely blocking, so a tokio::spawn-ed
1443        // task wouldn't be polled until run_in_pty returns to an await
1444        // point. A real OS thread sends independently.
1445        std::thread::spawn(move || {
1446            std::thread::sleep(Duration::from_millis(100));
1447            let _ = tx.send(());
1448        });
1449        let result = run_in_pty("sleep 30", &cwd, Duration::from_secs(30), rx).await;
1450        let elapsed = start.elapsed();
1451        let err = result.expect_err("sleep 30 must be aborted by signal");
1452        assert!(
1453            elapsed < Duration::from_secs(5),
1454            "abort took {elapsed:?} — likely a hang on join"
1455        );
1456        assert!(
1457            err.contains("aborted") || err.contains("Aborted"),
1458            "error must mention the abort: {err}"
1459        );
1460    }
1461}