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.
This commit is contained in:
chengyongru
2026-06-18 00:03:26 +08:00
committed by Xubin Ren
parent 51f2dae855
commit 42ce294665
3 changed files with 66 additions and 6 deletions
+1 -3
View File
@@ -101,15 +101,13 @@ class _FsTool(Tool):
return current_file_states(self._fallback_file_states) return current_file_states(self._fallback_file_states)
def _effective_allowed_root(self, access_allowed_root: Path | None) -> Path | None: 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: if self._allowed_dir is None or self._workspace is None:
return access_allowed_root return access_allowed_root
try: try:
allowed_dir = Path(self._allowed_dir).expanduser().resolve(strict=False) allowed_dir = Path(self._allowed_dir).expanduser().resolve(strict=False)
workspace = Path(self._workspace).expanduser().resolve(strict=False) workspace = Path(self._workspace).expanduser().resolve(strict=False)
except (OSError, RuntimeError, TypeError, ValueError): 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: if allowed_dir == workspace:
return access_allowed_root return access_allowed_root
return allowed_dir return allowed_dir
+40
View File
@@ -4,6 +4,11 @@ import pytest
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.providers.base import LLMResponse 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 from nanobot.utils.prompt_templates import render_template
@@ -173,6 +178,41 @@ class TestDreamTools:
assert "Successfully wrote" in result assert "Successfully wrote" in result
assert target.read_text(encoding="utf-8").startswith("---\nname: demo") 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 @pytest.mark.asyncio
async def test_dream_cannot_modify_memory_internal_files(self, store): async def test_dream_cannot_modify_memory_internal_files(self, store):
tools = store.build_dream_tools() tools = store.build_dream_tools()
+25 -3
View File
@@ -6,11 +6,13 @@ from types import SimpleNamespace
import pytest import pytest
from nanobot.agent.tools.cli_apps import CliAppsTool 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.image_generation import ImageGenerationError, ImageGenerationTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool 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 ( from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY, WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScopeError, WorkspaceScopeError,
@@ -20,8 +22,6 @@ from nanobot.security.workspace_access import (
validate_workspace_scope_payload, validate_workspace_scope_payload,
workspace_scope_from_metadata, workspace_scope_from_metadata,
) )
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig
PNG_BYTES = ( PNG_BYTES = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" 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) 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 @pytest.mark.asyncio
async def test_exec_tool_uses_scope_project_as_default_cwd(tmp_path: Path) -> None: async def test_exec_tool_uses_scope_project_as_default_cwd(tmp_path: Path) -> None:
project = tmp_path / "project" project = tmp_path / "project"