Skip to main content

matrixcode_core/tools/
bash.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use serde_json::{Value, json};
4use std::time::Duration;
5
6use super::{Tool, ToolDefinition};
7use crate::approval::RiskLevel;
8
9pub struct BashTool;
10
11const DEFAULT_TIMEOUT_MS: u64 = 120_000;
12const MAX_TIMEOUT_MS: u64 = 600_000;
13const MAX_OUTPUT: usize = 30_000;
14
15#[async_trait]
16impl Tool for BashTool {
17    fn definition(&self) -> ToolDefinition {
18        ToolDefinition {
19            name: "bash".to_string(),
20            description:
21                "Run a shell command in the current working directory and return \
22                 combined stdout + stderr. Use for builds, tests, git, package \
23                 managers, etc. The command runs via `sh -c` with a timeout."
24                    .to_string(),
25            parameters: json!({
26                "type": "object",
27                "properties": {
28                    "command": {
29                        "type": "string",
30                        "description": "The shell command to run"
31                    },
32                    "timeout_ms": {
33                        "type": "integer",
34                        "description": "Max runtime in milliseconds (default 120000, max 600000)"
35                    }
36                },
37                "required": ["command"]
38            }),
39        }
40    }
41
42    async fn execute(&self, params: Value) -> Result<String> {
43        // Create spinner immediately at the start to fill the gap before actual operation
44        // let mut spinner = ToolSpinner::new("preparing command");
45
46        let command = params["command"]
47            .as_str()
48            .ok_or_else(|| anyhow::anyhow!("missing 'command'"))?;
49
50        if let Some(reason) = refuse_reason(command) {
51            // spinner.finish_error("refused");
52            anyhow::bail!("refused: {}", reason);
53        }
54
55        let timeout_ms = params["timeout_ms"]
56            .as_u64()
57            .unwrap_or(DEFAULT_TIMEOUT_MS)
58            .min(MAX_TIMEOUT_MS);
59
60        // Update spinner message for the actual command execution
61        // spinner.set_message(&format!("running: {}", truncate_command(command, 50)));
62
63        let mut cmd = tokio::process::Command::new("sh");
64        cmd.arg("-c").arg(command).kill_on_drop(true);
65
66        let fut = cmd.output();
67        let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), fut).await {
68            Ok(result) => result?,
69            Err(_) => {
70                // spinner.finish_error("timed out");
71                anyhow::bail!("command timed out after {} ms", timeout_ms);
72            }
73        };
74
75        let mut stdout = String::from_utf8_lossy(&output.stdout).into_owned();
76        let stderr = String::from_utf8_lossy(&output.stderr);
77        if !stderr.is_empty() {
78            if !stdout.is_empty() {
79                stdout.push('\n');
80            }
81            stdout.push_str(&stderr);
82        }
83
84        let stdout = truncate_output(stdout);
85
86        let code = output.status.code().unwrap_or(-1);
87        if !output.status.success() {
88            // spinner.finish_error(&format!("exit {}", code));
89            return Ok(format!("[exit {}]\n{}", code, stdout));
90        }
91
92        // spinner.finish_success("done");
93        Ok(stdout)
94    }
95
96    fn risk_level(&self) -> RiskLevel {
97        RiskLevel::Dangerous
98    }
99}
100
101/// Very conservative reject-list covering clearly catastrophic commands.
102/// The goal is not a sandbox — it's a last-line guard against obvious
103/// accidents like `rm -rf /`. Anything subtle is the caller's responsibility.
104fn refuse_reason(cmd: &str) -> Option<&'static str> {
105    let norm: String = cmd.split_whitespace().collect::<Vec<_>>().join(" ");
106
107    const BANNED_EXACT_PREFIXES: &[&str] = &[
108        "rm -rf /",
109        "rm -rf /*",
110        "rm -rf ~",
111        "rm -rf $HOME",
112        "rm -rf --no-preserve-root /",
113        ":(){:|:&};:",
114        "dd if=/dev/zero of=/dev/",
115        "mkfs",
116        "shutdown",
117        "reboot",
118        "halt",
119    ];
120
121    for bad in BANNED_EXACT_PREFIXES {
122        if norm.starts_with(bad) {
123            return Some("destructive command blocked");
124        }
125    }
126    if norm.contains("rm -rf /") && !norm.contains("rm -rf /tmp") {
127        return Some("destructive rm -rf on root paths blocked");
128    }
129    None
130}
131
132fn truncate_output(mut s: String) -> String {
133    if s.len() <= MAX_OUTPUT {
134        return s;
135    }
136    let mut cut = MAX_OUTPUT;
137    while cut > 0 && !s.is_char_boundary(cut) {
138        cut -= 1;
139    }
140    s.truncate(cut);
141    s.push_str(&format!(
142        "\n... (truncated, output exceeded {} bytes)",
143        MAX_OUTPUT
144    ));
145    s
146}
147
148#[allow(dead_code)]  // For future use
149fn truncate_command(cmd: &str, max: usize) -> String {
150    if cmd.len() <= max {
151        cmd.to_string()
152    } else {
153        let mut end = max;
154        while end > 0 && !cmd.is_char_boundary(end) {
155            end -= 1;
156        }
157        format!("{}...", &cmd[..end])
158    }
159}