feat(exec): allow extra bwrap bind roots
This commit is contained in:
@@ -8,6 +8,7 @@ platform-specific binaries (all subprocess calls are mocked).
|
||||
import asyncio
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -473,6 +474,35 @@ class TestSandboxPlatform:
|
||||
spawned_cmd = mock_spawn.call_args[0][0]
|
||||
assert "bwrap" in spawned_cmd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bwrap_receives_configured_bind_roots(self):
|
||||
"""Configured bwrap bind roots should be forwarded to the sandbox wrapper."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"sandboxed", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch("nanobot.agent.tools.shell.wrap_command", return_value="bwrap -- sh -c ls") as mock_wrap,
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(
|
||||
sandbox="bwrap",
|
||||
working_dir="/workspace",
|
||||
sandbox_ro_binds=["/home/user/.local/bin"],
|
||||
sandbox_rw_binds=["/home/user/.cache/uv"],
|
||||
)
|
||||
await tool.execute(command="ls")
|
||||
|
||||
kwargs = mock_wrap.call_args.kwargs
|
||||
assert kwargs["sandbox_ro_binds"] == [
|
||||
str(Path("/home/user/.local/bin").resolve(strict=False))
|
||||
]
|
||||
assert kwargs["sandbox_rw_binds"] == [
|
||||
str(Path("/home/user/.cache/uv").resolve(strict=False))
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# end-to-end (mocked subprocess, full execute path)
|
||||
|
||||
@@ -314,6 +314,77 @@ def test_exec_still_blocks_real_outside_path_via_redirect(tmp_path):
|
||||
assert "path outside working dir" in blocked
|
||||
|
||||
|
||||
def test_exec_allows_absolute_path_inside_bwrap_ro_bind(tmp_path):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
tool_bin = tmp_path / "home" / ".local" / "bin"
|
||||
tool_bin.mkdir(parents=True)
|
||||
uv = tool_bin / "uv"
|
||||
uv.write_text("#!/bin/sh\n")
|
||||
tool = ExecTool(
|
||||
working_dir=str(workspace),
|
||||
restrict_to_workspace=True,
|
||||
sandbox="bwrap",
|
||||
sandbox_ro_binds=[str(tool_bin)],
|
||||
)
|
||||
|
||||
blocked = tool._guard_command(
|
||||
f"{uv} --version",
|
||||
str(workspace),
|
||||
restrict_to_workspace=True,
|
||||
workspace_root=str(workspace),
|
||||
)
|
||||
|
||||
assert blocked is None
|
||||
|
||||
|
||||
def test_exec_allows_absolute_path_inside_bwrap_rw_bind(tmp_path):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
cache_dir = tmp_path / "cache"
|
||||
cache_dir.mkdir()
|
||||
tool = ExecTool(
|
||||
working_dir=str(workspace),
|
||||
restrict_to_workspace=True,
|
||||
sandbox="bwrap",
|
||||
sandbox_rw_binds=[str(cache_dir)],
|
||||
)
|
||||
|
||||
blocked = tool._guard_command(
|
||||
f"touch {cache_dir / 'stamp'}",
|
||||
str(workspace),
|
||||
restrict_to_workspace=True,
|
||||
workspace_root=str(workspace),
|
||||
)
|
||||
|
||||
assert blocked is None
|
||||
|
||||
|
||||
def test_exec_bind_roots_do_not_widen_guard_without_bwrap(tmp_path):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
tool_bin = tmp_path / "home" / ".local" / "bin"
|
||||
tool_bin.mkdir(parents=True)
|
||||
uv = tool_bin / "uv"
|
||||
uv.write_text("#!/bin/sh\n")
|
||||
tool = ExecTool(
|
||||
working_dir=str(workspace),
|
||||
restrict_to_workspace=True,
|
||||
sandbox="",
|
||||
sandbox_ro_binds=[str(tool_bin)],
|
||||
)
|
||||
|
||||
blocked = tool._guard_command(
|
||||
f"{uv} --version",
|
||||
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 -----------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -150,6 +150,57 @@ class TestBwrapBackend:
|
||||
try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in try_indices}
|
||||
assert (str(fake_media), str(fake_media)) in try_pairs
|
||||
|
||||
def test_custom_read_only_binds_use_ro_bind_try(self, tmp_path):
|
||||
ws = tmp_path / "project"
|
||||
tool_bin = tmp_path / "home" / ".local" / "bin"
|
||||
|
||||
result = wrap_command(
|
||||
"bwrap",
|
||||
"uv --version",
|
||||
str(ws),
|
||||
str(ws),
|
||||
sandbox_ro_binds=[str(tool_bin)],
|
||||
)
|
||||
tokens = _parse(result)
|
||||
|
||||
try_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind-try"]
|
||||
try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in try_indices}
|
||||
assert (str(tool_bin.resolve(strict=False)), str(tool_bin.resolve(strict=False))) in try_pairs
|
||||
|
||||
def test_custom_read_write_binds_use_bind_try(self, tmp_path):
|
||||
ws = tmp_path / "project"
|
||||
cache_dir = tmp_path / "cache"
|
||||
|
||||
result = wrap_command(
|
||||
"bwrap",
|
||||
"touch cache/file",
|
||||
str(ws),
|
||||
str(ws),
|
||||
sandbox_rw_binds=[str(cache_dir)],
|
||||
)
|
||||
tokens = _parse(result)
|
||||
|
||||
bind_try_indices = [i for i, t in enumerate(tokens) if t == "--bind-try"]
|
||||
bind_try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in bind_try_indices}
|
||||
resolved = str(cache_dir.resolve(strict=False))
|
||||
assert (resolved, resolved) in bind_try_pairs
|
||||
|
||||
def test_custom_relative_bind_paths_are_ignored(self, tmp_path):
|
||||
ws = tmp_path / "project"
|
||||
|
||||
result = wrap_command(
|
||||
"bwrap",
|
||||
"ls",
|
||||
str(ws),
|
||||
str(ws),
|
||||
sandbox_ro_binds=["relative/bin"],
|
||||
sandbox_rw_binds=["relative/cache"],
|
||||
)
|
||||
tokens = _parse(result)
|
||||
|
||||
assert "relative/bin" not in tokens
|
||||
assert "relative/cache" not in tokens
|
||||
|
||||
|
||||
class TestUnknownBackend:
|
||||
def test_raises_value_error(self, tmp_path):
|
||||
|
||||
@@ -714,6 +714,22 @@ def test_exec_config_timeout_uncapped_and_zero() -> None:
|
||||
ExecToolConfig(timeout=-1)
|
||||
|
||||
|
||||
def test_exec_config_accepts_bwrap_bind_aliases() -> None:
|
||||
cfg = ExecToolConfig.model_validate(
|
||||
{
|
||||
"sandboxRoBinds": ["/home/user/.local/bin"],
|
||||
"sandboxRwBinds": ["/home/user/.cache/uv"],
|
||||
}
|
||||
)
|
||||
|
||||
dumped = cfg.model_dump(by_alias=True)
|
||||
|
||||
assert cfg.sandbox_ro_binds == ["/home/user/.local/bin"]
|
||||
assert cfg.sandbox_rw_binds == ["/home/user/.cache/uv"]
|
||||
assert dumped["sandboxRoBinds"] == ["/home/user/.local/bin"]
|
||||
assert dumped["sandboxRwBinds"] == ["/home/user/.cache/uv"]
|
||||
|
||||
|
||||
def test_resolve_timeout_config_uncapped_and_unlimited() -> None:
|
||||
"""Config timeout drives the hard timeout uncapped; 0 means no limit (#3595)."""
|
||||
assert ExecTool(timeout=3600)._resolve_timeout(None) == 3600
|
||||
|
||||
Reference in New Issue
Block a user