Skip to main content

zeroclaw_tools/
git_operations.rs

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
9/// Git operations tool for structured repository management.
10/// Provides safe, parsed git operations with JSON output.
11pub 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    /// Sanitize git arguments to prevent injection attacks
25    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            // Block dangerous git options that could lead to command injection
29            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            // Block `-c` config injection (exact match or `-c=...` prefix).
45            // This must not false-positive on `--cached` or `-cached`.
46            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    /// Check if an operation requires write access
55    fn requires_write_access(&self, operation: &str) -> bool {
56        matches!(
57            operation,
58            "commit" | "add" | "checkout" | "stash" | "reset" | "revert" | "worktree"
59        )
60    }
61
62    /// Check if an operation is read-only
63    #[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    /// Resolve a user-provided path to an absolute path within the workspace.
72    /// Returns the workspace_dir if no path is provided.
73    /// Rejects paths that escape the workspace via traversal.
74    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        // Parse git status output into structured format
233        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                // Ordinary changed entry
244                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        // Validate files argument against injection patterns
290        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        // Parse diff into structured hunks
302        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        // Sanitize commit message
472        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        // Limit message length
484        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        // Validate paths against injection patterns
521        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        // Sanitize branch name
558        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        // Block dangerous branch names
567        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    /// Parse `git worktree list --porcelain` output into structured format.
642    ///
643    /// Porcelain format emits one blank-line-delimited block per worktree:
644    ///   worktree <path>
645    ///   HEAD <hash>
646    ///   branch refs/heads/<name>   (or "detached")
647    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": &current_path,
662                        "branch": if is_detached { "HEAD" } else { &current_branch },
663                        "head": &current_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        // Flush final entry if output has no trailing blank line
683        if !current_path.is_empty() {
684            worktrees.push(json!({
685                "path": &current_path,
686                "branch": if is_detached { "HEAD" } else { current_branch.as_str() },
687                "head": &current_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                // git worktree add <path> [<branch>]
740                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        // Check if we're in a git repository
889        if !working_dir.join(".git").exists() {
890            // Try to find .git in parent directories
891            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        // Check autonomy level for write operations
911        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        // Record action for rate limiting
935        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        // Execute the requested operation
944        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        // Should block dangerous arguments
997        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        // Exact `-c` flag (config injection)
1019        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        // --cached must NOT be blocked by the `-c` check
1045        assert!(tool.sanitize_git_args("--cached").is_ok());
1046        // Other safe flags starting with -c prefix
1047        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        // Should allow safe arguments
1108        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        // worktree has write subcommands (add/remove), so it is not read-only
1143        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        // Branch listing is read-only; it must not require write access
1154        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        // Initialize a git repository
1162        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        // can_act() returns false for ReadOnly, so we get the "higher autonomy level" message
1180        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        // Initialize a git repository so the command can succeed
1193        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        // Branch listing must not be blocked by read-only autonomy
1207        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        // This will fail because there's no git repo, but it shouldn't be blocked by autonomy
1224        let result = tool.execute(json!({"operation": "status"})).await.unwrap();
1225        // The error should be about git (not about autonomy/read-only mode)
1226        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        // Initialize a git repository
1254        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}