Skip to content

Commit 181c148

Browse files
committed
fix(tools): keep read-only roots out of write paths
1 parent 7ff8e02 commit 181c148

3 files changed

Lines changed: 49 additions & 7 deletions

File tree

nanobot/agent/tools/filesystem.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
class _FsTool(Tool):
2424
"""Shared base for filesystem tools — common init and path resolution."""
2525

26+
_allow_extra_allowed_dirs = False
27+
_allow_media_dir = False
28+
2629
def __init__(
2730
self,
2831
workspace: Path | None = None,
@@ -57,7 +60,7 @@ def create(cls, ctx: Any) -> Tool:
5760
)
5861
sandbox_restricts = bool(ctx.config.exec.sandbox)
5962
allowed_dir = Path(ctx.workspace) if restrict else None
60-
extra_read = [BUILTIN_SKILLS_DIR]
63+
extra_read = [BUILTIN_SKILLS_DIR] if cls._allow_extra_allowed_dirs else None
6164
return cls(
6265
workspace=Path(ctx.workspace),
6366
allowed_dir=allowed_dir,
@@ -83,7 +86,8 @@ def _resolve(self, path: str) -> Path:
8386
path,
8487
access.project_path,
8588
access.allowed_root,
86-
self._extra_allowed_dirs,
89+
self._extra_allowed_dirs if self._allow_extra_allowed_dirs else None,
90+
include_media_dir=self._allow_media_dir,
8791
)
8892

8993
def _display_workspace(self) -> Path | None:
@@ -162,6 +166,8 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
162166
class ReadFileTool(_FsTool):
163167
"""Read file contents with optional line-based pagination."""
164168
_scopes = {"core", "subagent", "memory"}
169+
_allow_extra_allowed_dirs = True
170+
_allow_media_dir = True
165171

166172
_MAX_CHARS = 128_000
167173
_DEFAULT_LIMIT = 2000
@@ -951,6 +957,8 @@ def _not_found_msg(old_text: str, content: str, path: str) -> str:
951957
class ListDirTool(_FsTool):
952958
"""List directory contents with optional recursion."""
953959
_scopes = {"core", "subagent"}
960+
_allow_extra_allowed_dirs = True
961+
_allow_media_dir = True
954962

955963
_DEFAULT_MAX = 200
956964
_IGNORE_DIRS = {

nanobot/agent/tools/path_utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,16 @@ def resolve_workspace_path(
1919
workspace: Path | None = None,
2020
allowed_dir: Path | None = None,
2121
extra_allowed_dirs: list[Path] | None = None,
22+
*,
23+
include_media_dir: bool = True,
2224
) -> Path:
2325
"""Resolve path against workspace and enforce allowed directory containment."""
24-
extra_roots = [get_media_dir(), *(extra_allowed_dirs or [])] if allowed_dir else None
26+
extra_roots = None
27+
if allowed_dir:
28+
extra_roots = [
29+
*([get_media_dir()] if include_media_dir else []),
30+
*(extra_allowed_dirs or []),
31+
]
2532
return resolve_allowed_path(
2633
path,
2734
workspace=workspace,

tests/tools/test_filesystem_tools.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,13 +342,36 @@ async def test_extra_dirs_does_not_widen_write(self, tmp_path):
342342

343343
workspace = tmp_path / "ws"
344344
workspace.mkdir()
345-
outside = tmp_path / "outside"
346-
outside.mkdir()
345+
extra = tmp_path / "extra"
346+
extra.mkdir()
347+
348+
tool = WriteFileTool(
349+
workspace=workspace,
350+
allowed_dir=workspace,
351+
extra_allowed_dirs=[extra],
352+
)
353+
target = extra / "hack.txt"
354+
result = await tool.execute(path=str(target), content="pwned")
355+
assert "Error" in result
356+
assert "outside" in result.lower()
357+
assert not target.exists()
358+
359+
@pytest.mark.asyncio
360+
async def test_media_dir_does_not_widen_write(self, tmp_path, monkeypatch):
361+
from nanobot.agent.tools.filesystem import WriteFileTool
362+
363+
workspace = tmp_path / "ws"
364+
workspace.mkdir()
365+
media_dir = tmp_path / "media"
366+
media_dir.mkdir()
367+
monkeypatch.setattr("nanobot.agent.tools.path_utils.get_media_dir", lambda: media_dir)
347368

348369
tool = WriteFileTool(workspace=workspace, allowed_dir=workspace)
349-
result = await tool.execute(path=str(outside / "hack.txt"), content="pwned")
370+
target = media_dir / "generated.txt"
371+
result = await tool.execute(path=str(target), content="pwned")
350372
assert "Error" in result
351373
assert "outside" in result.lower()
374+
assert not target.exists()
352375

353376
@pytest.mark.asyncio
354377
async def test_read_still_blocked_for_unrelated_dir(self, tmp_path):
@@ -398,7 +421,11 @@ async def test_edit_blocked_in_extra_dir(self, tmp_path):
398421
skill_file.parent.mkdir()
399422
skill_file.write_text("# Weather\nOriginal content.")
400423

401-
tool = EditFileTool(workspace=workspace, allowed_dir=workspace)
424+
tool = EditFileTool(
425+
workspace=workspace,
426+
allowed_dir=workspace,
427+
extra_allowed_dirs=[skills_dir],
428+
)
402429
result = await tool.execute(
403430
path=str(skill_file),
404431
old_text="Original content.",

0 commit comments

Comments
 (0)