Skip to content

Commit a37158b

Browse files
jenn-newtonclaudepcarleton
authored
Merge commit from fork
Validate that repo_path arguments in tool calls are within the configured --repository path when the --repository flag is set. The fix: - Adds validate_repo_path() that resolves paths and checks containment using Path.relative_to() - Resolves symlinks before comparison - Maintains backward compatibility when --repository is not set 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Paul Carleton <paulc@anthropic.com>
1 parent 9e5d5b8 commit a37158b

File tree

2 files changed

+87
-1
lines changed

2 files changed

+87
-1
lines changed

src/git/src/mcp_server_git/server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,27 @@ def git_show(repo: git.Repo, revision: str) -> str:
217217
output.append(d.diff)
218218
return "".join(output)
219219

220+
def validate_repo_path(repo_path: Path, allowed_repository: Path | None) -> None:
221+
"""Validate that repo_path is within the allowed repository path."""
222+
if allowed_repository is None:
223+
return # No restriction configured
224+
225+
# Resolve both paths to handle symlinks and relative paths
226+
try:
227+
resolved_repo = repo_path.resolve()
228+
resolved_allowed = allowed_repository.resolve()
229+
except (OSError, RuntimeError):
230+
raise ValueError(f"Invalid path: {repo_path}")
231+
232+
# Check if repo_path is the same as or a subdirectory of allowed_repository
233+
try:
234+
resolved_repo.relative_to(resolved_allowed)
235+
except ValueError:
236+
raise ValueError(
237+
f"Repository path '{repo_path}' is outside the allowed repository '{allowed_repository}'"
238+
)
239+
240+
220241
def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, not_contains: str | None = None) -> str:
221242
match contains:
222243
case None:
@@ -359,6 +380,9 @@ def by_commandline() -> Sequence[str]:
359380
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
360381
repo_path = Path(arguments["repo_path"])
361382

383+
# Validate repo_path is within allowed repository
384+
validate_repo_path(repo_path, repository)
385+
362386
# For all commands, we need an existing repo
363387
repo = git.Repo(repo_path)
364388

src/git/tests/test_server.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
git_reset,
1414
git_log,
1515
git_create_branch,
16-
git_show
16+
git_show,
17+
validate_repo_path,
1718
)
1819
import shutil
1920

@@ -250,6 +251,67 @@ def test_git_show_initial_commit(test_repository):
250251
assert "test.txt" in result
251252

252253

254+
# Tests for validate_repo_path (repository scoping security fix)
255+
256+
def test_validate_repo_path_no_restriction():
257+
"""When no repository restriction is configured, any path should be allowed."""
258+
validate_repo_path(Path("/any/path"), None) # Should not raise
259+
260+
261+
def test_validate_repo_path_exact_match(tmp_path: Path):
262+
"""When repo_path exactly matches allowed_repository, validation should pass."""
263+
allowed = tmp_path / "repo"
264+
allowed.mkdir()
265+
validate_repo_path(allowed, allowed) # Should not raise
266+
267+
268+
def test_validate_repo_path_subdirectory(tmp_path: Path):
269+
"""When repo_path is a subdirectory of allowed_repository, validation should pass."""
270+
allowed = tmp_path / "repo"
271+
allowed.mkdir()
272+
subdir = allowed / "subdir"
273+
subdir.mkdir()
274+
validate_repo_path(subdir, allowed) # Should not raise
275+
276+
277+
def test_validate_repo_path_outside_allowed(tmp_path: Path):
278+
"""When repo_path is outside allowed_repository, validation should raise ValueError."""
279+
allowed = tmp_path / "allowed_repo"
280+
allowed.mkdir()
281+
outside = tmp_path / "other_repo"
282+
outside.mkdir()
283+
284+
with pytest.raises(ValueError) as exc_info:
285+
validate_repo_path(outside, allowed)
286+
assert "outside the allowed repository" in str(exc_info.value)
287+
288+
289+
def test_validate_repo_path_traversal_attempt(tmp_path: Path):
290+
"""Path traversal attempts (../) should be caught and rejected."""
291+
allowed = tmp_path / "allowed_repo"
292+
allowed.mkdir()
293+
# Attempt to escape via ../
294+
traversal_path = allowed / ".." / "other_repo"
295+
296+
with pytest.raises(ValueError) as exc_info:
297+
validate_repo_path(traversal_path, allowed)
298+
assert "outside the allowed repository" in str(exc_info.value)
299+
300+
301+
def test_validate_repo_path_symlink_escape(tmp_path: Path):
302+
"""Symlinks pointing outside allowed_repository should be rejected."""
303+
allowed = tmp_path / "allowed_repo"
304+
allowed.mkdir()
305+
outside = tmp_path / "outside"
306+
outside.mkdir()
307+
308+
# Create a symlink inside allowed that points outside
309+
symlink = allowed / "escape_link"
310+
symlink.symlink_to(outside)
311+
312+
with pytest.raises(ValueError) as exc_info:
313+
validate_repo_path(symlink, allowed)
314+
assert "outside the allowed repository" in str(exc_info.value)
253315
# Tests for argument injection protection
254316

255317
def test_git_diff_rejects_flag_injection(test_repository):

0 commit comments

Comments
 (0)