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                unsafe {
322                    libc::kill(pgid, libc::SIGKILL);
323                }
324            }
325        }
326        let _ = child.kill().await;
327        let _ = child.wait().await;
328    }
329
330    /// Format error output for timeout/abort cases.
331    fn format_error_output(
332        stdout_str: &str,
333        stderr_str: &str,
334        error_msg: &str,
335        elapsed: Duration,
336    ) -> String {
337        let mut output = String::new();
338        if !stdout_str.is_empty() {
339            output.push_str(stdout_str);
340        }
341        if !stderr_str.is_empty() {
342            if !output.is_empty() {
343                output.push('\n');
344            }
345            output.push_str(stderr_str);
346        }
347
348        if !output.is_empty() {
349            let truncation = truncate::truncate_head(&output, &TruncationOptions::default());
350            output = truncation.content;
351        }
352
353        output.push_str(&format!("\n\n{}", error_msg));
354        output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
355        output
356    }
357
358    /// Execute a command using tokio::process::Command with full feature support.
359    async fn run_command(
360        root_dir: &Path,
361        command: &str,
362        cwd: Option<&str>,
363        env: Option<&serde_json::Map<String, Value>>,
364        timeout_secs: Option<u64>,
365        progress_cb: &Option<ProgressCallback>,
366        mut signal: Option<oneshot::Receiver<()>>,
367    ) -> Result<AgentToolResult, ToolError> {
368        if let Some(cb) = progress_cb {
369            cb(format!("Executing: {}", command));
370        }
371
372        let timeout = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
373        let start = Instant::now();
374
375        // Resolve working directory
376        let work_dir = match cwd {
377            Some(dir) if !dir.is_empty() => {
378                let validated = validate_cwd(dir, Some(root_dir))?;
379                Some(validated.to_string_lossy().to_string())
380            }
381            _ => Some(root_dir.to_string_lossy().to_string()),
382        };
383
384        // Build the command
385        let mut cmd = Self::build_shell_command(command, &work_dir, env);
386
387        // Spawn the child process
388        let mut child = cmd
389            .spawn()
390            .map_err(|e| format!("Failed to spawn command: {}", e))?;
391
392        // Take stdout and stderr handles for separate capture
393        let mut stdout_pipe = child
394            .stdout
395            .take()
396            .ok_or_else(|| "Failed to capture stdout".to_string())?;
397        let mut stderr_pipe = child
398            .stderr
399            .take()
400            .ok_or_else(|| "Failed to capture stderr".to_string())?;
401
402        // Read stdout and stderr concurrently
403        let stdout_handle = tokio::spawn(async move {
404            let mut buf = Vec::new();
405            let _ = stdout_pipe.read_to_end(&mut buf).await;
406            buf
407        });
408        let stderr_handle = tokio::spawn(async move {
409            let mut buf = Vec::new();
410            let _ = stderr_pipe.read_to_end(&mut buf).await;
411            buf
412        });
413
414        // Wait for the process with timeout and signal handling
415        let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
416
417        let elapsed = start.elapsed();
418
419        // Collect stdout and stderr
420        let stdout_bytes = stdout_handle.await.unwrap_or_default();
421        let stderr_bytes = stderr_handle.await.unwrap_or_default();
422
423        let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
424        let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
425
426        if let Some(cb) = progress_cb {
427            cb(format!(
428                "Process completed in {}",
429                Self::format_duration(elapsed)
430            ));
431        }
432
433        match result {
434            Ok(status) => {
435                let exit_code = status.code();
436                if let Some(code) = exit_code {
437                    if let Some(cb) = progress_cb {
438                        cb(format!("Process exited with code {}", code));
439                    }
440                }
441                let combined = if stderr_str.is_empty() {
442                    stdout_str.clone()
443                } else if stdout_str.is_empty() {
444                    stderr_str.clone()
445                } else {
446                    format!("{}\n{}", stdout_str, stderr_str)
447                };
448
449                let security_warning = is_dangerous_command(command);
450
451                let truncation = truncate::truncate_head(
452                    if combined.is_empty() {
453                        "(no output)"
454                    } else {
455                        &combined
456                    },
457                    &TruncationOptions::default(),
458                );
459
460                let mut output = Self::build_output(&truncation, elapsed, exit_code);
461
462                if let Some(ref warning) = security_warning {
463                    output.push_str(&format!("\n{}", warning));
464                }
465
466                if status.success() {
467                    Ok(AgentToolResult::success(output))
468                } else {
469                    Ok(AgentToolResult::error(output))
470                }
471            }
472            Err(e) => {
473                let output = Self::format_error_output(&stdout_str, &stderr_str, &e, elapsed);
474                Ok(AgentToolResult::error(output))
475            }
476        }
477    }
478}
479
480impl Default for BashTool {
481    fn default() -> Self {
482        Self::new()
483    }
484}
485
486#[async_trait]
487impl AgentTool for BashTool {
488    fn name(&self) -> &str {
489        "bash"
490    }
491
492    fn label(&self) -> &str {
493        "Bash"
494    }
495
496    fn essential(&self) -> bool {
497        true
498    }
499    fn description(&self) -> &str {
500        "Execute a bash command in a shell. Returns stdout and stderr. \
501         Output is truncated to 2000 lines or 50KB (whichever is hit first). \
502         Set timeout to limit execution time."
503    }
504
505    fn parameters_schema(&self) -> Value {
506        json!({
507            "type": "object",
508            "properties": {
509                "command": {
510                    "type": "string",
511                    "description": "The bash command to execute"
512                },
513                "timeout": {
514                    "type": "integer",
515                    "description": "Timeout in seconds (default: 120)",
516                    "default": 120
517                },
518                "cwd": {
519                    "type": "string",
520                    "description": "Working directory for the command (optional)"
521                },
522                "env": {
523                    "type": "object",
524                    "description": "Environment variables as key-value pairs (optional)",
525                    "additionalProperties": {
526                        "type": "string"
527                    }
528                }
529            },
530            "required": ["command"]
531        })
532    }
533
534    async fn execute(
535        &self,
536        _tool_call_id: &str,
537        params: Value,
538        signal: Option<oneshot::Receiver<()>>,
539        ctx: &ToolContext,
540    ) -> Result<AgentToolResult, ToolError> {
541        let command = params
542            .get("command")
543            .and_then(|v: &Value| v.as_str())
544            .ok_or_else(|| "Missing required parameter: command".to_string())?;
545
546        let cwd = params.get("cwd").and_then(|v: &Value| v.as_str());
547        let timeout = params.get("timeout").and_then(|v: &Value| v.as_u64());
548        let env = params.get("env").and_then(|v: &Value| v.as_object());
549
550        let progress_cb = self
551            .progress_callback
552            .lock()
553            .expect("progress callback lock poisoned")
554            .clone();
555
556        // Use root_dir if set, else ctx.root()
557        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
558
559        Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
560    }
561
562    fn on_progress(&self, callback: ProgressCallback) {
563        let cb = self.progress_callback.clone();
564        let mut guard = cb.lock().expect("progress callback lock poisoned");
565        *guard = Some(callback);
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn make_params(command: &str) -> Value {
574        json!({ "command": command })
575    }
576
577    fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
578        json!({ "command": command, "timeout": timeout })
579    }
580
581    fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
582        json!({ "command": command, "cwd": cwd })
583    }
584
585    fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
586        json!({ "command": command, "env": env })
587    }
588
589    #[tokio::test]
590    async fn test_simple_command() {
591        let tool = BashTool::new();
592        let result = tool
593            .execute(
594                "test-1",
595                make_params("echo hello"),
596                None,
597                &ToolContext::default(),
598            )
599            .await
600            .unwrap();
601        assert!(result.success);
602        assert!(result.output.contains("hello"));
603    }
604
605    #[tokio::test]
606    async fn test_command_with_args() {
607        let tool = BashTool::new();
608        let result = tool
609            .execute(
610                "test-2",
611                make_params("echo hello world"),
612                None,
613                &ToolContext::default(),
614            )
615            .await
616            .unwrap();
617        assert!(result.success);
618        assert!(result.output.contains("hello world"));
619    }
620
621    #[tokio::test]
622    async fn test_failed_command() {
623        let tool = BashTool::new();
624        let result = tool
625            .execute(
626                "test-3",
627                make_params("exit 1"),
628                None,
629                &ToolContext::default(),
630            )
631            .await
632            .unwrap();
633        assert!(!result.success);
634        assert!(result.output.contains("exited with code 1"));
635    }
636
637    #[tokio::test]
638    async fn test_missing_command_param() {
639        let tool = BashTool::new();
640        let result = tool
641            .execute("test-4", json!({}), None, &ToolContext::default())
642            .await;
643        assert!(result.is_err());
644        assert!(result
645            .unwrap_err()
646            .contains("Missing required parameter: command"));
647    }
648
649    #[tokio::test]
650    async fn test_no_output() {
651        let tool = BashTool::new();
652        let result = tool
653            .execute("test-5", make_params("true"), None, &ToolContext::default())
654            .await
655            .unwrap();
656        assert!(result.success);
657        assert!(result.output.contains("(no output)"));
658    }
659
660    #[tokio::test]
661    async fn test_stderr_capture() {
662        let tool = BashTool::new();
663        let result = tool
664            .execute(
665                "test-6",
666                make_params("echo error_msg >&2"),
667                None,
668                &ToolContext::default(),
669            )
670            .await
671            .unwrap();
672        assert!(result.success);
673        assert!(result.output.contains("error_msg"));
674    }
675
676    #[tokio::test]
677    async fn test_timeout_kills_process() {
678        let tool = BashTool::new();
679        let result = tool
680            .execute(
681                "test-7",
682                make_params_with_timeout("sleep 300", 1),
683                None,
684                &ToolContext::default(),
685            )
686            .await
687            .unwrap();
688        assert!(!result.success);
689        assert!(result.output.contains("timed out"));
690    }
691
692    #[tokio::test]
693    async fn test_timeout_default() {
694        // Verify default timeout is 120 seconds by checking the parameter schema
695        let tool = BashTool::new();
696        let schema = tool.parameters_schema();
697        assert_eq!(schema["properties"]["timeout"]["default"], 120);
698    }
699
700    #[tokio::test]
701    async fn test_working_directory() {
702        let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
703        let result = tool
704            .execute(
705                "test-8",
706                make_params_with_cwd("pwd", "/tmp"),
707                None,
708                &ToolContext::default(),
709            )
710            .await
711            .unwrap();
712        assert!(result.success);
713        assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
714    }
715
716    #[tokio::test]
717    async fn test_working_directory_nonexistent() {
718        let tool = BashTool::new();
719        let result = tool
720            .execute(
721                "test-9",
722                make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
723                None,
724                &ToolContext::default(),
725            )
726            .await;
727        assert!(result.is_err());
728        assert!(result.unwrap_err().contains("does not exist"));
729    }
730
731    #[tokio::test]
732    async fn test_working_directory_traversal() {
733        let tool = BashTool::new();
734        let result = tool
735            .execute(
736                "test-10",
737                make_params_with_cwd("echo hi", "/tmp/../etc"),
738                None,
739                &ToolContext::default(),
740            )
741            .await;
742        assert!(result.is_err());
743        assert!(result.unwrap_err().contains("Path traversal"));
744    }
745
746    #[tokio::test]
747    async fn test_env_variables() {
748        let tool = BashTool::new();
749        let result = tool
750            .execute(
751                "test-11",
752                make_params_with_env(
753                    "echo $OXI_TEST_VAR",
754                    json!({ "OXI_TEST_VAR": "hello_from_env" }),
755                ),
756                None,
757                &ToolContext::default(),
758            )
759            .await
760            .unwrap();
761        assert!(result.success);
762        assert!(result.output.contains("hello_from_env"));
763    }
764
765    #[tokio::test]
766    async fn test_env_variables_multiple() {
767        let tool = BashTool::new();
768        let result = tool
769            .execute(
770                "test-12",
771                make_params_with_env(
772                    "echo $OXI_A $OXI_B",
773                    json!({ "OXI_A": "first", "OXI_B": "second" }),
774                ),
775                None,
776                &ToolContext::default(),
777            )
778            .await
779            .unwrap();
780        assert!(result.success);
781        assert!(result.output.contains("first second"));
782    }
783
784    #[tokio::test]
785    async fn test_duration_timing() {
786        let tool = BashTool::new();
787        let result = tool
788            .execute(
789                "test-13",
790                make_params("sleep 0.1 && echo done"),
791                None,
792                &ToolContext::default(),
793            )
794            .await
795            .unwrap();
796        assert!(result.success);
797        assert!(result.output.contains("Took "));
798        assert!(result.output.contains("s")); // Should contain seconds
799    }
800
801    #[tokio::test]
802    async fn test_combined_stdout_stderr() {
803        let tool = BashTool::new();
804        let result = tool
805            .execute(
806                "test",
807                make_params("echo stdout_msg; echo stderr_msg >&2"),
808                None,
809                &ToolContext::default(),
810            )
811            .await
812            .unwrap();
813        assert!(result.success);
814        assert!(result.output.contains("stdout_msg"));
815        assert!(result.output.contains("stderr_msg"));
816    }
817
818    #[tokio::test]
819    async fn test_output_truncation() {
820        let tool = BashTool::new();
821        // Generate more than 2000 lines to trigger truncation
822        let result = tool
823            .execute(
824                "test-15",
825                make_params("seq 1 3000"),
826                None,
827                &ToolContext::default(),
828            )
829            .await
830            .unwrap();
831        assert!(result.success);
832        assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
833    }
834
835    #[tokio::test]
836    async fn test_signal_aborts_process() {
837        let tool = BashTool::new();
838        let (tx, rx) = oneshot::channel();
839
840        // Spawn a task that will send the abort signal after a short delay
841        tokio::spawn(async move {
842            tokio::time::sleep(Duration::from_millis(100)).await;
843            let _ = tx.send(());
844        });
845
846        let result = tool
847            .execute(
848                "test-16",
849                make_params("sleep 300"),
850                Some(rx),
851                &ToolContext::default(),
852            )
853            .await
854            .unwrap();
855        assert!(!result.success);
856        assert!(result.output.contains("aborted"));
857    }
858
859    #[tokio::test]
860    async fn test_parameters_schema() {
861        let tool = BashTool::new();
862        let schema = tool.parameters_schema();
863
864        // Check required fields
865        let required = schema["required"].as_array().unwrap();
866        assert!(required.iter().any(|r| r.as_str() == Some("command")));
867
868        // Check all expected properties exist
869        let props = schema["properties"].as_object().unwrap();
870        assert!(props.contains_key("command"));
871        assert!(props.contains_key("timeout"));
872        assert!(props.contains_key("cwd"));
873        assert!(props.contains_key("env"));
874
875        // Check types
876        assert_eq!(props["command"]["type"], "string");
877        assert_eq!(props["timeout"]["type"], "integer");
878        assert_eq!(props["cwd"]["type"], "string");
879        assert_eq!(props["env"]["type"], "object");
880    }
881
882    #[tokio::test]
883    async fn test_multiline_output() {
884        let tool = BashTool::new();
885        let result = tool
886            .execute(
887                "test",
888                make_params("echo line1 && echo line2 && echo line3"),
889                None,
890                &ToolContext::default(),
891            )
892            .await
893            .unwrap();
894        assert!(result.success);
895        assert!(result.output.contains("line1"));
896        assert!(result.output.contains("line2"));
897        assert!(result.output.contains("line3"));
898    }
899
900    #[tokio::test]
901    async fn test_format_duration() {
902        assert_eq!(
903            BashTool::format_duration(Duration::from_millis(500)),
904            "0.5s"
905        );
906        assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
907        assert_eq!(
908            BashTool::format_duration(Duration::from_secs(65)),
909            "1m 5.0s"
910        );
911        assert_eq!(
912            BashTool::format_duration(Duration::from_secs(120)),
913            "2m 0.0s"
914        );
915    }
916}