"""Shared plumbing for the three filesystem tools.
Read, write and AI actions live in separate `Tool` classes so the model picks
an intent first. Path resolution, sandbox checks and the common error wording
are identical, so they live here.
"""
from __future__ import annotations
from pathlib import Path
from navi.config import settings
from .._internal.base import (
Tool,
ToolContext,
ToolResult,
current_user_id,
current_user_role,
)
from .helpers import _check_path
class FilesystemBaseTool(Tool):
"""Base class: sandbox-aware path resolution and shared error results."""
def __init__(self, ai_helper=None) -> None:
self._ai = ai_helper
def _resolve_path(self, raw_path: str, ctx: ToolContext | None) -> Path | None:
user_id = ctx.user_id if ctx else current_user_id.get(None)
role = ctx.user_role if ctx else current_user_role.get()
return _check_path(raw_path, user_id, role, cwd=ctx.cwd if ctx else None)
def _path_result(self, raw_path: str, path: Path | None) -> ToolResult | None:
"""Return the standard missing/denied result, or None when the path is usable."""
if path is not None:
return None
if not raw_path or not raw_path.strip():
return ToolResult(
success=False,
output="'path' is required. Pass the file or directory path.",
error="missing_path",
)
return ToolResult(
success=False,
output=(
f"Access denied: '{raw_path}' is outside allowed paths "
f"({settings.fs_allowed_paths}). "
"Set FS_ALLOWED_PATHS=* in .env to allow all paths."
),
error="access_denied",
)
def _redirect(self, action: str, target: str) -> ToolResult:
return ToolResult(
success=False,
output=(
f"Action '{action}' belongs to '{target}', not this tool. "
f"Call {target} with action={action!r}."
),
error="wrong_tool",
)