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 crate::tools::typed::TypedTool;
13use async_trait::async_trait;
14use schemars::JsonSchema;
15use serde::Deserialize;
16use serde_json::{Value, json};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20use tokio::io::AsyncReadExt;
21use tokio::process::Command;
22use tokio::sync::oneshot;
23
24/// Environment variables that are blocked from injection via the LLM.
25/// These can be used for privilege escalation, library injection, or path manipulation.
26const BLOCKED_ENV_VARS: &[&str] = &[
27    "LD_PRELOAD",
28    "LD_LIBRARY_PATH",
29    "DYLD_INSERT_LIBRARIES",
30    "DYLD_LIBRARY_PATH",
31    "DYLD_FRAMEWORK_PATH",
32    "PATH",
33    "HOME",
34    "IFS",
35    "SHELL",
36    "USER",
37    "LOGNAME",
38    "PYTHONPATH",
39    "NODE_PATH",
40    "RUBYLIB",
41    "PERL5LIB",
42    "CLASSPATH",
43    "JAVA_TOOL_OPTIONS",
44    "MallocNanoZone",
45    "MallocSpaceEfficient",
46];
47
48/// Check if a command contains dangerous patterns.
49/// Returns a warning string if dangerous patterns are detected, or None if safe.
50/// This does NOT block execution - it only emits a warning.
51fn is_dangerous_command(command: &str) -> Option<String> {
52    let cmd_lower = command.to_lowercase();
53    let mut warnings: Vec<String> = Vec::new();
54
55    // Pipe to shell
56    if cmd_lower.contains("| sh") || cmd_lower.contains("| bash") || cmd_lower.contains("| zsh") {
57        warnings.push("pipe to shell".to_string());
58    }
59
60    // Sensitive file access via command substitution
61    if command.contains("/etc/passwd") || command.contains("/etc/shadow") {
62        warnings.push("access to sensitive authentication files".to_string());
63    }
64    if command.contains("id_rsa") || command.contains("id_ed25519") || command.contains(".ssh/") {
65        warnings.push("access to SSH private keys/directory".to_string());
66    }
67
68    // Network exfiltration patterns
69    if (cmd_lower.contains("curl") || cmd_lower.contains("wget")) && cmd_lower.contains("| nc") {
70        warnings.push("possible network exfiltration (pipe to netcat)".to_string());
71    }
72    if command.contains("/dev/tcp/") || command.contains("/dev/udp/") {
73        warnings.push("possible network exfiltration via /dev/tcp|udp".to_string());
74    }
75
76    // Privilege escalation
77    if cmd_lower.starts_with("sudo ")
78        || cmd_lower.contains("\nsudo ")
79        || cmd_lower.contains("&&sudo ")
80    {
81        warnings.push("sudo detected (privilege escalation)".to_string());
82    }
83    if cmd_lower.contains("su -") || cmd_lower.contains("su root") {
84        warnings.push("user switch to privileged account".to_string());
85    }
86
87    // Fork bomb patterns
88    if cmd_lower.contains(":(){ :|:& };") || cmd_lower.contains("fork bomb") {
89        warnings.push("fork bomb pattern detected".to_string());
90    }
91    // Also detect the common `:(){ :|:& };:` pattern (without spaces)
92    if command.contains(":(){") && command.contains(":|:&") {
93        warnings.push("fork bomb pattern detected".to_string());
94    }
95
96    // Write to system directories
97    let system_write_patterns: &[(&str, &str)] = &[
98        ("> /etc/", "/etc/"),
99        (">> /etc/", "/etc/"),
100        ("> /boot/", "/boot/"),
101        (">> /boot/", "/boot/"),
102        ("> /sys/", "/sys/"),
103        (">> /sys/", "/sys/"),
104        ("> /proc/", "/proc/"),
105        (">> /proc/", "/proc/"),
106    ];
107    for (pattern, dir) in system_write_patterns {
108        if cmd_lower.contains(pattern) {
109            warnings.push(format!("write to system directory {}", dir));
110            break;
111        }
112    }
113
114    if warnings.is_empty() {
115        None
116    } else {
117        Some(format!(
118            "⚠️  SECURITY WARNING: {}",
119            warnings
120                .iter()
121                .map(|s| s.as_str())
122                .collect::<Vec<_>>()
123                .join(", ")
124        ))
125    }
126}
127
128/// Validate that a working directory is within the allowed workspace.
129/// Returns an error message if the path is invalid/escapes, or the resolved path on success.
130fn validate_cwd(dir: &str, workspace: Option<&Path>) -> Result<PathBuf, String> {
131    let path = Path::new(dir);
132
133    // Reject path traversal
134    if path.components().any(|c| c.as_os_str() == "..") {
135        return Err("Path traversal (..) not allowed in working directory".to_string());
136    }
137
138    if !path.exists() {
139        return Err(format!("Working directory does not exist: {}", dir));
140    }
141
142    // If we have a workspace root, validate the cwd is within it
143    if let Some(workspace_root) = workspace {
144        // Canonicalize both paths to resolve symlinks and normalize
145        let canonical_cwd = path
146            .canonicalize()
147            .map_err(|e| format!("Failed to resolve working directory: {}", e))?;
148        let canonical_workspace = workspace_root
149            .canonicalize()
150            .map_err(|e| format!("Failed to resolve workspace directory: {}", e))?;
151
152        if !canonical_cwd.starts_with(&canonical_workspace) {
153            return Err(format!(
154                "Working directory '{}' is outside the allowed workspace '{}'",
155                canonical_cwd.display(),
156                canonical_workspace.display()
157            ));
158        }
159
160        return Ok(canonical_cwd);
161    }
162
163    // No workspace constraint - just return the original path
164    Ok(path.to_path_buf())
165}
166
167/// Default timeout in seconds
168const DEFAULT_TIMEOUT_SECS: u64 = 120;
169
170/// Typed arguments for [`BashTool`].
171///
172/// Deserialized from JSON tool call params; `timeout` defaults to 120s.
173/// `env` maps variable names to string values.
174#[derive(Deserialize, JsonSchema)]
175pub struct BashArgs {
176    command: String,
177    #[serde(default = "default_bash_timeout")]
178    timeout: u64,
179    cwd: Option<String>,
180    env: Option<serde_json::Map<String, serde_json::Value>>,
181}
182
183fn default_bash_timeout() -> u64 {
184    120
185}
186
187/// BashTool.
188pub struct BashTool {
189    root_dir: Option<PathBuf>,
190    progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
191}
192
193impl BashTool {
194    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
195    pub fn new() -> Self {
196        Self {
197            root_dir: None,
198            progress_callback: Arc::new(std::sync::Mutex::new(None)),
199        }
200    }
201
202    /// Create with a specific working directory (overrides ToolContext).
203    pub fn with_cwd(cwd: PathBuf) -> Self {
204        Self {
205            root_dir: Some(cwd),
206            progress_callback: Arc::new(std::sync::Mutex::new(None)),
207        }
208    }
209
210    /// Format a duration for human-readable display
211    fn format_duration(duration: Duration) -> String {
212        let secs = duration.as_secs();
213        let millis = duration.subsec_millis();
214        if secs >= 60 {
215            let mins = secs / 60;
216            let remain_secs = secs % 60;
217            format!(
218                "{}m {:.1}s",
219                mins,
220                remain_secs as f64 + millis as f64 / 1000.0
221            )
222        } else {
223            format!("{:.1}s", secs as f64 + millis as f64 / 1000.0)
224        }
225    }
226
227    /// Build the output string with optional truncation notice and timing.
228    fn build_output(
229        truncation: &TruncationResult,
230        elapsed: Duration,
231        exit_code: Option<i32>,
232    ) -> String {
233        let mut output = truncation.content.clone();
234
235        // Append truncation notice if output was truncated
236        if truncation.truncated {
237            let notice = match truncation.truncated_by {
238                truncate::TruncatedBy::Lines => format!(
239                    "\n\n[Truncated: showing {} of {} lines. {} bytes remaining]",
240                    truncation.output_lines,
241                    truncation.total_lines,
242                    truncate::format_bytes(
243                        truncation
244                            .total_bytes
245                            .saturating_sub(truncation.output_bytes)
246                    )
247                ),
248                truncate::TruncatedBy::Bytes => format!(
249                    "\n\n[Truncated: {} lines shown ({} byte limit). Total was {} lines, {}]",
250                    truncation.output_lines,
251                    truncate::format_bytes(truncate::DEFAULT_MAX_BYTES),
252                    truncation.total_lines,
253                    truncate::format_bytes(truncation.total_bytes)
254                ),
255                truncate::TruncatedBy::None => String::new(),
256            };
257            output.push_str(&notice);
258        }
259
260        // Append exit code for non-zero
261        if let Some(code) = exit_code
262            && code != 0
263        {
264            output.push_str(&format!("\n\nCommand exited with code {}", code));
265        }
266
267        // Append timing
268        output.push_str(&format!("\n\nTook {}", Self::format_duration(elapsed)));
269
270        output
271    }
272
273    /// Wait for a child process with timeout and optional abort signal.
274    async fn wait_with_timeout_and_signal(
275        child: &mut tokio::process::Child,
276        timeout: u64,
277        signal: &mut Option<oneshot::Receiver<()>>,
278    ) -> Result<std::process::ExitStatus, String> {
279        let timeout_duration = Duration::from_secs(timeout);
280
281        tokio::select! {
282            status = child.wait() => {
283                status.map_err(|e| format!("Failed to wait for process: {}", e))
284            }
285            _ = tokio::time::sleep(timeout_duration) => {
286                Self::kill_process_group(child).await;
287                Err(format!("Command timed out after {} seconds", timeout))
288            }
289            _ = async {
290                match signal {
291                    Some(rx) => { let _ = rx.await; }
292                    None => std::future::pending::<()>().await,
293                }
294            } => {
295                Self::kill_process_group(child).await;
296                Err("Command aborted".to_string())
297            }
298        }
299    }
300
301    /// Build the shell command with working directory and environment variables.
302    fn build_shell_command(
303        command: &str,
304        work_dir: &Option<String>,
305        env: Option<&serde_json::Map<String, Value>>,
306    ) -> Command {
307        let mut cmd = Command::new("sh");
308        cmd.arg("-c")
309            .arg(command)
310            .stdout(std::process::Stdio::piped())
311            .stderr(std::process::Stdio::piped())
312            .process_group(0);
313
314        if let Some(dir) = work_dir {
315            cmd.current_dir(dir);
316        }
317
318        if let Some(env_map) = env {
319            for (key, val) in env_map {
320                if BLOCKED_ENV_VARS
321                    .iter()
322                    .any(|blocked| blocked.eq_ignore_ascii_case(key))
323                {
324                    continue;
325                }
326                if let Some(val_str) = val.as_str() {
327                    cmd.env(key, val_str);
328                }
329            }
330        }
331
332        cmd
333    }
334
335    /// Kill a process group (Unix) or fall back to child.kill().
336    async fn kill_process_group(child: &mut tokio::process::Child) {
337        #[cfg(unix)]
338        {
339            if let Some(pid) = child.id() {
340                let pgid = -(pid as i32);
341                // SAFETY: libc::kill sends SIGKILL to the process group. The negative PID
342                // targets the entire group (shell + child processes). PID comes from
343                // child.id() which is a valid running process owned by this process.
344                unsafe {
345                    libc::kill(pgid, libc::SIGKILL);
346                }
347            }
348        }
349        let _ = child.kill().await;
350        let _ = child.wait().await;
351    }
352
353    /// Format error output for timeout/abort cases.
354    fn format_error_output(
355        stdout_str: &str,
356        stderr_str: &str,
357        error_msg: &str,
358        elapsed: Duration,
359    ) -> String {
360        let mut output = String::new();
361        if !stdout_str.is_empty() {
362            output.push_str(stdout_str);
363        }
364        if !stderr_str.is_empty() {
365            if !output.is_empty() {
366                output.push('\n');
367            }
368            output.push_str(stderr_str);
369        }
370
371        if !output.is_empty() {
372            let truncation = truncate::truncate_head(&output, &TruncationOptions::default());
373            output = truncation.content;
374        }
375
376        output.push_str(&format!("\n\n{}", error_msg));
377        output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
378        output
379    }
380
381    /// Execute a command using tokio::process::Command with full feature support.
382    async fn run_command(
383        root_dir: &Path,
384        command: &str,
385        cwd: Option<&str>,
386        env: Option<&serde_json::Map<String, Value>>,
387        timeout_secs: Option<u64>,
388        progress_cb: &Option<ProgressCallback>,
389        mut signal: Option<oneshot::Receiver<()>>,
390    ) -> Result<AgentToolResult, ToolError> {
391        if let Some(cb) = progress_cb {
392            cb(format!("Executing: {}", command));
393        }
394
395        let timeout = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
396        let start = Instant::now();
397
398        // Resolve working directory
399        let work_dir = match cwd {
400            Some(dir) if !dir.is_empty() => {
401                let validated = validate_cwd(dir, Some(root_dir))?;
402                Some(validated.to_string_lossy().to_string())
403            }
404            _ => Some(root_dir.to_string_lossy().to_string()),
405        };
406
407        // Build the command
408        let mut cmd = Self::build_shell_command(command, &work_dir, env);
409
410        // Spawn the child process
411        let mut child = cmd
412            .spawn()
413            .map_err(|e| format!("Failed to spawn command: {}", e))?;
414
415        // Take stdout and stderr handles for separate capture
416        let mut stdout_pipe = child
417            .stdout
418            .take()
419            .ok_or_else(|| "Failed to capture stdout".to_string())?;
420        let mut stderr_pipe = child
421            .stderr
422            .take()
423            .ok_or_else(|| "Failed to capture stderr".to_string())?;
424
425        // Read stdout and stderr concurrently
426        let stdout_handle = tokio::spawn(async move {
427            let mut buf = Vec::new();
428            let _ = stdout_pipe.read_to_end(&mut buf).await;
429            buf
430        });
431        let stderr_handle = tokio::spawn(async move {
432            let mut buf = Vec::new();
433            let _ = stderr_pipe.read_to_end(&mut buf).await;
434            buf
435        });
436
437        // Wait for the process with timeout and signal handling
438        let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
439
440        let elapsed = start.elapsed();
441
442        // Collect stdout and stderr
443        let stdout_bytes = stdout_handle.await.unwrap_or_default();
444        let stderr_bytes = stderr_handle.await.unwrap_or_default();
445
446        let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
447        let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
448
449        if let Some(cb) = progress_cb {
450            cb(format!(
451                "Process completed in {}",
452                Self::format_duration(elapsed)
453            ));
454        }
455
456        match result {
457            Ok(status) => {
458                let exit_code = status.code();
459                if let Some(code) = exit_code
460                    && let Some(cb) = progress_cb
461                {
462                    cb(format!("Process exited with code {}", code));
463                }
464                let combined = if stderr_str.is_empty() {
465                    stdout_str.clone()
466                } else if stdout_str.is_empty() {
467                    stderr_str.clone()
468                } else {
469                    format!("{}\n{}", stdout_str, stderr_str)
470                };
471
472                let security_warning = is_dangerous_command(command);
473
474                let truncation = truncate::truncate_head(
475                    if combined.is_empty() {
476                        "(no output)"
477                    } else {
478                        &combined
479                    },
480                    &TruncationOptions::default(),
481                );
482
483                let mut output = Self::build_output(&truncation, elapsed, exit_code);
484
485                if let Some(ref warning) = security_warning {
486                    output.push_str(&format!("\n{}", warning));
487                }
488
489                if status.success() {
490                    Ok(AgentToolResult::success(output))
491                } else {
492                    Ok(AgentToolResult::error(output))
493                }
494            }
495            Err(e) => {
496                let output = Self::format_error_output(&stdout_str, &stderr_str, &e, elapsed);
497                Ok(AgentToolResult::error(output))
498            }
499        }
500    }
501}
502
503impl Default for BashTool {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509#[async_trait]
510impl AgentTool for BashTool {
511    fn name(&self) -> &str {
512        "bash"
513    }
514
515    fn label(&self) -> &str {
516        "Bash"
517    }
518
519    fn essential(&self) -> bool {
520        true
521    }
522    fn description(&self) -> &str {
523        "Execute a bash command in a shell. Returns stdout and stderr. \
524         Output is truncated to 2000 lines or 50KB (whichever is hit first). \
525         Set timeout to limit execution time."
526    }
527
528    fn parameters_schema(&self) -> Value {
529        json!({
530            "type": "object",
531            "properties": {
532                "command": {
533                    "type": "string",
534                    "description": "The bash command to execute"
535                },
536                "timeout": {
537                    "type": "integer",
538                    "description": "Timeout in seconds (default: 120)",
539                    "default": 120
540                },
541                "cwd": {
542                    "type": "string",
543                    "description": "Working directory for the command (optional)"
544                },
545                "env": {
546                    "type": "object",
547                    "description": "Environment variables as key-value pairs (optional)",
548                    "additionalProperties": {
549                        "type": "string"
550                    }
551                }
552            },
553            "required": ["command"]
554        })
555    }
556
557    async fn execute(
558        &self,
559        _tool_call_id: &str,
560        params: Value,
561        signal: Option<oneshot::Receiver<()>>,
562        ctx: &ToolContext,
563    ) -> Result<AgentToolResult, ToolError> {
564        let args: BashArgs =
565            serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
566        self.execute_typed(_tool_call_id, args, signal, ctx).await
567    }
568
569    fn on_progress(&self, callback: ProgressCallback) {
570        let cb = self.progress_callback.clone();
571        let mut guard = cb.lock().expect("progress callback lock poisoned");
572        *guard = Some(callback);
573    }
574}
575
576#[async_trait]
577impl TypedTool for BashTool {
578    type Args = BashArgs;
579    async fn execute_typed(
580        &self,
581        _tool_call_id: &str,
582        args: Self::Args,
583        signal: Option<oneshot::Receiver<()>>,
584        ctx: &ToolContext,
585    ) -> Result<AgentToolResult, ToolError> {
586        if std::env::var_os("OXI_STRICT_BASH").as_deref() == Some(std::ffi::OsStr::new("1"))
587            && let Some(reason) = is_dangerous_command(&args.command)
588        {
589            return Err(format!(
590                "OXI_STRICT_BASH=1 blocked dangerous command: {reason}"
591            ));
592        }
593
594        let cwd = args.cwd.as_deref();
595        let timeout = Some(args.timeout);
596        let env = args.env.as_ref();
597        let progress_cb = self
598            .progress_callback
599            .lock()
600            .expect("progress callback lock poisoned")
601            .clone();
602        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
603        Self::run_command(root, &args.command, cwd, env, timeout, &progress_cb, signal).await
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    fn make_params(command: &str) -> Value {
612        json!({ "command": command })
613    }
614
615    fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
616        json!({ "command": command, "timeout": timeout })
617    }
618
619    fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
620        json!({ "command": command, "cwd": cwd })
621    }
622
623    fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
624        json!({ "command": command, "env": env })
625    }
626
627    #[tokio::test]
628    async fn test_simple_command() {
629        let tool = BashTool::new();
630        let result = tool
631            .execute(
632                "test-1",
633                make_params("echo hello"),
634                None,
635                &ToolContext::default(),
636            )
637            .await
638            .unwrap();
639        assert!(result.success);
640        assert!(result.output.contains("hello"));
641    }
642
643    #[tokio::test]
644    async fn test_command_with_args() {
645        let tool = BashTool::new();
646        let result = tool
647            .execute(
648                "test-2",
649                make_params("echo hello world"),
650                None,
651                &ToolContext::default(),
652            )
653            .await
654            .unwrap();
655        assert!(result.success);
656        assert!(result.output.contains("hello world"));
657    }
658
659    #[tokio::test]
660    async fn test_failed_command() {
661        let tool = BashTool::new();
662        let result = tool
663            .execute(
664                "test-3",
665                make_params("exit 1"),
666                None,
667                &ToolContext::default(),
668            )
669            .await
670            .unwrap();
671        assert!(!result.success);
672        assert!(result.output.contains("exited with code 1"));
673    }
674
675    #[tokio::test]
676    async fn test_missing_command_param() {
677        let tool = BashTool::new();
678        let result = tool
679            .execute("test-4", json!({}), None, &ToolContext::default())
680            .await;
681        assert!(result.is_err());
682        assert!(result.unwrap_err().contains("command"));
683    }
684
685    #[tokio::test]
686    async fn test_no_output() {
687        let tool = BashTool::new();
688        let result = tool
689            .execute("test-5", make_params("true"), None, &ToolContext::default())
690            .await
691            .unwrap();
692        assert!(result.success);
693        assert!(result.output.contains("(no output)"));
694    }
695
696    #[tokio::test]
697    async fn test_stderr_capture() {
698        let tool = BashTool::new();
699        let result = tool
700            .execute(
701                "test-6",
702                make_params("echo error_msg >&2"),
703                None,
704                &ToolContext::default(),
705            )
706            .await
707            .unwrap();
708        assert!(result.success);
709        assert!(result.output.contains("error_msg"));
710    }
711
712    #[tokio::test]
713    async fn test_timeout_kills_process() {
714        let tool = BashTool::new();
715        let result = tool
716            .execute(
717                "test-7",
718                make_params_with_timeout("sleep 300", 1),
719                None,
720                &ToolContext::default(),
721            )
722            .await
723            .unwrap();
724        assert!(!result.success);
725        assert!(result.output.contains("timed out"));
726    }
727
728    #[tokio::test]
729    async fn test_timeout_default() {
730        // Verify default timeout is 120 seconds by checking the parameter schema
731        let tool = BashTool::new();
732        let schema = tool.parameters_schema();
733        assert_eq!(schema["properties"]["timeout"]["default"], 120);
734    }
735
736    #[tokio::test]
737    async fn test_working_directory() {
738        let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
739        let result = tool
740            .execute(
741                "test-8",
742                make_params_with_cwd("pwd", "/tmp"),
743                None,
744                &ToolContext::default(),
745            )
746            .await
747            .unwrap();
748        assert!(result.success);
749        assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
750    }
751
752    #[tokio::test]
753    async fn test_working_directory_nonexistent() {
754        let tool = BashTool::new();
755        let result = tool
756            .execute(
757                "test-9",
758                make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
759                None,
760                &ToolContext::default(),
761            )
762            .await;
763        assert!(result.is_err());
764        assert!(result.unwrap_err().contains("does not exist"));
765    }
766
767    #[tokio::test]
768    async fn test_working_directory_traversal() {
769        let tool = BashTool::new();
770        let result = tool
771            .execute(
772                "test-10",
773                make_params_with_cwd("echo hi", "/tmp/../etc"),
774                None,
775                &ToolContext::default(),
776            )
777            .await;
778        assert!(result.is_err());
779        assert!(result.unwrap_err().contains("Path traversal"));
780    }
781
782    #[tokio::test]
783    async fn test_env_variables() {
784        let tool = BashTool::new();
785        let result = tool
786            .execute(
787                "test-11",
788                make_params_with_env(
789                    "echo $OXI_TEST_VAR",
790                    json!({ "OXI_TEST_VAR": "hello_from_env" }),
791                ),
792                None,
793                &ToolContext::default(),
794            )
795            .await
796            .unwrap();
797        assert!(result.success);
798        assert!(result.output.contains("hello_from_env"));
799    }
800
801    #[tokio::test]
802    async fn test_env_variables_multiple() {
803        let tool = BashTool::new();
804        let result = tool
805            .execute(
806                "test-12",
807                make_params_with_env(
808                    "echo $OXI_A $OXI_B",
809                    json!({ "OXI_A": "first", "OXI_B": "second" }),
810                ),
811                None,
812                &ToolContext::default(),
813            )
814            .await
815            .unwrap();
816        assert!(result.success);
817        assert!(result.output.contains("first second"));
818    }
819
820    #[tokio::test]
821    async fn test_duration_timing() {
822        let tool = BashTool::new();
823        let result = tool
824            .execute(
825                "test-13",
826                make_params("sleep 0.1 && echo done"),
827                None,
828                &ToolContext::default(),
829            )
830            .await
831            .unwrap();
832        assert!(result.success);
833        assert!(result.output.contains("Took "));
834        assert!(result.output.contains("s")); // Should contain seconds
835    }
836
837    #[tokio::test]
838    async fn test_combined_stdout_stderr() {
839        let tool = BashTool::new();
840        let result = tool
841            .execute(
842                "test",
843                make_params("echo stdout_msg; echo stderr_msg >&2"),
844                None,
845                &ToolContext::default(),
846            )
847            .await
848            .unwrap();
849        assert!(result.success);
850        assert!(result.output.contains("stdout_msg"));
851        assert!(result.output.contains("stderr_msg"));
852    }
853
854    #[tokio::test]
855    async fn test_output_truncation() {
856        let tool = BashTool::new();
857        // Generate more than 2000 lines to trigger truncation
858        let result = tool
859            .execute(
860                "test-15",
861                make_params("seq 1 3000"),
862                None,
863                &ToolContext::default(),
864            )
865            .await
866            .unwrap();
867        assert!(result.success);
868        assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
869    }
870
871    #[tokio::test]
872    async fn test_signal_aborts_process() {
873        let tool = BashTool::new();
874        let (tx, rx) = oneshot::channel();
875
876        // Spawn a task that will send the abort signal after a short delay
877        tokio::spawn(async move {
878            tokio::time::sleep(Duration::from_millis(100)).await;
879            let _ = tx.send(());
880        });
881
882        let result = tool
883            .execute(
884                "test-16",
885                make_params("sleep 300"),
886                Some(rx),
887                &ToolContext::default(),
888            )
889            .await
890            .unwrap();
891        assert!(!result.success);
892        assert!(result.output.contains("aborted"));
893    }
894
895    #[tokio::test]
896    async fn test_parameters_schema() {
897        let tool = BashTool::new();
898        let schema = tool.parameters_schema();
899
900        // Check required fields
901        let required = schema["required"].as_array().unwrap();
902        assert!(required.iter().any(|r| r.as_str() == Some("command")));
903
904        // Check all expected properties exist
905        let props = schema["properties"].as_object().unwrap();
906        assert!(props.contains_key("command"));
907        assert!(props.contains_key("timeout"));
908        assert!(props.contains_key("cwd"));
909        assert!(props.contains_key("env"));
910
911        // Check types
912        assert_eq!(props["command"]["type"], "string");
913        assert_eq!(props["timeout"]["type"], "integer");
914        assert_eq!(props["cwd"]["type"], "string");
915        assert_eq!(props["env"]["type"], "object");
916    }
917
918    #[tokio::test]
919    async fn test_multiline_output() {
920        let tool = BashTool::new();
921        let result = tool
922            .execute(
923                "test",
924                make_params("echo line1 && echo line2 && echo line3"),
925                None,
926                &ToolContext::default(),
927            )
928            .await
929            .unwrap();
930        assert!(result.success);
931        assert!(result.output.contains("line1"));
932        assert!(result.output.contains("line2"));
933        assert!(result.output.contains("line3"));
934    }
935
936    #[tokio::test]
937    async fn test_format_duration() {
938        assert_eq!(
939            BashTool::format_duration(Duration::from_millis(500)),
940            "0.5s"
941        );
942        assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
943        assert_eq!(
944            BashTool::format_duration(Duration::from_secs(65)),
945            "1m 5.0s"
946        );
947        assert_eq!(
948            BashTool::format_duration(Duration::from_secs(120)),
949            "2m 0.0s"
950        );
951    }
952
953    // ── F-10 regression: OXI_STRICT_BASH=1 blocks dangerous commands ─────
954
955    /// With `OXI_STRICT_BASH=1`, a command matching `is_dangerous_command`
956    /// must be refused BEFORE `sh -c` runs (audit finding F-10). Without
957    /// the gate, the agent receives an `AgentToolResult::success` with a
958    /// trailing warning — i.e. the dangerous command ran and only the
959    /// post-hoc warning tried to discourage it.
960    #[tokio::test]
961    async fn test_strict_bash_blocks_pipe_to_shell() {
962        // SAFETY: env mutation under test (Rust 2024 makes this unsafe);
963        // acceptable for a single isolated #[tokio::test].
964        unsafe {
965            std::env::set_var("OXI_STRICT_BASH", "1");
966        }
967        let tool = BashTool::new();
968        let result = tool
969            .execute(
970                "test-strict",
971                make_params("echo hi | sh"),
972                None,
973                &ToolContext::default(),
974            )
975            .await;
976        unsafe {
977            std::env::remove_var("OXI_STRICT_BASH");
978        }
979        // The command should be refused — `Err(...)` with the audit reason.
980        let err = result.expect_err("strict mode must refuse `| sh` commands");
981        assert!(
982            err.contains("OXI_STRICT_BASH") && err.contains("pipe to shell"),
983            "unexpected error: {err}"
984        );
985    }
986
987    /// Without `OXI_STRICT_BASH`, the legacy "warn after execution"
988    /// behavior is preserved (backward compatible).
989    #[tokio::test]
990    async fn test_strict_bash_off_preserves_warning_behavior() {
991        // Ensure strict mode is off.
992        unsafe {
993            std::env::remove_var("OXI_STRICT_BASH");
994        }
995        let tool = BashTool::new();
996        let result = tool
997            .execute(
998                "test-lenient",
999                make_params("echo hi"),
1000                None,
1001                &ToolContext::default(),
1002            )
1003            .await;
1004        let r = result.expect("non-dangerous command must succeed when strict is off");
1005        assert!(r.success, "echo hi must succeed: {}", r.output);
1006        assert!(!r.output.contains("OXI_STRICT_BASH"));
1007    }
1008}