Request approval for external paths and show token speed
This commit is contained in:
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
from duck_core.tools.paths import WorkspacePathError, candidate_path, resolve_workspace_path
|
||||
|
||||
|
||||
class FileReadTool:
|
||||
@@ -15,12 +15,14 @@ class FileReadTool:
|
||||
|
||||
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)
|
||||
path = resolve_workspace_path(self.workspace, raw_path, allow_outside=approved)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if self._requires_approval(path):
|
||||
return ToolResult(ok=False, error=f"Reading {raw_path} requires explicit approval")
|
||||
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:
|
||||
@@ -34,3 +36,15 @@ class FileReadTool:
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
from duck_core.tools.paths import WorkspacePathError, candidate_path, resolve_workspace_path
|
||||
|
||||
|
||||
class FileWriteTool:
|
||||
@@ -15,15 +15,31 @@ class FileWriteTool:
|
||||
raw_path = str(args.get("path", ""))
|
||||
content = str(args.get("content", ""))
|
||||
overwrite = bool(args.get("overwrite", False))
|
||||
approved = bool(args.get("_approved"))
|
||||
try:
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
path = resolve_workspace_path(self.workspace, raw_path, allow_outside=approved)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if path.exists() and not overwrite:
|
||||
path = candidate_path(self.workspace, raw_path)
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error=f"{exc}. Writing outside workspace requires approval.",
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"requires_approval": True,
|
||||
"risk_level": self.risk_level,
|
||||
"reason": "Path is outside workspace",
|
||||
},
|
||||
)
|
||||
if path.exists() and not overwrite and not approved:
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error="Refusing to overwrite existing file without overwrite=true or approval",
|
||||
metadata={"path": str(path)},
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"requires_approval": True,
|
||||
"risk_level": self.risk_level,
|
||||
"reason": "File exists and overwrite was not requested",
|
||||
},
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existed = path.exists()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
from duck_core.tools.paths import WorkspacePathError, candidate_path, resolve_workspace_path
|
||||
|
||||
|
||||
class ListDirTool:
|
||||
@@ -14,10 +14,21 @@ class ListDirTool:
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
raw_path = str(args.get("path") or ".")
|
||||
approved = bool(args.get("_approved"))
|
||||
try:
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
path = resolve_workspace_path(self.workspace, raw_path, allow_outside=approved)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
path = candidate_path(self.workspace, raw_path)
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error=f"{exc}. Access outside workspace requires approval.",
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"requires_approval": True,
|
||||
"risk_level": self.risk_level,
|
||||
"reason": "Path is outside workspace",
|
||||
},
|
||||
)
|
||||
if not path.exists():
|
||||
return ToolResult(ok=False, error=f"Directory not found: {raw_path}")
|
||||
if not path.is_dir():
|
||||
|
||||
@@ -5,9 +5,25 @@ class WorkspacePathError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_workspace_path(workspace: str, relative_path: str) -> Path:
|
||||
def candidate_path(workspace: str, requested_path: str) -> Path:
|
||||
root = Path(workspace).resolve()
|
||||
path = Path(requested_path).expanduser()
|
||||
if path.is_absolute():
|
||||
return path.resolve()
|
||||
return (root / path).resolve()
|
||||
|
||||
|
||||
def is_inside_workspace(workspace: str, path: Path) -> bool:
|
||||
root = Path(workspace).resolve()
|
||||
path = (root / relative_path).resolve()
|
||||
if root != path and root not in path.parents:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_workspace_path(
|
||||
workspace: str, relative_path: str, allow_outside: bool = False
|
||||
) -> Path:
|
||||
path = candidate_path(workspace, relative_path)
|
||||
if not allow_outside and not is_inside_workspace(workspace, path):
|
||||
raise WorkspacePathError(f"Path escapes workspace: {relative_path}")
|
||||
return path
|
||||
|
||||
@@ -2,7 +2,12 @@ from fnmatch import fnmatch
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
from duck_core.tools.paths import (
|
||||
WorkspacePathError,
|
||||
candidate_path,
|
||||
is_inside_workspace,
|
||||
resolve_workspace_path,
|
||||
)
|
||||
|
||||
|
||||
class SearchFilesTool:
|
||||
@@ -22,11 +27,22 @@ class SearchFilesTool:
|
||||
max_matches = min(int(args.get("max_matches") or self.max_matches), self.max_matches)
|
||||
if not query:
|
||||
return ToolResult(ok=False, error="Search query is required")
|
||||
approved = bool(args.get("_approved"))
|
||||
try:
|
||||
root = resolve_workspace_path(self.workspace, ".")
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
path = resolve_workspace_path(self.workspace, raw_path, allow_outside=approved)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
path = candidate_path(self.workspace, raw_path)
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error=f"{exc}. Searching outside workspace requires approval.",
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"requires_approval": True,
|
||||
"risk_level": self.risk_level,
|
||||
"reason": "Path is outside workspace",
|
||||
},
|
||||
)
|
||||
if not path.exists():
|
||||
return ToolResult(ok=False, error=f"Search path not found: {raw_path}")
|
||||
|
||||
@@ -39,7 +55,10 @@ class SearchFilesTool:
|
||||
break
|
||||
if not candidate.is_file() or ".git" in candidate.parts:
|
||||
continue
|
||||
relative = candidate.relative_to(root).as_posix()
|
||||
if is_inside_workspace(self.workspace, candidate):
|
||||
relative = candidate.relative_to(root).as_posix()
|
||||
else:
|
||||
relative = str(candidate)
|
||||
if not fnmatch(candidate.name, pattern) and not fnmatch(relative, pattern):
|
||||
continue
|
||||
if candidate.stat().st_size > self.max_file_bytes:
|
||||
|
||||
Reference in New Issue
Block a user