1use 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
21const 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
45fn is_dangerous_command(command: &str) -> Option<String> {
49 let cmd_lower = command.to_lowercase();
50 let mut warnings: Vec<String> = Vec::new();
51
52 if cmd_lower.contains("| sh") || cmd_lower.contains("| bash") || cmd_lower.contains("| zsh") {
54 warnings.push("pipe to shell".to_string());
55 }
56
57 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 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 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 if cmd_lower.contains(":(){ :|:& };") || cmd_lower.contains("fork bomb") {
86 warnings.push("fork bomb pattern detected".to_string());
87 }
88 if command.contains(":(){") && command.contains(":|:&") {
90 warnings.push("fork bomb pattern detected".to_string());
91 }
92
93 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
125fn validate_cwd(dir: &str, workspace: Option<&Path>) -> Result<PathBuf, String> {
128 let path = Path::new(dir);
129
130 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 let Some(workspace_root) = workspace {
141 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 Ok(path.to_path_buf())
162}
163
164const DEFAULT_TIMEOUT_SECS: u64 = 120;
166
167pub struct BashTool {
169 root_dir: Option<PathBuf>,
170 progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
171}
172
173impl BashTool {
174 pub fn new() -> Self {
176 Self {
177 root_dir: None,
178 progress_callback: Arc::new(std::sync::Mutex::new(None)),
179 }
180 }
181
182 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 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 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 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(¬ice);
238 }
239
240 if let Some(code) = exit_code
242 && code != 0
243 {
244 output.push_str(&format!("\n\nCommand exited with code {}", code));
245 }
246
247 output.push_str(&format!("\n\nTook {}", Self::format_duration(elapsed)));
249
250 output
251 }
252
253 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 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 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 {
325 libc::kill(pgid, libc::SIGKILL);
326 }
327 }
328 }
329 let _ = child.kill().await;
330 let _ = child.wait().await;
331 }
332
333 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 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 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 let mut cmd = Self::build_shell_command(command, &work_dir, env);
389
390 let mut child = cmd
392 .spawn()
393 .map_err(|e| format!("Failed to spawn command: {}", e))?;
394
395 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 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 let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
419
420 let elapsed = start.elapsed();
421
422 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 if std::env::var_os("OXI_STRICT_BASH").as_deref() == Some(std::ffi::OsStr::new("1"))
562 && let Some(reason) = is_dangerous_command(command)
563 {
564 return Err(format!(
565 "OXI_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 let progress_cb = self
574 .progress_callback
575 .lock()
576 .expect("progress callback lock poisoned")
577 .clone();
578
579 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
581
582 Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
583 }
584
585 fn on_progress(&self, callback: ProgressCallback) {
586 let cb = self.progress_callback.clone();
587 let mut guard = cb.lock().expect("progress callback lock poisoned");
588 *guard = Some(callback);
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 fn make_params(command: &str) -> Value {
597 json!({ "command": command })
598 }
599
600 fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
601 json!({ "command": command, "timeout": timeout })
602 }
603
604 fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
605 json!({ "command": command, "cwd": cwd })
606 }
607
608 fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
609 json!({ "command": command, "env": env })
610 }
611
612 #[tokio::test]
613 async fn test_simple_command() {
614 let tool = BashTool::new();
615 let result = tool
616 .execute(
617 "test-1",
618 make_params("echo hello"),
619 None,
620 &ToolContext::default(),
621 )
622 .await
623 .unwrap();
624 assert!(result.success);
625 assert!(result.output.contains("hello"));
626 }
627
628 #[tokio::test]
629 async fn test_command_with_args() {
630 let tool = BashTool::new();
631 let result = tool
632 .execute(
633 "test-2",
634 make_params("echo hello world"),
635 None,
636 &ToolContext::default(),
637 )
638 .await
639 .unwrap();
640 assert!(result.success);
641 assert!(result.output.contains("hello world"));
642 }
643
644 #[tokio::test]
645 async fn test_failed_command() {
646 let tool = BashTool::new();
647 let result = tool
648 .execute(
649 "test-3",
650 make_params("exit 1"),
651 None,
652 &ToolContext::default(),
653 )
654 .await
655 .unwrap();
656 assert!(!result.success);
657 assert!(result.output.contains("exited with code 1"));
658 }
659
660 #[tokio::test]
661 async fn test_missing_command_param() {
662 let tool = BashTool::new();
663 let result = tool
664 .execute("test-4", json!({}), None, &ToolContext::default())
665 .await;
666 assert!(result.is_err());
667 assert!(
668 result
669 .unwrap_err()
670 .contains("Missing required parameter: command")
671 );
672 }
673
674 #[tokio::test]
675 async fn test_no_output() {
676 let tool = BashTool::new();
677 let result = tool
678 .execute("test-5", make_params("true"), None, &ToolContext::default())
679 .await
680 .unwrap();
681 assert!(result.success);
682 assert!(result.output.contains("(no output)"));
683 }
684
685 #[tokio::test]
686 async fn test_stderr_capture() {
687 let tool = BashTool::new();
688 let result = tool
689 .execute(
690 "test-6",
691 make_params("echo error_msg >&2"),
692 None,
693 &ToolContext::default(),
694 )
695 .await
696 .unwrap();
697 assert!(result.success);
698 assert!(result.output.contains("error_msg"));
699 }
700
701 #[tokio::test]
702 async fn test_timeout_kills_process() {
703 let tool = BashTool::new();
704 let result = tool
705 .execute(
706 "test-7",
707 make_params_with_timeout("sleep 300", 1),
708 None,
709 &ToolContext::default(),
710 )
711 .await
712 .unwrap();
713 assert!(!result.success);
714 assert!(result.output.contains("timed out"));
715 }
716
717 #[tokio::test]
718 async fn test_timeout_default() {
719 let tool = BashTool::new();
721 let schema = tool.parameters_schema();
722 assert_eq!(schema["properties"]["timeout"]["default"], 120);
723 }
724
725 #[tokio::test]
726 async fn test_working_directory() {
727 let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
728 let result = tool
729 .execute(
730 "test-8",
731 make_params_with_cwd("pwd", "/tmp"),
732 None,
733 &ToolContext::default(),
734 )
735 .await
736 .unwrap();
737 assert!(result.success);
738 assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
739 }
740
741 #[tokio::test]
742 async fn test_working_directory_nonexistent() {
743 let tool = BashTool::new();
744 let result = tool
745 .execute(
746 "test-9",
747 make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
748 None,
749 &ToolContext::default(),
750 )
751 .await;
752 assert!(result.is_err());
753 assert!(result.unwrap_err().contains("does not exist"));
754 }
755
756 #[tokio::test]
757 async fn test_working_directory_traversal() {
758 let tool = BashTool::new();
759 let result = tool
760 .execute(
761 "test-10",
762 make_params_with_cwd("echo hi", "/tmp/../etc"),
763 None,
764 &ToolContext::default(),
765 )
766 .await;
767 assert!(result.is_err());
768 assert!(result.unwrap_err().contains("Path traversal"));
769 }
770
771 #[tokio::test]
772 async fn test_env_variables() {
773 let tool = BashTool::new();
774 let result = tool
775 .execute(
776 "test-11",
777 make_params_with_env(
778 "echo $OXI_TEST_VAR",
779 json!({ "OXI_TEST_VAR": "hello_from_env" }),
780 ),
781 None,
782 &ToolContext::default(),
783 )
784 .await
785 .unwrap();
786 assert!(result.success);
787 assert!(result.output.contains("hello_from_env"));
788 }
789
790 #[tokio::test]
791 async fn test_env_variables_multiple() {
792 let tool = BashTool::new();
793 let result = tool
794 .execute(
795 "test-12",
796 make_params_with_env(
797 "echo $OXI_A $OXI_B",
798 json!({ "OXI_A": "first", "OXI_B": "second" }),
799 ),
800 None,
801 &ToolContext::default(),
802 )
803 .await
804 .unwrap();
805 assert!(result.success);
806 assert!(result.output.contains("first second"));
807 }
808
809 #[tokio::test]
810 async fn test_duration_timing() {
811 let tool = BashTool::new();
812 let result = tool
813 .execute(
814 "test-13",
815 make_params("sleep 0.1 && echo done"),
816 None,
817 &ToolContext::default(),
818 )
819 .await
820 .unwrap();
821 assert!(result.success);
822 assert!(result.output.contains("Took "));
823 assert!(result.output.contains("s")); }
825
826 #[tokio::test]
827 async fn test_combined_stdout_stderr() {
828 let tool = BashTool::new();
829 let result = tool
830 .execute(
831 "test",
832 make_params("echo stdout_msg; echo stderr_msg >&2"),
833 None,
834 &ToolContext::default(),
835 )
836 .await
837 .unwrap();
838 assert!(result.success);
839 assert!(result.output.contains("stdout_msg"));
840 assert!(result.output.contains("stderr_msg"));
841 }
842
843 #[tokio::test]
844 async fn test_output_truncation() {
845 let tool = BashTool::new();
846 let result = tool
848 .execute(
849 "test-15",
850 make_params("seq 1 3000"),
851 None,
852 &ToolContext::default(),
853 )
854 .await
855 .unwrap();
856 assert!(result.success);
857 assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
858 }
859
860 #[tokio::test]
861 async fn test_signal_aborts_process() {
862 let tool = BashTool::new();
863 let (tx, rx) = oneshot::channel();
864
865 tokio::spawn(async move {
867 tokio::time::sleep(Duration::from_millis(100)).await;
868 let _ = tx.send(());
869 });
870
871 let result = tool
872 .execute(
873 "test-16",
874 make_params("sleep 300"),
875 Some(rx),
876 &ToolContext::default(),
877 )
878 .await
879 .unwrap();
880 assert!(!result.success);
881 assert!(result.output.contains("aborted"));
882 }
883
884 #[tokio::test]
885 async fn test_parameters_schema() {
886 let tool = BashTool::new();
887 let schema = tool.parameters_schema();
888
889 let required = schema["required"].as_array().unwrap();
891 assert!(required.iter().any(|r| r.as_str() == Some("command")));
892
893 let props = schema["properties"].as_object().unwrap();
895 assert!(props.contains_key("command"));
896 assert!(props.contains_key("timeout"));
897 assert!(props.contains_key("cwd"));
898 assert!(props.contains_key("env"));
899
900 assert_eq!(props["command"]["type"], "string");
902 assert_eq!(props["timeout"]["type"], "integer");
903 assert_eq!(props["cwd"]["type"], "string");
904 assert_eq!(props["env"]["type"], "object");
905 }
906
907 #[tokio::test]
908 async fn test_multiline_output() {
909 let tool = BashTool::new();
910 let result = tool
911 .execute(
912 "test",
913 make_params("echo line1 && echo line2 && echo line3"),
914 None,
915 &ToolContext::default(),
916 )
917 .await
918 .unwrap();
919 assert!(result.success);
920 assert!(result.output.contains("line1"));
921 assert!(result.output.contains("line2"));
922 assert!(result.output.contains("line3"));
923 }
924
925 #[tokio::test]
926 async fn test_format_duration() {
927 assert_eq!(
928 BashTool::format_duration(Duration::from_millis(500)),
929 "0.5s"
930 );
931 assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
932 assert_eq!(
933 BashTool::format_duration(Duration::from_secs(65)),
934 "1m 5.0s"
935 );
936 assert_eq!(
937 BashTool::format_duration(Duration::from_secs(120)),
938 "2m 0.0s"
939 );
940 }
941
942 #[tokio::test]
950 async fn test_strict_bash_blocks_pipe_to_shell() {
951 unsafe {
954 std::env::set_var("OXI_STRICT_BASH", "1");
955 }
956 let tool = BashTool::new();
957 let result = tool
958 .execute(
959 "test-strict",
960 make_params("echo hi | sh"),
961 None,
962 &ToolContext::default(),
963 )
964 .await;
965 unsafe {
966 std::env::remove_var("OXI_STRICT_BASH");
967 }
968 let err = result.expect_err("strict mode must refuse `| sh` commands");
970 assert!(
971 err.contains("OXI_STRICT_BASH") && err.contains("pipe to shell"),
972 "unexpected error: {err}"
973 );
974 }
975
976 #[tokio::test]
979 async fn test_strict_bash_off_preserves_warning_behavior() {
980 unsafe {
982 std::env::remove_var("OXI_STRICT_BASH");
983 }
984 let tool = BashTool::new();
985 let result = tool
986 .execute(
987 "test-lenient",
988 make_params("echo hi"),
989 None,
990 &ToolContext::default(),
991 )
992 .await;
993 let r = result.expect("non-dangerous command must succeed when strict is off");
994 assert!(r.success, "echo hi must succeed: {}", r.output);
995 assert!(!r.output.contains("OXI_STRICT_BASH"));
996 }
997}