""" Dev Server Router ================= API endpoints for dev server control (start/stop) and configuration. Uses project registry for path lookups and project_config for command detection. """ import logging import shlex import sys from pathlib import Path from fastapi import APIRouter, HTTPException from ..schemas import ( DevServerActionResponse, DevServerConfigResponse, DevServerConfigUpdate, DevServerStartRequest, DevServerStatus, ) from ..services.dev_server_manager import get_devserver_manager from ..services.project_config import ( clear_dev_command, get_dev_command, get_project_config, set_dev_command, ) from ..utils.project_helpers import get_project_path as _get_project_path from ..utils.validation import validate_project_name # Add root to path for security module import _root = Path(__file__).parent.parent.parent if str(_root) not in sys.path: sys.path.insert(0, str(_root)) from security import extract_commands, get_effective_commands, is_command_allowed logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"]) def get_project_dir(project_name: str) -> Path: """ Get the validated project directory for a project name. Args: project_name: Name of the project Returns: Path to the project directory Raises: HTTPException: If project is not found or directory does not exist """ project_name = validate_project_name(project_name) project_dir = _get_project_path(project_name) if not project_dir: raise HTTPException( status_code=404, detail=f"Project '{project_name}' not found in registry" ) if not project_dir.exists(): raise HTTPException( status_code=404, detail=f"Project directory not found: {project_dir}" ) return project_dir ALLOWED_RUNNERS = { "npm", "pnpm", "yarn", "npx", "uvicorn", "python", "python3", "flask", "poetry", "cargo", "go", } ALLOWED_NPM_SCRIPTS = {"dev", "start", "serve", "develop", "server", "preview"} # Allowed Python -m modules for dev servers ALLOWED_PYTHON_MODULES = {"uvicorn", "flask", "gunicorn", "http.server"} BLOCKED_SHELLS = {"sh", "bash", "zsh", "cmd", "powershell", "pwsh", "cmd.exe"} def validate_custom_command_strict(cmd: str) -> None: """ Strict allowlist validation for dev server commands. Prevents arbitrary command execution (no sh -c, no cmd /c, no python -c, etc.) """ if not isinstance(cmd, str) or not cmd.strip(): raise ValueError("custom_command cannot be empty") argv = shlex.split(cmd, posix=(sys.platform != "win32")) if not argv: raise ValueError("custom_command could not be parsed") base = Path(argv[0]).name.lower() # Block direct shells / interpreters commonly used for command injection if base in BLOCKED_SHELLS: raise ValueError(f"custom_command runner not allowed: {base}") if base not in ALLOWED_RUNNERS: raise ValueError( f"custom_command runner not allowed: {base}. " f"Allowed: {', '.join(sorted(ALLOWED_RUNNERS))}" ) # Block one-liner execution for python lowered = [a.lower() for a in argv] if base in {"python", "python3"}: if "-c" in lowered: raise ValueError("python -c is not allowed") if len(argv) >= 3 and argv[1] == "-m": # Allow: python -m ... if argv[2] not in ALLOWED_PYTHON_MODULES: raise ValueError( f"python -m {argv[2]} is not allowed. " f"Allowed modules: {', '.join(sorted(ALLOWED_PYTHON_MODULES))}" ) elif len(argv) >= 2 and argv[1].endswith(".py"): # Allow: python manage.py runserver, python app.py, etc. pass else: raise ValueError( "Python commands must use 'python -m ...' or 'python