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:
2026-05-18 14:24:35 +02:00
parent f04ab527b8
commit 42472dc91c
2 changed files with 18 additions and 8 deletions

View File

@@ -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

View File

@@ -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()