diff --git a/Makefile b/Makefile index eecdb3d..824a00d 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,28 @@ install: pip install requests test: + python -m pytest tests/ -v --cov=claude_code_api --cov-report=html --cov-report=term-missing + +test-no-cov: python -m pytest tests/ -v test-real: python tests/test_real_api.py +coverage: + @if [ -f htmlcov/index.html ]; then \ + echo "Opening coverage report..."; \ + if command -v xdg-open > /dev/null 2>&1; then \ + xdg-open htmlcov/index.html; \ + elif command -v open > /dev/null 2>&1; then \ + open htmlcov/index.html; \ + else \ + echo "Coverage report available at: htmlcov/index.html"; \ + fi \ + else \ + echo "No coverage report found. Run 'make test' first."; \ + fi + start: uvicorn claude_code_api.main:app --host 0.0.0.0 --port 8000 --reload --reload-exclude="*.db*" --reload-exclude="*.log" @@ -44,8 +61,10 @@ help: @echo "" @echo "Python API:" @echo " make install - Install Python dependencies" - @echo " make test - Run Python unit tests with real Claude integration" + @echo " make test - Run Python unit tests with coverage report" + @echo " make test-no-cov - Run Python unit tests without coverage" @echo " make test-real - Run REAL end-to-end tests (curls actual API)" + @echo " make coverage - View HTML coverage report (run after 'make test')" @echo " make start - Start Python API server (development with reload)" @echo " make start-prod - Start Python API server (production)" @echo "" diff --git a/claude_code_api/core/claude_manager.py b/claude_code_api/core/claude_manager.py index fd45211..1614a3e 100644 --- a/claude_code_api/core/claude_manager.py +++ b/claude_code_api/core/claude_manager.py @@ -167,33 +167,7 @@ async def send_input(self, text: str): session_id=self.session_id, error=str(e) ) - - async def _start_mock_process(self, prompt: str, model: str): - """Start mock process for testing.""" - self.is_running = True - - # Create mock Claude response - mock_response = { - "type": "result", - "sessionId": self.session_id, - "model": model or "claude-3-5-haiku-20241022", - "message": { - "role": "assistant", - "content": f"Hello! You said: '{prompt}'. This is a mock response from Claude Code API Gateway." - }, - "usage": { - "input_tokens": len(prompt.split()), - "output_tokens": 15, - "total_tokens": len(prompt.split()) + 15 - }, - "cost_usd": 0.001, - "duration_ms": 100 - } - - # Put the response in the queue - await self.output_queue.put(mock_response) - await self.output_queue.put(None) # End signal - + async def stop(self): """Stop Claude process.""" self.is_running = False @@ -336,20 +310,60 @@ async def continue_conversation( # Utility functions for project management +def sanitize_path_component(component: str) -> str: + """Sanitize a path component to prevent directory traversal attacks.""" + import re + # Remove any path separators and special characters + sanitized = re.sub(r'[^\w\-.]', '_', component) + # Remove leading dots to prevent hidden files + sanitized = sanitized.lstrip('.') + # Limit length + sanitized = sanitized[:255] + if not sanitized: + raise ValueError("Invalid path component: results in empty string after sanitization") + return sanitized + + def create_project_directory(project_id: str) -> str: - """Create project directory.""" - project_path = os.path.join(settings.project_root, project_id) + """Create project directory with path validation.""" + # Sanitize the project_id to prevent path traversal + safe_project_id = sanitize_path_component(project_id) + + # Construct the full path + project_path = os.path.join(settings.project_root, safe_project_id) + + # Verify the resulting path is still within project_root (defense in depth) + project_path = os.path.abspath(project_path) + root_path = os.path.abspath(settings.project_root) + + if not project_path.startswith(root_path + os.sep): + raise ValueError(f"Invalid project path: {project_id} resolves outside project root") + os.makedirs(project_path, exist_ok=True) + logger.info("Project directory created", project_id=safe_project_id, path=project_path) return project_path def cleanup_project_directory(project_path: str): - """Clean up project directory.""" + """Clean up project directory with path validation.""" try: import shutil - if os.path.exists(project_path): - shutil.rmtree(project_path) - logger.info("Project directory cleaned up", path=project_path) + + # Verify the path is within project_root before deletion + abs_project_path = os.path.abspath(project_path) + root_path = os.path.abspath(settings.project_root) + + if not abs_project_path.startswith(root_path + os.sep): + logger.error( + "Refused to cleanup directory outside project root", + path=project_path, + project_root=settings.project_root + ) + raise ValueError(f"Cannot cleanup path outside project root: {project_path}") + + if os.path.exists(abs_project_path): + shutil.rmtree(abs_project_path) + logger.info("Project directory cleaned up", path=abs_project_path) except Exception as e: logger.error("Failed to cleanup project directory", path=project_path, error=str(e)) diff --git a/claude_code_api/core/config.py b/claude_code_api/core/config.py index 266314b..088446f 100644 --- a/claude_code_api/core/config.py +++ b/claude_code_api/core/config.py @@ -94,15 +94,22 @@ def parse_api_keys(cls, v): log_format: str = "json" # CORS Configuration - allowed_origins: List[str] = Field(default=["*"]) - allowed_methods: List[str] = Field(default=["*"]) + # Default to localhost only for security - can be overridden via ALLOWED_ORIGINS env var + allowed_origins: List[str] = Field(default=[ + "http://localhost:*", + "http://127.0.0.1:*", + "http://[::1]:*" + ]) + allowed_methods: List[str] = Field(default=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]) allowed_headers: List[str] = Field(default=["*"]) - + @field_validator('allowed_origins', 'allowed_methods', 'allowed_headers', mode='before') def parse_cors_lists(cls, v): if isinstance(v, str): - return [x.strip() for x in v.split(',') if x.strip()] - return v or ["*"] + # Support comma-separated values or "*" for all + parsed = [x.strip() for x in v.split(',') if x.strip()] + return parsed if parsed else ["http://localhost:*"] + return v if v else ["http://localhost:*"] # Rate Limiting rate_limit_requests_per_minute: int = 100 diff --git a/pyproject.toml b/pyproject.toml index 0c76863..1d29896 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "claude-code-api" version = "1.0.0" description = "OpenAI-compatible API gateway for Claude Code with streaming support" readme = "README.md" -license = {text = "MIT"} +license = {text = "GPL-3.0-or-later"} authors = [ {name = "Claude Code API Team"} ] @@ -15,48 +15,47 @@ keywords = ["claude", "api", "openai", "streaming", "ai"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", ] -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ - "fastapi>=0.104.0", - "uvicorn[standard]>=0.24.0", - "pydantic>=2.5.0", - "httpx>=0.25.0", - "aiofiles>=23.2.1", - "structlog>=23.2.0", - "asyncio-mqtt>=0.16.1", - "python-multipart>=0.0.6", - "pydantic-settings>=2.1.0", - "sqlalchemy>=2.0.23", - "aiosqlite>=0.19.0", - "alembic>=1.13.0", + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic>=2.9.0", + "httpx>=0.27.0", + "aiofiles>=24.1.0", + "structlog>=24.4.0", + "python-multipart>=0.0.12", + "pydantic-settings>=2.6.0", + "sqlalchemy>=2.0.35", + "aiosqlite>=0.20.0", + "alembic>=1.13.3", "passlib[bcrypt]>=1.7.4", "python-jose[cryptography]>=3.3.0", - "python-dotenv>=1.0.0", - "openai>=1.0.0", + "python-dotenv>=1.0.1", + "openai>=1.54.0", ] [project.optional-dependencies] test = [ - "pytest>=7.4.0", - "pytest-asyncio>=0.21.0", - "pytest-cov>=4.1.0", - "httpx>=0.25.0", - "pytest-mock>=3.12.0", + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", + "httpx>=0.27.0", + "pytest-mock>=3.14.0", ] dev = [ - "black>=23.0.0", - "isort>=5.12.0", - "flake8>=6.0.0", - "mypy>=1.7.0", - "pre-commit>=3.5.0", + "black>=24.10.0", + "isort>=5.13.0", + "flake8>=7.1.0", + "mypy>=1.13.0", + "pre-commit>=4.0.0", ] [project.urls] @@ -105,7 +104,7 @@ exclude_lines = [ [tool.black] line-length = 88 -target-version = ['py310'] +target-version = ['py311'] include = '\\.pyi?$' extend-exclude = ''' /( @@ -128,7 +127,7 @@ line_length = 88 known_first_party = ["claude_code_api"] [tool.mypy] -python_version = "3.10" +python_version = "3.11" check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true diff --git a/setup.py b/setup.py index c93f29d..e029c4e 100644 --- a/setup.py +++ b/setup.py @@ -13,38 +13,38 @@ author="Claude Code API Team", url="https://github.com/claude-code-api/claude-code-api", packages=find_packages(), - python_requires=">=3.10", + python_requires=">=3.11", install_requires=[ - "fastapi>=0.104.0", - "uvicorn[standard]>=0.24.0", - "pydantic>=2.5.0", - "httpx>=0.25.0", - "aiofiles>=23.2.1", - "structlog>=23.2.0", - "asyncio-mqtt>=0.16.1", - "python-multipart>=0.0.6", - "pydantic-settings>=2.1.0", - "sqlalchemy>=2.0.23", - "aiosqlite>=0.19.0", - "alembic>=1.13.0", + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic>=2.9.0", + "httpx>=0.27.0", + "aiofiles>=24.1.0", + "structlog>=24.4.0", + "python-multipart>=0.0.12", + "pydantic-settings>=2.6.0", + "sqlalchemy>=2.0.35", + "aiosqlite>=0.20.0", + "alembic>=1.13.3", "passlib[bcrypt]>=1.7.4", "python-jose[cryptography]>=3.3.0", - "python-dotenv>=1.0.0", + "python-dotenv>=1.0.1", + "openai>=1.54.0", ], extras_require={ "test": [ - "pytest>=7.4.0", - "pytest-asyncio>=0.21.0", - "pytest-cov>=4.1.0", - "httpx>=0.25.0", - "pytest-mock>=3.12.0", + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", + "httpx>=0.27.0", + "pytest-mock>=3.14.0", ], "dev": [ - "black>=23.0.0", - "isort>=5.12.0", - "flake8>=6.0.0", - "mypy>=1.7.0", - "pre-commit>=3.5.0", + "black>=24.10.0", + "isort>=5.13.0", + "flake8>=7.1.0", + "mypy>=1.13.0", + "pre-commit>=4.0.0", ], }, entry_points={ @@ -55,11 +55,11 @@ classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", ],