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                "在当前工作目录执行 shell 命令,返回合并的 stdout + stderr。\
22                 用于构建、测试、git、包管理器等操作。命令通过 `sh -c` 执行并有超时限制。"
23                    .to_string(),
24            parameters: json!({
25                "type": "object",
26                "properties": {
27                    "command": {
28                        "type": "string",
29                        "description": "要执行的 shell 命令"
30                    },
31                    "timeout_ms": {
32                        "type": "integer",
33                        "description": "最大运行时间(毫秒,默认 120000,最大 600000)"
34                    }
35                },
36                "required": ["command"]
37            }),
38        }
39    }
40
41    async fn execute(&self, params: Value) -> Result<String> {
42        // Create spinner immediately at the start to fill the gap before actual operation
43        // let mut spinner = ToolSpinner::new("preparing command");
44
45        let command = params["command"]
46            .as_str()
47            .ok_or_else(|| anyhow::anyhow!("missing 'command'"))?;
48
49        if let Some(reason) = refuse_reason(command) {
50            // spinner.finish_error("refused");
51            anyhow::bail!("refused: {}", reason);
52        }
53
54        let timeout_ms = params["timeout_ms"]
55            .as_u64()
56            .unwrap_or(DEFAULT_TIMEOUT_MS)
57            .min(MAX_TIMEOUT_MS);
58
59        // Update spinner message for the actual command execution
60        // spinner.set_message(&format!("running: {}", truncate_command(command, 50)));
61
62        let mut cmd = tokio::process::Command::new("sh");
63        cmd.arg("-c").arg(command).kill_on_drop(true);
64
65        let fut = cmd.output();
66        let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), fut).await {
67            Ok(result) => result?,
68            Err(_) => {
69                // spinner.finish_error("timed out");
70                anyhow::bail!("command timed out after {} ms", timeout_ms);
71            }
72        };
73
74        let mut stdout = String::from_utf8_lossy(&output.stdout).into_owned();
75        let stderr = String::from_utf8_lossy(&output.stderr);
76        if !stderr.is_empty() {
77            if !stdout.is_empty() {
78                stdout.push('\n');
79            }
80            stdout.push_str(&stderr);
81        }
82
83        let stdout = truncate_output(stdout);
84
85        let code = output.status.code().unwrap_or(-1);
86        if !output.status.success() {
87            // spinner.finish_error(&format!("exit {}", code));
88            return Ok(format!("[exit {}]\n{}", code, stdout));
89        }
90
91        // spinner.finish_success("done");
92        Ok(stdout)
93    }
94
95    fn risk_level(&self) -> RiskLevel {
96        RiskLevel::Dangerous
97    }
98}
99
100/// Very conservative reject-list covering clearly catastrophic commands.
101/// The goal is not a sandbox — it's a last-line guard against obvious
102/// accidents like `rm -rf /`. Anything subtle is the caller's responsibility.
103fn refuse_reason(cmd: &str) -> Option<&'static str> {
104    let norm: String = cmd.split_whitespace().collect::<Vec<_>>().join(" ");
105
106    const BANNED_EXACT_PREFIXES: &[&str] = &[
107        "rm -rf /",
108        "rm -rf /*",
109        "rm -rf ~",
110        "rm -rf $HOME",
111        "rm -rf --no-preserve-root /",
112        ":(){:|:&};:",
113        "dd if=/dev/zero of=/dev/",
114        "mkfs",
115        "shutdown",
116        "reboot",
117        "halt",
118    ];
119
120    for bad in BANNED_EXACT_PREFIXES {
121        if norm.starts_with(bad) {
122            return Some("destructive command blocked");
123        }
124    }
125    if norm.contains("rm -rf /") && !norm.contains("rm -rf /tmp") {
126        return Some("destructive rm -rf on root paths blocked");
127    }
128    None
129}
130
131fn truncate_output(mut s: String) -> String {
132    if s.len() <= MAX_OUTPUT {
133        return s;
134    }
135    let mut cut = MAX_OUTPUT;
136    while cut > 0 && !s.is_char_boundary(cut) {
137        cut -= 1;
138    }
139    s.truncate(cut);
140    s.push_str(&format!(
141        "\n... (truncated, output exceeded {} bytes)",
142        MAX_OUTPUT
143    ));
144    s
145}
146
147#[allow(dead_code)]  // For future use
148fn truncate_command(cmd: &str, max: usize) -> String {
149    if cmd.len() <= max {
150        cmd.to_string()
151    } else {
152        let mut end = max;
153        while end > 0 && !cmd.is_char_boundary(end) {
154            end -= 1;
155        }
156        format!("{}...", &cmd[..end])
157    }
158}