224 lines
6.5 KiB
Python
224 lines
6.5 KiB
Python
|
|
"""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)
|