Skip to main content

brainwires_tools/
bash.rs

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/// Output limiting mode for proactive context management
12#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
13#[serde(rename_all = "snake_case")]
14pub enum OutputMode {
15    /// No output limiting
16    #[default]
17    Full,
18    /// Limit to first N lines (head)
19    Head,
20    /// Limit to last N lines (tail)
21    Tail,
22    /// Filter output by pattern (grep)
23    Filter,
24    /// Return only line count
25    Count,
26    /// Auto-detect best strategy based on command
27    Smart,
28}
29
30/// Stderr handling mode
31#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
32#[serde(rename_all = "snake_case")]
33pub enum StderrMode {
34    /// Keep stdout and stderr separate (default)
35    #[default]
36    Separate,
37    /// Merge stderr into stdout (2>&1)
38    Combined,
39    /// Only capture stderr, discard stdout
40    StderrOnly,
41    /// Suppress stderr (2>/dev/null)
42    Suppress,
43}
44
45/// Output limiting configuration
46#[derive(Debug, Clone, Default)]
47pub struct OutputLimits {
48    /// Maximum number of lines to return
49    pub max_lines: Option<u32>,
50    /// Output mode (head, tail, filter, etc.)
51    pub output_mode: OutputMode,
52    /// Pattern for filter mode (grep pattern)
53    pub filter_pattern: Option<String>,
54    /// How to handle stderr
55    pub stderr_mode: StderrMode,
56    /// Whether to auto-apply smart limits
57    pub auto_limit: bool,
58}
59
60/// Absolute byte cap per stream (stdout, stderr). Safety net so a single
61/// long line or binary blob can't blow past context limits regardless of
62/// line-based output_mode. Picked to roughly match Claude Code's read tool.
63const MAX_STREAM_BYTES: usize = 25_000;
64
65/// Global sandbox mode for bash tool invocations.
66///
67/// Checked at command-build time so every bash tool call goes through the
68/// same policy gate regardless of which agent or tool path invoked it. Opt
69/// in by setting `BRAINWIRES_BASH_SANDBOX=network-deny` (or via the CLI
70/// `--sandbox=network-deny` flag).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum BashSandboxMode {
73    /// No sandboxing (current default).
74    Off,
75    /// Run bash inside a new user + network namespace via `unshare -U -r -n`.
76    /// Outbound network is denied, bind mounts from the host remain visible.
77    /// Linux-only; silently falls through to `Off` on other platforms with
78    /// an attached warning in the tool result.
79    NetworkDeny,
80}
81
82impl BashSandboxMode {
83    /// Read the active sandbox mode from env. `network-deny` / `networkdeny`
84    /// / `1` enables; anything else (including unset) is `Off`.
85    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
93/// Wrap a command for the active sandbox mode. The wrapper runs the original
94/// command inside `unshare -U -r -n -- bash -o pipefail -c '<orig>'` on
95/// Linux when `NetworkDeny` is requested.
96///
97/// On non-Linux platforms with `NetworkDeny`, this function returns the
98/// original command verbatim — the caller should surface a warning.
99fn 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                // -U: new user namespace (no root needed)
105                // -r: map invoking uid to root inside the namespace
106                // -n: new network namespace (no outbound network)
107                // --: stop unshare arg parsing, everything after is the program.
108                format!(
109                    "unshare -U -r -n -- bash -o pipefail -c {}",
110                    shell_escape(command)
111                )
112            } else {
113                // Not supported on this platform — fall through; the caller
114                // surfaces a warning so the model knows sandboxing was not
115                // actually enforced.
116                command.to_string()
117            }
118        }
119    }
120}
121
122/// Truncate a stream to at most `max_bytes`, preserving head and tail with
123/// an explicit marker in between so the model can reason about the gap.
124fn 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    // Clamp head/tail to nearest char boundary to avoid slicing mid-UTF8.
131    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
148/// Interactive commands that should be rejected
149const 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
182/// Bash execution tool implementation
183pub struct BashTool;
184
185impl BashTool {
186    /// Get all bash tool definitions
187    pub fn get_tools() -> Vec<Tool> {
188        vec![Self::execute_command_tool()]
189    }
190
191    /// Execute command tool definition
192    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    /// Execute a bash command tool
260    #[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(&params.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(&params.command)
292            ));
293        }
294
295        Self::validate_command(&params.command)?;
296
297        let limits = Self::resolve_output_limits(&params);
298        let transformed_command = Self::transform_command(&params.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(&params.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        // Sandbox wrap happens *last* so the unshare wrapper sees the final
555        // transformed pipeline (with any `head -n`, `grep`, stderr routing
556        // already applied inside its bash shell).
557        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    /// Execute a bash command that requires sudo, piping the password via stdin.
585    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(&params.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(&params.command)
619            ));
620        }
621        Self::validate_command(&params.command)?;
622        let limits = Self::resolve_output_limits(&params);
623        let transformed_command = Self::transform_command(&params.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(&params.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(&params.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        // Build a string with multi-byte chars straddling the midpoint.
911        let s = "é".repeat(1_000); // each é is 2 bytes => 2000 bytes total
912        let got = truncate_middle(&s, 100);
913        assert!(got.contains("truncated"));
914        // Must not panic / produce invalid UTF-8 — if we got here, we're good.
915        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        // Can't mutate env safely in a multi-threaded test runner, so just
935        // check the mapping logic with an explicit closure equivalent.
936        // (see from_env implementation)
937        // This mainly guards against a refactor that breaks the default.
938        match std::env::var("BRAINWIRES_BASH_SANDBOX").as_deref() {
939            Ok(_) => {} // test env set it — skip
940            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        // Formatted output must be shorter than the raw stdout AND contain the
960        // truncation marker.
961        assert!(formatted.len() < MAX_STREAM_BYTES * 2 + 200);
962        assert!(formatted.contains("truncated"));
963    }
964}