"""File write executor with security controls.""" import os import tempfile import time from pathlib import Path from typing import Any from app.config import get_settings from app.executors.base import BaseExecutor, ExecutionResult from app.utils.validation import ValidationError, sanitize_input, validate_file_path class FileExecutor(BaseExecutor): """Write files with strict security controls. Security measures: - Path validation against allowed root directories - Directory traversal prevention - Maximum file size enforcement - Atomic writes (temp file + rename) - Content sanitization Supported roots: - /opt/agent_data (general file operations) - /opt/letsbe/env (ENV file operations) Payload: { "path": "/opt/letsbe/env/app.env", "content": "KEY=value\\nKEY2=value2", "mode": "write" # "write" (default) or "append" } Result: { "written": true, "path": "/opt/letsbe/env/app.env", "size": 123 } """ @property def task_type(self) -> str: return "FILE_WRITE" async def execute(self, payload: dict[str, Any]) -> ExecutionResult: """Write content to a file. Args: payload: Must contain "path" and "content", optionally "mode" Returns: ExecutionResult with write confirmation """ self.validate_payload(payload, ["path", "content"]) settings = get_settings() file_path = payload["path"] content = payload["content"] mode = payload.get("mode", "write") if mode not in ("write", "append"): return ExecutionResult( success=False, data={}, error=f"Invalid mode: {mode}. Must be 'write' or 'append'", ) # Validate path against allowed roots (env or general) # Try env root first if path starts with it, otherwise use general root try: allowed_root = self._determine_allowed_root(file_path, settings) validated_path = validate_file_path( file_path, allowed_root, must_exist=False, ) sanitized_content = sanitize_input(content, max_length=settings.max_file_size) except ValidationError as e: self.logger.warning("file_validation_failed", path=file_path, error=str(e)) return ExecutionResult( success=False, data={}, error=f"Validation failed: {e}", ) # Check content size content_bytes = sanitized_content.encode("utf-8") if len(content_bytes) > settings.max_file_size: return ExecutionResult( success=False, data={}, error=f"Content size {len(content_bytes)} exceeds max {settings.max_file_size}", ) self.logger.info( "file_writing", path=str(validated_path), mode=mode, size=len(content_bytes), ) start_time = time.time() try: if mode == "write": bytes_written = await self._atomic_write(validated_path, content_bytes) else: bytes_written = await self._append(validated_path, content_bytes) duration_ms = (time.time() - start_time) * 1000 self.logger.info( "file_written", path=str(validated_path), bytes_written=bytes_written, duration_ms=duration_ms, ) return ExecutionResult( success=True, data={ "written": True, "path": str(validated_path), "size": bytes_written, }, duration_ms=duration_ms, ) except Exception as e: duration_ms = (time.time() - start_time) * 1000 self.logger.error("file_write_error", path=str(validated_path), error=str(e)) return ExecutionResult( success=False, data={}, error=str(e), duration_ms=duration_ms, ) def _determine_allowed_root(self, file_path: str, settings) -> str: """Determine which allowed root to use based on file path. Args: file_path: The requested file path settings: Application settings Returns: The appropriate allowed root directory """ from pathlib import Path as P # Normalize the path for comparison normalized = str(P(file_path).expanduser()) # Check if path is under env root env_root = str(P(settings.allowed_env_root).expanduser()) if normalized.startswith(env_root): return settings.allowed_env_root # Default to general file root return settings.allowed_file_root async def _atomic_write(self, path: Path, content: bytes) -> int: """Write file atomically using temp file + rename. Args: path: Target file path content: Content to write Returns: Number of bytes written """ import asyncio def _write() -> int: # Ensure parent directory exists path.parent.mkdir(parents=True, exist_ok=True) # Write to temp file in same directory (for atomic rename) fd, temp_path = tempfile.mkstemp( dir=path.parent, prefix=".tmp_", suffix=path.suffix, ) try: os.write(fd, content) os.fsync(fd) # Ensure data is on disk finally: os.close(fd) # Atomic rename os.rename(temp_path, path) return len(content) return await asyncio.to_thread(_write) async def _append(self, path: Path, content: bytes) -> int: """Append content to file. Args: path: Target file path content: Content to append Returns: Number of bytes written """ import asyncio def _append() -> int: # Ensure parent directory exists path.parent.mkdir(parents=True, exist_ok=True) with open(path, "ab") as f: written = f.write(content) f.flush() os.fsync(f.fileno()) return written return await asyncio.to_thread(_append)