Clarify filesystem workspace write policy

This commit is contained in:
chengyongru
2026-06-18 00:03:26 +08:00
committed by Xubin Ren
parent fc635377bc
commit 732992df4f
9 changed files with 371 additions and 81 deletions
+6 -4
View File
@@ -4,11 +4,13 @@ The agent operates with significant power (file system, shell, web). The followi
## Workspace Restriction
Filesystem tools (`read_file`, `write_file`, `edit_file`, `list_dir`) resolve paths through `_resolve_path` (`agent/tools/filesystem.py`), which enforces that the resolved path must lie under `allowed_dir` (typically the configured workspace), plus the media upload directory (`get_media_dir()`) and any `extra_allowed_dirs`.
Filesystem tools (`read_file`, `write_file`, `edit_file`, `list_dir`, `apply_patch`) resolve paths through the workspace path resolver (`agent/tools/filesystem.py` / `agent/tools/path_utils.py`), which enforces that the resolved path must lie under the active workspace when workspace restriction is enabled. The media upload directory is always an internal extra read root while restricted.
Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_workspace`: if enabled and `working_dir` is outside the workspace, the command is rejected before execution.
Additional filesystem roots must be capability-specific. `extra_allowed_dirs` is a legacy read-only alias. Use `extra_read_allowed_dirs` for read-only roots and `extra_write_allowed_dirs` only when a write-capable tool is intentionally allowed to modify an extra root.
**Rule**: Any new path-handling logic must go through `_resolve_path` or perform an equivalent `allowed_dir` check.
Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_workspace` as an application-level guard: if enabled and `working_dir` is outside the workspace, the command is rejected before execution, and command text is checked for obvious workspace escapes. This is not process-level isolation; use an exec sandbox backend for that.
**Rule**: Any new path-handling logic must go through the workspace path resolver or perform an equivalent containment check with explicit read/write capability semantics.
## SSRF Protection
@@ -22,6 +24,6 @@ HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs
## Shell Sandbox
`tools/sandbox.py` provides optional command wrapping. The only backend currently shipped is `bwrap` (bubblewrap), intended for containerized deployments. On Windows and bare-metal Linux without `bwrap`, commands run in the native shell with workspace restriction as the only guard.
`tools/sandbox.py` provides optional command wrapping. The only backend currently shipped is `bwrap` (bubblewrap), intended for containerized deployments. On Windows and bare-metal Linux without `bwrap`, commands run in the native shell with workspace restriction as an application-level guard only.
**Rule**: If adding a new sandbox backend, implement `_wrap_<name>(command, workspace, cwd) -> str` and register it in `_BACKENDS`.
+3 -3
View File
@@ -1720,14 +1720,14 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
## Security
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
> For production deployments, set both `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config. `restrictToWorkspace` enables nanobot's application-level workspace guards; `tools.exec.sandbox` provides process-level isolation for shell commands.
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
| Option | Default | Description |
|--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
| `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. |
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables workspace restriction for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
| `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. |
+5 -5
View File
@@ -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(
+9 -13
View File
@@ -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")
+55 -8
View File
@@ -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():
+3 -1
View File
@@ -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,
+85 -7
View File
@@ -126,6 +126,86 @@ class TestDreamTools:
"write_file",
}
@pytest.mark.asyncio
async def test_dream_can_edit_canonical_memory_files(self, store):
tools = store.build_dream_tools()
memory_result = await tools.execute(
"apply_patch",
{
"edits": [
{
"path": "memory/MEMORY.md",
"action": "replace",
"old_text": "Project X active",
"new_text": "Project Y active",
}
]
},
)
soul_result = await tools.execute(
"edit_file",
{
"path": "SOUL.md",
"old_text": "Helpful",
"new_text": "Precise",
},
)
assert "Patch applied" in memory_result
assert "Successfully edited" in soul_result
assert "Project Y active" in store.memory_file.read_text(encoding="utf-8")
assert "Precise" in store.soul_file.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_dream_can_write_workspace_skills(self, store):
tools = store.build_dream_tools()
target = store.workspace / "skills" / "demo" / "SKILL.md"
result = await tools.execute(
"write_file",
{
"path": "skills/demo/SKILL.md",
"content": "---\nname: demo\ndescription: Demo skill.\n---\n\nUse when needed.\n",
},
)
assert "Successfully wrote" in result
assert target.read_text(encoding="utf-8").startswith("---\nname: demo")
@pytest.mark.asyncio
async def test_dream_cannot_modify_memory_internal_files(self, store):
tools = store.build_dream_tools()
store.history_file.write_text("before\n", encoding="utf-8")
store._dream_cursor_file.write_text("1", encoding="utf-8")
history_result = await tools.execute(
"apply_patch",
{
"edits": [
{
"path": "memory/history.jsonl",
"action": "replace",
"old_text": "before",
"new_text": "after",
}
]
},
)
cursor_result = await tools.execute(
"edit_file",
{
"path": "memory/.dream_cursor",
"old_text": "1",
"new_text": "2",
},
)
assert "outside allowed directory" in history_result
assert "outside allowed directory" in cursor_result
assert store.history_file.read_text(encoding="utf-8") == "before\n"
assert store._dream_cursor_file.read_text(encoding="utf-8") == "1"
class TestEphemeralDirect:
"""Tests for the ephemeral flag that skips history.jsonl writes for Dream."""
@@ -149,7 +229,9 @@ class TestEphemeralDirect:
provider.supports_tools = True
provider.generation = MagicMock(max_tokens=4096)
provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="done", tool_calls=[], finish_reason="stop", usage={})
return_value=MagicMock(
content="done", finish_reason="stop", tool_calls=[], usage={},
)
)
with (
@@ -181,13 +263,9 @@ class TestEphemeralDirect:
mock_archive.assert_not_called()
async def test_non_ephemeral_runs_normally(self, tmp_path, _make_loop):
"""Without ephemeral, the normal path returns the model response."""
"""Without ephemeral, the normal path is untouched — no crash."""
loop, store = _make_loop
response = await loop.process_direct("test", session_key="cli:normal")
assert response is not None
assert response.content == "done"
loop.provider.chat_with_retry.assert_awaited()
await loop.process_direct("test", session_key="cli:normal")
async def test_ephemeral_sets_ctx_flag(self, tmp_path, _make_loop):
"""Verify that ephemeral=True is forwarded to TurnContext."""
+135 -31
View File
@@ -256,14 +256,56 @@ def test_apply_patch_edits_dry_run_validates_without_writing(tmp_path):
assert not (tmp_path / "added.txt").exists()
def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
def test_apply_patch_edits_allows_absolute_and_parent_paths_when_unrestricted(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
absolute_target = outside / "absolute.txt"
parent_target = outside / "parent.txt"
tool = ApplyPatchTool(workspace=workspace, restrict_to_workspace=False)
absolute = asyncio.run(
tool.execute(
edits=[
{
"path": "/tmp/owned.txt",
"path": str(absolute_target),
"action": "add",
"new_text": "absolute",
}
]
)
)
parent = asyncio.run(
tool.execute(
edits=[
{
"path": "../outside/parent.txt",
"action": "add",
"new_text": "parent",
}
]
)
)
assert "Patch applied" in absolute
assert "Patch applied" in parent
assert absolute_target.read_text() == "absolute\n"
assert parent_target.read_text() == "parent\n"
def test_apply_patch_edits_rejects_outside_paths_when_restricted(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
tool = ApplyPatchTool(workspace=workspace, allowed_dir=workspace)
absolute = asyncio.run(
tool.execute(
edits=[
{
"path": str(outside / "absolute.txt"),
"action": "add",
"new_text": "nope",
}
@@ -274,29 +316,7 @@ def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
tool.execute(
edits=[
{
"path": "../owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
windows_absolute = asyncio.run(
tool.execute(
edits=[
{
"path": r"C:\owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
windows_parent = asyncio.run(
tool.execute(
edits=[
{
"path": r"..\owned.txt",
"path": "../outside/parent.txt",
"action": "add",
"new_text": "nope",
}
@@ -304,11 +324,95 @@ def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
)
)
assert "must be relative" in absolute
assert "must not contain '..'" in parent
assert "must be relative" in windows_absolute
assert "must not contain '..'" in windows_parent
assert not (tmp_path.parent / "owned.txt").exists()
assert "outside allowed directory" in absolute
assert "outside allowed directory" in parent
assert not (outside / "absolute.txt").exists()
assert not (outside / "parent.txt").exists()
def test_apply_patch_legacy_extra_allowed_dirs_are_read_only(tmp_path):
workspace = tmp_path / "workspace"
skills = tmp_path / "skills"
workspace.mkdir()
skills.mkdir()
target = skills / "demo.md"
target.write_text("before\n", encoding="utf-8")
tool = ApplyPatchTool(
workspace=workspace,
allowed_dir=workspace,
extra_allowed_dirs=[skills],
)
result = asyncio.run(
tool.execute(
edits=[
{
"path": str(target),
"action": "replace",
"old_text": "before",
"new_text": "after",
}
]
)
)
assert "outside allowed directory" in result
assert target.read_text(encoding="utf-8") == "before\n"
def test_apply_patch_media_dir_is_read_only_by_default(tmp_path, monkeypatch):
workspace = tmp_path / "workspace"
media = tmp_path / "media"
workspace.mkdir()
media.mkdir()
target = media / "demo.md"
target.write_text("before\n", encoding="utf-8")
monkeypatch.setattr("nanobot.agent.tools.path_utils.get_media_dir", lambda: media)
tool = ApplyPatchTool(workspace=workspace, allowed_dir=workspace)
result = asyncio.run(
tool.execute(
edits=[
{
"path": str(target),
"action": "replace",
"old_text": "before",
"new_text": "after",
}
]
)
)
assert "outside allowed directory" in result
assert target.read_text(encoding="utf-8") == "before\n"
def test_apply_patch_allows_explicit_extra_write_allowed_dirs_when_restricted(tmp_path):
workspace = tmp_path / "workspace"
writable = tmp_path / "writable"
workspace.mkdir()
writable.mkdir()
target = writable / "allowed.txt"
tool = ApplyPatchTool(
workspace=workspace,
allowed_dir=workspace,
extra_write_allowed_dirs=[writable],
)
result = asyncio.run(
tool.execute(
edits=[
{
"path": str(target),
"action": "add",
"new_text": "allowed",
}
]
)
)
assert "Patch applied" in result
assert target.read_text(encoding="utf-8") == "allowed\n"
def test_apply_patch_edits_reports_invalid_edit_shapes(tmp_path):
+70 -9
View File
@@ -6,6 +6,7 @@ from nanobot.agent.tools.filesystem import (
EditFileTool,
ListDirTool,
ReadFileTool,
WriteFileTool,
_find_match,
)
@@ -283,7 +284,7 @@ class TestListDirTool:
# ---------------------------------------------------------------------------
# Workspace restriction + extra_allowed_dirs
# Workspace restriction + extra read/write allowed dirs
# ---------------------------------------------------------------------------
class TestWorkspaceRestriction:
@@ -314,7 +315,7 @@ class TestWorkspaceRestriction:
tool = ReadFileTool(
workspace=workspace, allowed_dir=workspace,
extra_allowed_dirs=[skills_dir],
extra_read_allowed_dirs=[skills_dir],
)
result = await tool.execute(path=str(skill_file))
assert "Test Skill" in result
@@ -337,18 +338,52 @@ class TestWorkspaceRestriction:
assert "Error" not in result
@pytest.mark.asyncio
async def test_extra_dirs_does_not_widen_write(self, tmp_path):
from nanobot.agent.tools.filesystem import WriteFileTool
async def test_write_blocked_in_media_dir_by_default(self, tmp_path, monkeypatch):
workspace = tmp_path / "ws"
workspace.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
media_dir = tmp_path / "media"
media_dir.mkdir()
monkeypatch.setattr("nanobot.agent.tools.path_utils.get_media_dir", lambda: media_dir)
tool = WriteFileTool(workspace=workspace, allowed_dir=workspace)
result = await tool.execute(path=str(outside / "hack.txt"), content="pwned")
result = await tool.execute(path=str(media_dir / "hack.txt"), content="pwned")
assert "Error" in result
assert "outside" in result.lower()
assert not (media_dir / "hack.txt").exists()
@pytest.mark.asyncio
async def test_legacy_extra_allowed_dirs_does_not_widen_write(self, tmp_path):
workspace = tmp_path / "ws"
workspace.mkdir()
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
tool = WriteFileTool(
workspace=workspace,
allowed_dir=workspace,
extra_allowed_dirs=[skills_dir],
)
result = await tool.execute(path=str(skills_dir / "hack.txt"), content="pwned")
assert "Error" in result
assert "outside" in result.lower()
assert not (skills_dir / "hack.txt").exists()
@pytest.mark.asyncio
async def test_write_allowed_with_extra_write_dir(self, tmp_path):
workspace = tmp_path / "ws"
workspace.mkdir()
writable = tmp_path / "writable"
writable.mkdir()
tool = WriteFileTool(
workspace=workspace,
allowed_dir=workspace,
extra_write_allowed_dirs=[writable],
)
result = await tool.execute(path=str(writable / "ok.txt"), content="allowed")
assert "Successfully wrote" in result
assert (writable / "ok.txt").read_text(encoding="utf-8") == "allowed"
@pytest.mark.asyncio
async def test_read_still_blocked_for_unrelated_dir(self, tmp_path):
@@ -398,7 +433,11 @@ class TestWorkspaceRestriction:
skill_file.parent.mkdir()
skill_file.write_text("# Weather\nOriginal content.")
tool = EditFileTool(workspace=workspace, allowed_dir=workspace)
tool = EditFileTool(
workspace=workspace,
allowed_dir=workspace,
extra_allowed_dirs=[skills_dir],
)
result = await tool.execute(
path=str(skill_file),
old_text="Original content.",
@@ -407,3 +446,25 @@ class TestWorkspaceRestriction:
assert "Error" in result
assert "outside" in result.lower()
assert skill_file.read_text() == "# Weather\nOriginal content."
@pytest.mark.asyncio
async def test_edit_allowed_with_extra_write_dir(self, tmp_path):
workspace = tmp_path / "ws"
workspace.mkdir()
writable = tmp_path / "writable"
writable.mkdir()
target = writable / "note.txt"
target.write_text("before\n", encoding="utf-8")
tool = EditFileTool(
workspace=workspace,
allowed_dir=workspace,
extra_write_allowed_dirs=[writable],
)
result = await tool.execute(
path=str(target),
old_text="before",
new_text="after",
)
assert "Successfully edited" in result
assert target.read_text(encoding="utf-8") == "after\n"