diff --git a/apps/coordinator/pyproject.toml b/apps/coordinator/pyproject.toml index 2017ffa..c8cd787 100644 --- a/apps/coordinator/pyproject.toml +++ b/apps/coordinator/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "pydantic-settings>=2.1.0", "python-dotenv>=1.0.0", "anthropic>=0.39.0", + "slowapi>=0.1.9", ] [project.optional-dependencies] diff --git a/apps/coordinator/src/gates/build_gate.py b/apps/coordinator/src/gates/build_gate.py index 4cbb650..64876d3 100644 --- a/apps/coordinator/src/gates/build_gate.py +++ b/apps/coordinator/src/gates/build_gate.py @@ -24,6 +24,7 @@ class BuildGate: capture_output=True, text=True, check=False, # Don't raise on non-zero exit + timeout=300, # 5 minute timeout ) if result.returncode == 0: @@ -54,11 +55,11 @@ class BuildGate: details={"error": str(e)}, ) - except subprocess.CalledProcessError as e: + except subprocess.TimeoutExpired as e: return GateResult( passed=False, - message="Build gate failed: Error running mypy", - details={"error": str(e), "return_code": e.returncode}, + message=f"Build gate failed: mypy timed out after {e.timeout} seconds", + details={"error": str(e), "timeout": e.timeout}, ) except Exception as e: diff --git a/apps/coordinator/src/gates/coverage_gate.py b/apps/coordinator/src/gates/coverage_gate.py index d658ad2..256fba5 100644 --- a/apps/coordinator/src/gates/coverage_gate.py +++ b/apps/coordinator/src/gates/coverage_gate.py @@ -1,6 +1,7 @@ """CoverageGate - Enforces 85% minimum test coverage via pytest-cov.""" import json +import os import subprocess from pathlib import Path @@ -35,6 +36,7 @@ class CoverageGate: capture_output=True, text=True, check=False, # Don't raise on non-zero exit + timeout=300, # 5 minute timeout ) # Try to read coverage data from coverage.json @@ -94,11 +96,11 @@ class CoverageGate: details={"error": str(e)}, ) - except subprocess.CalledProcessError as e: + except subprocess.TimeoutExpired as e: return GateResult( passed=False, - message="Coverage gate failed: Error running pytest", - details={"error": str(e), "return_code": e.returncode}, + message=f"Coverage gate failed: pytest timed out after {e.timeout} seconds", + details={"error": str(e), "timeout": e.timeout}, ) except Exception as e: @@ -111,18 +113,28 @@ class CoverageGate: def _extract_coverage_from_json(self) -> float | None: """Extract coverage percentage from coverage.json file. + Uses absolute path resolved from current working directory and validates + that the path is within project boundaries to prevent path traversal attacks. + Returns: float | None: Coverage percentage or None if file not found """ try: - coverage_file = Path("coverage.json") + # Get absolute path from current working directory + cwd = Path.cwd().resolve() + coverage_file = (cwd / "coverage.json").resolve() + + # Validate that coverage file is within project directory (prevent path traversal) + if not str(coverage_file).startswith(str(cwd)): + return None + if coverage_file.exists(): with open(coverage_file) as f: data = json.load(f) percent = data.get("totals", {}).get("percent_covered") if percent is not None and isinstance(percent, (int, float)): return float(percent) - except (FileNotFoundError, json.JSONDecodeError, KeyError): + except (FileNotFoundError, json.JSONDecodeError, KeyError, OSError): pass return None diff --git a/apps/coordinator/src/gates/lint_gate.py b/apps/coordinator/src/gates/lint_gate.py index 7d3524d..97ee44d 100644 --- a/apps/coordinator/src/gates/lint_gate.py +++ b/apps/coordinator/src/gates/lint_gate.py @@ -24,6 +24,7 @@ class LintGate: capture_output=True, text=True, check=False, # Don't raise on non-zero exit + timeout=300, # 5 minute timeout ) if result.returncode == 0: @@ -54,11 +55,11 @@ class LintGate: details={"error": str(e)}, ) - except subprocess.CalledProcessError as e: + except subprocess.TimeoutExpired as e: return GateResult( passed=False, - message="Lint gate failed: Error running ruff", - details={"error": str(e), "return_code": e.returncode}, + message=f"Lint gate failed: ruff timed out after {e.timeout} seconds", + details={"error": str(e), "timeout": e.timeout}, ) except Exception as e: diff --git a/apps/coordinator/src/gates/test_gate.py b/apps/coordinator/src/gates/test_gate.py index bc29cd5..754feda 100644 --- a/apps/coordinator/src/gates/test_gate.py +++ b/apps/coordinator/src/gates/test_gate.py @@ -24,6 +24,7 @@ class TestGate: capture_output=True, text=True, check=False, # Don't raise on non-zero exit + timeout=300, # 5 minute timeout ) if result.returncode == 0: @@ -54,11 +55,11 @@ class TestGate: details={"error": str(e)}, ) - except subprocess.CalledProcessError as e: + except subprocess.TimeoutExpired as e: return GateResult( passed=False, - message="Test gate failed: Error running pytest", - details={"error": str(e), "return_code": e.returncode}, + message=f"Test gate failed: pytest timed out after {e.timeout} seconds", + details={"error": str(e), "timeout": e.timeout}, ) except Exception as e: diff --git a/apps/coordinator/src/main.py b/apps/coordinator/src/main.py index 75da040..d0b1153 100644 --- a/apps/coordinator/src/main.py +++ b/apps/coordinator/src/main.py @@ -7,8 +7,11 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Any -from fastapi import FastAPI +from fastapi import FastAPI, Request from pydantic import BaseModel +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address from .config import settings from .coordinator import Coordinator @@ -104,6 +107,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: logger.info("Mosaic-coordinator shutdown complete") +# Initialize rate limiter +limiter = Limiter(key_func=get_remote_address) + # Create FastAPI application app = FastAPI( title="Mosaic Coordinator", @@ -112,6 +118,10 @@ app = FastAPI( lifespan=lifespan, ) +# Register rate limiter +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + class HealthResponse(BaseModel): """Health check response model.""" diff --git a/apps/coordinator/src/quality_orchestrator.py b/apps/coordinator/src/quality_orchestrator.py index 551929a..a3e343d 100644 --- a/apps/coordinator/src/quality_orchestrator.py +++ b/apps/coordinator/src/quality_orchestrator.py @@ -1,7 +1,9 @@ """Quality Orchestrator service for coordinating quality gate execution.""" import asyncio -from typing import Any +import inspect +from typing import Any, cast +from unittest.mock import Mock from pydantic import BaseModel, Field @@ -127,37 +129,51 @@ class QualityOrchestrator: Production gates are run in a thread pool to avoid blocking the event loop. Test mocks can be async functions or lambdas returning coroutines. """ - import inspect - from typing import cast - from unittest.mock import Mock - # Check if gate.check is an async function if inspect.iscoroutinefunction(gate.check): return cast(GateResult, await gate.check()) - # Check if gate.check is a Mock/MagicMock (testing scenario) + # Check if it's a real production gate instance + if self._is_real_gate(gate): + # Real gate - run in thread pool to avoid blocking event loop + return cast(GateResult, await asyncio.to_thread(gate.check)) + + # Handle test mocks and callables + return await self._handle_test_mock(gate) + + def _is_real_gate(self, gate: Any) -> bool: + """Check if gate is a real production gate instance. + + Args: + gate: Gate instance to check + + Returns: + bool: True if gate is a real production gate + """ + if not inspect.ismethod(gate.check): + return False + + gate_class_name = gate.__class__.__name__ + return gate_class_name in ("BuildGate", "LintGate", "TestGate", "CoverageGate") + + async def _handle_test_mock(self, gate: Any) -> GateResult: + """Handle test mocks and callables. + + Args: + gate: Gate mock or callable to handle + + Returns: + GateResult: Result from the mock + """ + # Check if it's a Mock/MagicMock (testing scenario) mock_types = ("Mock", "MagicMock", "AsyncMock") if isinstance(gate.check, Mock) or type(gate.check).__name__ in mock_types: - # It's a mock - call it and handle the result result_or_coro = gate.check() if asyncio.iscoroutine(result_or_coro): return cast(GateResult, await result_or_coro) return cast(GateResult, result_or_coro) - # Check if gate.check is a lambda or other callable (could be test or production) - # For lambdas in tests that return coroutines, we need to call and await - # But we need to avoid calling real production gates outside of to_thread - # The distinguishing factor: real gates are methods on BuildGate/LintGate/etc classes - - # Check if it's a bound method on a real gate class - if inspect.ismethod(gate.check): - # Check if the class is one of our real gate classes - gate_class_name = gate.__class__.__name__ - if gate_class_name in ("BuildGate", "LintGate", "TestGate", "CoverageGate"): - # It's a real gate - run in thread pool - return cast(GateResult, await asyncio.to_thread(gate.check)) - - # For any other callable (lambdas, functions), try calling and see what it returns + # For any other callable (lambdas, functions), call and check result result_or_coro = gate.check() if asyncio.iscoroutine(result_or_coro): return cast(GateResult, await result_or_coro) diff --git a/apps/coordinator/src/webhook.py b/apps/coordinator/src/webhook.py index 18ea2eb..c68410a 100644 --- a/apps/coordinator/src/webhook.py +++ b/apps/coordinator/src/webhook.py @@ -5,6 +5,8 @@ from typing import Any from fastapi import APIRouter, Header, HTTPException, Request from pydantic import BaseModel, Field +from slowapi import Limiter +from slowapi.util import get_remote_address from .config import settings from .security import verify_signature @@ -13,6 +15,9 @@ logger = logging.getLogger(__name__) router = APIRouter() +# Initialize limiter for this module +limiter = Limiter(key_func=get_remote_address) + class WebhookResponse(BaseModel): """Response model for webhook endpoint.""" @@ -34,6 +39,7 @@ class GiteaWebhookPayload(BaseModel): @router.post("/webhook/gitea", response_model=WebhookResponse) +@limiter.limit("100/minute") # Per-IP rate limit: 100 requests per minute async def handle_gitea_webhook( request: Request, payload: GiteaWebhookPayload, diff --git a/apps/coordinator/tests/gates/test_build_gate.py b/apps/coordinator/tests/gates/test_build_gate.py index 3f1d04d..7dd4c41 100644 --- a/apps/coordinator/tests/gates/test_build_gate.py +++ b/apps/coordinator/tests/gates/test_build_gate.py @@ -131,3 +131,37 @@ class TestBuildGate: assert result.passed is False assert "unexpected error" in result.message.lower() assert "error" in result.details + + def test_check_uses_timeout(self) -> None: + """Test that check() sets a timeout on subprocess.run.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Success: no issues found" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result) as mock_run: + gate = BuildGate() + gate.check() + + # Verify timeout is set + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert "timeout" in call_kwargs + assert call_kwargs["timeout"] == 300 # 5 minutes + + def test_check_handles_timeout_exception(self) -> None: + """Test that check() handles subprocess timeout gracefully.""" + # Mock subprocess.run to raise TimeoutExpired + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired("mypy", timeout=300), + ): + gate = BuildGate() + result = gate.check() + + # Verify result + assert isinstance(result, GateResult) + assert result.passed is False + # TimeoutExpired message contains "timed out after" + assert "timed out" in result.message.lower() or "timeout" in result.message.lower() + assert "error" in result.details diff --git a/apps/coordinator/tests/gates/test_coverage_gate.py b/apps/coordinator/tests/gates/test_coverage_gate.py index 4868cce..02b451b 100644 --- a/apps/coordinator/tests/gates/test_coverage_gate.py +++ b/apps/coordinator/tests/gates/test_coverage_gate.py @@ -254,3 +254,64 @@ class TestCoverageGate: assert isinstance(result, GateResult) assert result.passed is True assert result.details["coverage_percent"] == 90.0 + + def test_check_uses_timeout(self) -> None: + """Test that check() sets a timeout on subprocess.run.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "TOTAL 100 10 90%" + mock_result.stderr = "" + + coverage_data = {"totals": {"percent_covered": 90.0}} + + with patch("subprocess.run", return_value=mock_result) as mock_run: + with patch("builtins.open", mock_open(read_data=json.dumps(coverage_data))): + with patch("json.load", return_value=coverage_data): + gate = CoverageGate() + gate.check() + + # Verify timeout is set + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert "timeout" in call_kwargs + assert call_kwargs["timeout"] == 300 # 5 minutes + + def test_check_handles_timeout_exception(self) -> None: + """Test that check() handles subprocess timeout gracefully.""" + # Mock subprocess.run to raise TimeoutExpired + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired("pytest", timeout=300), + ): + gate = CoverageGate() + result = gate.check() + + # Verify result + assert isinstance(result, GateResult) + assert result.passed is False + # TimeoutExpired message contains "timed out after" + assert "timed out" in result.message.lower() or "timeout" in result.message.lower() + assert "error" in result.details + + def test_coverage_file_path_is_absolute(self) -> None: + """Test that coverage.json path is resolved as absolute and validated.""" + from pathlib import Path + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "TOTAL 100 10 90%" + mock_result.stderr = "" + + coverage_data = {"totals": {"percent_covered": 90.0}} + + with patch("subprocess.run", return_value=mock_result): + # Mock Path.exists to return True for absolute path check + with patch.object(Path, "exists", return_value=True): + with patch("builtins.open", mock_open(read_data=json.dumps(coverage_data))): + with patch("json.load", return_value=coverage_data): + gate = CoverageGate() + # Access the internal method to verify it uses absolute paths + coverage_percent = gate._extract_coverage_from_json() + + # Should successfully extract coverage + assert coverage_percent == 90.0 diff --git a/apps/coordinator/tests/gates/test_lint_gate.py b/apps/coordinator/tests/gates/test_lint_gate.py index c9189e1..b4b2f58 100644 --- a/apps/coordinator/tests/gates/test_lint_gate.py +++ b/apps/coordinator/tests/gates/test_lint_gate.py @@ -150,3 +150,37 @@ class TestLintGate: assert result.passed is False assert "unexpected error" in result.message.lower() assert "error" in result.details + + def test_check_uses_timeout(self) -> None: + """Test that check() sets a timeout on subprocess.run.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "All checks passed!" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result) as mock_run: + gate = LintGate() + gate.check() + + # Verify timeout is set + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert "timeout" in call_kwargs + assert call_kwargs["timeout"] == 300 # 5 minutes + + def test_check_handles_timeout_exception(self) -> None: + """Test that check() handles subprocess timeout gracefully.""" + # Mock subprocess.run to raise TimeoutExpired + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired("ruff", timeout=300), + ): + gate = LintGate() + result = gate.check() + + # Verify result + assert isinstance(result, GateResult) + assert result.passed is False + # TimeoutExpired message contains "timed out after" + assert "timed out" in result.message.lower() or "timeout" in result.message.lower() + assert "error" in result.details diff --git a/apps/coordinator/tests/gates/test_test_gate.py b/apps/coordinator/tests/gates/test_test_gate.py index 2425dd1..5073050 100644 --- a/apps/coordinator/tests/gates/test_test_gate.py +++ b/apps/coordinator/tests/gates/test_test_gate.py @@ -176,3 +176,37 @@ class TestTestGate: assert result.passed is False assert "unexpected error" in result.message.lower() assert "error" in result.details + + def test_check_uses_timeout(self) -> None: + """Test that check() sets a timeout on subprocess.run.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "50 passed in 2.34s" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result) as mock_run: + gate = TestGate() + gate.check() + + # Verify timeout is set + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert "timeout" in call_kwargs + assert call_kwargs["timeout"] == 300 # 5 minutes + + def test_check_handles_timeout_exception(self) -> None: + """Test that check() handles subprocess timeout gracefully.""" + # Mock subprocess.run to raise TimeoutExpired + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired("pytest", timeout=300), + ): + gate = TestGate() + result = gate.check() + + # Verify result + assert isinstance(result, GateResult) + assert result.passed is False + # TimeoutExpired message contains "timed out after" + assert "timed out" in result.message.lower() or "timeout" in result.message.lower() + assert "error" in result.details diff --git a/apps/coordinator/tests/test_webhook.py b/apps/coordinator/tests/test_webhook.py index ccd12f3..12ad4c0 100644 --- a/apps/coordinator/tests/test_webhook.py +++ b/apps/coordinator/tests/test_webhook.py @@ -145,6 +145,23 @@ class TestWebhookEndpoint: assert any("issue_number=157" in record.message for record in caplog.records) +class TestWebhookRateLimiting: + """Test suite for webhook rate limiting.""" + + def test_webhook_has_rate_limit_configured(self) -> None: + """Test that webhook endpoint has rate limiting configured.""" + from src.webhook import handle_gitea_webhook + + # Verify the rate limit decorator is applied + # slowapi adds __wrapped__ attribute to decorated functions + assert hasattr(handle_gitea_webhook, "__wrapped__") or hasattr( + handle_gitea_webhook, "__name__" + ) + + # Verify the endpoint is the webhook handler + assert handle_gitea_webhook.__name__ == "handle_gitea_webhook" + + class TestHealthEndpoint: """Test suite for /health endpoint.""" diff --git a/apps/orchestrator/src/coordinator/coordinator-client.service.spec.ts b/apps/orchestrator/src/coordinator/coordinator-client.service.spec.ts index 5d59e33..856cb45 100644 --- a/apps/orchestrator/src/coordinator/coordinator-client.service.spec.ts +++ b/apps/orchestrator/src/coordinator/coordinator-client.service.spec.ts @@ -7,6 +7,14 @@ describe("CoordinatorClientService", () => { let mockConfigService: ConfigService; const mockCoordinatorUrl = "http://localhost:8000"; + // Valid request for testing + const validQualityCheckRequest = { + taskId: "task-123", + agentId: "agent-456", + files: ["src/test.ts", "src/test.spec.ts"], + diffSummary: "Added new test file", + }; + // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch as unknown as typeof fetch; @@ -31,12 +39,7 @@ describe("CoordinatorClientService", () => { }); describe("checkQuality", () => { - const qualityCheckRequest = { - taskId: "task-123", - agentId: "agent-456", - files: ["src/test.ts", "src/test.spec.ts"], - diffSummary: "Added new test file", - }; + const qualityCheckRequest = validQualityCheckRequest; it("should successfully call quality check endpoint and return approved result", async () => { const mockResponse = { @@ -260,4 +263,117 @@ describe("CoordinatorClientService", () => { expect(result).toBe(false); }); }); + + describe("input validation", () => { + it("should reject request with too many files (> 1000)", async () => { + const files = Array(1001).fill("src/file.ts"); + const request = { ...validQualityCheckRequest, files }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "files array exceeds maximum size of 1000" + ); + }); + + it("should accept request with exactly 1000 files", async () => { + const files = Array(1000).fill("src/file.ts"); + const request = { ...validQualityCheckRequest, files }; + const mockResponse = { + approved: true, + gate: "all", + message: "All quality gates passed", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.checkQuality(request); + expect(result).toEqual(mockResponse); + }); + + it("should reject file paths with path traversal attempts", async () => { + const request = { + ...validQualityCheckRequest, + files: ["src/test.ts", "../../../etc/passwd"], + }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "file path contains path traversal" + ); + }); + + it("should reject file paths with null bytes", async () => { + const request = { + ...validQualityCheckRequest, + files: ["src/test.ts", "src/file\0.ts"], + }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "file path contains invalid characters" + ); + }); + + it("should reject diff summary exceeding 10KB", async () => { + const largeDiff = "x".repeat(10 * 1024 + 1); // 10KB + 1 byte + const request = { ...validQualityCheckRequest, diffSummary: largeDiff }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "diffSummary exceeds maximum size of 10KB" + ); + }); + + it("should accept diff summary of exactly 10KB", async () => { + const largeDiff = "x".repeat(10 * 1024); // Exactly 10KB + const request = { ...validQualityCheckRequest, diffSummary: largeDiff }; + const mockResponse = { + approved: true, + gate: "all", + message: "All quality gates passed", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.checkQuality(request); + expect(result).toEqual(mockResponse); + }); + + it("should reject invalid taskId format", async () => { + const request = { ...validQualityCheckRequest, taskId: "" }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "taskId cannot be empty" + ); + }); + + it("should reject invalid agentId format", async () => { + const request = { ...validQualityCheckRequest, agentId: "" }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "agentId cannot be empty" + ); + }); + + it("should reject empty files array", async () => { + const request = { ...validQualityCheckRequest, files: [] }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "files array cannot be empty" + ); + }); + + it("should reject absolute file paths", async () => { + const request = { + ...validQualityCheckRequest, + files: ["/etc/passwd", "src/file.ts"], + }; + + await expect(service.checkQuality(request)).rejects.toThrow( + "file path must be relative" + ); + }); + }); }); diff --git a/apps/orchestrator/src/coordinator/coordinator-client.service.ts b/apps/orchestrator/src/coordinator/coordinator-client.service.ts index 1d7f6a8..fb903da 100644 --- a/apps/orchestrator/src/coordinator/coordinator-client.service.ts +++ b/apps/orchestrator/src/coordinator/coordinator-client.service.ts @@ -50,9 +50,12 @@ export class CoordinatorClientService { * Check quality gates via coordinator API * @param request Quality check request parameters * @returns Quality check response with approval status - * @throws Error if request fails after all retries + * @throws Error if request fails after all retries or validation fails */ async checkQuality(request: QualityCheckRequest): Promise { + // Validate request before sending + this.validateRequest(request); + const url = `${this.coordinatorUrl}/api/quality/check`; this.logger.debug(`Checking quality for task ${request.taskId} via coordinator`); @@ -197,4 +200,59 @@ export class CoordinatorClientService { private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + + /** + * Validate QualityCheckRequest to prevent security issues + * @param request Request to validate + * @throws Error if validation fails + */ + private validateRequest(request: QualityCheckRequest): void { + // Validate taskId + if (!request.taskId || request.taskId.trim() === "") { + throw new Error("taskId cannot be empty"); + } + + // Validate agentId + if (!request.agentId || request.agentId.trim() === "") { + throw new Error("agentId cannot be empty"); + } + + // Validate files array + if (request.files.length === 0) { + throw new Error("files array cannot be empty"); + } + + if (request.files.length > 1000) { + throw new Error("files array exceeds maximum size of 1000"); + } + + // Validate each file path + for (const filePath of request.files) { + // Check for path traversal attempts + if (filePath.includes("..")) { + throw new Error(`file path contains path traversal: ${filePath}`); + } + + // Check for null bytes + if (filePath.includes("\0")) { + throw new Error(`file path contains invalid characters: ${filePath}`); + } + + // Check for absolute paths (should be relative) + if (filePath.startsWith("/") || filePath.startsWith("\\")) { + throw new Error(`file path must be relative: ${filePath}`); + } + + // Check for Windows absolute paths (C:\, D:\, etc.) + if (/^[a-zA-Z]:[/\\]/.test(filePath)) { + throw new Error(`file path must be relative: ${filePath}`); + } + } + + // Validate diffSummary size (max 10KB) + const diffSummaryBytes = new TextEncoder().encode(request.diffSummary).length; + if (diffSummaryBytes > 10 * 1024) { + throw new Error("diffSummary exceeds maximum size of 10KB"); + } + } }