1use async_trait::async_trait;
2use serde_json::json;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use zeroclaw_api::tool::{Tool, ToolResult};
6use zeroclaw_config::autonomy::AutonomyLevel;
7use zeroclaw_config::policy::SecurityPolicy;
8
9pub struct GitOperationsTool {
12 security: Arc<SecurityPolicy>,
13 workspace_dir: std::path::PathBuf,
14}
15
16impl GitOperationsTool {
17 pub fn new(security: Arc<SecurityPolicy>, workspace_dir: std::path::PathBuf) -> Self {
18 Self {
19 security,
20 workspace_dir,
21 }
22 }
23
24 fn sanitize_git_args(&self, args: &str) -> anyhow::Result<Vec<String>> {
26 let mut result = Vec::new();
27 for arg in args.split_whitespace() {
28 let arg_lower = arg.to_lowercase();
30 if arg_lower.starts_with("--exec=")
31 || arg_lower.starts_with("--upload-pack=")
32 || arg_lower.starts_with("--receive-pack=")
33 || arg_lower.starts_with("--pager=")
34 || arg_lower.starts_with("--editor=")
35 || arg_lower == "--no-verify"
36 || arg_lower.contains("$(")
37 || arg_lower.contains('`')
38 || arg.contains('|')
39 || arg.contains(';')
40 || arg.contains('>')
41 {
42 anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
43 }
44 if arg_lower == "-c" || arg_lower.starts_with("-c=") {
47 anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
48 }
49 result.push(arg.to_string());
50 }
51 Ok(result)
52 }
53
54 fn requires_write_access(&self, operation: &str) -> bool {
56 matches!(
57 operation,
58 "commit" | "add" | "checkout" | "stash" | "reset" | "revert" | "worktree"
59 )
60 }
61
62 #[cfg(test)]
64 fn is_read_only(&self, operation: &str) -> bool {
65 matches!(
66 operation,
67 "status" | "diff" | "log" | "show" | "branch" | "rev-parse"
68 )
69 }
70
71 fn resolve_working_dir(&self, path: Option<&str>) -> anyhow::Result<std::path::PathBuf> {
75 let base = match path {
76 Some(p) if !p.is_empty() => {
77 let candidate = if std::path::Path::new(p).is_absolute() {
78 std::path::PathBuf::from(p)
79 } else {
80 self.workspace_dir.join(p)
81 };
82 let resolved = candidate.canonicalize().map_err(|e| {
83 ::zeroclaw_log::record!(
84 WARN,
85 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
86 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
87 .with_attrs(::serde_json::json!({
88 "path": p,
89 "error": format!("{}", e),
90 })),
91 "git_operations: cannot resolve path"
92 );
93 anyhow::Error::msg(format!("Cannot resolve path '{}': {}", p, e))
94 })?;
95 let workspace_canonical = self
96 .workspace_dir
97 .canonicalize()
98 .unwrap_or_else(|_| self.workspace_dir.clone());
99 if !resolved.starts_with(&workspace_canonical) {
100 anyhow::bail!("Path '{}' resolves outside the workspace directory", p);
101 }
102 resolved
103 }
104 _ => self.workspace_dir.clone(),
105 };
106 Ok(base)
107 }
108
109 fn candidate_path(&self, raw_path: &str) -> anyhow::Result<PathBuf> {
110 if raw_path.contains('\0') {
111 anyhow::bail!("Path not allowed: contains null byte");
112 }
113 if Path::new(raw_path)
114 .components()
115 .any(|c| matches!(c, std::path::Component::ParentDir))
116 {
117 anyhow::bail!("Path not allowed: parent-directory traversal is not allowed");
118 }
119
120 let raw = Path::new(raw_path);
121 Ok(if raw.is_absolute() {
122 raw.to_path_buf()
123 } else {
124 self.workspace_dir.join(raw)
125 })
126 }
127
128 fn ensure_worktree_add_target_allowed(&self, raw_path: &str) -> anyhow::Result<PathBuf> {
129 let candidate = self.candidate_path(raw_path)?;
130 let parent = candidate.parent().ok_or_else(|| {
131 ::zeroclaw_log::record!(
132 WARN,
133 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
134 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
135 .with_attrs(::serde_json::json!({"raw_path": raw_path})),
136 "git_operations: worktree path has no parent"
137 );
138 anyhow::Error::msg("Worktree path must have a parent directory")
139 })?;
140 let file_name = candidate.file_name().ok_or_else(|| {
141 ::zeroclaw_log::record!(
142 WARN,
143 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
144 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
145 .with_attrs(::serde_json::json!({"raw_path": raw_path})),
146 "git_operations: worktree path has no file name"
147 );
148 anyhow::Error::msg("Worktree path must include a final path component")
149 })?;
150 let resolved_parent = parent.canonicalize().map_err(|e| {
151 ::zeroclaw_log::record!(
152 WARN,
153 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
154 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
155 .with_attrs(::serde_json::json!({
156 "parent": parent.display().to_string(),
157 "error": format!("{}", e),
158 })),
159 "git_operations: cannot resolve worktree parent"
160 );
161 anyhow::Error::msg(format!(
162 "Cannot resolve worktree parent '{}': {e}",
163 parent.display()
164 ))
165 })?;
166 let resolved_target = resolved_parent.join(file_name);
167
168 if !self.security.is_resolved_path_allowed(&resolved_target) {
169 anyhow::bail!(
170 "Worktree path '{}' resolves outside the workspace or allowed roots",
171 raw_path
172 );
173 }
174
175 Ok(resolved_target)
176 }
177
178 fn ensure_worktree_remove_target_allowed(&self, raw_path: &str) -> anyhow::Result<PathBuf> {
179 let candidate = self.candidate_path(raw_path)?;
180 let resolved = candidate.canonicalize().map_err(|e| {
181 ::zeroclaw_log::record!(
182 WARN,
183 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
184 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
185 .with_attrs(::serde_json::json!({
186 "raw_path": raw_path,
187 "error": format!("{}", e),
188 })),
189 "git_operations: cannot resolve worktree path"
190 );
191 anyhow::Error::msg(format!("Cannot resolve worktree path '{}': {e}", raw_path))
192 })?;
193
194 if !self.security.is_resolved_path_allowed(&resolved) {
195 anyhow::bail!(
196 "Worktree path '{}' resolves outside the workspace or allowed roots",
197 raw_path
198 );
199 }
200
201 Ok(resolved)
202 }
203
204 async fn run_git_command(
205 &self,
206 args: &[&str],
207 working_dir: &std::path::Path,
208 ) -> anyhow::Result<String> {
209 let output = tokio::process::Command::new("git")
210 .args(args)
211 .current_dir(working_dir)
212 .output()
213 .await?;
214
215 if !output.status.success() {
216 let stderr = String::from_utf8_lossy(&output.stderr);
217 anyhow::bail!("Git command failed: {stderr}");
218 }
219
220 Ok(String::from_utf8_lossy(&output.stdout).to_string())
221 }
222
223 async fn git_status(
224 &self,
225 _args: serde_json::Value,
226 working_dir: &std::path::Path,
227 ) -> anyhow::Result<ToolResult> {
228 let output = self
229 .run_git_command(&["status", "--porcelain=2", "--branch"], working_dir)
230 .await?;
231
232 let mut result = serde_json::Map::new();
234 let mut branch = String::new();
235 let mut staged = Vec::new();
236 let mut unstaged = Vec::new();
237 let mut untracked = Vec::new();
238
239 for line in output.lines() {
240 if line.starts_with("# branch.head ") {
241 branch = line.trim_start_matches("# branch.head ").to_string();
242 } else if let Some(rest) = line.strip_prefix("1 ") {
243 let mut parts = rest.splitn(3, ' ');
245 if let (Some(staging), Some(path)) = (parts.next(), parts.next())
246 && !staging.is_empty()
247 {
248 let status_char = staging.chars().next().unwrap_or(' ');
249 if status_char != '.' && status_char != ' ' {
250 staged.push(json!({"path": path, "status": status_char}));
251 }
252 let status_char = staging.chars().nth(1).unwrap_or(' ');
253 if status_char != '.' && status_char != ' ' {
254 unstaged.push(json!({"path": path, "status": status_char}));
255 }
256 }
257 } else if let Some(rest) = line.strip_prefix("? ") {
258 untracked.push(rest.to_string());
259 }
260 }
261
262 result.insert("branch".to_string(), json!(branch));
263 result.insert("staged".to_string(), json!(staged));
264 result.insert("unstaged".to_string(), json!(unstaged));
265 result.insert("untracked".to_string(), json!(untracked));
266 result.insert(
267 "clean".to_string(),
268 json!(staged.is_empty() && unstaged.is_empty() && untracked.is_empty()),
269 );
270
271 Ok(ToolResult {
272 success: true,
273 output: serde_json::to_string_pretty(&result).unwrap_or_default(),
274 error: None,
275 })
276 }
277
278 async fn git_diff(
279 &self,
280 args: serde_json::Value,
281 working_dir: &std::path::Path,
282 ) -> anyhow::Result<ToolResult> {
283 let files = args.get("files").and_then(|v| v.as_str()).unwrap_or(".");
284 let cached = args
285 .get("cached")
286 .and_then(|v| v.as_bool())
287 .unwrap_or(false);
288
289 self.sanitize_git_args(files)?;
291
292 let mut git_args = vec!["diff", "--unified=3"];
293 if cached {
294 git_args.push("--cached");
295 }
296 git_args.push("--");
297 git_args.push(files);
298
299 let output = self.run_git_command(&git_args, working_dir).await?;
300
301 let mut result = serde_json::Map::new();
303 let mut hunks = Vec::new();
304 let mut current_file = String::new();
305 let mut current_hunk = serde_json::Map::new();
306 let mut lines = Vec::new();
307
308 for line in output.lines() {
309 if line.starts_with("diff --git ") {
310 if !lines.is_empty() {
311 current_hunk.insert("lines".to_string(), json!(lines));
312 if !current_hunk.is_empty() {
313 hunks.push(serde_json::Value::Object(current_hunk.clone()));
314 }
315 lines = Vec::new();
316 current_hunk = serde_json::Map::new();
317 }
318 let parts: Vec<&str> = line.split_whitespace().collect();
319 if parts.len() >= 4 {
320 current_file = parts[3].trim_start_matches("b/").to_string();
321 current_hunk.insert("file".to_string(), json!(current_file));
322 }
323 } else if line.starts_with("@@ ") {
324 if !lines.is_empty() {
325 current_hunk.insert("lines".to_string(), json!(lines));
326 if !current_hunk.is_empty() {
327 hunks.push(serde_json::Value::Object(current_hunk.clone()));
328 }
329 lines = Vec::new();
330 current_hunk = serde_json::Map::new();
331 current_hunk.insert("file".to_string(), json!(current_file));
332 }
333 current_hunk.insert("header".to_string(), json!(line));
334 } else if !line.is_empty() {
335 lines.push(json!({
336 "text": line,
337 "type": if line.starts_with('+') { "add" }
338 else if line.starts_with('-') { "delete" }
339 else { "context" }
340 }));
341 }
342 }
343
344 if !lines.is_empty() {
345 current_hunk.insert("lines".to_string(), json!(lines));
346 if !current_hunk.is_empty() {
347 hunks.push(serde_json::Value::Object(current_hunk));
348 }
349 }
350
351 result.insert("hunks".to_string(), json!(hunks));
352 result.insert("file_count".to_string(), json!(hunks.len()));
353
354 Ok(ToolResult {
355 success: true,
356 output: serde_json::to_string_pretty(&result).unwrap_or_default(),
357 error: None,
358 })
359 }
360
361 async fn git_log(
362 &self,
363 args: serde_json::Value,
364 working_dir: &std::path::Path,
365 ) -> anyhow::Result<ToolResult> {
366 let limit_raw = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10);
367 let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000);
368 let limit_str = limit.to_string();
369
370 let output = self
371 .run_git_command(
372 &[
373 "log",
374 &format!("-{limit_str}"),
375 "--pretty=format:%H|%an|%ae|%ad|%s",
376 "--date=iso",
377 ],
378 working_dir,
379 )
380 .await?;
381
382 let mut commits = Vec::new();
383
384 for line in output.lines() {
385 let parts: Vec<&str> = line.split('|').collect();
386 if parts.len() >= 5 {
387 commits.push(json!({
388 "hash": parts[0],
389 "author": parts[1],
390 "email": parts[2],
391 "date": parts[3],
392 "message": parts[4]
393 }));
394 }
395 }
396
397 Ok(ToolResult {
398 success: true,
399 output: serde_json::to_string_pretty(&json!({ "commits": commits }))
400 .unwrap_or_default(),
401 error: None,
402 })
403 }
404
405 async fn git_branch(
406 &self,
407 _args: serde_json::Value,
408 working_dir: &std::path::Path,
409 ) -> anyhow::Result<ToolResult> {
410 let output = self
411 .run_git_command(
412 &["branch", "--format=%(refname:short)|%(HEAD)"],
413 working_dir,
414 )
415 .await?;
416
417 let mut branches = Vec::new();
418 let mut current = String::new();
419
420 for line in output.lines() {
421 if let Some((name, head)) = line.split_once('|') {
422 let is_current = head == "*";
423 if is_current {
424 current = name.to_string();
425 }
426 branches.push(json!({
427 "name": name,
428 "current": is_current
429 }));
430 }
431 }
432
433 Ok(ToolResult {
434 success: true,
435 output: serde_json::to_string_pretty(&json!({
436 "current": current,
437 "branches": branches
438 }))
439 .unwrap_or_default(),
440 error: None,
441 })
442 }
443
444 fn truncate_commit_message(message: &str) -> String {
445 if message.chars().count() > 2000 {
446 format!("{}...", message.chars().take(1997).collect::<String>())
447 } else {
448 message.to_string()
449 }
450 }
451
452 async fn git_commit(
453 &self,
454 args: serde_json::Value,
455 working_dir: &std::path::Path,
456 ) -> anyhow::Result<ToolResult> {
457 let message = args
458 .get("message")
459 .and_then(|v| v.as_str())
460 .ok_or_else(|| {
461 ::zeroclaw_log::record!(
462 WARN,
463 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
464 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
465 .with_attrs(::serde_json::json!({"param": "message"})),
466 "git_operations: missing message parameter"
467 );
468 anyhow::Error::msg("Missing 'message' parameter")
469 })?;
470
471 let sanitized = message
473 .lines()
474 .map(|l| l.trim())
475 .filter(|l| !l.is_empty())
476 .collect::<Vec<_>>()
477 .join("\n");
478
479 if sanitized.is_empty() {
480 anyhow::bail!("Commit message cannot be empty");
481 }
482
483 let message = Self::truncate_commit_message(&sanitized);
485
486 let output = self
487 .run_git_command(&["commit", "-m", &message], working_dir)
488 .await;
489
490 match output {
491 Ok(_) => Ok(ToolResult {
492 success: true,
493 output: format!("Committed: {message}"),
494 error: None,
495 }),
496 Err(e) => Ok(ToolResult {
497 success: false,
498 output: String::new(),
499 error: Some(format!("Commit failed: {e}")),
500 }),
501 }
502 }
503
504 async fn git_add(
505 &self,
506 args: serde_json::Value,
507 working_dir: &std::path::Path,
508 ) -> anyhow::Result<ToolResult> {
509 let paths = args.get("paths").and_then(|v| v.as_str()).ok_or_else(|| {
510 ::zeroclaw_log::record!(
511 WARN,
512 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
513 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
514 .with_attrs(::serde_json::json!({"param": "paths"})),
515 "git_operations: missing paths parameter"
516 );
517 anyhow::Error::msg("Missing 'paths' parameter")
518 })?;
519
520 self.sanitize_git_args(paths)?;
522
523 let output = self
524 .run_git_command(&["add", "--", paths], working_dir)
525 .await;
526
527 match output {
528 Ok(_) => Ok(ToolResult {
529 success: true,
530 output: format!("Staged: {paths}"),
531 error: None,
532 }),
533 Err(e) => Ok(ToolResult {
534 success: false,
535 output: String::new(),
536 error: Some(format!("Add failed: {e}")),
537 }),
538 }
539 }
540
541 async fn git_checkout(
542 &self,
543 args: serde_json::Value,
544 working_dir: &std::path::Path,
545 ) -> anyhow::Result<ToolResult> {
546 let branch = args.get("branch").and_then(|v| v.as_str()).ok_or_else(|| {
547 ::zeroclaw_log::record!(
548 WARN,
549 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
550 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
551 .with_attrs(::serde_json::json!({"param": "branch"})),
552 "git_operations: missing branch parameter"
553 );
554 anyhow::Error::msg("Missing 'branch' parameter")
555 })?;
556
557 let sanitized = self.sanitize_git_args(branch)?;
559
560 if sanitized.is_empty() || sanitized.len() > 1 {
561 anyhow::bail!("Invalid branch specification");
562 }
563
564 let branch_name = &sanitized[0];
565
566 if branch_name.contains('@') || branch_name.contains('^') || branch_name.contains('~') {
568 anyhow::bail!("Branch name contains invalid characters");
569 }
570
571 let output = self
572 .run_git_command(&["checkout", branch_name], working_dir)
573 .await;
574
575 match output {
576 Ok(_) => Ok(ToolResult {
577 success: true,
578 output: format!("Switched to branch: {branch_name}"),
579 error: None,
580 }),
581 Err(e) => Ok(ToolResult {
582 success: false,
583 output: String::new(),
584 error: Some(format!("Checkout failed: {e}")),
585 }),
586 }
587 }
588
589 async fn git_stash(
590 &self,
591 args: serde_json::Value,
592 working_dir: &std::path::Path,
593 ) -> anyhow::Result<ToolResult> {
594 let action = args
595 .get("action")
596 .and_then(|v| v.as_str())
597 .unwrap_or("push");
598
599 let output = match action {
600 "push" | "save" => {
601 self.run_git_command(&["stash", "push", "-m", "auto-stash"], working_dir)
602 .await
603 }
604 "pop" => self.run_git_command(&["stash", "pop"], working_dir).await,
605 "list" => self.run_git_command(&["stash", "list"], working_dir).await,
606 "drop" => {
607 let index_raw = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
608 let index = i32::try_from(index_raw).map_err(|_| {
609 ::zeroclaw_log::record!(
610 WARN,
611 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
612 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
613 .with_attrs(::serde_json::json!({"index": index_raw})),
614 "git_operations: stash index too large"
615 );
616 anyhow::Error::msg(format!("stash index too large: {index_raw}"))
617 })?;
618 self.run_git_command(
619 &["stash", "drop", &format!("stash@{{{index}}}")],
620 working_dir,
621 )
622 .await
623 }
624 _ => anyhow::bail!("Unknown stash action: {action}. Use: push, pop, list, drop"),
625 };
626
627 match output {
628 Ok(out) => Ok(ToolResult {
629 success: true,
630 output: out,
631 error: None,
632 }),
633 Err(e) => Ok(ToolResult {
634 success: false,
635 output: String::new(),
636 error: Some(format!("Stash {action} failed: {e}")),
637 }),
638 }
639 }
640
641 fn parse_worktree_list(&self, output: &str) -> serde_json::Value {
648 let mut worktrees = Vec::new();
649 let mut current_path = String::new();
650 let mut current_branch = String::new();
651 let mut current_head = String::new();
652 let mut is_detached = false;
653
654 let workspace = self.workspace_dir.to_string_lossy();
655
656 for line in output.lines() {
657 let line = line.trim();
658 if line.is_empty() {
659 if !current_path.is_empty() {
660 worktrees.push(json!({
661 "path": ¤t_path,
662 "branch": if is_detached { "HEAD" } else { ¤t_branch },
663 "head": ¤t_head,
664 "detached": is_detached,
665 "active": current_path == workspace.as_ref()
666 }));
667 current_path.clear();
668 current_branch.clear();
669 current_head.clear();
670 is_detached = false;
671 }
672 } else if let Some(p) = line.strip_prefix("worktree ") {
673 current_path = p.to_string();
674 } else if let Some(h) = line.strip_prefix("HEAD ") {
675 current_head = h.to_string();
676 } else if let Some(b) = line.strip_prefix("branch ") {
677 current_branch = b.trim_start_matches("refs/heads/").to_string();
678 } else if line == "detached" {
679 is_detached = true;
680 }
681 }
682 if !current_path.is_empty() {
684 worktrees.push(json!({
685 "path": ¤t_path,
686 "branch": if is_detached { "HEAD" } else { current_branch.as_str() },
687 "head": ¤t_head,
688 "detached": is_detached,
689 "active": current_path == workspace.as_ref()
690 }));
691 }
692
693 json!({ "worktrees": worktrees })
694 }
695
696 async fn git_worktree(
697 &self,
698 args: serde_json::Value,
699 working_dir: &std::path::Path,
700 ) -> anyhow::Result<ToolResult> {
701 let subcommand = match args.get("subcommand").and_then(|v| v.as_str()) {
702 Some(cmd) => cmd,
703 None => anyhow::bail!("Missing 'subcommand' parameter. Use: list, add, remove, prune"),
704 };
705
706 match subcommand {
707 "list" => {
708 let output = self
709 .run_git_command(&["worktree", "list", "--porcelain"], working_dir)
710 .await?;
711 let parsed = self.parse_worktree_list(&output);
712 Ok(ToolResult {
713 success: true,
714 output: serde_json::to_string_pretty(&parsed).unwrap_or_default(),
715 error: None,
716 })
717 }
718 "add" => {
719 let worktree_path = match args.get("worktree_path").and_then(|v| v.as_str()) {
720 Some(p) => p,
721 None => anyhow::bail!("Missing 'worktree_path' parameter for worktree add"),
722 };
723 self.sanitize_git_args(worktree_path)?;
724 let worktree_path = self.ensure_worktree_add_target_allowed(worktree_path)?;
725 let worktree_path = worktree_path.to_str().ok_or_else(|| {
726 ::zeroclaw_log::record!(
727 WARN,
728 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
729 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
730 "git_operations: worktree path not valid UTF-8"
731 );
732 anyhow::Error::msg("Worktree path must be valid UTF-8 for git execution")
733 })?;
734
735 let branch = args
736 .get("branch")
737 .and_then(|v| v.as_str())
738 .unwrap_or_default();
739 let mut git_args = vec!["worktree", "add", worktree_path];
741 if !branch.is_empty() {
742 self.sanitize_git_args(branch)?;
743 git_args.push(branch);
744 }
745
746 self.run_git_command(&git_args, working_dir).await?;
747 Ok(ToolResult {
748 success: true,
749 output: format!("Worktree added at: {worktree_path}"),
750 error: None,
751 })
752 }
753 "remove" => {
754 let worktree_path = match args.get("worktree_path").and_then(|v| v.as_str()) {
755 Some(p) => p,
756 None => anyhow::bail!("Missing 'worktree_path' parameter for worktree remove"),
757 };
758 self.sanitize_git_args(worktree_path)?;
759 let worktree_path = self.ensure_worktree_remove_target_allowed(worktree_path)?;
760 let worktree_path = worktree_path.to_str().ok_or_else(|| {
761 ::zeroclaw_log::record!(
762 WARN,
763 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
764 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
765 "git_operations: worktree path not valid UTF-8"
766 );
767 anyhow::Error::msg("Worktree path must be valid UTF-8 for git execution")
768 })?;
769
770 self.run_git_command(&["worktree", "remove", worktree_path], working_dir)
771 .await?;
772 Ok(ToolResult {
773 success: true,
774 output: format!("Worktree removed: {worktree_path}"),
775 error: None,
776 })
777 }
778 "prune" => {
779 self.run_git_command(&["worktree", "prune"], working_dir)
780 .await?;
781 Ok(ToolResult {
782 success: true,
783 output: "Worktree prune completed".to_string(),
784 error: None,
785 })
786 }
787 _ => anyhow::bail!(
788 "Unknown worktree subcommand: {subcommand}. Use: list, add, remove, prune"
789 ),
790 }
791 }
792}
793
794#[async_trait]
795impl Tool for GitOperationsTool {
796 fn name(&self) -> &str {
797 "git_operations"
798 }
799
800 fn description(&self) -> &str {
801 "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash, worktree). Provides parsed JSON output and integrates with security policy for autonomy controls."
802 }
803
804 fn parameters_schema(&self) -> serde_json::Value {
805 json!({
806 "type": "object",
807 "properties": {
808 "operation": {
809 "type": "string",
810 "enum": ["status", "diff", "log", "branch", "commit", "add", "checkout", "stash", "worktree"],
811 "description": "Git operation to perform"
812 },
813 "subcommand": {
814 "type": "string",
815 "enum": ["list", "add", "remove", "prune"],
816 "description": "Worktree subcommand"
817 },
818 "message": {
819 "type": "string",
820 "description": "Commit message (for 'commit' operation)"
821 },
822 "paths": {
823 "type": "string",
824 "description": "File paths to stage (for 'add' operation)"
825 },
826 "branch": {
827 "type": "string",
828 "description": "Branch name (for 'checkout' operation or 'worktree add' subcommand)"
829 },
830 "worktree_path": {
831 "type": "string",
832 "description": "Filesystem path for the worktree (for 'worktree add' and 'worktree remove' subcommands). Relative paths resolve under the workspace; absolute paths must stay inside the workspace or configured allowed roots."
833 },
834 "files": {
835 "type": "string",
836 "description": "File or path to diff (for 'diff' operation, default: '.')"
837 },
838 "cached": {
839 "type": "boolean",
840 "description": "Show staged changes (for 'diff' operation)"
841 },
842 "limit": {
843 "type": "integer",
844 "description": "Number of log entries (for 'log' operation, default: 10)"
845 },
846 "action": {
847 "type": "string",
848 "enum": ["push", "pop", "list", "drop"],
849 "description": "Stash action (for 'stash' operation)"
850 },
851 "index": {
852 "type": "integer",
853 "description": "Stash index (for 'stash' with 'drop' action)"
854 },
855 "path": {
856 "type": "string",
857 "description": "Optional subdirectory path within the workspace to run git operations in. Defaults to workspace root."
858 }
859 },
860 "required": ["operation"]
861 })
862 }
863
864 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
865 let operation = match args.get("operation").and_then(|v| v.as_str()) {
866 Some(op) => op,
867 None => {
868 return Ok(ToolResult {
869 success: false,
870 output: String::new(),
871 error: Some("Missing 'operation' parameter".into()),
872 });
873 }
874 };
875
876 let path = args.get("path").and_then(|v| v.as_str());
877 let working_dir = match self.resolve_working_dir(path) {
878 Ok(d) => d,
879 Err(e) => {
880 return Ok(ToolResult {
881 success: false,
882 output: String::new(),
883 error: Some(format!("Invalid path: {e}")),
884 });
885 }
886 };
887
888 if !working_dir.join(".git").exists() {
890 let mut current_dir = working_dir.as_path();
892 let mut found_git = false;
893 while current_dir.parent().is_some() {
894 if current_dir.join(".git").exists() {
895 found_git = true;
896 break;
897 }
898 current_dir = current_dir.parent().unwrap();
899 }
900
901 if !found_git {
902 return Ok(ToolResult {
903 success: false,
904 output: String::new(),
905 error: Some("Not in a git repository".into()),
906 });
907 }
908 }
909
910 if self.requires_write_access(operation) {
912 if !self.security.can_act() {
913 return Ok(ToolResult {
914 success: false,
915 output: String::new(),
916 error: Some(
917 "Action blocked: git write operations require higher autonomy level".into(),
918 ),
919 });
920 }
921
922 match self.security.autonomy {
923 AutonomyLevel::ReadOnly => {
924 return Ok(ToolResult {
925 success: false,
926 output: String::new(),
927 error: Some("Action blocked: read-only mode".into()),
928 });
929 }
930 AutonomyLevel::Supervised | AutonomyLevel::Full => {}
931 }
932 }
933
934 if !self.security.record_action() {
936 return Ok(ToolResult {
937 success: false,
938 output: String::new(),
939 error: Some("Action blocked: rate limit exceeded".into()),
940 });
941 }
942
943 match operation {
945 "status" => self.git_status(args, &working_dir).await,
946 "diff" => self.git_diff(args, &working_dir).await,
947 "log" => self.git_log(args, &working_dir).await,
948 "branch" => self.git_branch(args, &working_dir).await,
949 "commit" => self.git_commit(args, &working_dir).await,
950 "add" => self.git_add(args, &working_dir).await,
951 "checkout" => self.git_checkout(args, &working_dir).await,
952 "stash" => self.git_stash(args, &working_dir).await,
953 "worktree" => self.git_worktree(args, &working_dir).await,
954 _ => Ok(ToolResult {
955 success: false,
956 output: String::new(),
957 error: Some(format!("Unknown operation: {operation}")),
958 }),
959 }
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use tempfile::TempDir;
967 use zeroclaw_config::policy::SecurityPolicy;
968
969 fn test_tool(dir: &std::path::Path) -> GitOperationsTool {
970 let security = Arc::new(SecurityPolicy {
971 autonomy: AutonomyLevel::Supervised,
972 workspace_dir: dir.to_path_buf(),
973 ..SecurityPolicy::default()
974 });
975 GitOperationsTool::new(security, dir.to_path_buf())
976 }
977
978 fn test_tool_with_allowed_root(
979 dir: &std::path::Path,
980 allowed_root: std::path::PathBuf,
981 ) -> GitOperationsTool {
982 let security = Arc::new(SecurityPolicy {
983 autonomy: AutonomyLevel::Supervised,
984 workspace_dir: dir.to_path_buf(),
985 allowed_roots: vec![allowed_root],
986 ..SecurityPolicy::default()
987 });
988 GitOperationsTool::new(security, dir.to_path_buf())
989 }
990
991 #[test]
992 fn sanitize_git_blocks_injection() {
993 let tmp = TempDir::new().unwrap();
994 let tool = test_tool(tmp.path());
995
996 assert!(tool.sanitize_git_args("--exec=rm -rf /").is_err());
998 assert!(tool.sanitize_git_args("$(echo pwned)").is_err());
999 assert!(tool.sanitize_git_args("`malicious`").is_err());
1000 assert!(tool.sanitize_git_args("arg | cat").is_err());
1001 assert!(tool.sanitize_git_args("arg; rm file").is_err());
1002 }
1003
1004 #[test]
1005 fn sanitize_git_blocks_pager_editor_injection() {
1006 let tmp = TempDir::new().unwrap();
1007 let tool = test_tool(tmp.path());
1008
1009 assert!(tool.sanitize_git_args("--pager=less").is_err());
1010 assert!(tool.sanitize_git_args("--editor=vim").is_err());
1011 }
1012
1013 #[test]
1014 fn sanitize_git_blocks_config_injection() {
1015 let tmp = TempDir::new().unwrap();
1016 let tool = test_tool(tmp.path());
1017
1018 assert!(tool.sanitize_git_args("-c core.sshCommand=evil").is_err());
1020 assert!(tool.sanitize_git_args("-c=core.pager=less").is_err());
1021 }
1022
1023 #[test]
1024 fn sanitize_git_blocks_no_verify() {
1025 let tmp = TempDir::new().unwrap();
1026 let tool = test_tool(tmp.path());
1027
1028 assert!(tool.sanitize_git_args("--no-verify").is_err());
1029 }
1030
1031 #[test]
1032 fn sanitize_git_blocks_redirect_in_args() {
1033 let tmp = TempDir::new().unwrap();
1034 let tool = test_tool(tmp.path());
1035
1036 assert!(tool.sanitize_git_args("file.txt > /tmp/out").is_err());
1037 }
1038
1039 #[test]
1040 fn sanitize_git_cached_not_blocked() {
1041 let tmp = TempDir::new().unwrap();
1042 let tool = test_tool(tmp.path());
1043
1044 assert!(tool.sanitize_git_args("--cached").is_ok());
1046 assert!(tool.sanitize_git_args("-cached").is_ok());
1048 }
1049
1050 #[test]
1051 fn worktree_add_target_must_stay_inside_workspace_or_allowed_root() {
1052 let workspace = TempDir::new().unwrap();
1053 let outside = TempDir::new().unwrap();
1054 let tool = test_tool(workspace.path());
1055
1056 assert!(
1057 tool.ensure_worktree_add_target_allowed("new-worktree")
1058 .is_ok()
1059 );
1060 assert!(
1061 tool.ensure_worktree_add_target_allowed(
1062 outside.path().join("new-worktree").to_str().unwrap()
1063 )
1064 .is_err()
1065 );
1066 }
1067
1068 #[test]
1069 fn worktree_add_target_allows_configured_allowed_root() {
1070 let workspace = TempDir::new().unwrap();
1071 let allowed = TempDir::new().unwrap();
1072 let tool = test_tool_with_allowed_root(workspace.path(), allowed.path().to_path_buf());
1073
1074 assert!(
1075 tool.ensure_worktree_add_target_allowed(
1076 allowed.path().join("new-worktree").to_str().unwrap()
1077 )
1078 .is_ok()
1079 );
1080 }
1081
1082 #[test]
1083 fn worktree_remove_target_must_stay_inside_workspace() {
1084 let workspace = TempDir::new().unwrap();
1085 let outside = TempDir::new().unwrap();
1086 std::fs::create_dir(workspace.path().join("old-worktree")).unwrap();
1087 std::fs::create_dir(outside.path().join("old-worktree")).unwrap();
1088 let tool = test_tool(workspace.path());
1089
1090 assert!(
1091 tool.ensure_worktree_remove_target_allowed("old-worktree")
1092 .is_ok()
1093 );
1094 assert!(
1095 tool.ensure_worktree_remove_target_allowed(
1096 outside.path().join("old-worktree").to_str().unwrap()
1097 )
1098 .is_err()
1099 );
1100 }
1101
1102 #[test]
1103 fn sanitize_git_allows_safe() {
1104 let tmp = TempDir::new().unwrap();
1105 let tool = test_tool(tmp.path());
1106
1107 assert!(tool.sanitize_git_args("main").is_ok());
1109 assert!(tool.sanitize_git_args("feature/test-branch").is_ok());
1110 assert!(tool.sanitize_git_args("--cached").is_ok());
1111 assert!(tool.sanitize_git_args("src/main.rs").is_ok());
1112 assert!(tool.sanitize_git_args(".").is_ok());
1113 }
1114
1115 #[test]
1116 fn requires_write_detection() {
1117 let tmp = TempDir::new().unwrap();
1118 let tool = test_tool(tmp.path());
1119
1120 assert!(tool.requires_write_access("commit"));
1121 assert!(tool.requires_write_access("add"));
1122 assert!(tool.requires_write_access("checkout"));
1123 assert!(tool.requires_write_access("stash"));
1124 assert!(tool.requires_write_access("worktree"));
1125
1126 assert!(!tool.requires_write_access("status"));
1127 assert!(!tool.requires_write_access("diff"));
1128 assert!(!tool.requires_write_access("log"));
1129 assert!(!tool.requires_write_access("branch"));
1130 }
1131
1132 #[test]
1133 fn is_read_only_detection() {
1134 let tmp = TempDir::new().unwrap();
1135 let tool = test_tool(tmp.path());
1136
1137 assert!(tool.is_read_only("status"));
1138 assert!(tool.is_read_only("diff"));
1139 assert!(tool.is_read_only("log"));
1140 assert!(tool.is_read_only("branch"));
1141
1142 assert!(!tool.is_read_only("worktree"));
1144 assert!(!tool.is_read_only("commit"));
1145 assert!(!tool.is_read_only("add"));
1146 }
1147
1148 #[test]
1149 fn branch_is_not_write_gated() {
1150 let tmp = TempDir::new().unwrap();
1151 let tool = test_tool(tmp.path());
1152
1153 assert!(!tool.requires_write_access("branch"));
1155 assert!(tool.is_read_only("branch"));
1156 }
1157
1158 #[tokio::test]
1159 async fn blocks_readonly_mode_for_write_ops() {
1160 let tmp = TempDir::new().unwrap();
1161 std::process::Command::new("git")
1163 .args(["init"])
1164 .current_dir(tmp.path())
1165 .output()
1166 .unwrap();
1167
1168 let security = Arc::new(SecurityPolicy {
1169 autonomy: AutonomyLevel::ReadOnly,
1170 ..SecurityPolicy::default()
1171 });
1172 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
1173
1174 let result = tool
1175 .execute(json!({"operation": "commit", "message": "test"}))
1176 .await
1177 .unwrap();
1178 assert!(!result.success);
1179 assert!(
1181 result
1182 .error
1183 .as_deref()
1184 .unwrap_or("")
1185 .contains("higher autonomy")
1186 );
1187 }
1188
1189 #[tokio::test]
1190 async fn allows_branch_listing_in_readonly_mode() {
1191 let tmp = TempDir::new().unwrap();
1192 std::process::Command::new("git")
1194 .args(["init"])
1195 .current_dir(tmp.path())
1196 .output()
1197 .unwrap();
1198
1199 let security = Arc::new(SecurityPolicy {
1200 autonomy: AutonomyLevel::ReadOnly,
1201 ..SecurityPolicy::default()
1202 });
1203 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
1204
1205 let result = tool.execute(json!({"operation": "branch"})).await.unwrap();
1206 let error_msg = result.error.as_deref().unwrap_or("");
1208 assert!(
1209 !error_msg.contains("read-only") && !error_msg.contains("higher autonomy"),
1210 "branch listing should not be blocked in read-only mode, got: {error_msg}"
1211 );
1212 }
1213
1214 #[tokio::test]
1215 async fn allows_readonly_ops_in_readonly_mode() {
1216 let tmp = TempDir::new().unwrap();
1217 let security = Arc::new(SecurityPolicy {
1218 autonomy: AutonomyLevel::ReadOnly,
1219 ..SecurityPolicy::default()
1220 });
1221 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
1222
1223 let result = tool.execute(json!({"operation": "status"})).await.unwrap();
1225 assert!(!result.success, "Expected failure due to missing git repo");
1227 let error_msg = result.error.as_deref().unwrap_or("");
1228 assert!(
1229 !error_msg.contains("read-only") && !error_msg.contains("autonomy"),
1230 "Error should be about git, not about autonomy restrictions: {error_msg}"
1231 );
1232 }
1233
1234 #[tokio::test]
1235 async fn rejects_missing_operation() {
1236 let tmp = TempDir::new().unwrap();
1237 let tool = test_tool(tmp.path());
1238
1239 let result = tool.execute(json!({})).await.unwrap();
1240 assert!(!result.success);
1241 assert!(
1242 result
1243 .error
1244 .as_deref()
1245 .unwrap_or("")
1246 .contains("Missing 'operation'")
1247 );
1248 }
1249
1250 #[tokio::test]
1251 async fn rejects_unknown_operation() {
1252 let tmp = TempDir::new().unwrap();
1253 std::process::Command::new("git")
1255 .args(["init"])
1256 .current_dir(tmp.path())
1257 .output()
1258 .unwrap();
1259
1260 let tool = test_tool(tmp.path());
1261
1262 let result = tool.execute(json!({"operation": "push"})).await.unwrap();
1263 assert!(!result.success);
1264 assert!(
1265 result
1266 .error
1267 .as_deref()
1268 .unwrap_or("")
1269 .contains("Unknown operation")
1270 );
1271 }
1272
1273 #[test]
1274 fn truncates_multibyte_commit_message_without_panicking() {
1275 let long = "🦀".repeat(2500);
1276 let truncated = GitOperationsTool::truncate_commit_message(&long);
1277
1278 assert_eq!(truncated.chars().count(), 2000);
1279 }
1280
1281 #[test]
1282 fn resolve_working_dir_none_returns_workspace() {
1283 let tmp = TempDir::new().unwrap();
1284 let tool = test_tool(tmp.path());
1285
1286 let result = tool.resolve_working_dir(None).unwrap();
1287 assert_eq!(result, tmp.path().to_path_buf());
1288 }
1289
1290 #[test]
1291 fn resolve_working_dir_empty_returns_workspace() {
1292 let tmp = TempDir::new().unwrap();
1293 let tool = test_tool(tmp.path());
1294
1295 let result = tool.resolve_working_dir(Some("")).unwrap();
1296 assert_eq!(result, tmp.path().to_path_buf());
1297 }
1298
1299 #[test]
1300 fn resolve_working_dir_valid_subdir() {
1301 let tmp = TempDir::new().unwrap();
1302 std::fs::create_dir(tmp.path().join("subproject")).unwrap();
1303 let tool = test_tool(tmp.path());
1304
1305 let result = tool.resolve_working_dir(Some("subproject")).unwrap();
1306 let expected = tmp.path().join("subproject").canonicalize().unwrap();
1307 assert_eq!(result, expected);
1308 }
1309
1310 #[test]
1311 fn resolve_working_dir_rejects_traversal() {
1312 let tmp = TempDir::new().unwrap();
1313 let tool = test_tool(tmp.path());
1314
1315 let result = tool.resolve_working_dir(Some(".."));
1316 assert!(result.is_err());
1317 let err_msg = result.unwrap_err().to_string();
1318 assert!(
1319 err_msg.contains("resolves outside the workspace"),
1320 "Expected traversal rejection, got: {err_msg}"
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn git_operations_work_in_subdirectory() {
1326 let tmp = TempDir::new().unwrap();
1327 let sub = tmp.path().join("nested");
1328 std::fs::create_dir(&sub).unwrap();
1329 std::process::Command::new("git")
1330 .args(["init"])
1331 .current_dir(&sub)
1332 .output()
1333 .unwrap();
1334 std::process::Command::new("git")
1335 .args(["config", "user.email", "test@test.com"])
1336 .current_dir(&sub)
1337 .output()
1338 .unwrap();
1339 std::process::Command::new("git")
1340 .args(["config", "user.name", "Test"])
1341 .current_dir(&sub)
1342 .output()
1343 .unwrap();
1344
1345 let tool = test_tool(tmp.path());
1346
1347 let result = tool
1348 .execute(json!({"operation": "status", "path": "nested"}))
1349 .await
1350 .unwrap();
1351 assert!(
1352 result.success,
1353 "Expected success, got error: {:?}",
1354 result.error
1355 );
1356 assert!(result.output.contains("branch"));
1357 }
1358
1359 #[tokio::test]
1360 async fn git_worktree_list_works() {
1361 let tmp = TempDir::new().unwrap();
1362 std::process::Command::new("git")
1363 .args(["init"])
1364 .current_dir(tmp.path())
1365 .output()
1366 .unwrap();
1367
1368 let tool = test_tool(tmp.path());
1369
1370 let result = tool
1371 .execute(json!({"operation": "worktree", "subcommand": "list"}))
1372 .await
1373 .unwrap();
1374 assert!(result.success, "Expected success, got: {:?}", result.error);
1375
1376 let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
1377 let worktrees = parsed["worktrees"]
1378 .as_array()
1379 .expect("worktrees must be an array");
1380 assert!(
1381 !worktrees.is_empty(),
1382 "Expected at least the main worktree in the list"
1383 );
1384 assert!(
1385 worktrees[0]["path"].as_str().is_some_and(|p| !p.is_empty()),
1386 "Main worktree must have a non-empty path"
1387 );
1388 }
1389}