51 lines
1.9 KiB
Python
51 lines
1.9 KiB
Python
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from duck_core.tools.base import ToolResult
|
|
from duck_core.tools.paths import WorkspacePathError, candidate_path, resolve_workspace_path
|
|
|
|
|
|
class FileReadTool:
|
|
name = "file_read"
|
|
risk_level = "low"
|
|
|
|
def __init__(self, workspace: str, max_bytes: int = 1_000_000):
|
|
self.workspace = workspace
|
|
self.max_bytes = max_bytes
|
|
|
|
async def run(self, args: dict[str, Any]) -> ToolResult:
|
|
raw_path = str(args.get("path", ""))
|
|
approved = bool(args.get("_approved"))
|
|
try:
|
|
path = resolve_workspace_path(self.workspace, raw_path, allow_outside=approved)
|
|
except WorkspacePathError as exc:
|
|
path = candidate_path(self.workspace, raw_path)
|
|
return self._approval_required(raw_path, path, str(exc))
|
|
if self._requires_approval(path) and not approved:
|
|
return self._approval_required(raw_path, path, f"Reading {raw_path} requires explicit approval")
|
|
if not path.is_file():
|
|
return ToolResult(ok=False, error=f"File not found: {raw_path}")
|
|
if path.stat().st_size > self.max_bytes:
|
|
return ToolResult(ok=False, error=f"File exceeds max size: {self.max_bytes}")
|
|
return ToolResult(
|
|
ok=True,
|
|
output=path.read_text(errors="replace"),
|
|
metadata={"path": str(path), "bytes_read": path.stat().st_size},
|
|
)
|
|
|
|
def _requires_approval(self, path: Path) -> bool:
|
|
parts = set(path.parts)
|
|
return path.name == ".env" or ".ssh" in parts or str(path) == "/etc/shadow"
|
|
|
|
def _approval_required(self, raw_path: str, path: Path, reason: str) -> ToolResult:
|
|
return ToolResult(
|
|
ok=False,
|
|
error=reason,
|
|
metadata={
|
|
"path": str(path),
|
|
"requires_approval": True,
|
|
"risk_level": self.risk_level,
|
|
"reason": reason,
|
|
},
|
|
)
|