Clarify filesystem workspace write policy
This commit is contained in:
@@ -509,19 +509,19 @@ class MemoryStore:
|
||||
tools.register(ReadFileTool(
|
||||
workspace=workspace,
|
||||
allowed_dir=workspace,
|
||||
extra_allowed_dirs=extra_read,
|
||||
extra_read_allowed_dirs=extra_read,
|
||||
file_states=file_states,
|
||||
))
|
||||
tools.register(EditFileTool(
|
||||
workspace=workspace,
|
||||
allowed_dir=self.memory_dir,
|
||||
extra_allowed_dirs=editable_roots,
|
||||
allowed_dir=self.memory_file,
|
||||
extra_write_allowed_dirs=editable_roots,
|
||||
file_states=file_states,
|
||||
))
|
||||
tools.register(ApplyPatchTool(
|
||||
workspace=workspace,
|
||||
allowed_dir=self.memory_dir,
|
||||
extra_allowed_dirs=editable_roots,
|
||||
allowed_dir=self.memory_file,
|
||||
extra_write_allowed_dirs=editable_roots,
|
||||
file_states=file_states,
|
||||
))
|
||||
tools.register(WriteFileTool(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -31,19 +30,12 @@ class _PatchError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _validate_relative_path(path: str) -> str:
|
||||
def _validate_patch_path(path: str) -> str:
|
||||
normalized = path.strip()
|
||||
if not normalized:
|
||||
raise _PatchError("patch path cannot be empty")
|
||||
if "\0" in normalized:
|
||||
raise _PatchError(f"patch path contains a null byte: {path!r}")
|
||||
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
|
||||
raise _PatchError(f"patch path must be relative: {path}")
|
||||
if any(part == ".." for part in re.split(r"[\\/]+", normalized)):
|
||||
raise _PatchError(f"patch path must not contain '..': {path}")
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -98,7 +90,10 @@ def _format_summary(summary: _PatchSummary) -> str:
|
||||
tool_parameters_schema(
|
||||
edits=ArraySchema(
|
||||
items=ObjectSchema(
|
||||
path=StringSchema("Relative path to the file to edit."),
|
||||
path=StringSchema(
|
||||
"Path to the file to edit. Relative paths resolve against the "
|
||||
"workspace; absolute paths and '..' obey the workspace access policy."
|
||||
),
|
||||
action=StringSchema(
|
||||
"Operation type: replace or add.",
|
||||
enum=["replace", "add"],
|
||||
@@ -138,7 +133,8 @@ class ApplyPatchTool(_FsTool):
|
||||
"Default tool for code edits. Supports multi-file changes in a single call. "
|
||||
"Provide a list of structured edits, each specifying a file path, action "
|
||||
"(replace/add), and the exact text to change. "
|
||||
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
|
||||
"Paths are resolved by the current workspace access policy. "
|
||||
"Set dry_run=true to validate and preview without writing files. "
|
||||
"Use edit_file only for small exact replacements on a single file."
|
||||
)
|
||||
|
||||
@@ -161,11 +157,11 @@ class ApplyPatchTool(_FsTool):
|
||||
raw_path = edit.get("path")
|
||||
if not isinstance(raw_path, str):
|
||||
raise _PatchError("path required for edit")
|
||||
path = _validate_relative_path(raw_path)
|
||||
path = _validate_patch_path(raw_path)
|
||||
action = edit.get("action")
|
||||
if not isinstance(action, str):
|
||||
raise _PatchError(f"action required for edit: {path}")
|
||||
source = self._resolve(path)
|
||||
source = self._resolve_write(path)
|
||||
|
||||
if action == "add":
|
||||
new_text = edit.get("new_text")
|
||||
|
||||
@@ -45,13 +45,22 @@ class _FsTool(Tool):
|
||||
workspace: Path | None = None,
|
||||
allowed_dir: Path | None = None,
|
||||
extra_allowed_dirs: list[Path] | None = None,
|
||||
extra_read_allowed_dirs: list[Path] | None = None,
|
||||
extra_write_allowed_dirs: list[Path] | None = None,
|
||||
file_states: FileStates | None = None,
|
||||
restrict_to_workspace: bool | None = None,
|
||||
sandbox_restricts_workspace: bool = False,
|
||||
):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
self._extra_allowed_dirs = extra_allowed_dirs
|
||||
# Legacy alias: extra_allowed_dirs is read-only. Write-capable tools
|
||||
# must opt in via extra_write_allowed_dirs.
|
||||
self._extra_read_allowed_dirs = [
|
||||
*(extra_allowed_dirs or []),
|
||||
*(extra_read_allowed_dirs or []),
|
||||
]
|
||||
self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or [])
|
||||
self._extra_allowed_dirs = self._extra_read_allowed_dirs
|
||||
self._restrict_to_workspace = (
|
||||
bool(restrict_to_workspace)
|
||||
if restrict_to_workspace is not None
|
||||
@@ -78,7 +87,7 @@ class _FsTool(Tool):
|
||||
return cls(
|
||||
workspace=Path(ctx.workspace),
|
||||
allowed_dir=allowed_dir,
|
||||
extra_allowed_dirs=extra_read,
|
||||
extra_read_allowed_dirs=extra_read,
|
||||
file_states=ctx.file_state_store,
|
||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||
sandbox_restricts_workspace=sandbox_restricts,
|
||||
@@ -90,7 +99,27 @@ class _FsTool(Tool):
|
||||
return self._explicit_file_states
|
||||
return current_file_states(self._fallback_file_states)
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
def _effective_allowed_root(self, access_allowed_root: Path | None) -> Path | None:
|
||||
if access_allowed_root is None:
|
||||
return None
|
||||
if self._allowed_dir is None or self._workspace is None:
|
||||
return access_allowed_root
|
||||
try:
|
||||
allowed_dir = Path(self._allowed_dir).expanduser().resolve(strict=False)
|
||||
workspace = Path(self._workspace).expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError, TypeError, ValueError):
|
||||
return access_allowed_root
|
||||
if allowed_dir == workspace:
|
||||
return access_allowed_root
|
||||
return allowed_dir
|
||||
|
||||
def _resolve_with_extra(
|
||||
self,
|
||||
path: str,
|
||||
extra_allowed_dirs: list[Path] | None,
|
||||
*,
|
||||
include_media_dir: bool,
|
||||
) -> Path:
|
||||
access = current_tool_workspace(
|
||||
self._workspace,
|
||||
restrict_to_workspace=self._restrict_to_workspace,
|
||||
@@ -99,10 +128,28 @@ class _FsTool(Tool):
|
||||
return resolve_workspace_path(
|
||||
path,
|
||||
access.project_path,
|
||||
access.allowed_root,
|
||||
self._extra_allowed_dirs,
|
||||
self._effective_allowed_root(access.allowed_root),
|
||||
extra_allowed_dirs,
|
||||
include_media_dir=include_media_dir,
|
||||
)
|
||||
|
||||
def _resolve_read(self, path: str) -> Path:
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
self._extra_read_allowed_dirs,
|
||||
include_media_dir=True,
|
||||
)
|
||||
|
||||
def _resolve_write(self, path: str) -> Path:
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
self._extra_write_allowed_dirs,
|
||||
include_media_dir=False,
|
||||
)
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
return self._resolve_read(path)
|
||||
|
||||
def _display_workspace(self) -> Path | None:
|
||||
return current_tool_workspace(self._workspace).project_path
|
||||
|
||||
@@ -224,7 +271,7 @@ class ReadFileTool(_FsTool):
|
||||
if _is_blocked_device(path):
|
||||
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
|
||||
|
||||
fp = self._resolve(path)
|
||||
fp = self._resolve_read(path)
|
||||
if _is_blocked_device(fp):
|
||||
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
|
||||
if not fp.exists():
|
||||
@@ -436,7 +483,7 @@ class WriteFileTool(_FsTool):
|
||||
raise ValueError("Unknown path")
|
||||
if content is None:
|
||||
raise ValueError("Unknown content")
|
||||
fp = self._resolve(path)
|
||||
fp = self._resolve_write(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content, encoding="utf-8")
|
||||
self._file_states.record_write(fp)
|
||||
@@ -786,7 +833,7 @@ class EditFileTool(_FsTool):
|
||||
if expected_replacements is not None and expected_replacements < 1:
|
||||
return "Error: expected_replacements must be >= 1."
|
||||
|
||||
fp = self._resolve(path)
|
||||
fp = self._resolve_write(path)
|
||||
|
||||
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||
if not fp.exists():
|
||||
|
||||
@@ -19,9 +19,11 @@ def resolve_workspace_path(
|
||||
workspace: Path | None = None,
|
||||
allowed_dir: Path | None = None,
|
||||
extra_allowed_dirs: list[Path] | None = None,
|
||||
include_media_dir: bool = True,
|
||||
) -> Path:
|
||||
"""Resolve path against workspace and enforce allowed directory containment."""
|
||||
extra_roots = [get_media_dir(), *(extra_allowed_dirs or [])] if allowed_dir else None
|
||||
media_roots = [get_media_dir()] if include_media_dir else []
|
||||
extra_roots = [*media_roots, *(extra_allowed_dirs or [])] if allowed_dir else None
|
||||
return resolve_allowed_path(
|
||||
path,
|
||||
workspace=workspace,
|
||||
|
||||
Reference in New Issue
Block a user