Request approval for external paths and show token speed

This commit is contained in:
2026-05-21 23:29:33 +08:00
parent 234a3c957d
commit 9e5d5bbd5b
10 changed files with 244 additions and 20 deletions
+19 -5
View File
@@ -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,
},
)
+21 -5
View File
@@ -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()
+14 -3
View File
@@ -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():
+18 -2
View File
@@ -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
+23 -4
View File
@@ -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: