Initial commit
This commit is contained in:
268
server/tools/filesystem_tool.py
Normal file
268
server/tools/filesystem_tool.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
tools/filesystem_tool.py — Sandboxed filesystem access.
|
||||
|
||||
All paths are validated against FILESYSTEM_SANDBOX_DIRS before any operation.
|
||||
Symlinks are resolved before checking (prevents traversal attacks).
|
||||
Binary files are rejected — text only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ..context_vars import current_user as _current_user_var
|
||||
from ..security import SecurityError, assert_path_allowed, sanitize_external_content
|
||||
from ..security_screening import get_content_limit, is_option_enabled
|
||||
from .base import BaseTool, ToolResult
|
||||
|
||||
MAX_FILE_SIZE = 500 * 1024 # 500 KB (file size gate — still enforced regardless of truncation option)
|
||||
_DEFAULT_MAX_FILE_CHARS = 20_000 # default char limit when truncation option is enabled
|
||||
MAX_DIR_ENTRIES = 200
|
||||
|
||||
# Image extensions and their MIME types — returned as base64 for vision-capable models
|
||||
_IMAGE_MEDIA_TYPES: dict[str, str] = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".gif": "image/gif",
|
||||
".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".tiff": "image/tiff", ".tif": "image/tiff",
|
||||
}
|
||||
|
||||
|
||||
class FilesystemTool(BaseTool):
|
||||
name = "filesystem"
|
||||
description = (
|
||||
"Read and write files in the owner's designated directories. "
|
||||
"Operations: read_file, list_directory, write_file, delete_file, "
|
||||
"create_directory, delete_directory. "
|
||||
"Image files (jpg, png, gif, webp, etc.) are returned as visual content "
|
||||
"that vision-capable models can analyse. "
|
||||
"write_file, delete_file, and delete_directory require user confirmation."
|
||||
)
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["read_file", "list_directory", "write_file", "delete_file",
|
||||
"create_directory", "delete_directory"],
|
||||
"description": "The filesystem operation to perform",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute or relative path to the file or directory",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "File content for write_file operations",
|
||||
},
|
||||
},
|
||||
"required": ["operation", "path"],
|
||||
}
|
||||
requires_confirmation = False # set dynamically per operation
|
||||
allowed_in_scheduled_tasks = True
|
||||
|
||||
# Operations that require confirmation
|
||||
_CONFIRM_OPS = {"write_file", "delete_file", "delete_directory"}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
operation: str,
|
||||
path: str,
|
||||
content: str = "",
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
# Check operation
|
||||
if operation not in ("read_file", "list_directory", "write_file", "delete_file",
|
||||
"create_directory", "delete_directory"):
|
||||
return ToolResult(success=False, error=f"Unknown operation: {operation!r}")
|
||||
|
||||
# Sandbox check
|
||||
try:
|
||||
safe_path = await assert_path_allowed(path)
|
||||
except SecurityError as e:
|
||||
return ToolResult(success=False, error=str(e))
|
||||
|
||||
# Per-user folder restriction: non-admins may only access their personal folder
|
||||
_user = _current_user_var.get()
|
||||
if _user is not None and not _user.is_admin:
|
||||
from ..database import credential_store
|
||||
base = await credential_store.get("system:users_base_folder")
|
||||
if not base:
|
||||
return ToolResult(success=False, error="Filesystem access is not available for your account.")
|
||||
user_folder = Path(base.rstrip("/")) / _user.username
|
||||
try:
|
||||
resolved = safe_path.resolve()
|
||||
user_folder_resolved = user_folder.resolve()
|
||||
if not str(resolved).startswith(str(user_folder_resolved) + "/") and resolved != user_folder_resolved:
|
||||
return ToolResult(success=False, error="Access denied: path is outside your personal folder.")
|
||||
except Exception:
|
||||
return ToolResult(success=False, error="Access denied: could not verify path.")
|
||||
|
||||
# Dispatch
|
||||
if operation == "read_file":
|
||||
return await self._read_file(safe_path)
|
||||
elif operation == "list_directory":
|
||||
return self._list_directory(safe_path)
|
||||
elif operation == "write_file":
|
||||
return self._write_file(safe_path, content)
|
||||
elif operation == "delete_file":
|
||||
return self._delete_file(safe_path)
|
||||
elif operation == "create_directory":
|
||||
return self._create_directory(safe_path)
|
||||
elif operation == "delete_directory":
|
||||
return self._delete_directory(safe_path)
|
||||
|
||||
async def _read_file(self, path: Path) -> ToolResult:
|
||||
if not path.exists():
|
||||
return ToolResult(success=False, error=f"File not found: {path}")
|
||||
if not path.is_file():
|
||||
return ToolResult(success=False, error=f"Not a file: {path}")
|
||||
|
||||
size = path.stat().st_size
|
||||
if size > MAX_FILE_SIZE:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=f"File too large ({size:,} bytes). Max: {MAX_FILE_SIZE:,} bytes.",
|
||||
)
|
||||
|
||||
# Image files: return as base64 for vision-capable models
|
||||
media_type = _IMAGE_MEDIA_TYPES.get(path.suffix.lower())
|
||||
if media_type:
|
||||
try:
|
||||
import base64
|
||||
image_data = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
except PermissionError:
|
||||
return ToolResult(success=False, error=f"Permission denied: {path}")
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"path": str(path),
|
||||
"is_image": True,
|
||||
"media_type": media_type,
|
||||
"image_data": image_data,
|
||||
"size_bytes": size,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return ToolResult(success=False, error="Binary files are not supported. Text files only.")
|
||||
except PermissionError:
|
||||
return ToolResult(success=False, error=f"Permission denied: {path}")
|
||||
|
||||
# Apply configurable char limit — truncate rather than reject
|
||||
truncated = False
|
||||
if await is_option_enabled("system:security_truncation_enabled"):
|
||||
max_chars = await get_content_limit("system:security_max_file_chars", _DEFAULT_MAX_FILE_CHARS)
|
||||
total_chars = len(text)
|
||||
if total_chars > max_chars:
|
||||
text = text[:max_chars]
|
||||
text += f"\n\n[File truncated: showing first {max_chars:,} of {total_chars:,} total chars]"
|
||||
truncated = True
|
||||
|
||||
# Sanitise before returning to agent
|
||||
text = await sanitize_external_content(text, source="file")
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={"path": str(path), "content": text, "size_bytes": size, "truncated": truncated},
|
||||
)
|
||||
|
||||
def _list_directory(self, path: Path) -> ToolResult:
|
||||
if not path.exists():
|
||||
return ToolResult(success=False, error=f"Directory not found: {path}")
|
||||
if not path.is_dir():
|
||||
return ToolResult(success=False, error=f"Not a directory: {path}")
|
||||
|
||||
try:
|
||||
entries = []
|
||||
for i, entry in enumerate(sorted(path.iterdir())):
|
||||
if i >= MAX_DIR_ENTRIES:
|
||||
entries.append({"name": f"... ({i} entries truncated)", "type": "truncated"})
|
||||
break
|
||||
stat = entry.stat()
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"type": "directory" if entry.is_dir() else "file",
|
||||
"size_bytes": stat.st_size if entry.is_file() else None,
|
||||
})
|
||||
except PermissionError:
|
||||
return ToolResult(success=False, error=f"Permission denied: {path}")
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={"path": str(path), "entries": entries, "count": len(entries)},
|
||||
)
|
||||
|
||||
def _write_file(self, path: Path, content: str) -> ToolResult:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Support base64 data URLs for binary files (e.g. images from image_gen tool)
|
||||
if content.startswith("data:") and ";base64," in content:
|
||||
import base64 as _b64
|
||||
try:
|
||||
_, b64_data = content.split(",", 1)
|
||||
raw_bytes = _b64.b64decode(b64_data)
|
||||
path.write_bytes(raw_bytes)
|
||||
return ToolResult(success=True, data={"path": str(path), "bytes_written": len(raw_bytes)})
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Failed to decode base64 data: {e}")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
except PermissionError:
|
||||
return ToolResult(success=False, error=f"Permission denied: {path}")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Write failed: {e}")
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={"path": str(path), "bytes_written": len(content.encode())},
|
||||
)
|
||||
|
||||
def _delete_file(self, path: Path) -> ToolResult:
|
||||
if not path.exists():
|
||||
return ToolResult(success=False, error=f"File not found: {path}")
|
||||
if not path.is_file():
|
||||
return ToolResult(success=False, error="Can only delete files, not directories.")
|
||||
|
||||
try:
|
||||
path.unlink()
|
||||
except PermissionError:
|
||||
return ToolResult(success=False, error=f"Permission denied: {path}")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Delete failed: {e}")
|
||||
|
||||
return ToolResult(success=True, data={"deleted": str(path)})
|
||||
|
||||
def _create_directory(self, path: Path) -> ToolResult:
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
return ToolResult(success=False, error=f"Permission denied: {path}")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Create directory failed: {e}")
|
||||
return ToolResult(success=True, data={"created": str(path)})
|
||||
|
||||
def _delete_directory(self, path: Path) -> ToolResult:
|
||||
if not path.exists():
|
||||
return ToolResult(success=False, error=f"Directory not found: {path}")
|
||||
if not path.is_dir():
|
||||
return ToolResult(success=False, error=f"Not a directory: {path}")
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except PermissionError:
|
||||
return ToolResult(success=False, error=f"Permission denied: {path}")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Delete directory failed: {e}")
|
||||
return ToolResult(success=True, data={"deleted": str(path)})
|
||||
|
||||
def confirmation_description(self, operation: str = "", path: str = "", **kwargs) -> str:
|
||||
if operation == "delete_file":
|
||||
return f"Permanently delete file: {path}"
|
||||
if operation == "delete_directory":
|
||||
return f"Permanently delete directory and all its contents: {path}"
|
||||
if operation == "write_file":
|
||||
content_preview = kwargs.get("content", "")[:80]
|
||||
return f"Write to file: {path}\nContent preview: {content_preview}..."
|
||||
return f"{operation}: {path}"
|
||||
Reference in New Issue
Block a user