letsbe-sysadmin/app/executors/file_executor.py

224 lines
6.5 KiB
Python
Raw Normal View History

"""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)