**feat(git-ops): enhance MCP server with Git provider updates and SSRF protection**

- Added `mcp-git-ops` service to `docker-compose.dev.yml` with health checks and configurations.
- Integrated SSRF protection in repository URL validation for enhanced security.
- Expanded `pyproject.toml` mypy settings and adjusted code to meet stricter type checking.
- Improved workspace management and GitWrapper operations with error handling refinements.
- Updated input validation, branching, and repository operations to align with new error structure.
- Shut down thread pool executor gracefully during server cleanup.
This commit is contained in:
2026-01-07 09:17:00 +01:00
parent 1779239c07
commit 76d7de5334
11 changed files with 781 additions and 181 deletions

View File

@@ -13,7 +13,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import aiofiles
import aiofiles # type: ignore[import-untyped]
from filelock import FileLock, Timeout
from config import Settings, get_settings
@@ -54,10 +54,25 @@ class WorkspaceManager:
self.base_path.mkdir(parents=True, exist_ok=True)
def _get_workspace_path(self, project_id: str) -> Path:
"""Get the path for a project workspace."""
"""Get the path for a project workspace with path traversal protection."""
# Sanitize project ID for filesystem
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in project_id)
return self.base_path / safe_id
# Reject reserved names
reserved_names = {".", "..", "con", "prn", "aux", "nul"}
if safe_id.lower() in reserved_names:
raise ValueError(f"Invalid project ID: reserved name '{project_id}'")
# Construct path and verify it's within base_path (prevent path traversal)
workspace_path = (self.base_path / safe_id).resolve()
base_resolved = self.base_path.resolve()
if not workspace_path.is_relative_to(base_resolved):
raise ValueError(
f"Invalid project ID: path traversal detected '{project_id}'"
)
return workspace_path
def _get_lock_path(self, project_id: str) -> Path:
"""Get the lock file path for a workspace."""
@@ -230,10 +245,7 @@ class WorkspaceManager:
raise WorkspaceNotFoundError(project_id)
# Check if already locked by someone else
if (
workspace.state == WorkspaceState.LOCKED
and workspace.lock_holder != holder
):
if workspace.state == WorkspaceState.LOCKED and workspace.lock_holder != holder:
# Check if lock expired
if workspace.lock_expires and workspace.lock_expires > datetime.now(UTC):
raise WorkspaceLockedError(project_id, workspace.lock_holder)
@@ -275,11 +287,7 @@ class WorkspaceManager:
raise WorkspaceNotFoundError(project_id)
# Verify holder
if (
not force
and workspace.lock_holder
and workspace.lock_holder != holder
):
if not force and workspace.lock_holder and workspace.lock_holder != holder:
raise WorkspaceLockedError(project_id, workspace.lock_holder)
# Clear lock
@@ -341,7 +349,7 @@ class WorkspaceManager:
return True
size_bytes = await self._calculate_size(workspace_path)
size_gb = size_bytes / (1024 ** 3)
size_gb = size_bytes / (1024**3)
max_size_gb = self.settings.workspace_max_size_gb
if size_gb > max_size_gb:
@@ -362,7 +370,7 @@ class WorkspaceManager:
Returns:
List of WorkspaceInfo
"""
workspaces = []
workspaces: list[WorkspaceInfo] = []
if not self.base_path.exists():
return workspaces
@@ -532,9 +540,7 @@ class WorkspaceLock:
self.holder,
)
except Exception as e:
logger.warning(
f"Failed to release lock for {self.project_id}: {e}"
)
logger.warning(f"Failed to release lock for {self.project_id}: {e}")
class FileLockManager: