diff --git a/docs/configuration.md b/docs/configuration.md index 3d4be10d..0c94d010 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1995,8 +1995,8 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets] | `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.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | -| `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. | -| `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. | +| `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read; paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory. | +| `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored. | | `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. | | `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. | | `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. | diff --git a/nanobot/agent/tools/sandbox.py b/nanobot/agent/tools/sandbox.py index 0d602abf..4c5cbf85 100644 --- a/nanobot/agent/tools/sandbox.py +++ b/nanobot/agent/tools/sandbox.py @@ -13,7 +13,11 @@ from typing import Iterable from nanobot.config.paths import get_media_dir -def _normalize_bind_paths(paths: Iterable[str] | None) -> list[str]: +def _normalize_bind_paths( + paths: Iterable[str] | None, + *, + workspace: Path | None = None, +) -> list[str]: out: list[str] = [] seen: set[str] = set() for raw in paths or []: @@ -23,7 +27,17 @@ def _normalize_bind_paths(paths: Iterable[str] | None) -> list[str]: path = Path(os.path.expandvars(value)).expanduser() if not path.is_absolute(): continue - resolved = str(path.resolve(strict=False)) + resolved_path = path.resolve(strict=False) + if workspace is not None: + try: + workspace.relative_to(resolved_path) + except ValueError: + pass + else: + # A later bind of the workspace or one of its parents could + # cover the tmpfs that hides the config directory. + continue + resolved = str(resolved_path) if resolved in seen: continue seen.add(resolved) @@ -79,9 +93,9 @@ def _bwrap( "--bind", str(ws), str(ws), "--ro-bind-try", str(media), str(media), # read-only access to media ] - for p in _normalize_bind_paths(sandbox_ro_binds): + for p in _normalize_bind_paths(sandbox_ro_binds, workspace=ws): args += ["--ro-bind-try", p, p] - for p in _normalize_bind_paths(sandbox_rw_binds): + for p in _normalize_bind_paths(sandbox_rw_binds, workspace=ws): args += ["--bind-try", p, p] args += ["--chdir", sandbox_cwd, "--", "sh", "-c", command] return shlex.join(args) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index be3e5605..71b369c8 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -809,7 +809,9 @@ class ExecTool(Tool): if workspace_root else None ) - sandbox_bind_roots = self._active_sandbox_bind_roots() + sandbox_bind_roots = self._active_sandbox_bind_roots( + resolved_workspace or cwd_path + ) for raw in self._extract_absolute_paths(cmd): try: @@ -960,7 +962,17 @@ class ExecTool(Tool): roots.append(resolved) return roots - def _active_sandbox_bind_roots(self) -> list[Path]: + def _active_sandbox_bind_roots( + self, + workspace_root: Path | None = None, + ) -> list[Path]: if self.sandbox != "bwrap" or _IS_WINDOWS: return [] - return [*self.sandbox_ro_binds, *self.sandbox_rw_binds] + roots = [*self.sandbox_ro_binds, *self.sandbox_rw_binds] + if workspace_root is None: + return roots + return [ + root + for root in roots + if not is_path_within(workspace_root, root) + ] diff --git a/tests/tools/test_exec_security.py b/tests/tools/test_exec_security.py index bc6d8a95..508cd1e6 100644 --- a/tests/tools/test_exec_security.py +++ b/tests/tools/test_exec_security.py @@ -387,6 +387,30 @@ def test_exec_bind_roots_do_not_widen_guard_without_bwrap(tmp_path): assert "path outside working dir" in blocked +def test_exec_bwrap_bind_parent_does_not_widen_workspace_guard(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + secret = tmp_path / "config.json" + secret.write_text("secret") + monkeypatch.setattr("nanobot.agent.tools.shell._IS_WINDOWS", False) + tool = ExecTool( + working_dir=str(workspace), + restrict_to_workspace=True, + sandbox="bwrap", + sandbox_ro_binds=[str(tmp_path)], + ) + + blocked = tool._guard_command( + f"cat {secret}", + str(workspace), + restrict_to_workspace=True, + workspace_root=str(workspace), + ) + + assert blocked is not None + assert "path outside working dir" in blocked + + # --- format command blocking ----------------------------------------------- diff --git a/tests/tools/test_sandbox.py b/tests/tools/test_sandbox.py index 75271351..c83ef422 100644 --- a/tests/tools/test_sandbox.py +++ b/tests/tools/test_sandbox.py @@ -201,6 +201,27 @@ class TestBwrapBackend: assert "relative/bin" not in tokens assert "relative/cache" not in tokens + def test_custom_workspace_parent_binds_are_ignored(self, tmp_path): + ws = tmp_path / "private" / "project" + parent = ws.parent.resolve(strict=False) + + result = wrap_command( + "bwrap", + "cat ../config.json", + str(ws), + str(ws), + sandbox_ro_binds=[str(parent)], + sandbox_rw_binds=[str(parent)], + ) + tokens = _parse(result) + + ro_try_indices = [i for i, token in enumerate(tokens) if token == "--ro-bind-try"] + ro_try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in ro_try_indices} + bind_try_indices = [i for i, token in enumerate(tokens) if token == "--bind-try"] + bind_try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in bind_try_indices} + assert (str(parent), str(parent)) not in ro_try_pairs + assert (str(parent), str(parent)) not in bind_try_pairs + class TestUnknownBackend: def test_raises_value_error(self, tmp_path):