""" SandboxedFS - Secure File System Operations This service restricts all file operations to within a project's root directory, preventing path traversal attacks and ensuring AI operations are safely scoped. Security Features: - Path resolution with escape detection - Symlink attack prevention - Read/write operation logging """ import os from pathlib import Path from typing import List, Dict, Optional import logging logger = logging.getLogger(__name__) class SandboxedFS: """ File system operations restricted to a project root. All paths are validated to ensure they don't escape the sandbox. This is critical for AI operations that may receive untrusted input. """ def __init__(self, root_path: str): """ Initialize sandboxed file system. Args: root_path: Absolute path to project root directory Raises: ValueError: If root_path doesn't exist or isn't a directory """ self.root = Path(root_path).resolve() if not self.root.is_dir(): raise ValueError(f"Invalid root path: {root_path}") logger.info(f"SandboxedFS initialized with root: {self.root}") def _validate_path(self, relative_path: str) -> Path: """ Validate and resolve a path within the sandbox. Args: relative_path: Path relative to project root Returns: Resolved absolute Path within sandbox Raises: PermissionError: If path escapes sandbox """ # Normalize the path clean_path = os.path.normpath(relative_path) # Resolve full path full_path = (self.root / clean_path).resolve() # Security check: must be within root try: full_path.relative_to(self.root) except ValueError: logger.warning(f"Path traversal attempt blocked: {relative_path}") raise PermissionError(f"Path escapes sandbox: {relative_path}") return full_path def read_file(self, relative_path: str, max_size_kb: int = 500) -> str: """ Read file content within sandbox. Args: relative_path: Path relative to project root max_size_kb: Maximum file size in KB (default 500KB) Returns: File content as string Raises: FileNotFoundError: If file doesn't exist PermissionError: If path escapes sandbox ValueError: If file exceeds max size """ path = self._validate_path(relative_path) if not path.is_file(): raise FileNotFoundError(f"File not found: {relative_path}") # Check file size size_kb = path.stat().st_size / 1024 if size_kb > max_size_kb: raise ValueError(f"File too large: {size_kb:.1f}KB > {max_size_kb}KB limit") content = path.read_text(encoding='utf-8') logger.debug(f"Read file: {relative_path} ({len(content)} chars)") return content def write_file(self, relative_path: str, content: str) -> None: """ Write file content within sandbox. Args: relative_path: Path relative to project root content: Content to write Raises: PermissionError: If path escapes sandbox """ path = self._validate_path(relative_path) # Create parent directories if needed path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding='utf-8') logger.info(f"Wrote file: {relative_path} ({len(content)} chars)") def delete_file(self, relative_path: str) -> None: """ Delete file within sandbox. Args: relative_path: Path relative to project root Raises: FileNotFoundError: If file doesn't exist PermissionError: If path escapes sandbox """ path = self._validate_path(relative_path) if not path.is_file(): raise FileNotFoundError(f"File not found: {relative_path}") path.unlink() logger.info(f"Deleted file: {relative_path}") def list_directory(self, relative_path: str = ".") -> List[Dict[str, any]]: """ List directory contents within sandbox. Args: relative_path: Path relative to project root Returns: List of dicts with name, type, and size Raises: NotADirectoryError: If path isn't a directory PermissionError: If path escapes sandbox """ path = self._validate_path(relative_path) if not path.is_dir(): raise NotADirectoryError(f"Not a directory: {relative_path}") result = [] for item in sorted(path.iterdir()): entry = { "name": item.name, "type": "directory" if item.is_dir() else "file", } if item.is_file(): entry["size"] = item.stat().st_size result.append(entry) return result def file_exists(self, relative_path: str) -> bool: """ Check if file exists within sandbox. Args: relative_path: Path relative to project root Returns: True if file exists, False otherwise """ try: path = self._validate_path(relative_path) return path.exists() except PermissionError: return False def get_file_tree(self, max_depth: int = 3, include_hidden: bool = False) -> Dict: """ Get hierarchical file tree for AI context injection. Args: max_depth: Maximum directory depth to traverse include_hidden: Include hidden files (starting with .) Returns: Nested dict representing file tree with sizes """ def build_tree(path: Path, depth: int) -> Dict: if depth > max_depth: return {"...": "truncated"} result = {} try: items = sorted(path.iterdir()) except PermissionError: return {"error": "permission denied"} for item in items: # Skip hidden files unless requested if not include_hidden and item.name.startswith('.'): # Always include .dss config folder if item.name != '.dss': continue # Skip common non-essential directories if item.name in ('node_modules', '__pycache__', '.git', 'dist', 'build'): result[item.name + "/"] = {"...": "skipped"} continue if item.is_dir(): result[item.name + "/"] = build_tree(item, depth + 1) else: result[item.name] = item.stat().st_size return result return build_tree(self.root, 0) def get_root_path(self) -> str: """Get the absolute root path of this sandbox.""" return str(self.root)