Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +17 to +29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The shell logic to find a command to open the browser is clever, but it's not fully portable (e.g., it might not work on all Linux distributions or in environments like WSL without xdg-open). A more portable and simpler approach is to use Python's built-in webbrowser module, which handles the cross-platform differences internally.

coverage:
	@if [ -f htmlcov/index.html ]; then \
		echo "Opening coverage report in browser..."; \
		python -m webbrowser -t "htmlcov/index.html"; \
	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"

Expand Down Expand Up @@ -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 ""
Expand Down
80 changes: 47 additions & 33 deletions claude_code_api/core/claude_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +313 to +324

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This sanitization function is a good security measure. However, it could be made more robust and aligned with best practices:

  1. The import re should be at the top of the file (as per PEP 8), along with import shutil used in cleanup_project_directory.
  2. The function doesn't explicitly reject . or .. as path components. While the downstream create_project_directory function's abspath check provides defense-in-depth, a sanitizer's primary role is to reject or clean invalid input. Allowing . or .. to pass through is risky.

Here is a suggested improvement that addresses the second point. Please also move the import to the top of the file.

Suggested change
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 sanitize_path_component(component: str) -> str:
"""Sanitize a path component to prevent directory traversal attacks."""
import re
# Prohibit special path components that could be used for traversal.
if component in (".", ".."):
raise ValueError(f"Invalid path component: '{component}' is not allowed.")
# 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)
Comment on lines +335 to +337

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There is a potential security vulnerability here. os.path.abspath does not resolve symbolic links, which could allow a specially crafted project_id (containing a symlink) to bypass the directory traversal check. The subsequent os.makedirs could then write files outside the intended project root. You should use os.path.realpath to resolve all symlinks in the path before validation.

Suggested change
# 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)
# Verify the resulting path is still within project_root (defense in depth)
project_path = os.path.realpath(project_path)
root_path = os.path.realpath(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)
Comment on lines +352 to +354

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Similar to the create_project_directory function, using os.path.abspath here presents a security risk. It does not resolve symlinks, which could lead to a Time-of-check to time-of-use (TOCTOU) vulnerability. An attacker could replace a directory with a symlink after validation but before deletion, causing shutil.rmtree to delete files outside the project root. Please use os.path.realpath to ensure the canonical, symlink-resolved path is checked and used for deletion.

Suggested change
# 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)
# Verify the path is within project_root before deletion
abs_project_path = os.path.realpath(project_path)
root_path = os.path.realpath(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))

Expand Down
17 changes: 12 additions & 5 deletions claude_code_api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"]
Comment on lines 106 to +112

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This shared validator for allowed_origins, allowed_methods, and allowed_headers has two issues:

  1. It incorrectly applies the same fallback logic to all three fields. If ALLOWED_METHODS is set to an empty string, it will be assigned ['http://localhost:*'], which is incorrect.
  2. For allowed_origins, when no value is provided, it falls back to ['http://localhost:*'], which is inconsistent with the field's default value that contains three patterns (for localhost, 127.0.0.1, and ::1).

These fields should have separate validators to handle their distinct logic and defaults correctly.

Suggested change
@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:*"]
@field_validator('allowed_origins', mode='before')
def parse_allowed_origins(cls, v):
default = [
"http://localhost:*",
"http://127.0.0.1:*",
"http://[::1]:*",
]
if isinstance(v, str):
parsed = [x.strip() for x in v.split(',') if x.strip()]
return parsed if parsed else default
return v if v else default
@field_validator('allowed_methods', 'allowed_headers', mode='before')
def parse_other_cors(cls, v):
if isinstance(v, str):
parsed = [x.strip() for x in v.split(',') if x.strip()]
# Return None if parsing an empty string, so Pydantic uses the field's default.
return parsed if parsed else None
return v


# Rate Limiting
rate_limit_requests_per_minute: int = 100
Expand Down
59 changes: 29 additions & 30 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,56 +7,55 @@ 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"}
]
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]
Expand Down Expand Up @@ -105,7 +104,7 @@ exclude_lines = [

[tool.black]
line-length = 88
target-version = ['py310']
target-version = ['py311']
include = '\\.pyi?$'
extend-exclude = '''
/(
Expand All @@ -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
Expand Down
52 changes: 26 additions & 26 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand All @@ -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",
],
Expand Down