1use async_trait::async_trait;
6use rucora_core::{
7 error::ToolError,
8 tool::{Tool, ToolCategory},
9};
10use serde_json::{Value, json};
11use std::path::{Path, PathBuf};
12
13const ALLOWED_COMMANDS: &[&str] = &[
15 "status",
16 "log",
17 "diff",
18 "show",
19 "branch",
20 "remote",
21 "config",
22 "rev-parse",
23 "add",
24 "commit",
25 "checkout",
26 "stash",
27 "reset",
28 "revert",
29 "merge",
30 "rebase",
31 "pull",
32 "push",
33 "fetch",
34 "clone",
35 "init",
36 "describe",
37 "tag",
38];
39
40const WRITE_COMMANDS: &[&str] = &[
42 "add", "commit", "checkout", "stash", "reset", "revert", "merge", "rebase", "pull", "push",
43 "fetch", "clone", "init",
44];
45
46const FORBIDDEN_ARGS: &[&str] = &[
48 "--exec",
49 "--upload-pack",
50 "--receive-pack",
51 "--pager",
52 "--editor",
53 "--no-verify",
54 "--no-gpg-sign",
55 "-c",
56];
57
58pub struct GitTool {
78 allowed_roots: Option<Vec<PathBuf>>,
80 allow_write: bool,
82}
83
84impl GitTool {
85 pub fn new() -> Self {
87 Self {
88 allowed_roots: None,
89 allow_write: true,
90 }
91 }
92
93 pub fn with_allowed_roots(mut self, roots: Vec<PathBuf>) -> Self {
95 self.allowed_roots = Some(roots);
96 self
97 }
98
99 pub fn with_allow_write(mut self, allow: bool) -> Self {
101 self.allow_write = allow;
102 self
103 }
104
105 fn is_write_command(&self, command: &str) -> bool {
107 WRITE_COMMANDS.contains(&command)
108 }
109
110 fn validate_command(&self, command: &str) -> Result<(), ToolError> {
112 let cmd_lower = command.to_lowercase();
113
114 if !ALLOWED_COMMANDS.contains(&cmd_lower.as_str()) {
116 return Err(ToolError::Message(format!(
117 "不支持的 Git 命令:{command}(允许的命令:{ALLOWED_COMMANDS:?})"
118 )));
119 }
120
121 if self.is_write_command(&cmd_lower) && !self.allow_write {
123 return Err(ToolError::Message(format!(
124 "Git 写入操作已被禁用:{command}"
125 )));
126 }
127
128 Ok(())
129 }
130
131 fn validate_path(&self, path: &str) -> Result<PathBuf, ToolError> {
133 let path = Path::new(path);
134
135 let canonical_path = if path.is_absolute() {
137 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
138 } else {
139 std::env::current_dir()
140 .unwrap_or_else(|_| PathBuf::from("."))
141 .join(path)
142 .canonicalize()
143 .unwrap_or_else(|_| path.to_path_buf())
144 };
145
146 if let Some(allowed_roots) = &self.allowed_roots {
148 let is_allowed = allowed_roots
149 .iter()
150 .any(|root| canonical_path.starts_with(root));
151 if !is_allowed {
152 return Err(ToolError::Message(format!(
153 "Git 仓库路径不在允许的范围内(允许的根目录:{allowed_roots:?})"
154 )));
155 }
156 }
157
158 let path_str = canonical_path.to_string_lossy().to_lowercase();
160 let forbidden_prefixes = [
161 "/etc/",
162 "/proc/",
163 "/sys/",
164 "/dev/",
165 "/boot/",
166 "/bin/",
167 "/sbin/",
168 "c:\\windows\\",
169 "c:\\program files",
170 ];
171 for prefix in &forbidden_prefixes {
172 if path_str.starts_with(prefix) {
173 return Err(ToolError::Message(format!(
174 "禁止在系统敏感路径执行 Git 操作:{}",
175 canonical_path.display()
176 )));
177 }
178 }
179
180 Ok(canonical_path)
181 }
182
183 fn sanitize_args(&self, args: &[String]) -> Result<Vec<String>, ToolError> {
185 let mut result = Vec::with_capacity(args.len());
186
187 for arg in args {
188 let arg_lower = arg.to_lowercase();
189
190 for forbidden in FORBIDDEN_ARGS {
192 if arg_lower.starts_with(&forbidden.to_lowercase()) {
193 return Err(ToolError::Message(format!(
194 "禁止使用 Git 参数:{arg}(存在安全风险)"
195 )));
196 }
197 }
198
199 if arg.contains("$(")
201 || arg.contains('`')
202 || arg.contains('|')
203 || arg.contains(';')
204 || arg.contains("&&")
205 || arg.contains("||")
206 || arg.contains('>')
207 || arg.contains('<')
208 || arg.contains('\n')
209 || arg.contains('\r')
210 {
211 return Err(ToolError::Message(format!(
212 "参数包含危险字符,可能存在注入风险:{arg}"
213 )));
214 }
215
216 if arg.contains("..\\") || arg.contains("../") {
218 if arg.starts_with("..")
220 || arg.ends_with("..")
221 || arg.contains("/../")
222 || arg.contains("\\..\\")
223 {
224 return Err(ToolError::Message(format!("参数包含路径遍历:{arg}")));
225 }
226 }
227
228 result.push(arg.clone());
229 }
230
231 Ok(result)
232 }
233}
234
235impl Default for GitTool {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241#[async_trait]
242impl Tool for GitTool {
243 fn name(&self) -> &str {
245 "git"
246 }
247
248 fn description(&self) -> Option<&str> {
250 Some("执行 Git 操作(有安全限制:命令白名单、参数检查、路径限制)")
251 }
252
253 fn categories(&self) -> &'static [ToolCategory] {
255 &[ToolCategory::System]
256 }
257
258 fn input_schema(&self) -> Value {
260 json!({
261 "type": "object",
262 "properties": {
263 "command": {
264 "type": "string",
265 "description": "Git 命令,如 status、log、add、commit"
266 },
267 "args": {
268 "type": "array",
269 "items": {
270 "type": "string"
271 },
272 "description": "命令参数列表"
273 },
274 "path": {
275 "type": "string",
276 "description": "Git 仓库路径,默认为当前目录",
277 "default": "."
278 }
279 },
280 "required": ["command"]
281 })
282 }
283
284 async fn call(&self, input: Value) -> Result<Value, ToolError> {
286 let command = input
287 .get("command")
288 .and_then(|v| v.as_str())
289 .ok_or_else(|| ToolError::Message("缺少必需的 'command' 字段".to_string()))?;
290
291 self.validate_command(command)?;
293
294 let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
295 let work_dir = self.validate_path(path_str)?;
296
297 let args_vec: Vec<String> = input
299 .get("args")
300 .and_then(|v| v.as_array())
301 .map(|arr| {
302 arr.iter()
303 .filter_map(|v| v.as_str().map(String::from))
304 .collect()
305 })
306 .unwrap_or_default();
307
308 let sanitized_args = self.sanitize_args(&args_vec)?;
310
311 let output = tokio::process::Command::new("git")
313 .arg(command)
314 .args(&sanitized_args)
315 .current_dir(&work_dir)
316 .env_clear()
317 .env("PATH", std::env::var("PATH").unwrap_or_default())
318 .env("HOME", std::env::var("HOME").unwrap_or_default())
319 .env(
320 "USERPROFILE",
321 std::env::var("USERPROFILE").unwrap_or_default(),
322 )
323 .output()
324 .await
325 .map_err(|e| ToolError::Message(format!("Git 命令执行失败:{e}")))?;
326
327 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
328 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
329 let is_write = self.is_write_command(&command.to_lowercase());
330
331 Ok(json!({
332 "command": command,
333 "args": sanitized_args,
334 "work_dir": work_dir.display().to_string(),
335 "stdout": stdout,
336 "stderr": stderr,
337 "success": output.status.success(),
338 "is_write_operation": is_write
339 }))
340 }
341}