From 42ce2946653324d870a93846921857d8c24455ba Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 10 Jun 2026 17:59:36 +0800 Subject: [PATCH] fix: preserve internal tool write scopes Maintainer edit: keep capability-specific allowed_dir boundaries active even when the outer workspace scope is full access, and cover Dream plus ordinary full-access filesystem behavior. --- nanobot/agent/tools/filesystem.py | 4 +-- tests/agent/test_dream.py | 40 +++++++++++++++++++++++++++++ tests/agent/test_workspace_scope.py | 28 +++++++++++++++++--- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index c1984ea1d..cb1db6bab 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -101,15 +101,13 @@ class _FsTool(Tool): return current_file_states(self._fallback_file_states) 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 + return access_allowed_root if access_allowed_root is not None else self._allowed_dir if allowed_dir == workspace: return access_allowed_root return allowed_dir diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index 0f0b0c8af..c105eec17 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -4,6 +4,11 @@ import pytest from nanobot.agent.memory import MemoryStore from nanobot.providers.base import LLMResponse +from nanobot.security.workspace_access import ( + bind_workspace_scope, + default_workspace_scope, + reset_workspace_scope, +) from nanobot.utils.prompt_templates import render_template @@ -173,6 +178,41 @@ class TestDreamTools: assert "Successfully wrote" in result assert target.read_text(encoding="utf-8").startswith("---\nname: demo") + @pytest.mark.asyncio + async def test_dream_tools_keep_internal_write_scope_under_full_access(self, store): + tools = store.build_dream_tools() + scope = default_workspace_scope(store.workspace, restrict_to_workspace=False) + outside = store.workspace.parent / f"{store.workspace.name}-outside" + outside.mkdir() + outside_target = outside / "escape.txt" + skill_target = store.workspace / "skills" / "scoped" / "SKILL.md" + + token = bind_workspace_scope(scope) + try: + outside_result = await tools.execute( + "write_file", + {"path": str(outside_target), "content": "owned"}, + ) + skill_result = await tools.execute( + "apply_patch", + { + "edits": [ + { + "path": "skills/scoped/SKILL.md", + "action": "add", + "new_text": "---\nname: scoped\n---\n", + } + ] + }, + ) + finally: + reset_workspace_scope(token) + + assert "outside allowed directory" in outside_result + assert not outside_target.exists() + assert "Patch applied" in skill_result + assert skill_target.read_text(encoding="utf-8").startswith("---\nname: scoped") + @pytest.mark.asyncio async def test_dream_cannot_modify_memory_internal_files(self, store): tools = store.build_dream_tools() diff --git a/tests/agent/test_workspace_scope.py b/tests/agent/test_workspace_scope.py index 9b2cff25e..4b482b840 100644 --- a/tests/agent/test_workspace_scope.py +++ b/tests/agent/test_workspace_scope.py @@ -6,11 +6,13 @@ from types import SimpleNamespace import pytest from nanobot.agent.tools.cli_apps import CliAppsTool -from nanobot.agent.tools.filesystem import ReadFileTool +from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.spawn import SpawnTool +from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig +from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig from nanobot.security.workspace_access import ( WORKSPACE_SCOPE_METADATA_KEY, WorkspaceScopeError, @@ -20,8 +22,6 @@ from nanobot.security.workspace_access import ( validate_workspace_scope_payload, workspace_scope_from_metadata, ) -from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig -from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig PNG_BYTES = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" @@ -116,6 +116,28 @@ async def test_filesystem_tool_uses_current_restricted_workspace_scope(tmp_path: reset_workspace_scope(token) +@pytest.mark.asyncio +async def test_filesystem_write_tool_full_scope_allows_outside_project(tmp_path: Path) -> None: + project = tmp_path / "project" + outside = tmp_path / "outside" + project.mkdir() + outside.mkdir() + tool = WriteFileTool(workspace=tmp_path, allowed_dir=tmp_path, restrict_to_workspace=True) + scope = validate_workspace_scope_payload( + {"project_path": str(project), "access_mode": "full"}, + default_workspace=tmp_path, + default_restrict_to_workspace=True, + ) + token = bind_workspace_scope(scope) + try: + result = await tool.execute(path=str(outside / "outside.txt"), content="ok") + finally: + reset_workspace_scope(token) + + assert "Successfully wrote" in result + assert (outside / "outside.txt").read_text(encoding="utf-8") == "ok" + + @pytest.mark.asyncio async def test_exec_tool_uses_scope_project_as_default_cwd(tmp_path: Path) -> None: project = tmp_path / "project"