mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
Clarify filesystem workspace write policy
This commit is contained in:
parent
fc635377bc
commit
732992df4f
@ -4,11 +4,13 @@ The agent operates with significant power (file system, shell, web). The followi
|
|||||||
|
|
||||||
## Workspace Restriction
|
## 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
|
## SSRF Protection
|
||||||
|
|
||||||
@ -22,6 +24,6 @@ HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs
|
|||||||
|
|
||||||
## Shell Sandbox
|
## 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`.
|
**Rule**: If adding a new sandbox backend, implement `_wrap_<name>(command, workspace, cwd) -> str` and register it in `_BACKENDS`.
|
||||||
|
|||||||
@ -1720,14 +1720,14 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
> [!TIP]
|
> [!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`.
|
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 |
|
| 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.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 `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.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.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.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. |
|
| `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. |
|
||||||
|
|||||||
@ -509,19 +509,19 @@ class MemoryStore:
|
|||||||
tools.register(ReadFileTool(
|
tools.register(ReadFileTool(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
allowed_dir=workspace,
|
allowed_dir=workspace,
|
||||||
extra_allowed_dirs=extra_read,
|
extra_read_allowed_dirs=extra_read,
|
||||||
file_states=file_states,
|
file_states=file_states,
|
||||||
))
|
))
|
||||||
tools.register(EditFileTool(
|
tools.register(EditFileTool(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
allowed_dir=self.memory_dir,
|
allowed_dir=self.memory_file,
|
||||||
extra_allowed_dirs=editable_roots,
|
extra_write_allowed_dirs=editable_roots,
|
||||||
file_states=file_states,
|
file_states=file_states,
|
||||||
))
|
))
|
||||||
tools.register(ApplyPatchTool(
|
tools.register(ApplyPatchTool(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
allowed_dir=self.memory_dir,
|
allowed_dir=self.memory_file,
|
||||||
extra_allowed_dirs=editable_roots,
|
extra_write_allowed_dirs=editable_roots,
|
||||||
file_states=file_states,
|
file_states=file_states,
|
||||||
))
|
))
|
||||||
tools.register(WriteFileTool(
|
tools.register(WriteFileTool(
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -31,19 +30,12 @@ class _PatchError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
def _validate_patch_path(path: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _validate_relative_path(path: str) -> str:
|
|
||||||
normalized = path.strip()
|
normalized = path.strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise _PatchError("patch path cannot be empty")
|
raise _PatchError("patch path cannot be empty")
|
||||||
if "\0" in normalized:
|
if "\0" in normalized:
|
||||||
raise _PatchError(f"patch path contains a null byte: {path!r}")
|
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
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@ -98,7 +90,10 @@ def _format_summary(summary: _PatchSummary) -> str:
|
|||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
edits=ArraySchema(
|
edits=ArraySchema(
|
||||||
items=ObjectSchema(
|
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(
|
action=StringSchema(
|
||||||
"Operation type: replace or add.",
|
"Operation type: replace or add.",
|
||||||
enum=["replace", "add"],
|
enum=["replace", "add"],
|
||||||
@ -138,7 +133,8 @@ class ApplyPatchTool(_FsTool):
|
|||||||
"Default tool for code edits. Supports multi-file changes in a single call. "
|
"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 "
|
"Provide a list of structured edits, each specifying a file path, action "
|
||||||
"(replace/add), and the exact text to change. "
|
"(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."
|
"Use edit_file only for small exact replacements on a single file."
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -161,11 +157,11 @@ class ApplyPatchTool(_FsTool):
|
|||||||
raw_path = edit.get("path")
|
raw_path = edit.get("path")
|
||||||
if not isinstance(raw_path, str):
|
if not isinstance(raw_path, str):
|
||||||
raise _PatchError("path required for edit")
|
raise _PatchError("path required for edit")
|
||||||
path = _validate_relative_path(raw_path)
|
path = _validate_patch_path(raw_path)
|
||||||
action = edit.get("action")
|
action = edit.get("action")
|
||||||
if not isinstance(action, str):
|
if not isinstance(action, str):
|
||||||
raise _PatchError(f"action required for edit: {path}")
|
raise _PatchError(f"action required for edit: {path}")
|
||||||
source = self._resolve(path)
|
source = self._resolve_write(path)
|
||||||
|
|
||||||
if action == "add":
|
if action == "add":
|
||||||
new_text = edit.get("new_text")
|
new_text = edit.get("new_text")
|
||||||
|
|||||||
@ -45,13 +45,22 @@ class _FsTool(Tool):
|
|||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
allowed_dir: Path | None = None,
|
allowed_dir: Path | None = None,
|
||||||
extra_allowed_dirs: list[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,
|
file_states: FileStates | None = None,
|
||||||
restrict_to_workspace: bool | None = None,
|
restrict_to_workspace: bool | None = None,
|
||||||
sandbox_restricts_workspace: bool = False,
|
sandbox_restricts_workspace: bool = False,
|
||||||
):
|
):
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
self._allowed_dir = allowed_dir
|
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 = (
|
self._restrict_to_workspace = (
|
||||||
bool(restrict_to_workspace)
|
bool(restrict_to_workspace)
|
||||||
if restrict_to_workspace is not None
|
if restrict_to_workspace is not None
|
||||||
@ -78,7 +87,7 @@ class _FsTool(Tool):
|
|||||||
return cls(
|
return cls(
|
||||||
workspace=Path(ctx.workspace),
|
workspace=Path(ctx.workspace),
|
||||||
allowed_dir=allowed_dir,
|
allowed_dir=allowed_dir,
|
||||||
extra_allowed_dirs=extra_read,
|
extra_read_allowed_dirs=extra_read,
|
||||||
file_states=ctx.file_state_store,
|
file_states=ctx.file_state_store,
|
||||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
sandbox_restricts_workspace=sandbox_restricts,
|
sandbox_restricts_workspace=sandbox_restricts,
|
||||||
@ -90,7 +99,27 @@ class _FsTool(Tool):
|
|||||||
return self._explicit_file_states
|
return self._explicit_file_states
|
||||||
return current_file_states(self._fallback_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(
|
access = current_tool_workspace(
|
||||||
self._workspace,
|
self._workspace,
|
||||||
restrict_to_workspace=self._restrict_to_workspace,
|
restrict_to_workspace=self._restrict_to_workspace,
|
||||||
@ -99,10 +128,28 @@ class _FsTool(Tool):
|
|||||||
return resolve_workspace_path(
|
return resolve_workspace_path(
|
||||||
path,
|
path,
|
||||||
access.project_path,
|
access.project_path,
|
||||||
access.allowed_root,
|
self._effective_allowed_root(access.allowed_root),
|
||||||
self._extra_allowed_dirs,
|
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:
|
def _display_workspace(self) -> Path | None:
|
||||||
return current_tool_workspace(self._workspace).project_path
|
return current_tool_workspace(self._workspace).project_path
|
||||||
|
|
||||||
@ -224,7 +271,7 @@ class ReadFileTool(_FsTool):
|
|||||||
if _is_blocked_device(path):
|
if _is_blocked_device(path):
|
||||||
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
|
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):
|
if _is_blocked_device(fp):
|
||||||
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
|
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
|
||||||
if not fp.exists():
|
if not fp.exists():
|
||||||
@ -436,7 +483,7 @@ class WriteFileTool(_FsTool):
|
|||||||
raise ValueError("Unknown path")
|
raise ValueError("Unknown path")
|
||||||
if content is None:
|
if content is None:
|
||||||
raise ValueError("Unknown content")
|
raise ValueError("Unknown content")
|
||||||
fp = self._resolve(path)
|
fp = self._resolve_write(path)
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fp.write_text(content, encoding="utf-8")
|
fp.write_text(content, encoding="utf-8")
|
||||||
self._file_states.record_write(fp)
|
self._file_states.record_write(fp)
|
||||||
@ -786,7 +833,7 @@ class EditFileTool(_FsTool):
|
|||||||
if expected_replacements is not None and expected_replacements < 1:
|
if expected_replacements is not None and expected_replacements < 1:
|
||||||
return "Error: expected_replacements must be >= 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
|
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||||
if not fp.exists():
|
if not fp.exists():
|
||||||
|
|||||||
@ -19,9 +19,11 @@ def resolve_workspace_path(
|
|||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
allowed_dir: Path | None = None,
|
allowed_dir: Path | None = None,
|
||||||
extra_allowed_dirs: list[Path] | None = None,
|
extra_allowed_dirs: list[Path] | None = None,
|
||||||
|
include_media_dir: bool = True,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Resolve path against workspace and enforce allowed directory containment."""
|
"""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(
|
return resolve_allowed_path(
|
||||||
path,
|
path,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
|
|||||||
@ -126,6 +126,86 @@ class TestDreamTools:
|
|||||||
"write_file",
|
"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:
|
class TestEphemeralDirect:
|
||||||
"""Tests for the ephemeral flag that skips history.jsonl writes for Dream."""
|
"""Tests for the ephemeral flag that skips history.jsonl writes for Dream."""
|
||||||
@ -149,7 +229,9 @@ class TestEphemeralDirect:
|
|||||||
provider.supports_tools = True
|
provider.supports_tools = True
|
||||||
provider.generation = MagicMock(max_tokens=4096)
|
provider.generation = MagicMock(max_tokens=4096)
|
||||||
provider.chat_with_retry = AsyncMock(
|
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 (
|
with (
|
||||||
@ -181,13 +263,9 @@ class TestEphemeralDirect:
|
|||||||
mock_archive.assert_not_called()
|
mock_archive.assert_not_called()
|
||||||
|
|
||||||
async def test_non_ephemeral_runs_normally(self, tmp_path, _make_loop):
|
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
|
loop, store = _make_loop
|
||||||
response = await loop.process_direct("test", session_key="cli:normal")
|
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()
|
|
||||||
|
|
||||||
async def test_ephemeral_sets_ctx_flag(self, tmp_path, _make_loop):
|
async def test_ephemeral_sets_ctx_flag(self, tmp_path, _make_loop):
|
||||||
"""Verify that ephemeral=True is forwarded to TurnContext."""
|
"""Verify that ephemeral=True is forwarded to TurnContext."""
|
||||||
|
|||||||
@ -256,14 +256,56 @@ def test_apply_patch_edits_dry_run_validates_without_writing(tmp_path):
|
|||||||
assert not (tmp_path / "added.txt").exists()
|
assert not (tmp_path / "added.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
|
def test_apply_patch_edits_allows_absolute_and_parent_paths_when_unrestricted(tmp_path):
|
||||||
tool = ApplyPatchTool(workspace=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(
|
absolute = asyncio.run(
|
||||||
tool.execute(
|
tool.execute(
|
||||||
edits=[
|
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",
|
"action": "add",
|
||||||
"new_text": "nope",
|
"new_text": "nope",
|
||||||
}
|
}
|
||||||
@ -274,29 +316,7 @@ def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
|
|||||||
tool.execute(
|
tool.execute(
|
||||||
edits=[
|
edits=[
|
||||||
{
|
{
|
||||||
"path": "../owned.txt",
|
"path": "../outside/parent.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",
|
|
||||||
"action": "add",
|
"action": "add",
|
||||||
"new_text": "nope",
|
"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 "outside allowed directory" in absolute
|
||||||
assert "must not contain '..'" in parent
|
assert "outside allowed directory" in parent
|
||||||
assert "must be relative" in windows_absolute
|
assert not (outside / "absolute.txt").exists()
|
||||||
assert "must not contain '..'" in windows_parent
|
assert not (outside / "parent.txt").exists()
|
||||||
assert not (tmp_path.parent / "owned.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):
|
def test_apply_patch_edits_reports_invalid_edit_shapes(tmp_path):
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from nanobot.agent.tools.filesystem import (
|
|||||||
EditFileTool,
|
EditFileTool,
|
||||||
ListDirTool,
|
ListDirTool,
|
||||||
ReadFileTool,
|
ReadFileTool,
|
||||||
|
WriteFileTool,
|
||||||
_find_match,
|
_find_match,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -283,7 +284,7 @@ class TestListDirTool:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Workspace restriction + extra_allowed_dirs
|
# Workspace restriction + extra read/write allowed dirs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestWorkspaceRestriction:
|
class TestWorkspaceRestriction:
|
||||||
@ -314,7 +315,7 @@ class TestWorkspaceRestriction:
|
|||||||
|
|
||||||
tool = ReadFileTool(
|
tool = ReadFileTool(
|
||||||
workspace=workspace, allowed_dir=workspace,
|
workspace=workspace, allowed_dir=workspace,
|
||||||
extra_allowed_dirs=[skills_dir],
|
extra_read_allowed_dirs=[skills_dir],
|
||||||
)
|
)
|
||||||
result = await tool.execute(path=str(skill_file))
|
result = await tool.execute(path=str(skill_file))
|
||||||
assert "Test Skill" in result
|
assert "Test Skill" in result
|
||||||
@ -337,18 +338,52 @@ class TestWorkspaceRestriction:
|
|||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extra_dirs_does_not_widen_write(self, tmp_path):
|
async def test_write_blocked_in_media_dir_by_default(self, tmp_path, monkeypatch):
|
||||||
from nanobot.agent.tools.filesystem import WriteFileTool
|
|
||||||
|
|
||||||
workspace = tmp_path / "ws"
|
workspace = tmp_path / "ws"
|
||||||
workspace.mkdir()
|
workspace.mkdir()
|
||||||
outside = tmp_path / "outside"
|
media_dir = tmp_path / "media"
|
||||||
outside.mkdir()
|
media_dir.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.agent.tools.path_utils.get_media_dir", lambda: media_dir)
|
||||||
|
|
||||||
tool = WriteFileTool(workspace=workspace, allowed_dir=workspace)
|
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 "Error" in result
|
||||||
assert "outside" in result.lower()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_read_still_blocked_for_unrelated_dir(self, tmp_path):
|
async def test_read_still_blocked_for_unrelated_dir(self, tmp_path):
|
||||||
@ -398,7 +433,11 @@ class TestWorkspaceRestriction:
|
|||||||
skill_file.parent.mkdir()
|
skill_file.parent.mkdir()
|
||||||
skill_file.write_text("# Weather\nOriginal content.")
|
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(
|
result = await tool.execute(
|
||||||
path=str(skill_file),
|
path=str(skill_file),
|
||||||
old_text="Original content.",
|
old_text="Original content.",
|
||||||
@ -407,3 +446,25 @@ class TestWorkspaceRestriction:
|
|||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert "outside" in result.lower()
|
assert "outside" in result.lower()
|
||||||
assert skill_file.read_text() == "# Weather\nOriginal content."
|
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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user