1use anyhow::{Context, Result};
2use serde::Deserialize;
3use serde_json::{Value, json};
4use std::collections::HashMap;
5use std::process::Command;
6use std::time::Duration;
7use zeroize::Zeroizing;
8
9use brainwires_core::{Tool, ToolContext, ToolInputSchema, ToolResult};
10
11#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
13#[serde(rename_all = "snake_case")]
14pub enum OutputMode {
15 #[default]
17 Full,
18 Head,
20 Tail,
22 Filter,
24 Count,
26 Smart,
28}
29
30#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
32#[serde(rename_all = "snake_case")]
33pub enum StderrMode {
34 #[default]
36 Separate,
37 Combined,
39 StderrOnly,
41 Suppress,
43}
44
45#[derive(Debug, Clone, Default)]
47pub struct OutputLimits {
48 pub max_lines: Option<u32>,
50 pub output_mode: OutputMode,
52 pub filter_pattern: Option<String>,
54 pub stderr_mode: StderrMode,
56 pub auto_limit: bool,
58}
59
60const MAX_STREAM_BYTES: usize = 25_000;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum BashSandboxMode {
73 Off,
75 NetworkDeny,
80}
81
82impl BashSandboxMode {
83 pub fn from_env() -> Self {
86 match std::env::var("BRAINWIRES_BASH_SANDBOX").as_deref() {
87 Ok("network-deny") | Ok("networkdeny") | Ok("1") | Ok("on") => Self::NetworkDeny,
88 _ => Self::Off,
89 }
90 }
91}
92
93fn apply_sandbox(command: &str, mode: BashSandboxMode) -> String {
100 match mode {
101 BashSandboxMode::Off => command.to_string(),
102 BashSandboxMode::NetworkDeny => {
103 if cfg!(target_os = "linux") {
104 format!(
109 "unshare -U -r -n -- bash -o pipefail -c {}",
110 shell_escape(command)
111 )
112 } else {
113 command.to_string()
117 }
118 }
119 }
120}
121
122fn truncate_middle(s: &str, max_bytes: usize) -> std::borrow::Cow<'_, str> {
125 if s.len() <= max_bytes {
126 return std::borrow::Cow::Borrowed(s);
127 }
128 let head_bytes = max_bytes / 2;
129 let tail_bytes = max_bytes - head_bytes;
130 let mut head_end = head_bytes.min(s.len());
132 while !s.is_char_boundary(head_end) {
133 head_end -= 1;
134 }
135 let mut tail_start = s.len().saturating_sub(tail_bytes);
136 while !s.is_char_boundary(tail_start) {
137 tail_start += 1;
138 }
139 let skipped = s.len() - head_end - (s.len() - tail_start);
140 std::borrow::Cow::Owned(format!(
141 "{}\n… [{} bytes truncated] …\n{}",
142 &s[..head_end],
143 skipped,
144 &s[tail_start..],
145 ))
146}
147
148const INTERACTIVE_COMMANDS: &[&str] = &[
150 "vim",
151 "vi",
152 "nvim",
153 "nano",
154 "emacs",
155 "pico",
156 "less",
157 "more",
158 "most",
159 "top",
160 "htop",
161 "btop",
162 "glances",
163 "man",
164 "info",
165 "ssh",
166 "telnet",
167 "ftp",
168 "sftp",
169 "python",
170 "python3",
171 "node",
172 "irb",
173 "ghci",
174 "lua",
175 "mysql",
176 "psql",
177 "sqlite3",
178 "mongo",
179 "redis-cli",
180];
181
182pub struct BashTool;
184
185impl BashTool {
186 pub fn get_tools() -> Vec<Tool> {
188 vec![Self::execute_command_tool()]
189 }
190
191 fn execute_command_tool() -> Tool {
193 let mut properties = HashMap::new();
194 properties.insert(
195 "command".to_string(),
196 json!({
197 "type": "string",
198 "description": "The bash command to execute"
199 }),
200 );
201 properties.insert(
202 "timeout".to_string(),
203 json!({
204 "type": "number",
205 "description": "Timeout in seconds (default: 30)",
206 "default": 30
207 }),
208 );
209 properties.insert(
210 "max_lines".to_string(),
211 json!({
212 "type": "number",
213 "description": "Maximum output lines. Applies head -n or tail -n based on output_mode."
214 }),
215 );
216 properties.insert(
217 "output_mode".to_string(),
218 json!({
219 "type": "string",
220 "enum": ["full", "head", "tail", "filter", "count", "smart"],
221 "description": "Output limiting mode: full (no limit), head (first N lines), tail (last N lines), filter (grep pattern), count (line count only), smart (auto-detect based on command)",
222 "default": "smart"
223 }),
224 );
225 properties.insert(
226 "filter_pattern".to_string(),
227 json!({
228 "type": "string",
229 "description": "Grep pattern to filter output (used when output_mode is 'filter')"
230 }),
231 );
232 properties.insert(
233 "stderr_mode".to_string(),
234 json!({
235 "type": "string",
236 "enum": ["separate", "combined", "stderr_only", "suppress"],
237 "description": "Stderr handling: separate (keep separate), combined (merge with stdout via 2>&1), stderr_only (discard stdout), suppress (discard stderr)",
238 "default": "combined"
239 }),
240 );
241 properties.insert(
242 "auto_limit".to_string(),
243 json!({
244 "type": "boolean",
245 "description": "Automatically apply smart output limits based on command type (default: true)",
246 "default": true
247 }),
248 );
249
250 Tool {
251 name: "execute_command".to_string(),
252 description: "Execute a bash command and return the output. Supports proactive output limiting to manage context size.".to_string(),
253 input_schema: ToolInputSchema::object(properties, vec!["command".to_string()]),
254 requires_approval: true,
255 ..Default::default()
256 }
257 }
258
259 #[tracing::instrument(name = "tool.execute", skip(input, context), fields(tool_name))]
261 pub fn execute(
262 tool_use_id: &str,
263 tool_name: &str,
264 input: &Value,
265 context: &ToolContext,
266 ) -> ToolResult {
267 let result = match tool_name {
268 "execute_command" => Self::execute_command(input, context),
269 _ => Err(anyhow::anyhow!("Unknown bash tool: {}", tool_name)),
270 };
271
272 match result {
273 Ok(output) => ToolResult::success(tool_use_id.to_string(), output),
274 Err(e) => ToolResult::error(
275 tool_use_id.to_string(),
276 format!("Command execution failed: {}", e),
277 ),
278 }
279 }
280
281 fn execute_command(input: &Value, context: &ToolContext) -> Result<String> {
282 let params = Self::parse_command_params(input)?;
283
284 if Self::is_interactive_command(¶ms.command) {
285 return Err(anyhow::anyhow!(
286 "Interactive command detected: '{}'. Use non-interactive alternatives instead.",
287 params
288 .command
289 .split_whitespace()
290 .next()
291 .unwrap_or(¶ms.command)
292 ));
293 }
294
295 Self::validate_command(¶ms.command)?;
296
297 let limits = Self::resolve_output_limits(¶ms);
298 let transformed_command = Self::transform_command(¶ms.command, &limits);
299
300 let output = Self::run_command_with_timeout(
301 &transformed_command,
302 &context.working_directory,
303 Duration::from_secs(params.timeout),
304 )?;
305
306 Self::format_command_output(¶ms.command, &transformed_command, &output, &limits)
307 }
308
309 fn is_interactive_command(command: &str) -> bool {
310 let first_word = command.split_whitespace().next().unwrap_or("");
311 let effective_command = if first_word == "sudo" || first_word == "env" {
312 command.split_whitespace().nth(1).unwrap_or("")
313 } else {
314 first_word
315 };
316 INTERACTIVE_COMMANDS.contains(&effective_command)
317 }
318
319 fn get_smart_limits(command: &str) -> OutputLimits {
320 let cmd_lower = command.to_lowercase();
321 let first_word = command.split_whitespace().next().unwrap_or("");
322
323 match first_word {
324 "cargo" if cmd_lower.contains("build") => OutputLimits {
325 max_lines: Some(80),
326 output_mode: OutputMode::Head,
327 stderr_mode: StderrMode::Combined,
328 ..Default::default()
329 },
330 "cargo" if cmd_lower.contains("test") => OutputLimits {
331 max_lines: Some(100),
332 output_mode: OutputMode::Head,
333 stderr_mode: StderrMode::Combined,
334 ..Default::default()
335 },
336 "cargo" if cmd_lower.contains("check") => OutputLimits {
337 max_lines: Some(60),
338 output_mode: OutputMode::Head,
339 stderr_mode: StderrMode::Combined,
340 ..Default::default()
341 },
342 "cargo" if cmd_lower.contains("clippy") => OutputLimits {
343 max_lines: Some(80),
344 output_mode: OutputMode::Head,
345 stderr_mode: StderrMode::Combined,
346 ..Default::default()
347 },
348 "npm" | "yarn" | "pnpm" | "bun" => OutputLimits {
349 max_lines: Some(50),
350 output_mode: OutputMode::Head,
351 stderr_mode: StderrMode::Combined,
352 ..Default::default()
353 },
354 "make" | "cmake" | "ninja" => OutputLimits {
355 max_lines: Some(100),
356 output_mode: OutputMode::Head,
357 stderr_mode: StderrMode::Combined,
358 ..Default::default()
359 },
360 "go" if cmd_lower.contains("build") || cmd_lower.contains("test") => OutputLimits {
361 max_lines: Some(50),
362 output_mode: OutputMode::Head,
363 stderr_mode: StderrMode::Combined,
364 ..Default::default()
365 },
366 "find" | "fd" => OutputLimits {
367 max_lines: Some(50),
368 output_mode: OutputMode::Head,
369 ..Default::default()
370 },
371 "locate" => OutputLimits {
372 max_lines: Some(30),
373 output_mode: OutputMode::Head,
374 ..Default::default()
375 },
376 "git" if cmd_lower.contains("log") => OutputLimits {
377 max_lines: Some(30),
378 output_mode: OutputMode::Head,
379 ..Default::default()
380 },
381 "git" if cmd_lower.contains("diff") => OutputLimits {
382 max_lines: Some(100),
383 output_mode: OutputMode::Head,
384 ..Default::default()
385 },
386 "git" if cmd_lower.contains("status") => OutputLimits {
387 max_lines: Some(50),
388 output_mode: OutputMode::Head,
389 ..Default::default()
390 },
391 "ps" => OutputLimits {
392 max_lines: Some(30),
393 output_mode: OutputMode::Head,
394 ..Default::default()
395 },
396 "docker" if cmd_lower.contains("logs") => OutputLimits {
397 max_lines: Some(50),
398 output_mode: OutputMode::Tail,
399 ..Default::default()
400 },
401 "docker" if cmd_lower.contains("ps") => OutputLimits {
402 max_lines: Some(30),
403 output_mode: OutputMode::Head,
404 ..Default::default()
405 },
406 "kubectl" if cmd_lower.contains("logs") => OutputLimits {
407 max_lines: Some(50),
408 output_mode: OutputMode::Tail,
409 ..Default::default()
410 },
411 "kubectl" => OutputLimits {
412 max_lines: Some(50),
413 output_mode: OutputMode::Head,
414 ..Default::default()
415 },
416 "pm2" if cmd_lower.contains("logs") => OutputLimits {
417 max_lines: Some(50),
418 output_mode: OutputMode::Tail,
419 ..Default::default()
420 },
421 "journalctl" => OutputLimits {
422 max_lines: Some(100),
423 output_mode: OutputMode::Tail,
424 ..Default::default()
425 },
426 "supervisorctl" if cmd_lower.contains("tail") => OutputLimits {
427 max_lines: Some(100),
428 output_mode: OutputMode::Tail,
429 ..Default::default()
430 },
431 "ls" => OutputLimits {
432 max_lines: Some(50),
433 output_mode: OutputMode::Head,
434 ..Default::default()
435 },
436 "tree" => OutputLimits {
437 max_lines: Some(80),
438 output_mode: OutputMode::Head,
439 ..Default::default()
440 },
441 "grep" | "rg" | "ag" | "ack" => OutputLimits {
442 max_lines: Some(50),
443 output_mode: OutputMode::Head,
444 ..Default::default()
445 },
446 _ => OutputLimits::default(),
447 }
448 }
449
450 fn handle_streaming_commands(command: &str, limits: &OutputLimits) -> String {
451 let cmd_lower = command.to_lowercase();
452 let first_word = command.split_whitespace().next().unwrap_or("");
453 let lines = limits.max_lines.unwrap_or(50);
454
455 match first_word {
456 "pm2" if cmd_lower.contains("logs") && !cmd_lower.contains("--nostream") => {
457 if cmd_lower.contains("--lines") {
458 format!("{} --nostream", command)
459 } else {
460 format!("{} --nostream --lines {}", command, lines)
461 }
462 }
463 "journalctl" if !cmd_lower.contains("-n ") && !cmd_lower.contains("--lines") => {
464 let mut result = command.to_string();
465 if !cmd_lower.contains("--no-pager") {
466 result = format!("{} --no-pager", result);
467 }
468 format!("{} -n {}", result, lines)
469 }
470 "docker"
471 if cmd_lower.contains("logs")
472 && (cmd_lower.contains("-f") || cmd_lower.contains("--follow")) =>
473 {
474 let cleaned = command
475 .replace(" -f ", " ")
476 .replace(" -f", "")
477 .replace(" --follow ", " ")
478 .replace(" --follow", "");
479 if !cleaned.to_lowercase().contains("--tail") {
480 format!("{} --tail {}", cleaned, lines)
481 } else {
482 cleaned
483 }
484 }
485 "kubectl"
486 if cmd_lower.contains("logs")
487 && (cmd_lower.contains("-f") || cmd_lower.contains("--follow")) =>
488 {
489 let cleaned = command
490 .replace(" -f ", " ")
491 .replace(" -f", "")
492 .replace(" --follow ", " ")
493 .replace(" --follow", "");
494 if !cleaned.to_lowercase().contains("--tail") {
495 format!("{} --tail={}", cleaned, lines)
496 } else {
497 cleaned
498 }
499 }
500 _ => command.to_string(),
501 }
502 }
503
504 fn transform_command(command: &str, limits: &OutputLimits) -> String {
505 let mut cmd = Self::handle_streaming_commands(command, limits);
506
507 if cmd == command
508 && limits.max_lines.is_none()
509 && limits.filter_pattern.is_none()
510 && limits.stderr_mode == StderrMode::Separate
511 && limits.output_mode == OutputMode::Full
512 {
513 return command.to_string();
514 }
515
516 match limits.stderr_mode {
517 StderrMode::Combined => {
518 cmd = format!("{} 2>&1", cmd);
519 }
520 StderrMode::StderrOnly => {
521 cmd = format!("{} 2>&1 >/dev/null", cmd);
522 }
523 StderrMode::Suppress => {
524 cmd = format!("{} 2>/dev/null", cmd);
525 }
526 StderrMode::Separate => {}
527 }
528
529 if let Some(pattern) = &limits.filter_pattern {
530 let escaped = pattern.replace('\'', "'\\''");
531 cmd = format!("{} | grep -E '{}'", cmd, escaped);
532 }
533
534 if let Some(n) = limits.max_lines {
535 match limits.output_mode {
536 OutputMode::Tail => {
537 cmd = format!("{} | tail -n {}", cmd, n);
538 }
539 OutputMode::Count => {
540 cmd = format!("{} | wc -l", cmd);
541 }
542 OutputMode::Head | OutputMode::Smart | OutputMode::Full | OutputMode::Filter => {
543 if limits.output_mode != OutputMode::Full {
544 cmd = format!("{} | head -n {}", cmd, n);
545 }
546 }
547 }
548 }
549
550 if cmd != command {
551 cmd = format!("set -o pipefail; {}", cmd);
552 }
553
554 let sandbox = BashSandboxMode::from_env();
558 if sandbox != BashSandboxMode::Off {
559 cmd = apply_sandbox(&cmd, sandbox);
560 }
561
562 cmd
563 }
564
565 fn validate_command(command: &str) -> Result<()> {
566 let dangerous_patterns = vec![
567 "rm -rf /",
568 "mkfs",
569 "> /dev/sda",
570 "dd if=/dev/zero",
571 ":(){ :|:& };:",
572 ];
573 for pattern in dangerous_patterns {
574 if command.contains(pattern) {
575 return Err(anyhow::anyhow!(
576 "Command contains potentially dangerous pattern: {}",
577 pattern
578 ));
579 }
580 }
581 Ok(())
582 }
583
584 pub fn execute_with_sudo(
586 tool_use_id: &str,
587 tool_name: &str,
588 input: &Value,
589 context: &ToolContext,
590 password: Zeroizing<String>,
591 ) -> ToolResult {
592 let result = match tool_name {
593 "execute_command" => Self::execute_command_with_sudo(input, context, password),
594 _ => Err(anyhow::anyhow!("Unknown bash tool: {}", tool_name)),
595 };
596 match result {
597 Ok(output) => ToolResult::success(tool_use_id.to_string(), output),
598 Err(e) => ToolResult::error(
599 tool_use_id.to_string(),
600 format!("Command execution failed: {}", e),
601 ),
602 }
603 }
604
605 fn execute_command_with_sudo(
606 input: &Value,
607 context: &ToolContext,
608 password: Zeroizing<String>,
609 ) -> Result<String> {
610 let params = Self::parse_command_params(input)?;
611 if Self::is_interactive_command(¶ms.command) {
612 return Err(anyhow::anyhow!(
613 "Interactive command detected: '{}'. Use non-interactive alternatives instead.",
614 params
615 .command
616 .split_whitespace()
617 .next()
618 .unwrap_or(¶ms.command)
619 ));
620 }
621 Self::validate_command(¶ms.command)?;
622 let limits = Self::resolve_output_limits(¶ms);
623 let transformed_command = Self::transform_command(¶ms.command, &limits);
624 let output = Self::run_command_with_sudo(
625 &transformed_command,
626 &context.working_directory,
627 password,
628 )?;
629 Self::format_command_output(¶ms.command, &transformed_command, &output, &limits)
630 }
631
632 fn run_command_with_sudo(
633 command: &str,
634 working_dir: &str,
635 password: Zeroizing<String>,
636 ) -> Result<CommandOutput> {
637 use std::io::Write;
638 use std::process::Stdio;
639
640 let effective_command = command.strip_prefix("sudo ").unwrap_or(command);
641 let sudo_command = format!(
642 "sudo -S bash -o pipefail -c {}",
643 shell_escape(effective_command)
644 );
645
646 let mut child = Command::new("bash")
647 .arg("-c")
648 .arg(&sudo_command)
649 .current_dir(working_dir)
650 .stdin(Stdio::piped())
651 .stdout(Stdio::piped())
652 .stderr(Stdio::piped())
653 .spawn()
654 .with_context(|| format!("Failed to spawn sudo command: {}", command))?;
655
656 if let Some(mut stdin) = child.stdin.take() {
657 let _ = writeln!(stdin, "{}", password.as_str());
658 }
659 drop(password);
660
661 let output = child
662 .wait_with_output()
663 .with_context(|| format!("Failed to wait for sudo command: {}", command))?;
664 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
665 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
666 let exit_code = output.status.code().unwrap_or(-1);
667 let filtered_stderr = stderr
668 .lines()
669 .filter(|line| !line.contains("[sudo] password for"))
670 .collect::<Vec<_>>()
671 .join("\n");
672
673 Ok(CommandOutput {
674 stdout,
675 stderr: filtered_stderr,
676 exit_code,
677 })
678 }
679
680 fn parse_command_params(input: &Value) -> Result<ParsedCommandParams> {
681 #[derive(Deserialize)]
682 struct ExecuteCommandInput {
683 command: String,
684 #[serde(default = "default_timeout")]
685 timeout: u64,
686 #[serde(default)]
687 max_lines: Option<u32>,
688 #[serde(default)]
689 output_mode: OutputMode,
690 #[serde(default)]
691 filter_pattern: Option<String>,
692 #[serde(default)]
693 stderr_mode: StderrMode,
694 #[serde(default = "default_auto_limit")]
695 auto_limit: bool,
696 }
697 fn default_timeout() -> u64 {
698 30
699 }
700 fn default_auto_limit() -> bool {
701 true
702 }
703
704 let raw: ExecuteCommandInput = serde_json::from_value(input.clone())?;
705 Ok(ParsedCommandParams {
706 command: raw.command,
707 timeout: raw.timeout,
708 max_lines: raw.max_lines,
709 output_mode: raw.output_mode,
710 filter_pattern: raw.filter_pattern,
711 stderr_mode: raw.stderr_mode,
712 auto_limit: raw.auto_limit,
713 })
714 }
715
716 fn resolve_output_limits(params: &ParsedCommandParams) -> OutputLimits {
717 let mut limits = OutputLimits {
718 max_lines: params.max_lines,
719 output_mode: params.output_mode.clone(),
720 filter_pattern: params.filter_pattern.clone(),
721 stderr_mode: params.stderr_mode.clone(),
722 auto_limit: params.auto_limit,
723 };
724 if limits.auto_limit && limits.output_mode == OutputMode::Smart {
725 let smart_limits = Self::get_smart_limits(¶ms.command);
726 if limits.max_lines.is_none() {
727 limits.max_lines = smart_limits.max_lines;
728 }
729 if limits.output_mode == OutputMode::Smart {
730 limits.output_mode = smart_limits.output_mode;
731 }
732 if limits.stderr_mode == StderrMode::Separate {
733 limits.stderr_mode = smart_limits.stderr_mode;
734 }
735 }
736 limits
737 }
738
739 fn format_command_output(
740 original_command: &str,
741 transformed_command: &str,
742 output: &CommandOutput,
743 limits: &OutputLimits,
744 ) -> Result<String> {
745 let mut result = format!("Command: {}\n", original_command);
746 if transformed_command != original_command {
747 result.push_str(&format!("Transformed: {}\n", transformed_command));
748 }
749 result.push_str(&format!("Exit Code: {}\n\n", output.exit_code));
750
751 let stdout_capped = truncate_middle(&output.stdout, MAX_STREAM_BYTES);
752 let stderr_capped = truncate_middle(&output.stderr, MAX_STREAM_BYTES);
753
754 if limits.stderr_mode == StderrMode::Combined
755 || limits.stderr_mode == StderrMode::StderrOnly
756 {
757 result.push_str(&format!("Output:\n{}", stdout_capped));
758 if !stderr_capped.is_empty() {
759 result.push_str(&format!("\n\nStderr (unmerged):\n{}", stderr_capped));
760 }
761 } else {
762 result.push_str(&format!(
763 "Stdout:\n{}\n\nStderr:\n{}",
764 stdout_capped, stderr_capped
765 ));
766 }
767 Ok(result)
768 }
769
770 fn run_command_with_timeout(
771 command: &str,
772 working_dir: &str,
773 _timeout: Duration,
774 ) -> Result<CommandOutput> {
775 use std::process::Stdio;
776 let output = Command::new("bash")
777 .arg("-o")
778 .arg("pipefail")
779 .arg("-c")
780 .arg(command)
781 .current_dir(working_dir)
782 .stdout(Stdio::piped())
783 .stderr(Stdio::piped())
784 .output()
785 .with_context(|| format!("Failed to execute command: {}", command))?;
786
787 Ok(CommandOutput {
788 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
789 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
790 exit_code: output.status.code().unwrap_or(-1),
791 })
792 }
793}
794
795struct CommandOutput {
796 stdout: String,
797 stderr: String,
798 exit_code: i32,
799}
800
801struct ParsedCommandParams {
802 command: String,
803 timeout: u64,
804 max_lines: Option<u32>,
805 output_mode: OutputMode,
806 filter_pattern: Option<String>,
807 stderr_mode: StderrMode,
808 auto_limit: bool,
809}
810
811fn shell_escape(s: &str) -> String {
812 format!("'{}'", s.replace('\'', "'\\''"))
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use serde_json::json;
819 use std::env;
820
821 fn create_test_context() -> ToolContext {
822 ToolContext {
823 working_directory: env::current_dir().unwrap().to_str().unwrap().to_string(),
824 ..Default::default()
825 }
826 }
827
828 #[test]
829 fn test_get_tools() {
830 let tools = BashTool::get_tools();
831 assert_eq!(tools.len(), 1);
832 assert_eq!(tools[0].name, "execute_command");
833 assert!(tools[0].requires_approval);
834 }
835
836 #[test]
837 fn test_execute_simple_command() {
838 let context = create_test_context();
839 let input = json!({"command": "echo 'Hello World'", "timeout": 5});
840 let result = BashTool::execute("bash-123", "execute_command", &input, &context);
841 assert!(!result.is_error);
842 assert!(result.content.contains("Hello World"));
843 assert!(result.content.contains("Exit Code: 0"));
844 }
845
846 #[test]
847 fn test_validate_command_dangerous_rm() {
848 let result = BashTool::validate_command("rm -rf /");
849 assert!(result.is_err());
850 }
851
852 #[test]
853 fn test_validate_command_safe() {
854 let result = BashTool::validate_command("ls -la");
855 assert!(result.is_ok());
856 }
857
858 #[test]
859 fn test_is_interactive_command() {
860 assert!(BashTool::is_interactive_command("vim file.txt"));
861 assert!(BashTool::is_interactive_command("sudo vim file.txt"));
862 assert!(!BashTool::is_interactive_command("ls -la"));
863 assert!(!BashTool::is_interactive_command("cargo build"));
864 }
865
866 #[test]
867 fn test_smart_limits_cargo_build() {
868 let limits = BashTool::get_smart_limits("cargo build");
869 assert_eq!(limits.max_lines, Some(80));
870 assert_eq!(limits.output_mode, OutputMode::Head);
871 }
872
873 #[test]
874 fn test_transform_command_no_limits() {
875 let limits = OutputLimits::default();
876 let result = BashTool::transform_command("echo test", &limits);
877 assert_eq!(result, "echo test");
878 }
879
880 #[test]
881 fn test_transform_command_head_limit() {
882 let limits = OutputLimits {
883 max_lines: Some(50),
884 output_mode: OutputMode::Head,
885 ..Default::default()
886 };
887 let result = BashTool::transform_command("cat file.txt", &limits);
888 assert!(result.contains("head -n 50"));
889 }
890
891 #[test]
892 fn test_truncate_middle_short_input_passthrough() {
893 let s = "hello world";
894 let got = truncate_middle(s, 100);
895 assert_eq!(got.as_ref(), s);
896 }
897
898 #[test]
899 fn test_truncate_middle_long_input_keeps_head_and_tail() {
900 let s = format!("{}{}", "A".repeat(10_000), "Z".repeat(10_000));
901 let got = truncate_middle(&s, 1_000);
902 assert!(got.len() < s.len());
903 assert!(got.contains("truncated"));
904 assert!(got.starts_with('A'), "head should be preserved");
905 assert!(got.ends_with('Z'), "tail should be preserved");
906 }
907
908 #[test]
909 fn test_truncate_middle_respects_utf8_boundaries() {
910 let s = "é".repeat(1_000); let got = truncate_middle(&s, 100);
913 assert!(got.contains("truncated"));
914 assert!(!got.as_bytes().is_empty());
916 }
917
918 #[test]
919 #[cfg(target_os = "linux")]
920 fn test_apply_sandbox_network_deny_wraps_with_unshare_on_linux() {
921 let wrapped = apply_sandbox("echo hi", BashSandboxMode::NetworkDeny);
922 assert!(wrapped.starts_with("unshare -U -r -n -- bash -o pipefail -c "));
923 assert!(wrapped.contains("echo hi"));
924 }
925
926 #[test]
927 fn test_apply_sandbox_off_is_identity() {
928 let got = apply_sandbox("echo hi", BashSandboxMode::Off);
929 assert_eq!(got, "echo hi");
930 }
931
932 #[test]
933 fn test_bash_sandbox_mode_from_env_off_by_default() {
934 match std::env::var("BRAINWIRES_BASH_SANDBOX").as_deref() {
939 Ok(_) => {} Err(_) => assert_eq!(BashSandboxMode::from_env(), BashSandboxMode::Off),
941 }
942 }
943
944 #[test]
945 fn test_format_command_output_applies_byte_cap() {
946 let big = "x".repeat(MAX_STREAM_BYTES * 2);
947 let output = CommandOutput {
948 stdout: big,
949 stderr: String::new(),
950 exit_code: 0,
951 };
952 let limits = OutputLimits {
953 stderr_mode: StderrMode::Combined,
954 ..Default::default()
955 };
956 let formatted =
957 BashTool::format_command_output("cat huge.bin", "cat huge.bin", &output, &limits)
958 .unwrap();
959 assert!(formatted.len() < MAX_STREAM_BYTES * 2 + 200);
962 assert!(formatted.contains("truncated"));
963 }
964}