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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user