-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement built-in file system tools (#18) #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4098a00
ffffdb6
be7a6d0
485d5ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| """Built-in file system tools for workspace interaction. | ||
|
|
||
| Provides tools for reading, writing, editing, listing, and deleting | ||
| files within a sandboxed workspace directory. | ||
| """ | ||
|
|
||
| from ai_company.tools.file_system._base_fs_tool import BaseFileSystemTool | ||
| from ai_company.tools.file_system._path_validator import PathValidator | ||
| from ai_company.tools.file_system.delete_file import DeleteFileTool | ||
| from ai_company.tools.file_system.edit_file import EditFileTool | ||
| from ai_company.tools.file_system.list_directory import ListDirectoryTool | ||
| from ai_company.tools.file_system.read_file import ReadFileTool | ||
| from ai_company.tools.file_system.write_file import WriteFileTool | ||
|
|
||
| __all__ = [ | ||
| "BaseFileSystemTool", | ||
| "DeleteFileTool", | ||
| "EditFileTool", | ||
| "ListDirectoryTool", | ||
| "PathValidator", | ||
| "ReadFileTool", | ||
| "WriteFileTool", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| """Base class for file system tools. | ||
|
|
||
| Provides the common ``ToolCategory.FILE_SYSTEM`` category and a | ||
| ``PathValidator`` instance bound to the workspace root. | ||
| """ | ||
|
|
||
| from abc import ABC | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| from ai_company.core.enums import ToolCategory | ||
| from ai_company.tools.base import BaseTool | ||
| from ai_company.tools.file_system._path_validator import PathValidator | ||
|
|
||
| if TYPE_CHECKING: | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def _map_os_error(exc: OSError, user_path: str, verb: str) -> tuple[str, str]: | ||
| """Map an OS error to ``(log_key, user_message)`` for FS operations. | ||
|
|
||
| Args: | ||
| exc: The caught OS-level exception. | ||
| user_path: The original user-supplied path string. | ||
| verb: Action verb for the fallback message | ||
| (e.g. ``"reading"``, ``"editing"``). | ||
|
|
||
| Returns: | ||
| A two-tuple of (structured log key, human-readable message). | ||
| """ | ||
| if isinstance(exc, FileNotFoundError): | ||
| return "not_found", f"File not found: {user_path}" | ||
| if isinstance(exc, IsADirectoryError): | ||
| return "is_directory", f"Path is a directory, not a file: {user_path}" | ||
| if isinstance(exc, PermissionError): | ||
| return "permission_denied", f"Permission denied: {user_path}" | ||
| return "os_error", f"OS error {verb} file '{user_path}': {exc}" | ||
|
|
||
|
|
||
| class BaseFileSystemTool(BaseTool, ABC): | ||
| """Abstract base for all file system tools. | ||
|
|
||
| Sets ``category=ToolCategory.FILE_SYSTEM`` and holds a shared | ||
| ``PathValidator`` for workspace-scoped path resolution. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| workspace_root: Path, | ||
| name: str, | ||
| description: str = "", | ||
| parameters_schema: dict[str, Any] | None = None, | ||
| ) -> None: | ||
| """Initialize with a workspace root and tool metadata. | ||
|
|
||
| Args: | ||
| workspace_root: Root directory bounding file access. | ||
| name: Tool name. | ||
| description: Human-readable description. | ||
| parameters_schema: JSON Schema for tool parameters. | ||
| """ | ||
| super().__init__( | ||
| name=name, | ||
| description=description, | ||
| category=ToolCategory.FILE_SYSTEM, | ||
| parameters_schema=parameters_schema, | ||
| ) | ||
| self._path_validator = PathValidator(workspace_root) | ||
|
|
||
| @property | ||
| def workspace_root(self) -> Path: | ||
| """The resolved workspace root directory.""" | ||
| return self._path_validator.workspace_root | ||
|
|
||
| @property | ||
| def path_validator(self) -> PathValidator: | ||
| """The path validator instance.""" | ||
| return self._path_validator |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,126 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Workspace path validation for file system tools. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| All file system tools delegate path validation to ``PathValidator``, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| which ensures that resolved paths remain within the configured | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| workspace root. This prevents path-traversal attacks via ``../``, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| symlinks, or absolute paths outside the workspace. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from pathlib import PurePosixPath, PureWindowsPath | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import TYPE_CHECKING | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from ai_company.observability import get_logger | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from ai_company.observability.events.tool import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TOOL_FS_PARENT_NOT_FOUND, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TOOL_FS_PATH_VIOLATION, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TOOL_FS_WORKSPACE_INVALID, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if TYPE_CHECKING: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger = get_logger(__name__) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class PathValidator: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Validates and resolves paths against a workspace root. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| All resolved paths must remain within ``workspace_root``. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Symlinks are resolved before checking, so a symlink pointing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outside the workspace is rejected. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Attributes: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| workspace_root: The resolved workspace root directory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def __init__(self, workspace_root: Path) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Initialize with a workspace root directory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| workspace_root: Root directory that bounds all file access. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ValueError: If workspace_root does not exist or is not a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| directory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resolved = workspace_root.resolve() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not resolved.is_dir(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TOOL_FS_WORKSPACE_INVALID, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| workspace_root=str(workspace_root), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msg = f"Workspace root is not an existing directory: {workspace_root}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(msg) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._workspace_root = resolved | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @property | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def workspace_root(self) -> Path: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """The resolved workspace root directory.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return self._workspace_root | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def validate(self, path: str) -> Path: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Resolve *path* against workspace root and validate containment. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path: A relative or absolute path string from the user. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The resolved ``Path`` guaranteed to be within the workspace. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ValueError: If the resolved path escapes the workspace. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Reject absolute paths outright — agents must use relative paths. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if PurePosixPath(path).is_absolute() or PureWindowsPath(path).is_absolute(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning(TOOL_FS_PATH_VIOLATION, user_path=path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msg = f"Absolute paths not allowed: {path}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(msg) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # NOTE: There is an inherent TOCTOU gap between this validation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # and the actual file operation (which runs in asyncio.to_thread). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # A concurrent process could swap in a symlink between validation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # and use. Full mitigation requires OS-level sandboxing (e.g. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # openat2 RESOLVE_BENEATH on Linux). User-space path validation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # is a best-effort defence-in-depth layer. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resolved = (self._workspace_root / path).resolve() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except OSError as exc: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TOOL_FS_PATH_VIOLATION, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user_path=path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| error=str(exc), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msg = f"Invalid path: {path} ({exc})" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(msg) from exc | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not resolved.is_relative_to(self._workspace_root): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TOOL_FS_PATH_VIOLATION, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user_path=path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msg = f"Path escapes workspace: {path}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(msg) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return resolved | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def validate_parent_exists(self, path: str) -> Path: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Like ``validate`` but also checks that the parent directory exists. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path: A relative or absolute path string from the user. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The resolved ``Path`` within the workspace whose parent exists. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ValueError: If the resolved path escapes the workspace or | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| the parent directory does not exist. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resolved = self.validate(path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not resolved.parent.exists(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TOOL_FS_PARENT_NOT_FOUND, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path=path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msg = f"Parent directory does not exist: {path}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(msg) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+115
to
+125
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ValueError: If the resolved path escapes the workspace or | |
| the parent directory does not exist. | |
| """ | |
| resolved = self.validate(path) | |
| if not resolved.parent.exists(): | |
| logger.warning( | |
| TOOL_FS_PARENT_NOT_FOUND, | |
| path=path, | |
| ) | |
| msg = f"Parent directory does not exist: {path}" | |
| raise ValueError(msg) | |
| ValueError: If the resolved path escapes the workspace, the | |
| parent directory does not exist, or the parent is not a | |
| directory. | |
| """ | |
| resolved = self.validate(path) | |
| parent = resolved.parent | |
| if not parent.exists(): | |
| logger.warning( | |
| TOOL_FS_PARENT_NOT_FOUND, | |
| path=path, | |
| ) | |
| msg = f"Parent directory does not exist: {path}" | |
| raise ValueError(msg) | |
| if not parent.is_dir(): | |
| logger.warning( | |
| TOOL_FS_PARENT_NOT_FOUND, | |
| path=path, | |
| ) | |
| msg = f"Parent path is not a directory: {path}" | |
| raise ValueError(msg) |
Uh oh!
There was an error while loading. Please reload this page.