fix(exec): preserve bwrap workspace masking
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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 -----------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user