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
164#[cfg(unix)]
170#[derive(Debug, Clone)]
171pub struct PtyOutcome {
172 pub output: String,
174 pub exit_code: Option<i32>,
176}
177
178#[cfg(unix)]
186fn ansi_filter(input: &str) -> String {
187 let bytes = input.as_bytes();
188 let mut out = Vec::with_capacity(bytes.len());
189 let mut i = 0;
190 while i < bytes.len() {
191 let b = bytes[i];
192 if b != 0x1b {
193 out.push(b);
194 i += 1;
195 continue;
196 }
197 if i + 1 >= bytes.len() {
198 out.push(b);
199 i += 1;
200 continue;
201 }
202 match bytes[i + 1] {
203 b'[' => {
204 let mut j = i + 2;
206 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
207 j += 1;
208 }
209 if j < bytes.len() {
210 if bytes[j] == b'm' {
211 out.extend_from_slice(&bytes[i..=j]);
212 }
213 i = j + 1;
214 } else {
215 i += 1;
216 }
217 }
218 b']' => {
219 let mut j = i + 2;
221 while j < bytes.len() {
222 if bytes[j] == 0x07 {
223 j += 1;
224 break;
225 }
226 if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
227 j += 2;
228 break;
229 }
230 j += 1;
231 }
232 i = j;
233 }
234 _ => {
235 i += 1;
238 }
239 }
240 }
241 String::from_utf8_lossy(&out).into_owned()
242}
243
244#[cfg(unix)]
252pub async fn run_in_pty(
253 cmd: &str,
254 cwd: &Path,
255 timeout: Duration,
256 abort: oneshot::Receiver<()>,
257) -> Result<PtyOutcome, ToolError> {
258 use portable_pty::{CommandBuilder, PtySize, native_pty_system};
259
260 let pty_system = native_pty_system();
261 let pair = pty_system
262 .openpty(PtySize {
263 rows: 24,
264 cols: 80,
265 pixel_width: 0,
266 pixel_height: 0,
267 })
268 .map_err(|e| format!("failed to open PTY: {e}"))?;
269
270 let wrapped = format!("exec 2>&1; {cmd}");
278 let mut builder = CommandBuilder::new("bash");
279 builder.arg("-c");
280 builder.arg(&wrapped);
281 builder.cwd(cwd);
282
283 let mut child = pair
284 .slave
285 .spawn_command(builder)
286 .map_err(|e| format!("failed to spawn PTY child: {e}"))?;
287
288 let mut killer = child.clone_killer();
291 let pid = child.process_id();
292
293 let mut reader = pair
294 .master
295 .try_clone_reader()
296 .map_err(|e| format!("failed to clone PTY reader: {e}"))?;
297 drop(pair.slave);
298
299 let (read_tx, read_rx) = std::sync::mpsc::channel::<String>();
301 let read_thread = std::thread::spawn(move || {
302 let mut buf = String::new();
303 let mut chunk = [0u8; 4096];
304 loop {
305 match reader.read(&mut chunk) {
306 Ok(0) => break,
307 Ok(n) => buf.push_str(&String::from_utf8_lossy(&chunk[..n])),
308 Err(e) => {
309 if e.kind() == std::io::ErrorKind::Interrupted {
310 continue;
311 }
312 break;
314 }
315 }
316 }
317 let _ = read_tx.send(buf);
318 });
319
320 let wait_thread = std::thread::spawn(move || child.wait());
322
323 let abort_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
328 {
329 let abort_flag = abort_flag.clone();
330 std::thread::spawn(move || {
331 let mut abort = abort;
332 loop {
333 match abort.try_recv() {
334 Ok(_) => {
335 abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
336 return;
337 }
338 Err(oneshot::error::TryRecvError::Closed) => return,
339 Err(oneshot::error::TryRecvError::Empty) => {
340 std::thread::sleep(Duration::from_millis(20));
341 }
342 }
343 }
344 });
345 }
346
347 let timeout_at = std::time::Instant::now() + timeout;
348 let wait_handle = wait_thread;
349 let (status_opt, timed_out, aborted): (Option<portable_pty::ExitStatus>, bool, bool) = loop {
350 if wait_handle.is_finished() {
351 let join_result = wait_handle.join();
352 let status = match join_result {
353 Ok(Ok(s)) => Some(s),
354 _ => None,
355 };
356 break (status, false, false);
357 }
358 if std::time::Instant::now() >= timeout_at {
359 pty_kill_process_group(pid, &mut killer);
360 break (None, true, false);
361 }
362 if abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
363 pty_kill_process_group(pid, &mut killer);
364 break (None, true, true);
365 }
366 std::thread::sleep(Duration::from_millis(10));
367 };
368
369 let raw = read_rx
375 .recv_timeout(Duration::from_millis(500))
376 .unwrap_or_default();
377 let join_deadline = std::time::Instant::now() + Duration::from_secs(2);
378 while !read_thread.is_finished() && std::time::Instant::now() < join_deadline {
379 std::thread::sleep(Duration::from_millis(20));
380 }
381 drop(read_thread);
385
386 if aborted {
387 let filtered = ansi_filter(&raw);
388 return Err(format!("Command aborted; partial output:\n{filtered}"));
389 }
390 if timed_out {
391 let filtered = ansi_filter(&raw);
392 return Err(format!(
393 "Command timed out after {} seconds; partial output:\n{filtered}",
394 timeout.as_secs(),
395 ));
396 }
397
398 let filtered = ansi_filter(&raw);
399 Ok(PtyOutcome {
400 output: filtered,
401 exit_code: status_opt.map(|s| s.exit_code() as i32),
402 })
403}
404
405#[cfg(unix)]
409fn pty_kill_process_group(
410 pid: Option<u32>,
411 killer: &mut Box<dyn portable_pty::ChildKiller + Send + Sync>,
412) {
413 if let Some(pid) = pid {
414 unsafe {
418 libc::kill(-(pid as i32), libc::SIGKILL);
419 }
420 } else {
421 let _ = killer.kill();
422 }
423}
424
425const DEFAULT_TIMEOUT_SECS: u64 = 120;
427
428pub struct BashTool {
430 root_dir: Option<PathBuf>,
431 progress_callback: Arc<std::sync::Mutex<Option<ProgressCallback>>>,
432}
433
434impl BashTool {
435 pub fn new() -> Self {
437 Self {
438 root_dir: None,
439 progress_callback: Arc::new(std::sync::Mutex::new(None)),
440 }
441 }
442
443 pub fn with_cwd(cwd: PathBuf) -> Self {
445 Self {
446 root_dir: Some(cwd),
447 progress_callback: Arc::new(std::sync::Mutex::new(None)),
448 }
449 }
450
451 fn format_duration(duration: Duration) -> String {
453 let secs = duration.as_secs();
454 let millis = duration.subsec_millis();
455 if secs >= 60 {
456 let mins = secs / 60;
457 let remain_secs = secs % 60;
458 format!(
459 "{}m {:.1}s",
460 mins,
461 remain_secs as f64 + millis as f64 / 1000.0
462 )
463 } else {
464 format!("{:.1}s", secs as f64 + millis as f64 / 1000.0)
465 }
466 }
467
468 fn build_output(
470 truncation: &TruncationResult,
471 elapsed: Duration,
472 exit_code: Option<i32>,
473 ) -> String {
474 let mut output = truncation.content.clone();
475
476 if truncation.truncated {
478 let notice = match truncation.truncated_by {
479 truncate::TruncatedBy::Lines => format!(
480 "\n\n[Truncated: showing {} of {} lines. {} bytes remaining]",
481 truncation.output_lines,
482 truncation.total_lines,
483 truncate::format_bytes(
484 truncation
485 .total_bytes
486 .saturating_sub(truncation.output_bytes)
487 )
488 ),
489 truncate::TruncatedBy::Bytes => format!(
490 "\n\n[Truncated: {} lines shown ({} byte limit). Total was {} lines, {}]",
491 truncation.output_lines,
492 truncate::format_bytes(truncate::DEFAULT_MAX_BYTES),
493 truncation.total_lines,
494 truncate::format_bytes(truncation.total_bytes)
495 ),
496 truncate::TruncatedBy::None => String::new(),
497 };
498 output.push_str(¬ice);
499 }
500
501 if let Some(code) = exit_code
503 && code != 0
504 {
505 output.push_str(&format!("\n\nCommand exited with code {}", code));
506 }
507
508 output.push_str(&format!("\n\nTook {}", Self::format_duration(elapsed)));
510
511 output
512 }
513
514 async fn wait_with_timeout_and_signal(
516 child: &mut tokio::process::Child,
517 timeout: u64,
518 signal: &mut Option<oneshot::Receiver<()>>,
519 ) -> Result<std::process::ExitStatus, String> {
520 let timeout_duration = Duration::from_secs(timeout);
521
522 tokio::select! {
523 status = child.wait() => {
524 status.map_err(|e| format!("Failed to wait for process: {}", e))
525 }
526 _ = tokio::time::sleep(timeout_duration) => {
527 Self::kill_process_group(child).await;
528 Err(format!("Command timed out after {} seconds", timeout))
529 }
530 _ = async {
531 match signal {
532 Some(rx) => { let _ = rx.await; }
533 None => std::future::pending::<()>().await,
534 }
535 } => {
536 Self::kill_process_group(child).await;
537 Err("Command aborted".to_string())
538 }
539 }
540 }
541
542 fn build_shell_command(
544 command: &str,
545 work_dir: &Option<String>,
546 env: Option<&serde_json::Map<String, Value>>,
547 ) -> Command {
548 let mut cmd = Command::new("sh");
549 cmd.arg("-c")
550 .arg(command)
551 .stdout(std::process::Stdio::piped())
552 .stderr(std::process::Stdio::piped())
553 .process_group(0);
554
555 if let Some(dir) = work_dir {
556 cmd.current_dir(dir);
557 }
558
559 if let Some(env_map) = env {
560 for (key, val) in env_map {
561 if BLOCKED_ENV_VARS
562 .iter()
563 .any(|blocked| blocked.eq_ignore_ascii_case(key))
564 {
565 continue;
566 }
567 if let Some(val_str) = val.as_str() {
568 cmd.env(key, val_str);
569 }
570 }
571 }
572
573 cmd
574 }
575
576 async fn kill_process_group(child: &mut tokio::process::Child) {
578 #[cfg(unix)]
579 {
580 if let Some(pid) = child.id() {
581 let pgid = -(pid as i32);
582 unsafe {
586 libc::kill(pgid, libc::SIGKILL);
587 }
588 }
589 }
590 let _ = child.kill().await;
591 let _ = child.wait().await;
592 }
593
594 fn format_error_output(
596 stdout_str: &str,
597 stderr_str: &str,
598 error_msg: &str,
599 elapsed: Duration,
600 ) -> String {
601 let mut output = String::new();
602 if !stdout_str.is_empty() {
603 output.push_str(stdout_str);
604 }
605 if !stderr_str.is_empty() {
606 if !output.is_empty() {
607 output.push('\n');
608 }
609 output.push_str(stderr_str);
610 }
611
612 if !output.is_empty() {
613 let truncation = truncate::truncate_head(&output, &TruncationOptions::default());
614 output = truncation.content;
615 }
616
617 output.push_str(&format!("\n\n{}", error_msg));
618 output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
619 output
620 }
621
622 async fn run_command(
624 root_dir: &Path,
625 command: &str,
626 cwd: Option<&str>,
627 env: Option<&serde_json::Map<String, Value>>,
628 timeout_secs: Option<u64>,
629 progress_cb: &Option<ProgressCallback>,
630 mut signal: Option<oneshot::Receiver<()>>,
631 ) -> Result<AgentToolResult, ToolError> {
632 if let Some(cb) = progress_cb {
633 cb(format!("Executing: {}", command));
634 }
635
636 let timeout = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
637 let start = Instant::now();
638
639 let work_dir = match cwd {
641 Some(dir) if !dir.is_empty() => {
642 let validated = validate_cwd(dir, Some(root_dir))?;
643 Some(validated.to_string_lossy().to_string())
644 }
645 _ => Some(root_dir.to_string_lossy().to_string()),
646 };
647
648 let mut cmd = Self::build_shell_command(command, &work_dir, env);
650
651 let mut child = cmd
653 .spawn()
654 .map_err(|e| format!("Failed to spawn command: {}", e))?;
655
656 let mut stdout_pipe = child
658 .stdout
659 .take()
660 .ok_or_else(|| "Failed to capture stdout".to_string())?;
661 let mut stderr_pipe = child
662 .stderr
663 .take()
664 .ok_or_else(|| "Failed to capture stderr".to_string())?;
665
666 let stdout_handle = tokio::spawn(async move {
668 let mut buf = Vec::new();
669 let _ = stdout_pipe.read_to_end(&mut buf).await;
670 buf
671 });
672 let stderr_handle = tokio::spawn(async move {
673 let mut buf = Vec::new();
674 let _ = stderr_pipe.read_to_end(&mut buf).await;
675 buf
676 });
677
678 let result = Self::wait_with_timeout_and_signal(&mut child, timeout, &mut signal).await;
680
681 let elapsed = start.elapsed();
682
683 let stdout_bytes = stdout_handle.await.unwrap_or_default();
685 let stderr_bytes = stderr_handle.await.unwrap_or_default();
686
687 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
688 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
689
690 if let Some(cb) = progress_cb {
691 cb(format!(
692 "Process completed in {}",
693 Self::format_duration(elapsed)
694 ));
695 }
696
697 match result {
698 Ok(status) => {
699 let exit_code = status.code();
700 if let Some(code) = exit_code
701 && let Some(cb) = progress_cb
702 {
703 cb(format!("Process exited with code {}", code));
704 }
705 let combined = if stderr_str.is_empty() {
706 stdout_str.clone()
707 } else if stdout_str.is_empty() {
708 stderr_str.clone()
709 } else {
710 format!("{}\n{}", stdout_str, stderr_str)
711 };
712
713 let security_warning = is_dangerous_command(command);
714
715 let truncation = truncate::truncate_head(
716 if combined.is_empty() {
717 "(no output)"
718 } else {
719 &combined
720 },
721 &TruncationOptions::default(),
722 );
723
724 let mut output = Self::build_output(&truncation, elapsed, exit_code);
725
726 if let Some(ref warning) = security_warning {
727 output.push_str(&format!("\n{}", warning));
728 }
729
730 if status.success() {
731 Ok(AgentToolResult::success(output))
732 } else {
733 Ok(AgentToolResult::error(output))
734 }
735 }
736 Err(e) => {
737 let output = Self::format_error_output(&stdout_str, &stderr_str, &e, elapsed);
738 Ok(AgentToolResult::error(output))
739 }
740 }
741 }
742}
743
744impl Default for BashTool {
745 fn default() -> Self {
746 Self::new()
747 }
748}
749
750#[async_trait]
751impl AgentTool for BashTool {
752 fn name(&self) -> &str {
753 "bash"
754 }
755
756 fn label(&self) -> &str {
757 "Bash"
758 }
759
760 fn essential(&self) -> bool {
761 true
762 }
763 fn description(&self) -> &str {
764 "Execute a bash command in a shell. Returns stdout and stderr. \
765 Output is truncated to 2000 lines or 50KB (whichever is hit first). \
766 Set timeout to limit execution time."
767 }
768
769 fn parameters_schema(&self) -> Value {
770 json!({
771 "type": "object",
772 "properties": {
773 "command": {
774 "type": "string",
775 "description": "The bash command to execute"
776 },
777 "timeout": {
778 "type": "integer",
779 "description": "Timeout in seconds (default: 120)",
780 "default": 120
781 },
782 "cwd": {
783 "type": "string",
784 "description": "Working directory for the command (optional)"
785 },
786 "env": {
787 "type": "object",
788 "description": "Environment variables as key-value pairs (optional)",
789 "additionalProperties": {
790 "type": "string"
791 }
792 }
793 },
794 "required": ["command"]
795 })
796 }
797
798 async fn execute(
799 &self,
800 _tool_call_id: &str,
801 params: Value,
802 signal: Option<oneshot::Receiver<()>>,
803 ctx: &ToolContext,
804 ) -> Result<AgentToolResult, ToolError> {
805 let command = params
806 .get("command")
807 .and_then(|v: &Value| v.as_str())
808 .ok_or_else(|| "Missing required parameter: command".to_string())?;
809
810 if std::env::var_os("OXICODE_STRICT_BASH").as_deref() == Some(std::ffi::OsStr::new("1"))
823 && let Some(reason) = is_dangerous_command(command)
824 {
825 return Err(format!(
826 "OXICODE_STRICT_BASH=1 blocked dangerous command: {reason}"
827 ));
828 }
829
830 let cwd = params.get("cwd").and_then(|v: &Value| v.as_str());
831 let timeout = params.get("timeout").and_then(|v: &Value| v.as_u64());
832 let env = params.get("env").and_then(|v: &Value| v.as_object());
833
834 #[allow(clippy::expect_used)]
838 let progress_cb = self
839 .progress_callback
840 .lock()
841 .expect("progress callback lock poisoned")
842 .clone();
843
844 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
846
847 #[cfg(unix)]
857 if std::env::var_os("OXICODE_BASH_PTY").as_deref() == Some(std::ffi::OsStr::new("1")) {
858 let work_dir = match cwd {
859 Some(dir) if !dir.is_empty() => validate_cwd(dir, Some(root))?,
860 _ => root.to_path_buf(),
861 };
862 let timeout_secs = timeout.unwrap_or(DEFAULT_TIMEOUT_SECS);
863 let start = Instant::now();
864 if let Some(cb) = &progress_cb {
865 cb(format!("Executing (pty): {}", command));
866 }
867 let abort_rx = match signal {
872 Some(rx) => rx,
873 None => {
874 let (tx, rx) = oneshot::channel();
875 drop(tx);
876 rx
877 }
878 };
879 let outcome = run_in_pty(
880 command,
881 &work_dir,
882 Duration::from_secs(timeout_secs),
883 abort_rx,
884 )
885 .await;
886 let elapsed = start.elapsed();
887 if let Some(cb) = &progress_cb {
888 cb(format!(
889 "Process (pty) completed in {}",
890 Self::format_duration(elapsed)
891 ));
892 }
893 return match outcome {
894 Ok(o) => {
895 let combined = if o.output.is_empty() {
896 "(no output)".to_string()
897 } else {
898 o.output
899 };
900 let truncation =
901 truncate::truncate_head(&combined, &TruncationOptions::default());
902 let mut output = Self::build_output(&truncation, elapsed, o.exit_code);
903 if let Some(reason) = is_dangerous_command(command) {
904 output.push_str(&format!("\n{}", reason));
905 }
906 if o.exit_code == Some(0) {
907 Ok(AgentToolResult::success(output))
908 } else {
909 Ok(AgentToolResult::error(output))
910 }
911 }
912 Err(e) => {
913 let mut output = format!("\n\n{}", e);
914 output.push_str(&format!("\nTook {}", Self::format_duration(elapsed)));
915 Ok(AgentToolResult::error(output))
916 }
917 };
918 }
919
920 Self::run_command(root, command, cwd, env, timeout, &progress_cb, signal).await
921 }
922
923 fn on_progress(&self, callback: ProgressCallback) {
924 let cb = self.progress_callback.clone();
925 #[allow(clippy::expect_used)]
928 let mut guard = cb.lock().expect("progress callback lock poisoned");
929 *guard = Some(callback);
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 fn make_params(command: &str) -> Value {
938 json!({ "command": command })
939 }
940
941 fn make_params_with_timeout(command: &str, timeout: u64) -> Value {
942 json!({ "command": command, "timeout": timeout })
943 }
944
945 fn make_params_with_cwd(command: &str, cwd: &str) -> Value {
946 json!({ "command": command, "cwd": cwd })
947 }
948
949 fn make_params_with_env(command: &str, env: serde_json::Value) -> Value {
950 json!({ "command": command, "env": env })
951 }
952
953 #[tokio::test]
954 async fn test_simple_command() {
955 let tool = BashTool::new();
956 let result = tool
957 .execute(
958 "test-1",
959 make_params("echo hello"),
960 None,
961 &ToolContext::default(),
962 )
963 .await
964 .unwrap();
965 assert!(result.success);
966 assert!(result.output.contains("hello"));
967 }
968
969 #[tokio::test]
970 async fn test_command_with_args() {
971 let tool = BashTool::new();
972 let result = tool
973 .execute(
974 "test-2",
975 make_params("echo hello world"),
976 None,
977 &ToolContext::default(),
978 )
979 .await
980 .unwrap();
981 assert!(result.success);
982 assert!(result.output.contains("hello world"));
983 }
984
985 #[tokio::test]
986 async fn test_failed_command() {
987 let tool = BashTool::new();
988 let result = tool
989 .execute(
990 "test-3",
991 make_params("exit 1"),
992 None,
993 &ToolContext::default(),
994 )
995 .await
996 .unwrap();
997 assert!(!result.success);
998 assert!(result.output.contains("exited with code 1"));
999 }
1000
1001 #[tokio::test]
1002 async fn test_missing_command_param() {
1003 let tool = BashTool::new();
1004 let result = tool
1005 .execute("test-4", json!({}), None, &ToolContext::default())
1006 .await;
1007 assert!(result.is_err());
1008 assert!(
1009 result
1010 .unwrap_err()
1011 .contains("Missing required parameter: command")
1012 );
1013 }
1014
1015 #[tokio::test]
1016 async fn test_no_output() {
1017 let tool = BashTool::new();
1018 let result = tool
1019 .execute("test-5", make_params("true"), None, &ToolContext::default())
1020 .await
1021 .unwrap();
1022 assert!(result.success);
1023 assert!(result.output.contains("(no output)"));
1024 }
1025
1026 #[tokio::test]
1027 async fn test_stderr_capture() {
1028 let tool = BashTool::new();
1029 let result = tool
1030 .execute(
1031 "test-6",
1032 make_params("echo error_msg >&2"),
1033 None,
1034 &ToolContext::default(),
1035 )
1036 .await
1037 .unwrap();
1038 assert!(result.success);
1039 assert!(result.output.contains("error_msg"));
1040 }
1041
1042 #[tokio::test]
1043 async fn test_timeout_kills_process() {
1044 let tool = BashTool::new();
1045 let result = tool
1046 .execute(
1047 "test-7",
1048 make_params_with_timeout("sleep 300", 1),
1049 None,
1050 &ToolContext::default(),
1051 )
1052 .await
1053 .unwrap();
1054 assert!(!result.success);
1055 assert!(result.output.contains("timed out"));
1056 }
1057
1058 #[tokio::test]
1059 async fn test_timeout_default() {
1060 let tool = BashTool::new();
1062 let schema = tool.parameters_schema();
1063 assert_eq!(schema["properties"]["timeout"]["default"], 120);
1064 }
1065
1066 #[tokio::test]
1067 async fn test_working_directory() {
1068 let tool = BashTool::with_cwd(PathBuf::from("/tmp"));
1069 let result = tool
1070 .execute(
1071 "test-8",
1072 make_params_with_cwd("pwd", "/tmp"),
1073 None,
1074 &ToolContext::default(),
1075 )
1076 .await
1077 .unwrap();
1078 assert!(result.success);
1079 assert!(result.output.contains("/tmp") || result.output.contains("/private/tmp"));
1080 }
1081
1082 #[tokio::test]
1083 async fn test_working_directory_nonexistent() {
1084 let tool = BashTool::new();
1085 let result = tool
1086 .execute(
1087 "test-9",
1088 make_params_with_cwd("echo hi", "/nonexistent/dir/xyz"),
1089 None,
1090 &ToolContext::default(),
1091 )
1092 .await;
1093 assert!(result.is_err());
1094 assert!(result.unwrap_err().contains("does not exist"));
1095 }
1096
1097 #[tokio::test]
1098 async fn test_working_directory_traversal() {
1099 let tool = BashTool::new();
1100 let result = tool
1101 .execute(
1102 "test-10",
1103 make_params_with_cwd("echo hi", "/tmp/../etc"),
1104 None,
1105 &ToolContext::default(),
1106 )
1107 .await;
1108 assert!(result.is_err());
1109 assert!(result.unwrap_err().contains("Path traversal"));
1110 }
1111
1112 #[tokio::test]
1113 async fn test_env_variables() {
1114 let tool = BashTool::new();
1115 let result = tool
1116 .execute(
1117 "test-11",
1118 make_params_with_env(
1119 "echo $OXICODE_TEST_VAR",
1120 json!({ "OXICODE_TEST_VAR": "hello_from_env" }),
1121 ),
1122 None,
1123 &ToolContext::default(),
1124 )
1125 .await
1126 .unwrap();
1127 assert!(result.success);
1128 assert!(result.output.contains("hello_from_env"));
1129 }
1130
1131 #[tokio::test]
1132 async fn test_env_variables_multiple() {
1133 let tool = BashTool::new();
1134 let result = tool
1135 .execute(
1136 "test-12",
1137 make_params_with_env(
1138 "echo $OXICODE_A $OXICODE_B",
1139 json!({ "OXICODE_A": "first", "OXICODE_B": "second" }),
1140 ),
1141 None,
1142 &ToolContext::default(),
1143 )
1144 .await
1145 .unwrap();
1146 assert!(result.success);
1147 assert!(result.output.contains("first second"));
1148 }
1149
1150 #[tokio::test]
1151 async fn test_duration_timing() {
1152 let tool = BashTool::new();
1153 let result = tool
1154 .execute(
1155 "test-13",
1156 make_params("sleep 0.1 && echo done"),
1157 None,
1158 &ToolContext::default(),
1159 )
1160 .await
1161 .unwrap();
1162 assert!(result.success);
1163 assert!(result.output.contains("Took "));
1164 assert!(result.output.contains("s")); }
1166
1167 #[tokio::test]
1168 async fn test_combined_stdout_stderr() {
1169 let tool = BashTool::new();
1170 let result = tool
1171 .execute(
1172 "test",
1173 make_params("echo stdout_msg; echo stderr_msg >&2"),
1174 None,
1175 &ToolContext::default(),
1176 )
1177 .await
1178 .unwrap();
1179 assert!(result.success);
1180 assert!(result.output.contains("stdout_msg"));
1181 assert!(result.output.contains("stderr_msg"));
1182 }
1183
1184 #[tokio::test]
1185 async fn test_output_truncation() {
1186 let tool = BashTool::new();
1187 let result = tool
1189 .execute(
1190 "test-15",
1191 make_params("seq 1 3000"),
1192 None,
1193 &ToolContext::default(),
1194 )
1195 .await
1196 .unwrap();
1197 assert!(result.success);
1198 assert!(result.output.contains("truncated") || result.output.contains("Truncated"));
1199 }
1200
1201 #[tokio::test]
1202 async fn test_signal_aborts_process() {
1203 let tool = BashTool::new();
1204 let (tx, rx) = oneshot::channel();
1205
1206 tokio::spawn(async move {
1208 tokio::time::sleep(Duration::from_millis(100)).await;
1209 let _ = tx.send(());
1210 });
1211
1212 let result = tool
1213 .execute(
1214 "test-16",
1215 make_params("sleep 300"),
1216 Some(rx),
1217 &ToolContext::default(),
1218 )
1219 .await
1220 .unwrap();
1221 assert!(!result.success);
1222 assert!(result.output.contains("aborted"));
1223 }
1224
1225 #[tokio::test]
1226 async fn test_parameters_schema() {
1227 let tool = BashTool::new();
1228 let schema = tool.parameters_schema();
1229
1230 let required = schema["required"].as_array().unwrap();
1232 assert!(required.iter().any(|r| r.as_str() == Some("command")));
1233
1234 let props = schema["properties"].as_object().unwrap();
1236 assert!(props.contains_key("command"));
1237 assert!(props.contains_key("timeout"));
1238 assert!(props.contains_key("cwd"));
1239 assert!(props.contains_key("env"));
1240
1241 assert_eq!(props["command"]["type"], "string");
1243 assert_eq!(props["timeout"]["type"], "integer");
1244 assert_eq!(props["cwd"]["type"], "string");
1245 assert_eq!(props["env"]["type"], "object");
1246 }
1247
1248 #[tokio::test]
1249 async fn test_multiline_output() {
1250 let tool = BashTool::new();
1251 let result = tool
1252 .execute(
1253 "test",
1254 make_params("echo line1 && echo line2 && echo line3"),
1255 None,
1256 &ToolContext::default(),
1257 )
1258 .await
1259 .unwrap();
1260 assert!(result.success);
1261 assert!(result.output.contains("line1"));
1262 assert!(result.output.contains("line2"));
1263 assert!(result.output.contains("line3"));
1264 }
1265
1266 #[tokio::test]
1267 async fn test_format_duration() {
1268 assert_eq!(
1269 BashTool::format_duration(Duration::from_millis(500)),
1270 "0.5s"
1271 );
1272 assert_eq!(BashTool::format_duration(Duration::from_secs(1)), "1.0s");
1273 assert_eq!(
1274 BashTool::format_duration(Duration::from_secs(65)),
1275 "1m 5.0s"
1276 );
1277 assert_eq!(
1278 BashTool::format_duration(Duration::from_secs(120)),
1279 "2m 0.0s"
1280 );
1281 }
1282
1283 #[tokio::test]
1291 async fn test_strict_bash_blocks_pipe_to_shell() {
1292 unsafe {
1295 std::env::set_var("OXICODE_STRICT_BASH", "1");
1296 }
1297 let tool = BashTool::new();
1298 let result = tool
1299 .execute(
1300 "test-strict",
1301 make_params("echo hi | sh"),
1302 None,
1303 &ToolContext::default(),
1304 )
1305 .await;
1306 unsafe {
1307 std::env::remove_var("OXICODE_STRICT_BASH");
1308 }
1309 let err = result.expect_err("strict mode must refuse `| sh` commands");
1311 assert!(
1312 err.contains("OXICODE_STRICT_BASH") && err.contains("pipe to shell"),
1313 "unexpected error: {err}"
1314 );
1315 }
1316
1317 #[tokio::test]
1320 async fn test_strict_bash_off_preserves_warning_behavior() {
1321 unsafe {
1323 std::env::remove_var("OXICODE_STRICT_BASH");
1324 }
1325 let tool = BashTool::new();
1326 let result = tool
1327 .execute(
1328 "test-lenient",
1329 make_params("echo hi"),
1330 None,
1331 &ToolContext::default(),
1332 )
1333 .await;
1334 let r = result.expect("non-dangerous command must succeed when strict is off");
1335 assert!(r.success, "echo hi must succeed: {}", r.output);
1336 assert!(!r.output.contains("OXICODE_STRICT_BASH"));
1337 }
1338
1339 #[cfg(unix)]
1346 #[tokio::test]
1347 async fn pty_preserves_color_codes() {
1348 let cwd = std::env::current_dir().expect("current_dir");
1349 let (_tx, rx) = oneshot::channel::<()>();
1350 let outcome = run_in_pty(
1351 "printf '\\x1b[31mred\\x1b[0m'",
1352 &cwd,
1353 Duration::from_secs(10),
1354 rx,
1355 )
1356 .await
1357 .expect("run_in_pty");
1358 assert!(
1359 outcome.output.contains("\x1b[31m"),
1360 "PTY output must preserve the \\x1b[31m SGR escape; got: {:?}",
1361 outcome.output
1362 );
1363 assert!(
1364 outcome.output.contains("red"),
1365 "PTY output must contain the printed text; got: {:?}",
1366 outcome.output
1367 );
1368 }
1369
1370 #[cfg(unix)]
1376 #[tokio::test]
1377 async fn pty_strips_cursor_motion() {
1378 let cwd = std::env::current_dir().expect("current_dir");
1379 let (_tx, rx) = oneshot::channel::<()>();
1380 let outcome = run_in_pty(
1381 "printf '\\x1b[2Jhello\\x1b[H'",
1382 &cwd,
1383 Duration::from_secs(10),
1384 rx,
1385 )
1386 .await
1387 .expect("run_in_pty");
1388 assert!(
1389 !outcome.output.contains("\x1b[2J"),
1390 "PTY output must drop the screen-clear CSI; got: {:?}",
1391 outcome.output
1392 );
1393 assert!(
1394 !outcome.output.contains("\x1b[H"),
1395 "PTY output must drop the cursor-home CSI; got: {:?}",
1396 outcome.output
1397 );
1398 assert!(
1399 outcome.output.contains("hello"),
1400 "PTY output must contain the printed text; got: {:?}",
1401 outcome.output
1402 );
1403 }
1404
1405 #[cfg(unix)]
1412 #[tokio::test]
1413 async fn pty_timeout_kills_process_group_promptly() {
1414 let cwd = std::env::current_dir().expect("current_dir");
1415 let (_tx, rx) = oneshot::channel::<()>();
1416 let start = std::time::Instant::now();
1417 let result = run_in_pty("sleep 30", &cwd, Duration::from_millis(200), rx).await;
1418 let elapsed = start.elapsed();
1419 let err = result.expect_err("sleep 30 must time out within 200ms budget");
1420 assert!(
1423 elapsed < Duration::from_secs(5),
1424 "run_in_pty took {elapsed:?} after timeout — likely a hang on join"
1425 );
1426 assert!(
1427 err.contains("timed out") || err.contains("Timeout"),
1428 "error must mention the timeout: {err}"
1429 );
1430 }
1431
1432 #[cfg(unix)]
1436 #[tokio::test]
1437 async fn pty_abort_signal_tears_down_promptly() {
1438 let cwd = std::env::current_dir().expect("current_dir");
1439 let (tx, rx) = oneshot::channel::<()>();
1440 let start = std::time::Instant::now();
1441 std::thread::spawn(move || {
1446 std::thread::sleep(Duration::from_millis(100));
1447 let _ = tx.send(());
1448 });
1449 let result = run_in_pty("sleep 30", &cwd, Duration::from_secs(30), rx).await;
1450 let elapsed = start.elapsed();
1451 let err = result.expect_err("sleep 30 must be aborted by signal");
1452 assert!(
1453 elapsed < Duration::from_secs(5),
1454 "abort took {elapsed:?} — likely a hang on join"
1455 );
1456 assert!(
1457 err.contains("aborted") || err.contains("Aborted"),
1458 "error must mention the abort: {err}"
1459 );
1460 }
1461}