Fix file tool sandbox: prompts/ was unreachable and workspace paths were doubly nested
The workspace root matched every relative path, so prompts/ was never reached, and paths like workspace/notes.txt landed at workspace/workspace/notes.txt. Fix: _resolve_sandboxed_path now treats allowed_roots[0] as the resolution base (project root) and allowed_roots[1:] as permitted subdirectories. _get_allowed_roots returns (project_root, workspace_dir, prompts_root). Paths are now correctly routed: workspace/notes.txt -> workspace/notes.txt, prompts/ai/parts/x.md -> prompts/ai/parts/x.md. app/ and other project dirs remain blocked. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -358,7 +358,9 @@ class AIService:
|
|||||||
return FILE_TOOL_SCHEMAS + [PYTHON_EXEC_SCHEMA] + mcp_schemas
|
return FILE_TOOL_SCHEMAS + [PYTHON_EXEC_SCHEMA] + mcp_schemas
|
||||||
|
|
||||||
def _get_allowed_roots(self) -> tuple[Path, ...]:
|
def _get_allowed_roots(self) -> tuple[Path, ...]:
|
||||||
return (self.config.workspace_dir, self.config.prompt.root)
|
# [0] = base for resolution (project root); [1:] = permitted subdirs
|
||||||
|
project_root = self.config.workspace_dir.parent
|
||||||
|
return (project_root, self.config.workspace_dir, self.config.prompt.root)
|
||||||
|
|
||||||
def _build_exec_context(self, ctx: "CommandContext") -> dict[str, Any]:
|
def _build_exec_context(self, ctx: "CommandContext") -> dict[str, Any]:
|
||||||
actor = ctx.actor
|
actor = ctx.actor
|
||||||
|
|||||||
@@ -5,16 +5,23 @@ from typing import Any, Awaitable, Callable
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_sandboxed_path(raw_path: str, allowed_roots: tuple[Path, ...]) -> Path:
|
def _resolve_sandboxed_path(raw_path: str, allowed_roots: tuple[Path, ...]) -> Path:
|
||||||
|
"""
|
||||||
|
allowed_roots[0] — base directory used for resolving raw_path.
|
||||||
|
allowed_roots[1:] — permitted subdirectories; resolved path must fall inside one of them.
|
||||||
|
When only one root is given it acts as both base and permitted directory.
|
||||||
|
"""
|
||||||
if raw_path.startswith("/"):
|
if raw_path.startswith("/"):
|
||||||
raise ValueError(f"Absolute paths are not allowed: {raw_path!r}")
|
raise ValueError(f"Absolute paths are not allowed: {raw_path!r}")
|
||||||
for root in allowed_roots:
|
base = allowed_roots[0]
|
||||||
resolved_root = root.resolve()
|
allowed_dirs = allowed_roots[1:] if len(allowed_roots) > 1 else allowed_roots
|
||||||
candidate = (root / raw_path).resolve()
|
candidate = (base / raw_path).resolve()
|
||||||
if candidate == resolved_root or str(candidate).startswith(str(resolved_root) + "/"):
|
for allowed in allowed_dirs:
|
||||||
|
resolved = allowed.resolve()
|
||||||
|
if candidate == resolved or str(candidate).startswith(str(resolved) + "/"):
|
||||||
return candidate
|
return candidate
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Path {raw_path!r} is outside the allowed directories "
|
f"Path {raw_path!r} is outside the allowed directories "
|
||||||
f"({', '.join(str(r) for r in allowed_roots)})"
|
f"({', '.join(a.name for a in allowed_dirs)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,9 +139,10 @@ async def tool_list_files(arguments: dict, allowed_roots: tuple[Path, ...]) -> s
|
|||||||
if raw_path:
|
if raw_path:
|
||||||
base = _resolve_sandboxed_path(raw_path, allowed_roots)
|
base = _resolve_sandboxed_path(raw_path, allowed_roots)
|
||||||
else:
|
else:
|
||||||
# list all allowed roots combined
|
# list all allowed dirs (skip the base root at index 0)
|
||||||
|
dirs_to_list = allowed_roots[1:] if len(allowed_roots) > 1 else allowed_roots
|
||||||
entries: list[str] = []
|
entries: list[str] = []
|
||||||
for root in allowed_roots:
|
for root in dirs_to_list:
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
continue
|
continue
|
||||||
resolved_root = root.resolve()
|
resolved_root = root.resolve()
|
||||||
|
|||||||
Reference in New Issue
Block a user