@@ -38,15 +38,46 @@ class PathValidator:
3838 def __init__ (self , base_path : Path | str | None = None ):
3939 self .base_path = Path (base_path ).resolve () if base_path else None
4040
41+ def is_safe_path (self , path : Path ) -> bool :
42+ """Check if a path is safe to access.
43+
44+ A path is considered safe if:
45+ 1. It's absolute
46+ 2. It doesn't contain symlinks that could bypass restrictions
47+ 3. If base_path is set, it must be within that directory
48+ 4. It's not a system directory (/etc, /usr, /bin, etc.)
49+ """
50+ try :
51+ # Resolve to absolute path, following symlinks
52+ resolved = path .resolve ()
53+
54+ # Check if it's a system directory
55+ system_dirs = ["/etc" , "/usr" , "/bin" , "/sbin" , "/lib" , "/sys" , "/dev" , "/proc" ]
56+ if any (str (resolved ).startswith (sys_dir ) for sys_dir in system_dirs ):
57+ return False
58+
59+ # If base_path is set, ensure path is within it
60+ if self .base_path :
61+ return resolved .is_relative_to (self .base_path )
62+
63+ # In unconfigured mode, apply additional restrictions
64+ # Only allow paths under /home or custom project directories
65+ safe_roots = ["/home" , "/projects" , "/repo" ]
66+ return any (str (resolved ).startswith (root ) for root in safe_roots )
67+
68+ except (RuntimeError , OSError ):
69+ # RuntimeError can occur with circular symlinks
70+ # OSError can occur with invalid paths
71+ return False
72+
4173 def validate_path (self , path : str | Path ) -> Path :
42- if not self .base_path :
43- raise ValueError ("No base repository path configured" )
74+ """Validate and return a safe path."""
75+ if '..' in Path (path ).parts :
76+ raise ValueError ("Path traversal attempts not allowed" )
4477
45- # Normalize and compare canonical paths
4678 requested = Path (path ).resolve ()
4779
48- # Check for path traversal and ensure path is within base directory
49- if not requested .is_relative_to (self .base_path ):
80+ if not self .is_safe_path (requested ):
5081 raise ValueError (f"Path { requested } not in allowed scope" )
5182
5283 return requested
@@ -135,16 +166,47 @@ class GitTools(str, Enum):
135166
136167class GitServer :
137168 def __init__ (self , base_path : Path | None = None ):
138- self .path_validator = PathValidator (base_path )
169+ self .base_path = Path (base_path ).resolve () if base_path else None
170+ self .path_validator = PathValidator (self .base_path )
139171 self .logger = logging .getLogger (__name__ )
140172
173+ def resolve_repo_path (self , repo_path : str | Path ) -> Path :
174+ """Resolve repository path, handling both absolute and relative paths.
175+
176+ In Docker environment, relative paths are resolved against base_path.
177+ """
178+ path = Path (repo_path )
179+ if not path .is_absolute ():
180+ if not self .base_path :
181+ raise ValueError ("Base path must be set to use relative paths" )
182+ path = self .base_path / path
183+ return path .resolve ()
184+
141185 def get_repo (self , repo_path : str ) -> git .Repo :
142- """Get a Git repository with path validation."""
143- validated_path = self .path_validator .validate_path (repo_path )
186+ """Get a Git repository with path and repository validation.
187+
188+ Args:
189+ repo_path: Path to the repository (absolute or relative to base_path)
190+
191+ Returns:
192+ git.Repo: The validated Git repository
193+
194+ Raises:
195+ ValueError: If the path is invalid or not a Git repository
196+ """
197+ resolved_path = self .resolve_repo_path (repo_path )
198+ validated_path = self .path_validator .validate_path (resolved_path )
144199 try :
145- return git .Repo (validated_path )
200+ repo = git .Repo (validated_path )
201+ # Additional check to ensure the repo root is within allowed scope
202+ repo_root = Path (repo .git_dir ).parent .resolve ()
203+ if not self .path_validator .is_safe_path (repo_root ):
204+ raise ValueError (f"Repository root { repo_root } not in allowed scope" )
205+ return repo
146206 except git .InvalidGitRepositoryError :
147- raise ValueError (f"{ validated_path } is not a valid Git repository" )
207+ raise ValueError (f"{ validated_path } is not a Git repository" )
208+ except git .NoSuchPathError :
209+ raise ValueError (f"Path { validated_path } does not exist" )
148210
149211 def git_status (self , repo : git .Repo ) -> str :
150212 return repo .git .status ()
@@ -253,24 +315,31 @@ async def serve(repository: Path | None) -> None:
253315
254316 if repository is not None :
255317 repository = Path (repository ).resolve ()
256- try :
257- git . Repo ( repository )
258- logger . info ( f"Using repository at { repository } " )
259- except git . InvalidGitRepositoryError :
260- logger .error (f"{ repository } is not a valid Git repository " )
318+ if not repository . exists () :
319+ logger . error ( f"Path { repository } does not exist" )
320+ return
321+ if not repository . is_dir () :
322+ logger .error (f"Path { repository } is not a directory " )
261323 return
324+ logger .info (f"Using base directory at { repository } " )
262325
263326 server = Server ("mcp-git" )
264- git_server = GitServer (repository )
327+ git_server = GitServer (base_path = repository )
265328
266329 @server .list_tools ()
267330 async def list_tools () -> list [Tool ]:
268331 tools = []
269332 for tool in GitTools :
270333 schema = globals ()[tool .value ].schema ()
271- if repository : # Remove repo_path from schema when in single-repo mode
272- schema ["properties" ].pop ("repo_path" , None )
273- schema ["required" ] = [r for r in schema .get ("required" , []) if r != "repo_path" ]
334+ if repository :
335+ # In single-repo mode with a parent directory,
336+ # we still need repo_path but restrict it to subdirectories
337+ if not git .repo .fun .is_git_dir (repository / ".git" ):
338+ schema ["properties" ]["repo_path" ]["description" ] = "Path to Git repository (must be under the configured base directory)"
339+ else :
340+ # If repository itself is a Git repo, remove repo_path as before
341+ schema ["properties" ].pop ("repo_path" , None )
342+ schema ["required" ] = [r for r in schema .get ("required" , []) if r != "repo_path" ]
274343 tools .append (Tool (
275344 name = tool .value ,
276345 description = TOOL_DESCRIPTIONS [tool ],
@@ -281,20 +350,60 @@ async def list_tools() -> list[Tool]:
281350 @server .call_tool ()
282351 async def call_tool (name : str , arguments : dict ) -> list [TextContent ]:
283352 try :
284- # Auto-use configured repository when specified
285- if repository :
353+ # Auto-use configured repository only if it's actually a Git repository
354+ if repository and git .repo .fun .is_git_dir (repository / ".git" ):
355+ arguments = arguments .copy ()
286356 arguments ["repo_path" ] = str (repository )
287-
357+ elif repository :
358+ # If repository is a parent dir, require and validate repo_path
359+ if "repo_path" not in arguments :
360+ return [TextContent (
361+ type = "text" ,
362+ text = "Error: repository path not specified. When using --repository with a parent directory, you must specify which repository to operate on."
363+ )]
364+ # No need to resolve here as get_repo will handle it
365+ repo_path = arguments ["repo_path" ]
366+ try :
367+ # Just validate it can be resolved and is within bounds
368+ resolved = git_server .resolve_repo_path (repo_path )
369+ if not resolved .is_relative_to (repository ):
370+ return [TextContent (
371+ type = "text" ,
372+ text = f"Error: repository path must be under the configured directory { repository } "
373+ )]
374+ except ValueError as e :
375+ return [TextContent (
376+ type = "text" ,
377+ text = f"Error: { str (e )} "
378+ )]
379+
288380 # Handle git init separately
289381 if name == GitTools .INIT :
382+ if "repo_path" not in arguments :
383+ return [TextContent (
384+ type = "text" ,
385+ text = "Error: repository path not specified"
386+ )]
290387 result = git_server .git_init (arguments ["repo_path" ])
291388 return [TextContent (
292389 type = "text" ,
293390 text = result
294391 )]
295392
296393 # For all other commands, get a validated repo
297- repo = git_server .get_repo (arguments ["repo_path" ])
394+ if "repo_path" not in arguments :
395+ return [TextContent (
396+ type = "text" ,
397+ text = "Error: repository path not specified"
398+ )]
399+
400+ try :
401+ repo = git_server .get_repo (arguments ["repo_path" ])
402+ except ValueError as e :
403+ return [TextContent (
404+ type = "text" ,
405+ text = f"Error: { str (e )} "
406+ )]
298407
299408 match name :
300409 case GitTools .STATUS :
0 commit comments