matrixcode_core/tools/
bash.rs1use 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;
8use crate::truncate::truncate_string_in_place;
9
10pub struct BashTool;
11
12const DEFAULT_TIMEOUT_MS: u64 = 120_000;
13const MAX_TIMEOUT_MS: u64 = 600_000;
14const MAX_OUTPUT: usize = 30_000;
15
16#[async_trait]
17impl Tool for BashTool {
18 fn definition(&self) -> ToolDefinition {
19 ToolDefinition {
20 name: "bash".to_string(),
21 description: "在当前工作目录执行 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 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 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 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 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 return Ok(format!("[exit {}]\n{}", code, stdout));
89 }
90
91 Ok(stdout)
93 }
94
95 fn risk_level(&self) -> RiskLevel {
96 RiskLevel::Dangerous
97 }
98}
99
100fn 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 truncate_string_in_place(&mut s, MAX_OUTPUT);
136 s.push_str(&format!(
137 "\n... (truncated, output exceeded {} bytes)",
138 MAX_OUTPUT
139 ));
140 s
141}