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
|
||||
|
||||
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]:
|
||||
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:
|
||||
"""
|
||||
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("/"):
|
||||
raise ValueError(f"Absolute paths are not allowed: {raw_path!r}")
|
||||
for root in allowed_roots:
|
||||
resolved_root = root.resolve()
|
||||
candidate = (root / raw_path).resolve()
|
||||
if candidate == resolved_root or str(candidate).startswith(str(resolved_root) + "/"):
|
||||
base = allowed_roots[0]
|
||||
allowed_dirs = allowed_roots[1:] if len(allowed_roots) > 1 else allowed_roots
|
||||
candidate = (base / raw_path).resolve()
|
||||
for allowed in allowed_dirs:
|
||||
resolved = allowed.resolve()
|
||||
if candidate == resolved or str(candidate).startswith(str(resolved) + "/"):
|
||||
return candidate
|
||||
raise ValueError(
|
||||
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:
|
||||
base = _resolve_sandboxed_path(raw_path, allowed_roots)
|
||||
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] = []
|
||||
for root in allowed_roots:
|
||||
for root in dirs_to_list:
|
||||
if not root.exists():
|
||||
continue
|
||||
resolved_root = root.resolve()
|
||||
|
||||
Reference in New Issue
Block a user