1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::{Component, Path};
6use std::process::Stdio;
7use tokio::io::AsyncReadExt;
8use tokio::process::Command;
9
10const DEFAULT_LOG_LIMIT: usize = 20;
11const MAX_OUTPUT_BYTES: usize = 65536;
12
13#[derive(Debug, Deserialize)]
14#[serde(tag = "op")]
15#[serde(rename_all = "snake_case")]
16enum GitOp {
17 Status,
18 Diff {
19 #[serde(default)]
20 path: Option<String>,
21 #[serde(default)]
22 staged: bool,
23 },
24 Log {
25 #[serde(default = "default_log_limit")]
26 limit: usize,
27 #[serde(default)]
28 path: Option<String>,
29 },
30 Branch {
31 #[serde(default)]
32 name: Option<String>,
33 },
34 Checkout {
35 branch: String,
36 },
37 Add {
38 paths: Vec<String>,
39 },
40 Commit {
41 message: String,
42 },
43 Push {
44 #[serde(default)]
45 remote: Option<String>,
46 #[serde(default)]
47 branch: Option<String>,
48 },
49 Pull {
50 #[serde(default)]
51 remote: Option<String>,
52 #[serde(default)]
53 branch: Option<String>,
54 },
55 Show {
56 #[serde(default)]
57 rev: Option<String>,
58 },
59}
60
61fn default_log_limit() -> usize {
62 DEFAULT_LOG_LIMIT
63}
64
65pub struct GitOperationsTool;
66
67impl Default for GitOperationsTool {
68 fn default() -> Self {
69 Self
70 }
71}
72
73impl GitOperationsTool {
74 pub fn new() -> Self {
75 Self
76 }
77
78 fn validate_path(path: &str) -> anyhow::Result<()> {
79 if path.trim().is_empty() {
80 return Err(anyhow!("path must not be empty"));
81 }
82 let p = Path::new(path);
83 if p.is_absolute() {
84 return Err(anyhow!("absolute paths are not allowed"));
85 }
86 if p.components().any(|c| matches!(c, Component::ParentDir)) {
87 return Err(anyhow!("path traversal is not allowed"));
88 }
89 Ok(())
90 }
91
92 fn validate_ref(s: &str) -> anyhow::Result<()> {
93 if s.trim().is_empty() {
94 return Err(anyhow!("ref must not be empty"));
95 }
96 if s.chars()
98 .any(|c| matches!(c, ';' | '&' | '|' | '`' | '$' | '>' | '<' | '\n' | '\r'))
99 {
100 return Err(anyhow!("ref contains forbidden characters"));
101 }
102 Ok(())
103 }
104
105 async fn run_git(workspace_root: &str, args: &[&str]) -> anyhow::Result<ToolResult> {
106 let mut child = Command::new("git")
107 .args(args)
108 .current_dir(workspace_root)
109 .stdout(Stdio::piped())
110 .stderr(Stdio::piped())
111 .spawn()
112 .context("failed to spawn git")?;
113
114 let stdout_handle = child
115 .stdout
116 .take()
117 .context("stdout not piped on spawned child")?;
118 let stderr_handle = child
119 .stderr
120 .take()
121 .context("stderr not piped on spawned child")?;
122
123 let stdout_task = tokio::spawn(Self::read_limited(stdout_handle));
124 let stderr_task = tokio::spawn(Self::read_limited(stderr_handle));
125
126 let status = child.wait().await.context("git command failed")?;
127 let stdout = stdout_task.await.context("stdout join")??;
128 let stderr = stderr_task.await.context("stderr join")??;
129
130 let mut output = format!("exit={}\n", status.code().unwrap_or(-1));
131 if !stdout.is_empty() {
132 output.push_str(&stdout);
133 }
134 if !stderr.is_empty() {
135 output.push_str("\nstderr:\n");
136 output.push_str(&stderr);
137 }
138
139 Ok(ToolResult { output })
140 }
141
142 async fn read_limited<R: tokio::io::AsyncRead + Unpin>(
143 mut reader: R,
144 ) -> anyhow::Result<String> {
145 let mut buf = Vec::new();
146 let mut limited = (&mut reader).take((MAX_OUTPUT_BYTES + 1) as u64);
147 limited.read_to_end(&mut buf).await?;
148 let truncated = buf.len() > MAX_OUTPUT_BYTES;
149 if truncated {
150 buf.truncate(MAX_OUTPUT_BYTES);
151 }
152 let mut s = String::from_utf8_lossy(&buf).to_string();
153 if truncated {
154 s.push_str(&format!("\n<truncated at {} bytes>", MAX_OUTPUT_BYTES));
155 }
156 Ok(s)
157 }
158}
159
160#[async_trait]
161impl Tool for GitOperationsTool {
162 fn name(&self) -> &'static str {
163 "git_operations"
164 }
165
166 fn description(&self) -> &'static str {
167 "Perform git operations: status, diff, log, branch, add, commit, checkout, show, stash. Input is JSON with an \"op\" field."
168 }
169
170 fn input_schema(&self) -> Option<serde_json::Value> {
171 Some(serde_json::json!({
172 "type": "object",
173 "properties": {
174 "op": {
175 "type": "string",
176 "description": "Git operation: status, diff, log, branch, add, commit, checkout, show, stash",
177 "enum": ["status", "diff", "log", "branch", "add", "commit", "checkout", "show", "stash"]
178 }
179 },
180 "required": ["op"]
181 }))
182 }
183
184 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
185 let op: GitOp =
186 serde_json::from_str(input).context("git_operations expects JSON with \"op\" field")?;
187
188 match op {
189 GitOp::Status => Self::run_git(&ctx.workspace_root, &["status", "--short"]).await,
190
191 GitOp::Diff { path, staged } => {
192 let mut args = vec!["diff"];
193 if staged {
194 args.push("--cached");
195 }
196 if let Some(ref p) = path {
197 Self::validate_path(p)?;
198 args.push("--");
199 args.push(p);
200 }
201 Self::run_git(&ctx.workspace_root, &args).await
202 }
203
204 GitOp::Log { limit, path } => {
205 let limit_str = format!("-{}", limit.min(100));
206 let mut args = vec!["log", &limit_str, "--oneline"];
207 if let Some(ref p) = path {
208 Self::validate_path(p)?;
209 args.push("--");
210 args.push(p);
211 }
212 Self::run_git(&ctx.workspace_root, &args).await
213 }
214
215 GitOp::Branch { name } => {
216 if let Some(ref n) = name {
217 Self::validate_ref(n)?;
218 Self::run_git(&ctx.workspace_root, &["branch", n]).await
219 } else {
220 Self::run_git(&ctx.workspace_root, &["branch", "--list"]).await
221 }
222 }
223
224 GitOp::Checkout { ref branch } => {
225 Self::validate_ref(branch)?;
226 Self::run_git(&ctx.workspace_root, &["checkout", branch]).await
227 }
228
229 GitOp::Add { ref paths } => {
230 if paths.is_empty() {
231 return Err(anyhow!("add requires at least one path"));
232 }
233 for p in paths {
234 Self::validate_path(p)?;
235 }
236 let mut args = vec!["add"];
237 let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
238 args.extend(path_refs);
239 Self::run_git(&ctx.workspace_root, &args).await
240 }
241
242 GitOp::Commit { ref message } => {
243 if message.trim().is_empty() {
244 return Err(anyhow!("commit message must not be empty"));
245 }
246 Self::run_git(&ctx.workspace_root, &["commit", "-m", message]).await
247 }
248
249 GitOp::Push {
250 ref remote,
251 ref branch,
252 } => {
253 let mut args = vec!["push"];
254 if let Some(ref r) = remote {
255 Self::validate_ref(r)?;
256 args.push(r);
257 }
258 if let Some(ref b) = branch {
259 Self::validate_ref(b)?;
260 args.push(b);
261 }
262 Self::run_git(&ctx.workspace_root, &args).await
263 }
264
265 GitOp::Pull {
266 ref remote,
267 ref branch,
268 } => {
269 let mut args = vec!["pull"];
270 if let Some(ref r) = remote {
271 Self::validate_ref(r)?;
272 args.push(r);
273 }
274 if let Some(ref b) = branch {
275 Self::validate_ref(b)?;
276 args.push(b);
277 }
278 Self::run_git(&ctx.workspace_root, &args).await
279 }
280
281 GitOp::Show { ref rev } => {
282 let mut args = vec!["show", "--stat"];
283 if let Some(ref r) = rev {
284 Self::validate_ref(r)?;
285 args.push(r);
286 }
287 Self::run_git(&ctx.workspace_root, &args).await
288 }
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::GitOperationsTool;
296 use agentzero_core::{Tool, ToolContext};
297 use std::fs;
298 use std::path::PathBuf;
299 use std::sync::atomic::{AtomicU64, Ordering};
300 use std::time::{SystemTime, UNIX_EPOCH};
301
302 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
303
304 fn git_cmd(dir: &std::path::Path) -> std::process::Command {
308 let mut cmd = std::process::Command::new("git");
309 cmd.current_dir(dir);
310 for var in &[
311 "GIT_DIR",
312 "GIT_INDEX_FILE",
313 "GIT_OBJECT_DIRECTORY",
314 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
315 "GIT_WORK_TREE",
316 "GIT_COMMON_DIR",
317 ] {
318 cmd.env_remove(var);
319 }
320 cmd
321 }
322
323 fn temp_git_dir() -> PathBuf {
324 let nanos = SystemTime::now()
325 .duration_since(UNIX_EPOCH)
326 .expect("clock")
327 .as_nanos();
328 let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
329 let dir = std::env::temp_dir().join(format!(
330 "agentzero-git-ops-{}-{nanos}-{seq}",
331 std::process::id()
332 ));
333 fs::create_dir_all(&dir).expect("temp dir should be created");
334 git_cmd(&dir)
336 .args(["init"])
337 .output()
338 .expect("git init should succeed");
339 git_cmd(&dir)
340 .args(["config", "user.email", "test@test.com"])
341 .output()
342 .ok();
343 git_cmd(&dir)
344 .args(["config", "user.name", "Test"])
345 .output()
346 .ok();
347 let empty_hooks = dir.join(".no-hooks");
350 fs::create_dir_all(&empty_hooks).ok();
351 git_cmd(&dir)
352 .args(["config", "core.hooksPath", &empty_hooks.to_string_lossy()])
353 .output()
354 .ok();
355 git_cmd(&dir)
357 .args(["config", "commit.gpgsign", "false"])
358 .output()
359 .ok();
360 dir
361 }
362
363 #[tokio::test]
364 async fn git_status_in_repo() {
365 let dir = temp_git_dir();
366 let tool = GitOperationsTool::new();
367 let result = tool
368 .execute(
369 r#"{"op": "status"}"#,
370 &ToolContext::new(dir.to_string_lossy().to_string()),
371 )
372 .await
373 .expect("git status should succeed");
374 assert!(result.output.contains("exit=0"));
375 fs::remove_dir_all(dir).ok();
376 }
377
378 #[tokio::test]
379 async fn git_log_in_repo_with_commits() {
380 let dir = temp_git_dir();
381 fs::write(dir.join("test.txt"), "hello").unwrap();
382 git_cmd(&dir).args(["add", "test.txt"]).output().unwrap();
383 let commit_out = git_cmd(&dir)
384 .args(["commit", "-m", "initial"])
385 .output()
386 .unwrap();
387 assert!(
388 commit_out.status.success(),
389 "git commit failed: {}",
390 String::from_utf8_lossy(&commit_out.stderr)
391 );
392
393 let tool = GitOperationsTool::new();
394 let result = tool
395 .execute(
396 r#"{"op": "log", "limit": 5}"#,
397 &ToolContext::new(dir.to_string_lossy().to_string()),
398 )
399 .await
400 .expect("git log should succeed");
401 assert!(result.output.contains("initial"));
402 fs::remove_dir_all(dir).ok();
403 }
404
405 #[tokio::test]
406 async fn git_rejects_path_traversal_in_add_negative_path() {
407 let dir = temp_git_dir();
408 let tool = GitOperationsTool::new();
409 let err = tool
410 .execute(
411 r#"{"op": "add", "paths": ["../escape.txt"]}"#,
412 &ToolContext::new(dir.to_string_lossy().to_string()),
413 )
414 .await
415 .expect_err("path traversal should be denied");
416 assert!(err.to_string().contains("path traversal"));
417 fs::remove_dir_all(dir).ok();
418 }
419
420 #[tokio::test]
421 async fn git_rejects_metacharacters_in_ref_negative_path() {
422 let dir = temp_git_dir();
423 let tool = GitOperationsTool::new();
424 let err = tool
425 .execute(
426 r#"{"op": "checkout", "branch": "main;rm -rf /"}"#,
427 &ToolContext::new(dir.to_string_lossy().to_string()),
428 )
429 .await
430 .expect_err("metacharacters should be rejected");
431 assert!(err.to_string().contains("forbidden characters"));
432 fs::remove_dir_all(dir).ok();
433 }
434
435 #[tokio::test]
436 async fn git_rejects_empty_commit_message_negative_path() {
437 let dir = temp_git_dir();
438 let tool = GitOperationsTool::new();
439 let err = tool
440 .execute(
441 r#"{"op": "commit", "message": ""}"#,
442 &ToolContext::new(dir.to_string_lossy().to_string()),
443 )
444 .await
445 .expect_err("empty message should fail");
446 assert!(err.to_string().contains("commit message must not be empty"));
447 fs::remove_dir_all(dir).ok();
448 }
449}