nanobot/tests/tools/test_exec_platform.py
chengyongru d3689d143c fix(agent): prevent safety guard false positives and streamed message drop
Three independent fixes for issues exposed by PR #3493:

1. shell.py: allow /dev/* paths in workspace guard
   Commands like `rm file.txt 2>/dev/null` were blocked because
   _extract_absolute_paths captured /dev/null as a path outside
   the workspace. Allow /dev like media_path is already allowed.

2. shell.py: remove | from home_paths regex prefix
   Loki query operator `|~` was misinterpreted as pipe + home
   directory, causing false workspace violation errors.

3. loop.py: change _streamed from blacklist to whitelist
   stop_reason "tool_error" was not in the exclusion set
   {"ask_user", "error"}, so _streamed=True was set on fatal
   errors. channel manager then skipped channel.send() because
   it assumed the content was already streamed — but it never
   was. Whitelist to only {"stop", "end_turn", "max_tokens"}.

Also fixes a pre-existing Windows bug in _spawn where
create_subprocess_exec + list2cmdline breaks commands with
paths containing spaces (e.g. D:\Program Files\python.exe).

Closes: #3599, #3605
2026-05-04 01:25:52 +08:00

289 lines
10 KiB
Python

"""Tests for cross-platform shell execution.
Verifies that ExecTool selects the correct shell, environment, path-append
strategy, and sandbox behaviour per platform — without actually running
platform-specific binaries (all subprocess calls are mocked).
"""
import sys
from unittest.mock import AsyncMock, patch
import pytest
from nanobot.agent.tools.shell import ExecTool
_WINDOWS_ENV_KEYS = {
"APPDATA", "LOCALAPPDATA", "ProgramData",
"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432",
}
# ---------------------------------------------------------------------------
# _build_env
# ---------------------------------------------------------------------------
class TestBuildEnvUnix:
def test_expected_keys(self):
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
env = ExecTool()._build_env()
expected = {"HOME", "LANG", "TERM"}
assert expected <= set(env)
if sys.platform != "win32":
assert set(env) == expected
def test_home_from_environ(self, monkeypatch):
monkeypatch.setenv("HOME", "/Users/dev")
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
env = ExecTool()._build_env()
assert env["HOME"] == "/Users/dev"
def test_secrets_excluded(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
monkeypatch.setenv("NANOBOT_TOKEN", "tok-secret")
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
env = ExecTool()._build_env()
assert "OPENAI_API_KEY" not in env
assert "NANOBOT_TOKEN" not in env
for v in env.values():
assert "secret" not in v.lower()
class TestBuildEnvWindows:
_EXPECTED_KEYS = {
"SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE",
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH",
*_WINDOWS_ENV_KEYS,
}
def test_expected_keys(self):
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
env = ExecTool()._build_env()
assert set(env) == self._EXPECTED_KEYS
def test_secrets_excluded(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
monkeypatch.setenv("NANOBOT_TOKEN", "tok-secret")
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
env = ExecTool()._build_env()
assert "OPENAI_API_KEY" not in env
assert "NANOBOT_TOKEN" not in env
for v in env.values():
assert "secret" not in v.lower()
def test_path_has_sensible_default(self):
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch.dict("os.environ", {}, clear=True),
):
env = ExecTool()._build_env()
assert "system32" in env["PATH"].lower()
def test_systemroot_forwarded(self, monkeypatch):
monkeypatch.setenv("SYSTEMROOT", r"D:\Windows")
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
env = ExecTool()._build_env()
assert env["SYSTEMROOT"] == r"D:\Windows"
# ---------------------------------------------------------------------------
# _spawn
# ---------------------------------------------------------------------------
class TestSpawnUnix:
@pytest.mark.asyncio
async def test_uses_bash(self):
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
):
mock_exec.return_value = AsyncMock()
await ExecTool._spawn("echo hi", "/tmp", {"HOME": "/tmp"})
args = mock_exec.call_args[0]
assert "bash" in args[0]
assert "-l" in args
assert "-c" in args
assert "echo hi" in args
class TestSpawnWindows:
@pytest.mark.asyncio
async def test_uses_create_subprocess_shell(self):
env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""}
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
):
mock_shell.return_value = AsyncMock()
await ExecTool._spawn("dir", r"C:\work", env)
args = mock_shell.call_args[0]
assert "dir" in args
@pytest.mark.asyncio
async def test_passes_cwd_and_env(self):
env = {"PATH": "/usr/bin"}
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
):
mock_shell.return_value = AsyncMock()
await ExecTool._spawn("echo hi", r"C:\work", env)
kwargs = mock_shell.call_args[1]
assert kwargs["cwd"] == r"C:\work"
assert kwargs["env"] == env
# ---------------------------------------------------------------------------
# path_append
# ---------------------------------------------------------------------------
class TestPathAppendPlatform:
@pytest.mark.asyncio
async def test_unix_uses_env_var_in_fixed_export(self):
"""On Unix, path_append must not be interpolated into shell source."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"ok", b"")
mock_proc.returncode = 0
captured_cmd = None
captured_env = {}
async def capture_spawn(cmd, cwd, env):
nonlocal captured_cmd
captured_cmd = cmd
captured_env.update(env)
return mock_proc
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
patch("nanobot.agent.tools.shell.os.pathsep", ":"),
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool(path_append="/opt/bin; echo INJECTED")
await tool.execute(command="ls")
assert captured_cmd == 'export PATH="$PATH:$NANOBOT_PATH_APPEND"; ls'
assert captured_env["NANOBOT_PATH_APPEND"] == "/opt/bin; echo INJECTED"
assert "INJECTED" not in captured_cmd
@pytest.mark.asyncio
async def test_windows_modifies_env(self):
"""On Windows, path_append is appended to PATH in the env dict."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"ok", b"")
mock_proc.returncode = 0
captured_env = {}
async def capture_spawn(cmd, cwd, env):
captured_env.update(env)
return mock_proc
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("nanobot.agent.tools.shell.os.pathsep", ";"),
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool(path_append=r"C:\tools\bin")
await tool.execute(command="dir")
assert captured_env["PATH"].endswith(r";C:\tools\bin")
# ---------------------------------------------------------------------------
# sandbox
# ---------------------------------------------------------------------------
class TestSandboxPlatform:
@pytest.mark.asyncio
async def test_bwrap_skipped_on_windows(self):
"""bwrap must be silently skipped on Windows, not crash."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"ok", b"")
mock_proc.returncode = 0
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool(sandbox="bwrap")
result = await tool.execute(command="dir")
assert "ok" in result
spawned_cmd = mock_spawn.call_args[0][0]
assert "bwrap" not in spawned_cmd
@pytest.mark.asyncio
async def test_bwrap_applied_on_unix(self):
"""On Unix, sandbox wrapping should still happen normally."""
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) as mock_spawn,
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool(sandbox="bwrap", working_dir="/workspace")
await tool.execute(command="ls")
mock_wrap.assert_called_once()
spawned_cmd = mock_spawn.call_args[0][0]
assert "bwrap" in spawned_cmd
# ---------------------------------------------------------------------------
# end-to-end (mocked subprocess, full execute path)
# ---------------------------------------------------------------------------
class TestExecuteEndToEnd:
@pytest.mark.asyncio
async def test_windows_full_path(self):
"""Full execute() flow on Windows: env, spawn, output formatting."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"hello world\r\n", b"")
mock_proc.returncode = 0
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch.object(ExecTool, "_spawn", return_value=mock_proc),
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool()
result = await tool.execute(command="echo hello world")
assert "hello world" in result
assert "Exit code: 0" in result
@pytest.mark.asyncio
async def test_unix_full_path(self):
"""Full execute() flow on Unix: env, spawn, output formatting."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"hello world\n", b"")
mock_proc.returncode = 0
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
patch.object(ExecTool, "_spawn", return_value=mock_proc),
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool()
result = await tool.execute(command="echo hello world")
assert "hello world" in result
assert "Exit code: 0" in result