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;
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 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 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 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 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 return Ok(format!("[exit {}]\n{}", code, stdout));
90 }
91
92 Ok(stdout)
94 }
95
96 fn risk_level(&self) -> RiskLevel {
97 RiskLevel::Dangerous
98 }
99}
100
101fn 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") && !norm.contains("rm -rf /var") {
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)] fn 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}