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("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 #[allow(clippy::expect_used)]
577 let progress_cb = self
578 .progress_callback
579 .lock()
580 .expect("progress callback lock poisoned")
581 .clone();
582
583 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 #[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 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")); }
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 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 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 let required = schema["required"].as_array().unwrap();
898 assert!(required.iter().any(|r| r.as_str() == Some("command")));
899
900 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 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 #[tokio::test]
957 async fn test_strict_bash_blocks_pipe_to_shell() {
958 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 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 #[tokio::test]
986 async fn test_strict_bash_off_preserves_warning_behavior() {
987 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}