Skip to main content

oxicode_agent/tools/
bash.rs

1/// Bash tool - execute shell commands
2/// Features:
3/// - Timeout support with process group kill
4/// - Working directory (cwd) parameter
5/// - Environment variables support
6/// - Duration timing reporting
7/// - Output truncation (2000 lines / 50KB defaults via truncate module)
8/// - Separate stdout/stderr capture combined at end
9/// - Process tree kill on abort/cancel via signal
10use super::truncate::{self, TruncationOptions, TruncationResult};
11use super::{AgentTool, AgentToolResult, ProgressCallback, ToolContext, ToolError};
12use async_trait::async_trait;
13use serde_json::{Value, json};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tokio::io::AsyncReadExt;
18use tokio::process::Command;
19use tokio::sync::oneshot;
20
21/// Environment variables that are blocked from injection via the LLM.
22/// These can be used for privilege escalation, library injection, or path manipulation.
23const BLOCKED_ENV_VARS: &[&str] = &[
24    "LD_PRELOAD",
25    "LD_LIBRARY_PATH",
26    "DYLD_INSERT_LIBRARIES",
27    "DYLD_LIBRARY_PATH",
28    "DYLD_FRAMEWORK_PATH",
29    "PATH",
30    "HOME",
31    "IFS",
32    "SHELL",
33    "USER",
34    "LOGNAME",
35    "PYTHONPATH",
36    "NODE_PATH",
37    "RUBYLIB",
38    "PERL5LIB",
39    "CLASSPATH",
40    "JAVA_TOOL_OPTIONS",
41    "MallocNanoZone",
42    "MallocSpaceEfficient",
43];
44
45/// Check if a command contains dangerous patterns.
46/// Returns a warning string if dangerous patterns are detected, or None if safe.
47/// This does NOT block execution - it only emits a warning.
48fn is_dangerous_command(command: &str) -> Option<String> {
49    let cmd_lower = command.to_lowercase();
50    let mut warnings: Vec<String> = Vec::new();
51
52    // Pipe to shell
53    if cmd_lower.contains("| sh") || cmd_lower.contains("| bash") || cmd_lower.contains("| zsh") {
54        warnings.push("pipe to shell".to_string());
55    }
56
57    // Sensitive file access via command substitution
58    if command.contains("/etc/passwd") || command.contains("/etc/shadow") {
59        warnings.push("access to sensitive authentication files".to_string());
60    }
61    if command.contains("id_rsa") || command.contains("id_ed25519") || command.contains(".ssh/") {
62        warnings.push("access to SSH private keys/directory".to_string());
63    }
64
65    // Network exfiltration patterns
66    if (cmd_lower.contains("curl") || cmd_lower.contains("wget")) && cmd_lower.contains("| nc") {
67        warnings.push("possible network exfiltration (pipe to netcat)".to_string());
68    }
69    if command.contains("/dev/tcp/") || command.contains("/dev/udp/") {
70        warnings.push("possible network exfiltration via /dev/tcp|udp".to_string());
71    }
72
73    // Privilege escalation
74    if cmd_lower.starts_with("sudo ")
75        || cmd_lower.contains("\nsudo ")
76        || cmd_lower.contains("&&sudo ")
77    {
78        warnings.push("sudo detected (privilege escalation)".to_string());
79    }
80    if cmd_lower.contains("su -") || cmd_lower.contains("su root") {
81        warnings.push("user switch to privileged account".to_string());
82    }
83
84    // Fork bomb patterns
85    if cmd_lower.contains(":(){ :|:& };") || cmd_lower.contains("fork bomb") {
86        warnings.push("fork bomb pattern detected".to_string());
87    }
88    // Also detect the common `:(){ :|:& };:` pattern (without spaces)
89    if command.contains(":(){") && command.contains(":|:&") {
90        warnings.push("fork bomb pattern detected".to_string());
91    }
92
93    // Write to system directories
94    let system_write_patterns: &[(&str, &str)] = &[
95        ("> /etc/", "/etc/"),
96        (">> /etc/", "/etc/"),
97        ("> /boot/", "/boot/"),
98        (">> /boot/", "/boot/"),
99        ("> /sys/", "/sys/"),
100        (">> /sys/", "/sys/"),
101        ("> /proc/", "/proc/"),
102        (">> /proc/", "/proc/"),
103    ];
104    for (pattern, dir) in system_write_patterns {
105        if cmd_lower.contains(pattern) {
106            warnings.push(format!("write to system directory {}", dir));
107            break;
108        }
109    }
110
111    if warnings.is_empty() {
112        None
113    } else {
114        Some(format!(
115            "⚠️  SECURITY WARNING: {}",
116            warnings
117                .iter()
118                .map(|s| s.as_str())
119                .collect::<Vec<_>>()
120                .join(", ")
121        ))
122    }
123}
124
125/// Validate that a working directory is within the allowed workspace.
126/// Returns an error message if the path is invalid/escapes, or the resolved path on success.
127fn validate_cwd(dir: &str, workspace: Option<&Path>) -> Result<PathBuf, String> {
128    let path = Path::new(dir);
129
130    // Reject path traversal
131    if path.components().any(|c| c.as_os_str() == "..") {
132        return Err("Path traversal (..) not allowed in working directory".to_string());
133    }
134
135    if !path.exists() {
136        return Err(format!("Working directory does not exist: {}", dir));
137    }
138
139    // If we have a workspace root, validate the cwd is within it
140    if let Some(workspace_root) = workspace {
141        // Canonicalize both paths to resolve symlinks and normalize
142        let canonical_cwd = path
143            .canonicalize()
144            .map_err(|e| format!("Failed to resolve working directory: {}", e))?;
145        let canonical_workspace = workspace_root
146            .canonicalize()
147            .map_err(|e| format!("Failed to resolve workspace directory: {}", e))?;
148
149        if !canonical_cwd.starts_with(&canonical_workspace) {
150            return Err(format!(
151                "Working directory '{}' is outside the allowed workspace '{}'",
152                canonical_cwd.display(),
153                canonical_workspace.display()
154            ));
155        }
156
157        return Ok(canonical_cwd);
158    }
159
160    // No workspace constraint - just return the original path
161    Ok(path.to_path_buf())
162}
163
164/// 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            && code != 0
243        {
244            output.push_str(&format!("\n\nCommand exited with code {}", code));
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(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                    && let Some(cb) = progress_cb
441                {
442                    cb(format!("Process exited with code {}", code));
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        // F-10 (audit 2026-06-21): in strict mode (OXICODE_STRICT_BASH=1) refuse
550        // commands that match `is_dangerous_command` patterns outright
551        // instead of merely appending a warning after the fact. Without
552        // strict mode, the original "warn after execution" behavior is
553        // preserved (so existing users see no change). Strict mode is
554        // opt-in because some legitimate operations match the heuristic
555        // (e.g. `cat /etc/passwd` is sometimes needed to inspect user
556        // identity); making it opt-in preserves backward compatibility
557        // while giving security-conscious deployments a hard block.
558        // F-10 (audit 2026-06-21): collapsed `if` so the two predicates
559        // (`OXICODE_STRICT_BASH=1` AND a dangerous-pattern match) live on a
560        // single line — clippy::collapsible_if flagged the original form.
561        if std::env::var_os("OXICODE_STRICT_BASH").as_deref() == Some(std::ffi::OsStr::new("1"))
562            && let Some(reason) = is_dangerous_command(command)
563        {
564            return Err(format!(
565                "OXICODE_STRICT_BASH=1 blocked dangerous command: {reason}"
566            ));
567        }
568
569        let cwd = params.get("cwd").and_then(|v: &Value| v.as_str());
570        let timeout = params.get("timeout").and_then(|v: &Value| v.as_u64());
571        let env = params.get("env").and_then(|v: &Value| v.as_object());
572
573        // SAFETY: a poisoned lock means the previous holder panicked while
574        // holding it — a real bug that must surface, not be swallowed. The
575        // tool fails closed rather than proceeding with inconsistent state.
576        #[allow(clippy::expect_used)]
577        let progress_cb = self
578            .progress_callback
579            .lock()
580            .expect("progress callback lock poisoned")
581            .clone();
582
583        // Use root_dir if set, else ctx.root()
584        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
585
586        Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
587    }
588
589    fn on_progress(&self, callback: ProgressCallback) {
590        let cb = self.progress_callback.clone();
591        // SAFETY: a poisoned lock means the previous holder panicked while
592        // holding it — a real bug that must surface, not be swallowed.
593        #[allow(clippy::expect_used)]
594        let mut guard = cb.lock().expect("progress callback lock poisoned");
595        *guard = Some(callback);
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    fn make_params(command: &str) -> Value {
604        json!({ "command": command })
605    }
606
607    fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
608        json!({ "command": command, "timeout": timeout })
609    }
610
611    fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
612        json!({ "command": command, "cwd": cwd })
613    }
614
615    fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
616        json!({ "command": command, "env": env })
617    }
618
619    #[tokio::test]
620    async fn test_simple_command() {
621        let tool = BashTool::new();
622        let result = tool
623            .execute(
624                "test-1",
625                make_params("echo hello"),
626                None,
627                &ToolContext::default(),
628            )
629            .await
630            .unwrap();
631        assert!(result.success);
632        assert!(result.output.contains("hello"));
633    }
634
635    #[tokio::test]
636    async fn test_command_with_args() {
637        let tool = BashTool::new();
638        let result = tool
639            .execute(
640                "test-2",
641                make_params("echo hello world"),
642                None,
643                &ToolContext::default(),
644            )
645            .await
646            .unwrap();
647        assert!(result.success);
648        assert!(result.output.contains("hello world"));
649    }
650
651    #[tokio::test]
652    async fn test_failed_command() {
653        let tool = BashTool::new();
654        let result = tool
655            .execute(
656                "test-3",
657                make_params("exit 1"),
658                None,
659                &ToolContext::default(),
660            )
661            .await
662            .unwrap();
663        assert!(!result.success);
664        assert!(result.output.contains("exited with code 1"));
665    }
666
667    #[tokio::test]
668    async fn test_missing_command_param() {
669        let tool = BashTool::new();
670        let result = tool
671            .execute("test-4", json!({}), None, &ToolContext::default())
672            .await;
673        assert!(result.is_err());
674        assert!(
675            result
676                .unwrap_err()
677                .contains("Missing required parameter: command")
678        );
679    }
680
681    #[tokio::test]
682    async fn test_no_output() {
683        let tool = BashTool::new();
684        let result = tool
685            .execute("test-5", make_params("true"), None, &ToolContext::default())
686            .await
687            .unwrap();
688        assert!(result.success);
689        assert!(result.output.contains("(no output)"));
690    }
691
692    #[tokio::test]
693    async fn test_stderr_capture() {
694        let tool = BashTool::new();
695        let result = tool
696            .execute(
697                "test-6",
698                make_params("echo error_msg >&2"),
699                None,
700                &ToolContext::default(),
701            )
702            .await
703            .unwrap();
704        assert!(result.success);
705        assert!(result.output.contains("error_msg"));
706    }
707
708    #[tokio::test]
709    async fn test_timeout_kills_process() {
710        let tool = BashTool::new();
711        let result = tool
712            .execute(
713                "test-7",
714                make_params_with_timeout("sleep 300", 1),
715                None,
716                &ToolContext::default(),
717            )
718            .await
719            .unwrap();
720        assert!(!result.success);
721        assert!(result.output.contains("timed out"));
722    }
723
724    #[tokio::test]
725    async fn test_timeout_default() {
726        // Verify default timeout is 120 seconds by checking the parameter schema
727        let tool = BashTool::new();
728        let schema = tool.parameters_schema();
729        assert_eq!(schema["properties"]["timeout"]["default"], 120);
730    }
731
732    #[tokio::test]
733    async fn test_working_directory() {
734        let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
735        let result = tool
736            .execute(
737                "test-8",
738                make_params_with_cwd("pwd", "/tmp"),
739                None,
740                &ToolContext::default(),
741            )
742            .await
743            .unwrap();
744        assert!(result.success);
745        assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
746    }
747
748    #[tokio::test]
749    async fn test_working_directory_nonexistent() {
750        let tool = BashTool::new();
751        let result = tool
752            .execute(
753                "test-9",
754                make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
755                None,
756                &ToolContext::default(),
757            )
758            .await;
759        assert!(result.is_err());
760        assert!(result.unwrap_err().contains("does not exist"));
761    }
762
763    #[tokio::test]
764    async fn test_working_directory_traversal() {
765        let tool = BashTool::new();
766        let result = tool
767            .execute(
768                "test-10",
769                make_params_with_cwd("echo hi", "/tmp/../etc"),
770                None,
771                &ToolContext::default(),
772            )
773            .await;
774        assert!(result.is_err());
775        assert!(result.unwrap_err().contains("Path traversal"));
776    }
777
778    #[tokio::test]
779    async fn test_env_variables() {
780        let tool = BashTool::new();
781        let result = tool
782            .execute(
783                "test-11",
784                make_params_with_env(
785                    "echo $OXICODE_TEST_VAR",
786                    json!({ "OXICODE_TEST_VAR": "hello_from_env" }),
787                ),
788                None,
789                &ToolContext::default(),
790            )
791            .await
792            .unwrap();
793        assert!(result.success);
794        assert!(result.output.contains("hello_from_env"));
795    }
796
797    #[tokio::test]
798    async fn test_env_variables_multiple() {
799        let tool = BashTool::new();
800        let result = tool
801            .execute(
802                "test-12",
803                make_params_with_env(
804                    "echo $OXICODE_A $OXICODE_B",
805                    json!({ "OXICODE_A": "first", "OXICODE_B": "second" }),
806                ),
807                None,
808                &ToolContext::default(),
809            )
810            .await
811            .unwrap();
812        assert!(result.success);
813        assert!(result.output.contains("first second"));
814    }
815
816    #[tokio::test]
817    async fn test_duration_timing() {
818        let tool = BashTool::new();
819        let result = tool
820            .execute(
821                "test-13",
822                make_params("sleep 0.1 && echo done"),
823                None,
824                &ToolContext::default(),
825            )
826            .await
827            .unwrap();
828        assert!(result.success);
829        assert!(result.output.contains("Took "));
830        assert!(result.output.contains("s")); // Should contain seconds
831    }
832
833    #[tokio::test]
834    async fn test_combined_stdout_stderr() {
835        let tool = BashTool::new();
836        let result = tool
837            .execute(
838                "test",
839                make_params("echo stdout_msg; echo stderr_msg >&2"),
840                None,
841                &ToolContext::default(),
842            )
843            .await
844            .unwrap();
845        assert!(result.success);
846        assert!(result.output.contains("stdout_msg"));
847        assert!(result.output.contains("stderr_msg"));
848    }
849
850    #[tokio::test]
851    async fn test_output_truncation() {
852        let tool = BashTool::new();
853        // Generate more than 2000 lines to trigger truncation
854        let result = tool
855            .execute(
856                "test-15",
857                make_params("seq 1 3000"),
858                None,
859                &ToolContext::default(),
860            )
861            .await
862            .unwrap();
863        assert!(result.success);
864        assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
865    }
866
867    #[tokio::test]
868    async fn test_signal_aborts_process() {
869        let tool = BashTool::new();
870        let (tx, rx) = oneshot::channel();
871
872        // Spawn a task that will send the abort signal after a short delay
873        tokio::spawn(async move {
874            tokio::time::sleep(Duration::from_millis(100)).await;
875            let _ = tx.send(());
876        });
877
878        let result = tool
879            .execute(
880                "test-16",
881                make_params("sleep 300"),
882                Some(rx),
883                &ToolContext::default(),
884            )
885            .await
886            .unwrap();
887        assert!(!result.success);
888        assert!(result.output.contains("aborted"));
889    }
890
891    #[tokio::test]
892    async fn test_parameters_schema() {
893        let tool = BashTool::new();
894        let schema = tool.parameters_schema();
895
896        // Check required fields
897        let required = schema["required"].as_array().unwrap();
898        assert!(required.iter().any(|r| r.as_str() == Some("command")));
899
900        // Check all expected properties exist
901        let props = schema["properties"].as_object().unwrap();
902        assert!(props.contains_key("command"));
903        assert!(props.contains_key("timeout"));
904        assert!(props.contains_key("cwd"));
905        assert!(props.contains_key("env"));
906
907        // Check types
908        assert_eq!(props["command"]["type"], "string");
909        assert_eq!(props["timeout"]["type"], "integer");
910        assert_eq!(props["cwd"]["type"], "string");
911        assert_eq!(props["env"]["type"], "object");
912    }
913
914    #[tokio::test]
915    async fn test_multiline_output() {
916        let tool = BashTool::new();
917        let result = tool
918            .execute(
919                "test",
920                make_params("echo line1 && echo line2 && echo line3"),
921                None,
922                &ToolContext::default(),
923            )
924            .await
925            .unwrap();
926        assert!(result.success);
927        assert!(result.output.contains("line1"));
928        assert!(result.output.contains("line2"));
929        assert!(result.output.contains("line3"));
930    }
931
932    #[tokio::test]
933    async fn test_format_duration() {
934        assert_eq!(
935            BashTool::format_duration(Duration::from_millis(500)),
936            "0.5s"
937        );
938        assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
939        assert_eq!(
940            BashTool::format_duration(Duration::from_secs(65)),
941            "1m 5.0s"
942        );
943        assert_eq!(
944            BashTool::format_duration(Duration::from_secs(120)),
945            "2m 0.0s"
946        );
947    }
948
949    // ── F-10 regression: OXICODE_STRICT_BASH=1 blocks dangerous commands ─────
950
951    /// With `OXICODE_STRICT_BASH=1`, a command matching `is_dangerous_command`
952    /// must be refused BEFORE `sh -c` runs (audit finding F-10). Without
953    /// the gate, the agent receives an `AgentToolResult::success` with a
954    /// trailing warning — i.e. the dangerous command ran and only the
955    /// post-hoc warning tried to discourage it.
956    #[tokio::test]
957    async fn test_strict_bash_blocks_pipe_to_shell() {
958        // SAFETY: env mutation under test (Rust 2024 makes this unsafe);
959        // acceptable for a single isolated #[tokio::test].
960        unsafe {
961            std::env::set_var("OXICODE_STRICT_BASH", "1");
962        }
963        let tool = BashTool::new();
964        let result = tool
965            .execute(
966                "test-strict",
967                make_params("echo hi | sh"),
968                None,
969                &ToolContext::default(),
970            )
971            .await;
972        unsafe {
973            std::env::remove_var("OXICODE_STRICT_BASH");
974        }
975        // The command should be refused — `Err(...)` with the audit reason.
976        let err = result.expect_err("strict mode must refuse `| sh` commands");
977        assert!(
978            err.contains("OXICODE_STRICT_BASH") && err.contains("pipe to shell"),
979            "unexpected error: {err}"
980        );
981    }
982
983    /// Without `OXICODE_STRICT_BASH`, the legacy "warn after execution"
984    /// behavior is preserved (backward compatible).
985    #[tokio::test]
986    async fn test_strict_bash_off_preserves_warning_behavior() {
987        // Ensure strict mode is off.
988        unsafe {
989            std::env::remove_var("OXICODE_STRICT_BASH");
990        }
991        let tool = BashTool::new();
992        let result = tool
993            .execute(
994                "test-lenient",
995                make_params("echo hi"),
996                None,
997                &ToolContext::default(),
998            )
999            .await;
1000        let r = result.expect("non-dangerous command must succeed when strict is off");
1001        assert!(r.success, "echo hi must succeed: {}", r.output);
1002        assert!(!r.output.contains("OXICODE_STRICT_BASH"));
1003    }
1004}