1use 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
24const 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
48fn is_dangerous_command(command: &str) -> Option<String> {
52 let cmd_lower = command.to_lowercase();
53 let mut warnings: Vec<String> = Vec::new();
54
55 if cmd_lower.contains("| sh") || cmd_lower.contains("| bash") || cmd_lower.contains("| zsh") {
57 warnings.push("pipe to shell".to_string());
58 }
59
60 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 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 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 if cmd_lower.contains(":(){ :|:& };") || cmd_lower.contains("fork bomb") {
89 warnings.push("fork bomb pattern detected".to_string());
90 }
91 if command.contains(":(){") && command.contains(":|:&") {
93 warnings.push("fork bomb pattern detected".to_string());
94 }
95
96 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
128fn validate_cwd(dir: &str, workspace: Option<&Path>) -> Result<PathBuf, String> {
131 let path = Path::new(dir);
132
133 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 let Some(workspace_root) = workspace {
144 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 Ok(path.to_path_buf())
165}
166
167const DEFAULT_TIMEOUT_SECS: u64 = 120;
169
170#[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
187pub struct BashTool {
189 root_dir: Option<PathBuf>,
190 progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
191}
192
193impl BashTool {
194 pub fn new() -> Self {
196 Self {
197 root_dir: None,
198 progress_callback: Arc::new(std::sync::Mutex::new(None)),
199 }
200 }
201
202 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 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 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 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(¬ice);
258 }
259
260 if let Some(code) = exit_code
262 && code != 0
263 {
264 output.push_str(&format!("\n\nCommand exited with code {}", code));
265 }
266
267 output.push_str(&format!("\n\nTook {}", Self::format_duration(elapsed)));
269
270 output
271 }
272
273 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 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 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 unsafe {
345 libc::kill(pgid, libc::SIGKILL);
346 }
347 }
348 }
349 let _ = child.kill().await;
350 let _ = child.wait().await;
351 }
352
353 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 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 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 let mut cmd = Self::build_shell_command(command, &work_dir, env);
409
410 let mut child = cmd
412 .spawn()
413 .map_err(|e| format!("Failed to spawn command: {}", e))?;
414
415 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 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 let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
439
440 let elapsed = start.elapsed();
441
442 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 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")); }
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 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 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 let required = schema["required"].as_array().unwrap();
902 assert!(required.iter().any(|r| r.as_str() == Some("command")));
903
904 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 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 #[tokio::test]
961 async fn test_strict_bash_blocks_pipe_to_shell() {
962 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 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 #[tokio::test]
990 async fn test_strict_bash_off_preserves_warning_behavior() {
991 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}