1use super::traits::{Tool, ToolResult};
2use crate::security::{AutonomyLevel, SecurityPolicy};
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
6
7pub struct GitOperationsTool {
10 security: Arc<SecurityPolicy>,
11 workspace_dir: std::path::PathBuf,
12}
13
14impl GitOperationsTool {
15 pub fn new(security: Arc<SecurityPolicy>, workspace_dir: std::path::PathBuf) -> Self {
16 Self {
17 security,
18 workspace_dir,
19 }
20 }
21
22 fn sanitize_git_args(&self, args: &str) -> anyhow::Result<Vec<String>> {
24 let mut result = Vec::new();
25 for arg in args.split_whitespace() {
26 let arg_lower = arg.to_lowercase();
28 if arg_lower.starts_with("--exec=")
29 || arg_lower.starts_with("--upload-pack=")
30 || arg_lower.starts_with("--receive-pack=")
31 || arg_lower.starts_with("--pager=")
32 || arg_lower.starts_with("--editor=")
33 || arg_lower == "--no-verify"
34 || arg_lower.contains("$(")
35 || arg_lower.contains('`')
36 || arg.contains('|')
37 || arg.contains(';')
38 || arg.contains('>')
39 {
40 anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
41 }
42 if arg_lower == "-c" || arg_lower.starts_with("-c=") {
45 anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
46 }
47 result.push(arg.to_string());
48 }
49 Ok(result)
50 }
51
52 fn requires_write_access(&self, operation: &str) -> bool {
54 matches!(
55 operation,
56 "commit" | "add" | "checkout" | "stash" | "reset" | "revert"
57 )
58 }
59
60 fn is_read_only(&self, operation: &str) -> bool {
62 matches!(
63 operation,
64 "status" | "diff" | "log" | "show" | "branch" | "rev-parse"
65 )
66 }
67
68 fn resolve_working_dir(&self, path: Option<&str>) -> anyhow::Result<std::path::PathBuf> {
72 let base = match path {
73 Some(p) if !p.is_empty() => {
74 let candidate = if std::path::Path::new(p).is_absolute() {
75 std::path::PathBuf::from(p)
76 } else {
77 self.workspace_dir.join(p)
78 };
79 let resolved = candidate
80 .canonicalize()
81 .map_err(|e| anyhow::anyhow!("Cannot resolve path '{}': {}", p, e))?;
82 let workspace_canonical = self
83 .workspace_dir
84 .canonicalize()
85 .unwrap_or_else(|_| self.workspace_dir.clone());
86 if !resolved.starts_with(&workspace_canonical) {
87 anyhow::bail!("Path '{}' resolves outside the workspace directory", p);
88 }
89 resolved
90 }
91 _ => self.workspace_dir.clone(),
92 };
93 Ok(base)
94 }
95
96 async fn run_git_command(
97 &self,
98 args: &[&str],
99 working_dir: &std::path::Path,
100 ) -> anyhow::Result<String> {
101 let output = tokio::process::Command::new("git")
102 .args(args)
103 .current_dir(working_dir)
104 .output()
105 .await?;
106
107 if !output.status.success() {
108 let stderr = String::from_utf8_lossy(&output.stderr);
109 anyhow::bail!("Git command failed: {stderr}");
110 }
111
112 Ok(String::from_utf8_lossy(&output.stdout).to_string())
113 }
114
115 async fn git_status(
116 &self,
117 _args: serde_json::Value,
118 working_dir: &std::path::Path,
119 ) -> anyhow::Result<ToolResult> {
120 let output = self
121 .run_git_command(&["status", "--porcelain=2", "--branch"], working_dir)
122 .await?;
123
124 let mut result = serde_json::Map::new();
126 let mut branch = String::new();
127 let mut staged = Vec::new();
128 let mut unstaged = Vec::new();
129 let mut untracked = Vec::new();
130
131 for line in output.lines() {
132 if line.starts_with("# branch.head ") {
133 branch = line.trim_start_matches("# branch.head ").to_string();
134 } else if let Some(rest) = line.strip_prefix("1 ") {
135 let mut parts = rest.splitn(3, ' ');
137 if let (Some(staging), Some(path)) = (parts.next(), parts.next()) {
138 if !staging.is_empty() {
139 let status_char = staging.chars().next().unwrap_or(' ');
140 if status_char != '.' && status_char != ' ' {
141 staged.push(json!({"path": path, "status": status_char}));
142 }
143 let status_char = staging.chars().nth(1).unwrap_or(' ');
144 if status_char != '.' && status_char != ' ' {
145 unstaged.push(json!({"path": path, "status": status_char}));
146 }
147 }
148 }
149 } else if let Some(rest) = line.strip_prefix("? ") {
150 untracked.push(rest.to_string());
151 }
152 }
153
154 result.insert("branch".to_string(), json!(branch));
155 result.insert("staged".to_string(), json!(staged));
156 result.insert("unstaged".to_string(), json!(unstaged));
157 result.insert("untracked".to_string(), json!(untracked));
158 result.insert(
159 "clean".to_string(),
160 json!(staged.is_empty() && unstaged.is_empty() && untracked.is_empty()),
161 );
162
163 Ok(ToolResult {
164 success: true,
165 output: serde_json::to_string_pretty(&result).unwrap_or_default(),
166 error: None,
167 })
168 }
169
170 async fn git_diff(
171 &self,
172 args: serde_json::Value,
173 working_dir: &std::path::Path,
174 ) -> anyhow::Result<ToolResult> {
175 let files = args.get("files").and_then(|v| v.as_str()).unwrap_or(".");
176 let cached = args
177 .get("cached")
178 .and_then(|v| v.as_bool())
179 .unwrap_or(false);
180
181 self.sanitize_git_args(files)?;
183
184 let mut git_args = vec!["diff", "--unified=3"];
185 if cached {
186 git_args.push("--cached");
187 }
188 git_args.push("--");
189 git_args.push(files);
190
191 let output = self.run_git_command(&git_args, working_dir).await?;
192
193 let mut result = serde_json::Map::new();
195 let mut hunks = Vec::new();
196 let mut current_file = String::new();
197 let mut current_hunk = serde_json::Map::new();
198 let mut lines = Vec::new();
199
200 for line in output.lines() {
201 if line.starts_with("diff --git ") {
202 if !lines.is_empty() {
203 current_hunk.insert("lines".to_string(), json!(lines));
204 if !current_hunk.is_empty() {
205 hunks.push(serde_json::Value::Object(current_hunk.clone()));
206 }
207 lines = Vec::new();
208 current_hunk = serde_json::Map::new();
209 }
210 let parts: Vec<&str> = line.split_whitespace().collect();
211 if parts.len() >= 4 {
212 current_file = parts[3].trim_start_matches("b/").to_string();
213 current_hunk.insert("file".to_string(), json!(current_file));
214 }
215 } else if line.starts_with("@@ ") {
216 if !lines.is_empty() {
217 current_hunk.insert("lines".to_string(), json!(lines));
218 if !current_hunk.is_empty() {
219 hunks.push(serde_json::Value::Object(current_hunk.clone()));
220 }
221 lines = Vec::new();
222 current_hunk = serde_json::Map::new();
223 current_hunk.insert("file".to_string(), json!(current_file));
224 }
225 current_hunk.insert("header".to_string(), json!(line));
226 } else if !line.is_empty() {
227 lines.push(json!({
228 "text": line,
229 "type": if line.starts_with('+') { "add" }
230 else if line.starts_with('-') { "delete" }
231 else { "context" }
232 }));
233 }
234 }
235
236 if !lines.is_empty() {
237 current_hunk.insert("lines".to_string(), json!(lines));
238 if !current_hunk.is_empty() {
239 hunks.push(serde_json::Value::Object(current_hunk));
240 }
241 }
242
243 result.insert("hunks".to_string(), json!(hunks));
244 result.insert("file_count".to_string(), json!(hunks.len()));
245
246 Ok(ToolResult {
247 success: true,
248 output: serde_json::to_string_pretty(&result).unwrap_or_default(),
249 error: None,
250 })
251 }
252
253 async fn git_log(
254 &self,
255 args: serde_json::Value,
256 working_dir: &std::path::Path,
257 ) -> anyhow::Result<ToolResult> {
258 let limit_raw = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10);
259 let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000);
260 let limit_str = limit.to_string();
261
262 let output = self
263 .run_git_command(
264 &[
265 "log",
266 &format!("-{limit_str}"),
267 "--pretty=format:%H|%an|%ae|%ad|%s",
268 "--date=iso",
269 ],
270 working_dir,
271 )
272 .await?;
273
274 let mut commits = Vec::new();
275
276 for line in output.lines() {
277 let parts: Vec<&str> = line.split('|').collect();
278 if parts.len() >= 5 {
279 commits.push(json!({
280 "hash": parts[0],
281 "author": parts[1],
282 "email": parts[2],
283 "date": parts[3],
284 "message": parts[4]
285 }));
286 }
287 }
288
289 Ok(ToolResult {
290 success: true,
291 output: serde_json::to_string_pretty(&json!({ "commits": commits }))
292 .unwrap_or_default(),
293 error: None,
294 })
295 }
296
297 async fn git_branch(
298 &self,
299 _args: serde_json::Value,
300 working_dir: &std::path::Path,
301 ) -> anyhow::Result<ToolResult> {
302 let output = self
303 .run_git_command(
304 &["branch", "--format=%(refname:short)|%(HEAD)"],
305 working_dir,
306 )
307 .await?;
308
309 let mut branches = Vec::new();
310 let mut current = String::new();
311
312 for line in output.lines() {
313 if let Some((name, head)) = line.split_once('|') {
314 let is_current = head == "*";
315 if is_current {
316 current = name.to_string();
317 }
318 branches.push(json!({
319 "name": name,
320 "current": is_current
321 }));
322 }
323 }
324
325 Ok(ToolResult {
326 success: true,
327 output: serde_json::to_string_pretty(&json!({
328 "current": current,
329 "branches": branches
330 }))
331 .unwrap_or_default(),
332 error: None,
333 })
334 }
335
336 fn truncate_commit_message(message: &str) -> String {
337 if message.chars().count() > 2000 {
338 format!("{}...", message.chars().take(1997).collect::<String>())
339 } else {
340 message.to_string()
341 }
342 }
343
344 async fn git_commit(
345 &self,
346 args: serde_json::Value,
347 working_dir: &std::path::Path,
348 ) -> anyhow::Result<ToolResult> {
349 let message = args
350 .get("message")
351 .and_then(|v| v.as_str())
352 .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))?;
353
354 let sanitized = message
356 .lines()
357 .map(|l| l.trim())
358 .filter(|l| !l.is_empty())
359 .collect::<Vec<_>>()
360 .join("\n");
361
362 if sanitized.is_empty() {
363 anyhow::bail!("Commit message cannot be empty");
364 }
365
366 let message = Self::truncate_commit_message(&sanitized);
368
369 let output = self
370 .run_git_command(&["commit", "-m", &message], working_dir)
371 .await;
372
373 match output {
374 Ok(_) => Ok(ToolResult {
375 success: true,
376 output: format!("Committed: {message}"),
377 error: None,
378 }),
379 Err(e) => Ok(ToolResult {
380 success: false,
381 output: String::new(),
382 error: Some(format!("Commit failed: {e}")),
383 }),
384 }
385 }
386
387 async fn git_add(
388 &self,
389 args: serde_json::Value,
390 working_dir: &std::path::Path,
391 ) -> anyhow::Result<ToolResult> {
392 let paths = args
393 .get("paths")
394 .and_then(|v| v.as_str())
395 .ok_or_else(|| anyhow::anyhow!("Missing 'paths' parameter"))?;
396
397 self.sanitize_git_args(paths)?;
399
400 let output = self
401 .run_git_command(&["add", "--", paths], working_dir)
402 .await;
403
404 match output {
405 Ok(_) => Ok(ToolResult {
406 success: true,
407 output: format!("Staged: {paths}"),
408 error: None,
409 }),
410 Err(e) => Ok(ToolResult {
411 success: false,
412 output: String::new(),
413 error: Some(format!("Add failed: {e}")),
414 }),
415 }
416 }
417
418 async fn git_checkout(
419 &self,
420 args: serde_json::Value,
421 working_dir: &std::path::Path,
422 ) -> anyhow::Result<ToolResult> {
423 let branch = args
424 .get("branch")
425 .and_then(|v| v.as_str())
426 .ok_or_else(|| anyhow::anyhow!("Missing 'branch' parameter"))?;
427
428 let sanitized = self.sanitize_git_args(branch)?;
430
431 if sanitized.is_empty() || sanitized.len() > 1 {
432 anyhow::bail!("Invalid branch specification");
433 }
434
435 let branch_name = &sanitized[0];
436
437 if branch_name.contains('@') || branch_name.contains('^') || branch_name.contains('~') {
439 anyhow::bail!("Branch name contains invalid characters");
440 }
441
442 let output = self
443 .run_git_command(&["checkout", branch_name], working_dir)
444 .await;
445
446 match output {
447 Ok(_) => Ok(ToolResult {
448 success: true,
449 output: format!("Switched to branch: {branch_name}"),
450 error: None,
451 }),
452 Err(e) => Ok(ToolResult {
453 success: false,
454 output: String::new(),
455 error: Some(format!("Checkout failed: {e}")),
456 }),
457 }
458 }
459
460 async fn git_stash(
461 &self,
462 args: serde_json::Value,
463 working_dir: &std::path::Path,
464 ) -> anyhow::Result<ToolResult> {
465 let action = args
466 .get("action")
467 .and_then(|v| v.as_str())
468 .unwrap_or("push");
469
470 let output = match action {
471 "push" | "save" => {
472 self.run_git_command(&["stash", "push", "-m", "auto-stash"], working_dir)
473 .await
474 }
475 "pop" => self.run_git_command(&["stash", "pop"], working_dir).await,
476 "list" => self.run_git_command(&["stash", "list"], working_dir).await,
477 "drop" => {
478 let index_raw = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
479 let index = i32::try_from(index_raw)
480 .map_err(|_| anyhow::anyhow!("stash index too large: {index_raw}"))?;
481 self.run_git_command(
482 &["stash", "drop", &format!("stash@{{{index}}}")],
483 working_dir,
484 )
485 .await
486 }
487 _ => anyhow::bail!("Unknown stash action: {action}. Use: push, pop, list, drop"),
488 };
489
490 match output {
491 Ok(out) => Ok(ToolResult {
492 success: true,
493 output: out,
494 error: None,
495 }),
496 Err(e) => Ok(ToolResult {
497 success: false,
498 output: String::new(),
499 error: Some(format!("Stash {action} failed: {e}")),
500 }),
501 }
502 }
503}
504
505#[async_trait]
506impl Tool for GitOperationsTool {
507 fn name(&self) -> &str {
508 "git_operations"
509 }
510
511 fn description(&self) -> &str {
512 "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls."
513 }
514
515 fn parameters_schema(&self) -> serde_json::Value {
516 json!({
517 "type": "object",
518 "properties": {
519 "operation": {
520 "type": "string",
521 "enum": ["status", "diff", "log", "branch", "commit", "add", "checkout", "stash"],
522 "description": "Git operation to perform"
523 },
524 "message": {
525 "type": "string",
526 "description": "Commit message (for 'commit' operation)"
527 },
528 "paths": {
529 "type": "string",
530 "description": "File paths to stage (for 'add' operation)"
531 },
532 "branch": {
533 "type": "string",
534 "description": "Branch name (for 'checkout' operation)"
535 },
536 "files": {
537 "type": "string",
538 "description": "File or path to diff (for 'diff' operation, default: '.')"
539 },
540 "cached": {
541 "type": "boolean",
542 "description": "Show staged changes (for 'diff' operation)"
543 },
544 "limit": {
545 "type": "integer",
546 "description": "Number of log entries (for 'log' operation, default: 10)"
547 },
548 "action": {
549 "type": "string",
550 "enum": ["push", "pop", "list", "drop"],
551 "description": "Stash action (for 'stash' operation)"
552 },
553 "index": {
554 "type": "integer",
555 "description": "Stash index (for 'stash' with 'drop' action)"
556 },
557 "path": {
558 "type": "string",
559 "description": "Optional subdirectory path within the workspace to run git operations in. Defaults to workspace root."
560 }
561 },
562 "required": ["operation"]
563 })
564 }
565
566 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
567 let operation = match args.get("operation").and_then(|v| v.as_str()) {
568 Some(op) => op,
569 None => {
570 return Ok(ToolResult {
571 success: false,
572 output: String::new(),
573 error: Some("Missing 'operation' parameter".into()),
574 });
575 }
576 };
577
578 let path = args.get("path").and_then(|v| v.as_str());
579 let working_dir = match self.resolve_working_dir(path) {
580 Ok(d) => d,
581 Err(e) => {
582 return Ok(ToolResult {
583 success: false,
584 output: String::new(),
585 error: Some(format!("Invalid path: {e}")),
586 });
587 }
588 };
589
590 if !working_dir.join(".git").exists() {
592 let mut current_dir = working_dir.as_path();
594 let mut found_git = false;
595 while current_dir.parent().is_some() {
596 if current_dir.join(".git").exists() {
597 found_git = true;
598 break;
599 }
600 current_dir = current_dir.parent().unwrap();
601 }
602
603 if !found_git {
604 return Ok(ToolResult {
605 success: false,
606 output: String::new(),
607 error: Some("Not in a git repository".into()),
608 });
609 }
610 }
611
612 if self.requires_write_access(operation) {
614 if !self.security.can_act() {
615 return Ok(ToolResult {
616 success: false,
617 output: String::new(),
618 error: Some(
619 "Action blocked: git write operations require higher autonomy level".into(),
620 ),
621 });
622 }
623
624 match self.security.autonomy {
625 AutonomyLevel::ReadOnly => {
626 return Ok(ToolResult {
627 success: false,
628 output: String::new(),
629 error: Some("Action blocked: read-only mode".into()),
630 });
631 }
632 AutonomyLevel::Supervised | AutonomyLevel::Full => {}
633 }
634 }
635
636 if !self.security.record_action() {
638 return Ok(ToolResult {
639 success: false,
640 output: String::new(),
641 error: Some("Action blocked: rate limit exceeded".into()),
642 });
643 }
644
645 match operation {
647 "status" => self.git_status(args, &working_dir).await,
648 "diff" => self.git_diff(args, &working_dir).await,
649 "log" => self.git_log(args, &working_dir).await,
650 "branch" => self.git_branch(args, &working_dir).await,
651 "commit" => self.git_commit(args, &working_dir).await,
652 "add" => self.git_add(args, &working_dir).await,
653 "checkout" => self.git_checkout(args, &working_dir).await,
654 "stash" => self.git_stash(args, &working_dir).await,
655 _ => Ok(ToolResult {
656 success: false,
657 output: String::new(),
658 error: Some(format!("Unknown operation: {operation}")),
659 }),
660 }
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::security::SecurityPolicy;
668 use tempfile::TempDir;
669
670 fn test_tool(dir: &std::path::Path) -> GitOperationsTool {
671 let security = Arc::new(SecurityPolicy {
672 autonomy: AutonomyLevel::Supervised,
673 ..SecurityPolicy::default()
674 });
675 GitOperationsTool::new(security, dir.to_path_buf())
676 }
677
678 #[test]
679 fn sanitize_git_blocks_injection() {
680 let tmp = TempDir::new().unwrap();
681 let tool = test_tool(tmp.path());
682
683 assert!(tool.sanitize_git_args("--exec=rm -rf /").is_err());
685 assert!(tool.sanitize_git_args("$(echo pwned)").is_err());
686 assert!(tool.sanitize_git_args("`malicious`").is_err());
687 assert!(tool.sanitize_git_args("arg | cat").is_err());
688 assert!(tool.sanitize_git_args("arg; rm file").is_err());
689 }
690
691 #[test]
692 fn sanitize_git_blocks_pager_editor_injection() {
693 let tmp = TempDir::new().unwrap();
694 let tool = test_tool(tmp.path());
695
696 assert!(tool.sanitize_git_args("--pager=less").is_err());
697 assert!(tool.sanitize_git_args("--editor=vim").is_err());
698 }
699
700 #[test]
701 fn sanitize_git_blocks_config_injection() {
702 let tmp = TempDir::new().unwrap();
703 let tool = test_tool(tmp.path());
704
705 assert!(tool.sanitize_git_args("-c core.sshCommand=evil").is_err());
707 assert!(tool.sanitize_git_args("-c=core.pager=less").is_err());
708 }
709
710 #[test]
711 fn sanitize_git_blocks_no_verify() {
712 let tmp = TempDir::new().unwrap();
713 let tool = test_tool(tmp.path());
714
715 assert!(tool.sanitize_git_args("--no-verify").is_err());
716 }
717
718 #[test]
719 fn sanitize_git_blocks_redirect_in_args() {
720 let tmp = TempDir::new().unwrap();
721 let tool = test_tool(tmp.path());
722
723 assert!(tool.sanitize_git_args("file.txt > /tmp/out").is_err());
724 }
725
726 #[test]
727 fn sanitize_git_cached_not_blocked() {
728 let tmp = TempDir::new().unwrap();
729 let tool = test_tool(tmp.path());
730
731 assert!(tool.sanitize_git_args("--cached").is_ok());
733 assert!(tool.sanitize_git_args("-cached").is_ok());
735 }
736
737 #[test]
738 fn sanitize_git_allows_safe() {
739 let tmp = TempDir::new().unwrap();
740 let tool = test_tool(tmp.path());
741
742 assert!(tool.sanitize_git_args("main").is_ok());
744 assert!(tool.sanitize_git_args("feature/test-branch").is_ok());
745 assert!(tool.sanitize_git_args("--cached").is_ok());
746 assert!(tool.sanitize_git_args("src/main.rs").is_ok());
747 assert!(tool.sanitize_git_args(".").is_ok());
748 }
749
750 #[test]
751 fn requires_write_detection() {
752 let tmp = TempDir::new().unwrap();
753 let tool = test_tool(tmp.path());
754
755 assert!(tool.requires_write_access("commit"));
756 assert!(tool.requires_write_access("add"));
757 assert!(tool.requires_write_access("checkout"));
758
759 assert!(!tool.requires_write_access("status"));
760 assert!(!tool.requires_write_access("diff"));
761 assert!(!tool.requires_write_access("log"));
762 }
763
764 #[test]
765 fn branch_is_not_write_gated() {
766 let tmp = TempDir::new().unwrap();
767 let tool = test_tool(tmp.path());
768
769 assert!(!tool.requires_write_access("branch"));
771 assert!(tool.is_read_only("branch"));
772 }
773
774 #[test]
775 fn is_read_only_detection() {
776 let tmp = TempDir::new().unwrap();
777 let tool = test_tool(tmp.path());
778
779 assert!(tool.is_read_only("status"));
780 assert!(tool.is_read_only("diff"));
781 assert!(tool.is_read_only("log"));
782 assert!(tool.is_read_only("branch"));
783
784 assert!(!tool.is_read_only("commit"));
785 assert!(!tool.is_read_only("add"));
786 }
787
788 #[tokio::test]
789 async fn blocks_readonly_mode_for_write_ops() {
790 let tmp = TempDir::new().unwrap();
791 std::process::Command::new("git")
793 .args(["init"])
794 .current_dir(tmp.path())
795 .output()
796 .unwrap();
797
798 let security = Arc::new(SecurityPolicy {
799 autonomy: AutonomyLevel::ReadOnly,
800 ..SecurityPolicy::default()
801 });
802 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
803
804 let result = tool
805 .execute(json!({"operation": "commit", "message": "test"}))
806 .await
807 .unwrap();
808 assert!(!result.success);
809 assert!(
811 result
812 .error
813 .as_deref()
814 .unwrap_or("")
815 .contains("higher autonomy")
816 );
817 }
818
819 #[tokio::test]
820 async fn allows_branch_listing_in_readonly_mode() {
821 let tmp = TempDir::new().unwrap();
822 std::process::Command::new("git")
824 .args(["init"])
825 .current_dir(tmp.path())
826 .output()
827 .unwrap();
828
829 let security = Arc::new(SecurityPolicy {
830 autonomy: AutonomyLevel::ReadOnly,
831 ..SecurityPolicy::default()
832 });
833 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
834
835 let result = tool.execute(json!({"operation": "branch"})).await.unwrap();
836 let error_msg = result.error.as_deref().unwrap_or("");
838 assert!(
839 !error_msg.contains("read-only") && !error_msg.contains("higher autonomy"),
840 "branch listing should not be blocked in read-only mode, got: {error_msg}"
841 );
842 }
843
844 #[tokio::test]
845 async fn allows_readonly_ops_in_readonly_mode() {
846 let tmp = TempDir::new().unwrap();
847 let security = Arc::new(SecurityPolicy {
848 autonomy: AutonomyLevel::ReadOnly,
849 ..SecurityPolicy::default()
850 });
851 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
852
853 let result = tool.execute(json!({"operation": "status"})).await.unwrap();
855 assert!(!result.success, "Expected failure due to missing git repo");
857 let error_msg = result.error.as_deref().unwrap_or("");
858 assert!(
859 !error_msg.is_empty(),
860 "Expected a git-related error message"
861 );
862 assert!(
863 !error_msg.contains("read-only") && !error_msg.contains("autonomy"),
864 "Error should be about git, not about autonomy restrictions: {error_msg}"
865 );
866 }
867
868 #[tokio::test]
869 async fn rejects_missing_operation() {
870 let tmp = TempDir::new().unwrap();
871 let tool = test_tool(tmp.path());
872
873 let result = tool.execute(json!({})).await.unwrap();
874 assert!(!result.success);
875 assert!(
876 result
877 .error
878 .as_deref()
879 .unwrap_or("")
880 .contains("Missing 'operation'")
881 );
882 }
883
884 #[tokio::test]
885 async fn rejects_unknown_operation() {
886 let tmp = TempDir::new().unwrap();
887 std::process::Command::new("git")
889 .args(["init"])
890 .current_dir(tmp.path())
891 .output()
892 .unwrap();
893
894 let tool = test_tool(tmp.path());
895
896 let result = tool.execute(json!({"operation": "push"})).await.unwrap();
897 assert!(!result.success);
898 assert!(
899 result
900 .error
901 .as_deref()
902 .unwrap_or("")
903 .contains("Unknown operation")
904 );
905 }
906
907 #[test]
908 fn truncates_multibyte_commit_message_without_panicking() {
909 let long = "🦀".repeat(2500);
910 let truncated = GitOperationsTool::truncate_commit_message(&long);
911
912 assert_eq!(truncated.chars().count(), 2000);
913 }
914
915 #[test]
916 fn resolve_working_dir_none_returns_workspace() {
917 let tmp = TempDir::new().unwrap();
918 let tool = test_tool(tmp.path());
919
920 let result = tool.resolve_working_dir(None).unwrap();
921 assert_eq!(result, tmp.path().to_path_buf());
922 }
923
924 #[test]
925 fn resolve_working_dir_empty_returns_workspace() {
926 let tmp = TempDir::new().unwrap();
927 let tool = test_tool(tmp.path());
928
929 let result = tool.resolve_working_dir(Some("")).unwrap();
930 assert_eq!(result, tmp.path().to_path_buf());
931 }
932
933 #[test]
934 fn resolve_working_dir_valid_subdir() {
935 let tmp = TempDir::new().unwrap();
936 std::fs::create_dir(tmp.path().join("subproject")).unwrap();
937 let tool = test_tool(tmp.path());
938
939 let result = tool.resolve_working_dir(Some("subproject")).unwrap();
940 let expected = tmp.path().join("subproject").canonicalize().unwrap();
941 assert_eq!(result, expected);
942 }
943
944 #[test]
945 fn resolve_working_dir_rejects_traversal() {
946 let tmp = TempDir::new().unwrap();
947 let tool = test_tool(tmp.path());
948
949 let result = tool.resolve_working_dir(Some(".."));
950 assert!(result.is_err());
951 let err_msg = result.unwrap_err().to_string();
952 assert!(
953 err_msg.contains("resolves outside the workspace"),
954 "Expected traversal rejection, got: {err_msg}"
955 );
956 }
957
958 #[tokio::test]
959 async fn git_operations_work_in_subdirectory() {
960 let tmp = TempDir::new().unwrap();
961 let sub = tmp.path().join("nested");
962 std::fs::create_dir(&sub).unwrap();
963 std::process::Command::new("git")
964 .args(["init"])
965 .current_dir(&sub)
966 .output()
967 .unwrap();
968 std::process::Command::new("git")
969 .args(["config", "user.email", "test@test.com"])
970 .current_dir(&sub)
971 .output()
972 .unwrap();
973 std::process::Command::new("git")
974 .args(["config", "user.name", "Test"])
975 .current_dir(&sub)
976 .output()
977 .unwrap();
978
979 let tool = test_tool(tmp.path());
980
981 let result = tool
982 .execute(json!({"operation": "status", "path": "nested"}))
983 .await
984 .unwrap();
985 assert!(
986 result.success,
987 "Expected success, got error: {:?}",
988 result.error
989 );
990 assert!(result.output.contains("branch"));
991 }
992}