Skip to main content

oxi_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::{json, Value};
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/// Default timeout in seconds
165const DEFAULT_TIMEOUT_SECS: u64 = 120;
166
167/// BashTool.
168pub struct BashTool {
169    root_dir: Option<PathBuf>,
170    progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
171}
172
173impl BashTool {
174    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
175    pub fn new() -> Self {
176        Self {
177            root_dir: None,
178            progress_callback: Arc::new(std::sync::Mutex::new(None)),
179        }
180    }
181
182    /// Create with a specific working directory (overrides ToolContext).
183    pub fn with_cwd(cwd: PathBuf) -> Self {
184        Self {
185            root_dir: Some(cwd),
186            progress_callback: Arc::new(std::sync::Mutex::new(None)),
187        }
188    }
189
190    /// Format a duration for human-readable display
191    fn format_duration(duration: Duration) -> String {
192        let secs = duration.as_secs();
193        let millis = duration.subsec_millis();
194        if secs >= 60 {
195            let mins = secs / 60;
196            let remain_secs = secs % 60;
197            format!(
198                "{}m {:.1}s",
199                mins,
200                remain_secs as f64 + millis as f64 / 1000.0
201            )
202        } else {
203            format!("{:.1}s", secs as f64 + millis as f64 / 1000.0)
204        }
205    }
206
207    /// Build the output string with optional truncation notice and timing.
208    fn build_output(
209        truncation: &TruncationResult,
210        elapsed: Duration,
211        exit_code: Option<i32>,
212    ) -> String {
213        let mut output = truncation.content.clone();
214
215        // Append truncation notice if output was truncated
216        if truncation.truncated {
217            let notice = match truncation.truncated_by {
218                truncate::TruncatedBy::Lines => format!(
219                    "\n\n[Truncated: showing {} of {} lines. {} bytes remaining]",
220                    truncation.output_lines,
221                    truncation.total_lines,
222                    truncate::format_bytes(
223                        truncation
224                            .total_bytes
225                            .saturating_sub(truncation.output_bytes)
226                    )
227                ),
228                truncate::TruncatedBy::Bytes => format!(
229                    "\n\n[Truncated: {} lines shown ({} byte limit). Total was {} lines, {}]",
230                    truncation.output_lines,
231                    truncate::format_bytes(truncate::DEFAULT_MAX_BYTES),
232                    truncation.total_lines,
233                    truncate::format_bytes(truncation.total_bytes)
234                ),
235                truncate::TruncatedBy::None => String::new(),
236            };
237            output.push_str(&notice);
238        }
239
240        // Append exit code for non-zero
241        if let Some(code) = exit_code {
242            if code != 0 {
243                output.push_str(&format!("\n\nCommand exited with code {}", code));
244            }
245        }
246
247        // Append timing
248        output.push_str(&format!("\n\nTook {}", Self::format_duration(elapsed)));
249
250        output
251    }
252
253    /// Wait for a child process with timeout and optional abort signal.
254    async fn wait_with_timeout_and_signal(
255        child: &mut tokio::process::Child,
256        timeout: u64,
257        signal: &mut Option<oneshot::Receiver<()>>,
258    ) -> Result<std::process::ExitStatus, String> {
259        let timeout_duration = Duration::from_secs(timeout);
260
261        tokio::select! {
262            status = child.wait() => {
263                status.map_err(|e| format!("Failed to wait for process: {}", e))
264            }
265            _ = tokio::time::sleep(timeout_duration) => {
266                Self::kill_process_group(child).await;
267                Err(format!("Command timed out after {} seconds", timeout))
268            }
269            _ = async {
270                match signal {
271                    Some(rx) => { let _ = rx.await; }
272                    None => std::future::pending::<()>().await,
273                }
274            } => {
275                Self::kill_process_group(child).await;
276                Err("Command aborted".to_string())
277            }
278        }
279    }
280
281    /// Build the shell command with working directory and environment variables.
282    fn build_shell_command(
283        command: &str,
284        work_dir: &Option<String>,
285        env: Option<&serde_json::Map<String, Value>>,
286    ) -> Command {
287        let mut cmd = Command::new("sh");
288        cmd.arg("-c")
289            .arg(command)
290            .stdout(std::process::Stdio::piped())
291            .stderr(std::process::Stdio::piped())
292            .process_group(0);
293
294        if let Some(ref dir) = work_dir {
295            cmd.current_dir(dir);
296        }
297
298        if let Some(env_map) = env {
299            for (key, val) in env_map {
300                if BLOCKED_ENV_VARS
301                    .iter()
302                    .any(|blocked| blocked.eq_ignore_ascii_case(key))
303                {
304                    continue;
305                }
306                if let Some(val_str) = val.as_str() {
307                    cmd.env(key, val_str);
308                }
309            }
310        }
311
312        cmd
313    }
314
315    /// Kill a process group (Unix) or fall back to child.kill().
316    async fn kill_process_group(child: &mut tokio::process::Child) {
317        #[cfg(unix)]
318        {
319            if let Some(pid) = child.id() {
320                let pgid = -(pid as i32);
321                // SAFETY: libc::kill sends SIGKILL to the process group. The negative PID
322                // targets the entire group (shell + child processes). PID comes from
323                // child.id() which is a valid running process owned by this process.
324                unsafe {
325                    libc::kill(pgid, libc::SIGKILL);
326                }
327            }
328        }
329        let _ = child.kill().await;
330        let _ = child.wait().await;
331    }
332
333    /// Format error output for timeout/abort cases.
334    fn format_error_output(
335        stdout_str: &str,
336        stderr_str: &str,
337        error_msg: &str,
338        elapsed: Duration,
339    ) -> String {
340        let mut output = String::new();
341        if !stdout_str.is_empty() {
342            output.push_str(stdout_str);
343        }
344        if !stderr_str.is_empty() {
345            if !output.is_empty() {
346                output.push('\n');
347            }
348            output.push_str(stderr_str);
349        }
350
351        if !output.is_empty() {
352            let truncation = truncate::truncate_head(&output, &TruncationOptions::default());
353            output = truncation.content;
354        }
355
356        output.push_str(&format!("\n\n{}", error_msg));
357        output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
358        output
359    }
360
361    /// Execute a command using tokio::process::Command with full feature support.
362    async fn run_command(
363        root_dir: &Path,
364        command: &str,
365        cwd: Option<&str>,
366        env: Option<&serde_json::Map<String, Value>>,
367        timeout_secs: Option<u64>,
368        progress_cb: &Option<ProgressCallback>,
369        mut signal: Option<oneshot::Receiver<()>>,
370    ) -> Result<AgentToolResult, ToolError> {
371        if let Some(cb) = progress_cb {
372            cb(format!("Executing: {}", command));
373        }
374
375        let timeout = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
376        let start = Instant::now();
377
378        // Resolve working directory
379        let work_dir = match cwd {
380            Some(dir) if !dir.is_empty() => {
381                let validated = validate_cwd(dir, Some(root_dir))?;
382                Some(validated.to_string_lossy().to_string())
383            }
384            _ => Some(root_dir.to_string_lossy().to_string()),
385        };
386
387        // Build the command
388        let mut cmd = Self::build_shell_command(command, &work_dir, env);
389
390        // Spawn the child process
391        let mut child = cmd
392            .spawn()
393            .map_err(|e| format!("Failed to spawn command: {}", e))?;
394
395        // Take stdout and stderr handles for separate capture
396        let mut stdout_pipe = child
397            .stdout
398            .take()
399            .ok_or_else(|| "Failed to capture stdout".to_string())?;
400        let mut stderr_pipe = child
401            .stderr
402            .take()
403            .ok_or_else(|| "Failed to capture stderr".to_string())?;
404
405        // Read stdout and stderr concurrently
406        let stdout_handle = tokio::spawn(async move {
407            let mut buf = Vec::new();
408            let _ = stdout_pipe.read_to_end(&mut buf).await;
409            buf
410        });
411        let stderr_handle = tokio::spawn(async move {
412            let mut buf = Vec::new();
413            let _ = stderr_pipe.read_to_end(&mut buf).await;
414            buf
415        });
416
417        // Wait for the process with timeout and signal handling
418        let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
419
420        let elapsed = start.elapsed();
421
422        // Collect stdout and stderr
423        let stdout_bytes = stdout_handle.await.unwrap_or_default();
424        let stderr_bytes = stderr_handle.await.unwrap_or_default();
425
426        let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
427        let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
428
429        if let Some(cb) = progress_cb {
430            cb(format!(
431                "Process completed in {}",
432                Self::format_duration(elapsed)
433            ));
434        }
435
436        match result {
437            Ok(status) => {
438                let exit_code = status.code();
439                if let Some(code) = exit_code {
440                    if let Some(cb) = progress_cb {
441                        cb(format!("Process exited with code {}", code));
442                    }
443                }
444                let combined = if stderr_str.is_empty() {
445                    stdout_str.clone()
446                } else if stdout_str.is_empty() {
447                    stderr_str.clone()
448                } else {
449                    format!("{}\n{}", stdout_str, stderr_str)
450                };
451
452                let security_warning = is_dangerous_command(command);
453
454                let truncation = truncate::truncate_head(
455                    if combined.is_empty() {
456                        "(no output)"
457                    } else {
458                        &combined
459                    },
460                    &TruncationOptions::default(),
461                );
462
463                let mut output = Self::build_output(&truncation, elapsed, exit_code);
464
465                if let Some(ref warning) = security_warning {
466                    output.push_str(&format!("\n{}", warning));
467                }
468
469                if status.success() {
470                    Ok(AgentToolResult::success(output))
471                } else {
472                    Ok(AgentToolResult::error(output))
473                }
474            }
475            Err(e) => {
476                let output = Self::format_error_output(&stdout_str, &stderr_str, &e, elapsed);
477                Ok(AgentToolResult::error(output))
478            }
479        }
480    }
481}
482
483impl Default for BashTool {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489#[async_trait]
490impl AgentTool for BashTool {
491    fn name(&self) -> &str {
492        "bash"
493    }
494
495    fn label(&self) -> &str {
496        "Bash"
497    }
498
499    fn essential(&self) -> bool {
500        true
501    }
502    fn description(&self) -> &str {
503        "Execute a bash command in a shell. Returns stdout and stderr. \
504         Output is truncated to 2000 lines or 50KB (whichever is hit first). \
505         Set timeout to limit execution time."
506    }
507
508    fn parameters_schema(&self) -> Value {
509        json!({
510            "type": "object",
511            "properties": {
512                "command": {
513                    "type": "string",
514                    "description": "The bash command to execute"
515                },
516                "timeout": {
517                    "type": "integer",
518                    "description": "Timeout in seconds (default: 120)",
519                    "default": 120
520                },
521                "cwd": {
522                    "type": "string",
523                    "description": "Working directory for the command (optional)"
524                },
525                "env": {
526                    "type": "object",
527                    "description": "Environment variables as key-value pairs (optional)",
528                    "additionalProperties": {
529                        "type": "string"
530                    }
531                }
532            },
533            "required": ["command"]
534        })
535    }
536
537    async fn execute(
538        &self,
539        _tool_call_id: &str,
540        params: Value,
541        signal: Option<oneshot::Receiver<()>>,
542        ctx: &ToolContext,
543    ) -> Result<AgentToolResult, ToolError> {
544        let command = params
545            .get("command")
546            .and_then(|v: &Value| v.as_str())
547            .ok_or_else(|| "Missing required parameter: command".to_string())?;
548
549        let cwd = params.get("cwd").and_then(|v: &Value| v.as_str());
550        let timeout = params.get("timeout").and_then(|v: &Value| v.as_u64());
551        let env = params.get("env").and_then(|v: &Value| v.as_object());
552
553        let progress_cb = self
554            .progress_callback
555            .lock()
556            .expect("progress callback lock poisoned")
557            .clone();
558
559        // Use root_dir if set, else ctx.root()
560        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
561
562        Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
563    }
564
565    fn on_progress(&self, callback: ProgressCallback) {
566        let cb = self.progress_callback.clone();
567        let mut guard = cb.lock().expect("progress callback lock poisoned");
568        *guard = Some(callback);
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    fn make_params(command: &str) -> Value {
577        json!({ "command": command })
578    }
579
580    fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
581        json!({ "command": command, "timeout": timeout })
582    }
583
584    fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
585        json!({ "command": command, "cwd": cwd })
586    }
587
588    fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
589        json!({ "command": command, "env": env })
590    }
591
592    #[tokio::test]
593    async fn test_simple_command() {
594        let tool = BashTool::new();
595        let result = tool
596            .execute(
597                "test-1",
598                make_params("echo hello"),
599                None,
600                &ToolContext::default(),
601            )
602            .await
603            .unwrap();
604        assert!(result.success);
605        assert!(result.output.contains("hello"));
606    }
607
608    #[tokio::test]
609    async fn test_command_with_args() {
610        let tool = BashTool::new();
611        let result = tool
612            .execute(
613                "test-2",
614                make_params("echo hello world"),
615                None,
616                &ToolContext::default(),
617            )
618            .await
619            .unwrap();
620        assert!(result.success);
621        assert!(result.output.contains("hello world"));
622    }
623
624    #[tokio::test]
625    async fn test_failed_command() {
626        let tool = BashTool::new();
627        let result = tool
628            .execute(
629                "test-3",
630                make_params("exit 1"),
631                None,
632                &ToolContext::default(),
633            )
634            .await
635            .unwrap();
636        assert!(!result.success);
637        assert!(result.output.contains("exited with code 1"));
638    }
639
640    #[tokio::test]
641    async fn test_missing_command_param() {
642        let tool = BashTool::new();
643        let result = tool
644            .execute("test-4", json!({}), None, &ToolContext::default())
645            .await;
646        assert!(result.is_err());
647        assert!(result
648            .unwrap_err()
649            .contains("Missing required parameter: command"));
650    }
651
652    #[tokio::test]
653    async fn test_no_output() {
654        let tool = BashTool::new();
655        let result = tool
656            .execute("test-5", make_params("true"), None, &ToolContext::default())
657            .await
658            .unwrap();
659        assert!(result.success);
660        assert!(result.output.contains("(no output)"));
661    }
662
663    #[tokio::test]
664    async fn test_stderr_capture() {
665        let tool = BashTool::new();
666        let result = tool
667            .execute(
668                "test-6",
669                make_params("echo error_msg >&2"),
670                None,
671                &ToolContext::default(),
672            )
673            .await
674            .unwrap();
675        assert!(result.success);
676        assert!(result.output.contains("error_msg"));
677    }
678
679    #[tokio::test]
680    async fn test_timeout_kills_process() {
681        let tool = BashTool::new();
682        let result = tool
683            .execute(
684                "test-7",
685                make_params_with_timeout("sleep 300", 1),
686                None,
687                &ToolContext::default(),
688            )
689            .await
690            .unwrap();
691        assert!(!result.success);
692        assert!(result.output.contains("timed out"));
693    }
694
695    #[tokio::test]
696    async fn test_timeout_default() {
697        // Verify default timeout is 120 seconds by checking the parameter schema
698        let tool = BashTool::new();
699        let schema = tool.parameters_schema();
700        assert_eq!(schema["properties"]["timeout"]["default"], 120);
701    }
702
703    #[tokio::test]
704    async fn test_working_directory() {
705        let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
706        let result = tool
707            .execute(
708                "test-8",
709                make_params_with_cwd("pwd", "/tmp"),
710                None,
711                &ToolContext::default(),
712            )
713            .await
714            .unwrap();
715        assert!(result.success);
716        assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
717    }
718
719    #[tokio::test]
720    async fn test_working_directory_nonexistent() {
721        let tool = BashTool::new();
722        let result = tool
723            .execute(
724                "test-9",
725                make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
726                None,
727                &ToolContext::default(),
728            )
729            .await;
730        assert!(result.is_err());
731        assert!(result.unwrap_err().contains("does not exist"));
732    }
733
734    #[tokio::test]
735    async fn test_working_directory_traversal() {
736        let tool = BashTool::new();
737        let result = tool
738            .execute(
739                "test-10",
740                make_params_with_cwd("echo hi", "/tmp/../etc"),
741                None,
742                &ToolContext::default(),
743            )
744            .await;
745        assert!(result.is_err());
746        assert!(result.unwrap_err().contains("Path traversal"));
747    }
748
749    #[tokio::test]
750    async fn test_env_variables() {
751        let tool = BashTool::new();
752        let result = tool
753            .execute(
754                "test-11",
755                make_params_with_env(
756                    "echo $OXI_TEST_VAR",
757                    json!({ "OXI_TEST_VAR": "hello_from_env" }),
758                ),
759                None,
760                &ToolContext::default(),
761            )
762            .await
763            .unwrap();
764        assert!(result.success);
765        assert!(result.output.contains("hello_from_env"));
766    }
767
768    #[tokio::test]
769    async fn test_env_variables_multiple() {
770        let tool = BashTool::new();
771        let result = tool
772            .execute(
773                "test-12",
774                make_params_with_env(
775                    "echo $OXI_A $OXI_B",
776                    json!({ "OXI_A": "first", "OXI_B": "second" }),
777                ),
778                None,
779                &ToolContext::default(),
780            )
781            .await
782            .unwrap();
783        assert!(result.success);
784        assert!(result.output.contains("first second"));
785    }
786
787    #[tokio::test]
788    async fn test_duration_timing() {
789        let tool = BashTool::new();
790        let result = tool
791            .execute(
792                "test-13",
793                make_params("sleep 0.1 && echo done"),
794                None,
795                &ToolContext::default(),
796            )
797            .await
798            .unwrap();
799        assert!(result.success);
800        assert!(result.output.contains("Took "));
801        assert!(result.output.contains("s")); // Should contain seconds
802    }
803
804    #[tokio::test]
805    async fn test_combined_stdout_stderr() {
806        let tool = BashTool::new();
807        let result = tool
808            .execute(
809                "test",
810                make_params("echo stdout_msg; echo stderr_msg >&2"),
811                None,
812                &ToolContext::default(),
813            )
814            .await
815            .unwrap();
816        assert!(result.success);
817        assert!(result.output.contains("stdout_msg"));
818        assert!(result.output.contains("stderr_msg"));
819    }
820
821    #[tokio::test]
822    async fn test_output_truncation() {
823        let tool = BashTool::new();
824        // Generate more than 2000 lines to trigger truncation
825        let result = tool
826            .execute(
827                "test-15",
828                make_params("seq 1 3000"),
829                None,
830                &ToolContext::default(),
831            )
832            .await
833            .unwrap();
834        assert!(result.success);
835        assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
836    }
837
838    #[tokio::test]
839    async fn test_signal_aborts_process() {
840        let tool = BashTool::new();
841        let (tx, rx) = oneshot::channel();
842
843        // Spawn a task that will send the abort signal after a short delay
844        tokio::spawn(async move {
845            tokio::time::sleep(Duration::from_millis(100)).await;
846            let _ = tx.send(());
847        });
848
849        let result = tool
850            .execute(
851                "test-16",
852                make_params("sleep 300"),
853                Some(rx),
854                &ToolContext::default(),
855            )
856            .await
857            .unwrap();
858        assert!(!result.success);
859        assert!(result.output.contains("aborted"));
860    }
861
862    #[tokio::test]
863    async fn test_parameters_schema() {
864        let tool = BashTool::new();
865        let schema = tool.parameters_schema();
866
867        // Check required fields
868        let required = schema["required"].as_array().unwrap();
869        assert!(required.iter().any(|r| r.as_str() == Some("command")));
870
871        // Check all expected properties exist
872        let props = schema["properties"].as_object().unwrap();
873        assert!(props.contains_key("command"));
874        assert!(props.contains_key("timeout"));
875        assert!(props.contains_key("cwd"));
876        assert!(props.contains_key("env"));
877
878        // Check types
879        assert_eq!(props["command"]["type"], "string");
880        assert_eq!(props["timeout"]["type"], "integer");
881        assert_eq!(props["cwd"]["type"], "string");
882        assert_eq!(props["env"]["type"], "object");
883    }
884
885    #[tokio::test]
886    async fn test_multiline_output() {
887        let tool = BashTool::new();
888        let result = tool
889            .execute(
890                "test",
891                make_params("echo line1 && echo line2 && echo line3"),
892                None,
893                &ToolContext::default(),
894            )
895            .await
896            .unwrap();
897        assert!(result.success);
898        assert!(result.output.contains("line1"));
899        assert!(result.output.contains("line2"));
900        assert!(result.output.contains("line3"));
901    }
902
903    #[tokio::test]
904    async fn test_format_duration() {
905        assert_eq!(
906            BashTool::format_duration(Duration::from_millis(500)),
907            "0.5s"
908        );
909        assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
910        assert_eq!(
911            BashTool::format_duration(Duration::from_secs(65)),
912            "1m 5.0s"
913        );
914        assert_eq!(
915            BashTool::format_duration(Duration::from_secs(120)),
916            "2m 0.0s"
917        );
918    }
919}