c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Waiting to run
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Python Unittest Coverage / test (macos-15, 3.11) (push) Waiting to run
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Waiting to run
Python Unittest Coverage / test (windows-latest, 3.11) (push) Waiting to run
Web UI / check (push) Waiting to run
529 lines
18 KiB
Python
529 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Bash tool test case."""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from unittest.async_case import IsolatedAsyncioTestCase
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from agentscope.message import TextBlock
|
|
from agentscope.permission import (
|
|
PermissionBehavior,
|
|
PermissionContext,
|
|
PermissionRule,
|
|
)
|
|
from agentscope.tool import Bash, ToolChunk
|
|
from agentscope.tool._builtin._backend import (
|
|
_subprocess_creation_kwargs,
|
|
)
|
|
|
|
|
|
class BashSubprocessKwargsTest(unittest.TestCase):
|
|
"""Test platform-specific subprocess kwargs."""
|
|
|
|
def test_non_windows_subprocess_kwargs_are_empty(self) -> None:
|
|
"""On non-Windows the helper returns no extra subprocess kwargs."""
|
|
with patch(
|
|
"agentscope.tool._builtin._backend.os.name",
|
|
"posix",
|
|
):
|
|
self.assertEqual(_subprocess_creation_kwargs(), {})
|
|
|
|
def test_windows_subprocess_kwargs_hide_console(self) -> None:
|
|
"""On Windows the helper sets ``creationflags`` to hide the console."""
|
|
with patch("agentscope.tool._builtin._backend.os.name", "nt"):
|
|
self.assertEqual(
|
|
_subprocess_creation_kwargs(),
|
|
{"creationflags": 0x08000000},
|
|
)
|
|
|
|
|
|
class BashCwdTest(IsolatedAsyncioTestCase):
|
|
"""Test Bash working-directory wiring."""
|
|
|
|
async def test_cwd_is_passed_to_subprocess(self) -> None:
|
|
"""The constructor-level cwd should be used for each command."""
|
|
process = MagicMock()
|
|
process.returncode = 0
|
|
process.communicate = AsyncMock(return_value=(b"ok\n", b""))
|
|
|
|
create_process = AsyncMock(return_value=process)
|
|
with patch(
|
|
"agentscope.tool._builtin._backend."
|
|
"asyncio.create_subprocess_exec",
|
|
create_process,
|
|
):
|
|
chunks = []
|
|
async for chunk in await Bash(cwd="workspace")(command="pwd"):
|
|
chunks.append(chunk)
|
|
|
|
# cwd is forwarded, and the command line is wrapped in the
|
|
# platform's native shell (the backend primitive runs an argv
|
|
# without a shell): ``cmd /c`` on Windows, ``/bin/sh -c`` else.
|
|
self.assertEqual(create_process.call_args.kwargs["cwd"], "workspace")
|
|
expected_argv = (
|
|
("cmd", "/c", "pwd")
|
|
if os.name == "nt"
|
|
else ("/bin/sh", "-c", "pwd")
|
|
)
|
|
self.assertEqual(
|
|
create_process.call_args.args,
|
|
expected_argv,
|
|
)
|
|
self.assertEqual(chunks[0].state, "running")
|
|
|
|
|
|
@unittest.skipIf(
|
|
sys.platform == "win32",
|
|
"Bash tool is not supported on Windows",
|
|
)
|
|
class BashToolTest(IsolatedAsyncioTestCase):
|
|
"""The bash tool test case."""
|
|
|
|
async def asyncSetUp(self) -> None:
|
|
"""The async setup method."""
|
|
self.bash_tool = Bash()
|
|
|
|
async def test_tool_properties(self) -> None:
|
|
"""Test bash tool properties."""
|
|
self.assertEqual(self.bash_tool.name, "Bash")
|
|
self.assertIsInstance(self.bash_tool.description, str)
|
|
self.assertIsInstance(self.bash_tool.input_schema, dict)
|
|
self.assertFalse(self.bash_tool.is_mcp)
|
|
self.assertFalse(self.bash_tool.is_read_only)
|
|
self.assertFalse(self.bash_tool.is_concurrency_safe)
|
|
|
|
async def test_check_permissions(self) -> None:
|
|
"""Test bash tool permission checking."""
|
|
|
|
context = PermissionContext()
|
|
tool_input = {"command": "echo hello"}
|
|
decision = await self.bash_tool.check_permissions(tool_input, context)
|
|
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
|
|
|
|
async def test_simple_command(self) -> None:
|
|
"""Test executing a simple bash command."""
|
|
chunks = []
|
|
async for chunk in await self.bash_tool(command="echo 'Hello World'"):
|
|
chunks.append(chunk)
|
|
|
|
self.assertEqual(len(chunks), 1)
|
|
self.assertIsInstance(chunks[0], ToolChunk)
|
|
self.assertEqual(chunks[0].state, "running")
|
|
self.assertTrue(chunks[0].is_last)
|
|
self.assertEqual(len(chunks[0].content), 1)
|
|
self.assertIsInstance(chunks[0].content[0], TextBlock)
|
|
self.assertIn("Hello World", chunks[0].content[0].text)
|
|
|
|
async def test_command_with_error(self) -> None:
|
|
"""Test executing a command that fails."""
|
|
chunks = []
|
|
async for chunk in await self.bash_tool(command="exit 1"):
|
|
chunks.append(chunk)
|
|
|
|
self.assertEqual(len(chunks), 1)
|
|
self.assertEqual(chunks[0].state, "error")
|
|
self.assertTrue(chunks[0].is_last)
|
|
|
|
@unittest.skipIf(
|
|
sys.platform == "win32",
|
|
"sleep command not available on Windows",
|
|
)
|
|
async def test_command_timeout(self) -> None:
|
|
"""Test command timeout."""
|
|
chunks = []
|
|
async for chunk in await self.bash_tool(
|
|
command="sleep 10",
|
|
timeout=100, # 100ms timeout
|
|
):
|
|
chunks.append(chunk)
|
|
|
|
self.assertEqual(len(chunks), 1)
|
|
self.assertEqual(chunks[0].state, "error")
|
|
self.assertIn("timed out", chunks[0].content[0].text.lower())
|
|
|
|
|
|
@unittest.skipIf(
|
|
sys.platform == "win32",
|
|
"Bash tool is not supported on Windows",
|
|
)
|
|
class BashToolInjectionCheckTest(IsolatedAsyncioTestCase):
|
|
"""Test injection detection in Bash tool permission checks."""
|
|
|
|
async def asyncSetUp(self) -> None:
|
|
"""Set up test fixtures."""
|
|
self.bash_tool = Bash()
|
|
self.context = PermissionContext()
|
|
|
|
async def test_command_substitution_blocked(self) -> None:
|
|
"""Test that command substitution is blocked."""
|
|
test_cases = [
|
|
"ls $(pwd)",
|
|
"rm $(find . -name '*.tmp')",
|
|
"cat `which python`",
|
|
]
|
|
for cmd in test_cases:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
self.assertIn("command_substitution", decision.message)
|
|
|
|
async def test_control_flow_blocked(self) -> None:
|
|
"""Test that control flow structures are blocked."""
|
|
|
|
test_cases = [
|
|
"for f in *.txt; do cat $f; done",
|
|
"while read line; do echo $line; done < file.txt",
|
|
"if [ -f file.txt ]; then cat file.txt; fi",
|
|
]
|
|
for cmd in test_cases:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
self.assertIn(
|
|
"cannot be statically analyzed",
|
|
decision.message,
|
|
)
|
|
|
|
async def test_subshell_blocked(self) -> None:
|
|
"""Test that subshells are blocked."""
|
|
|
|
cmd = "(cd /tmp && ls)"
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
self.assertIn("subshell", decision.message)
|
|
|
|
async def test_injection_check_before_readonly(self) -> None:
|
|
"""Test that injection check runs before read-only check."""
|
|
|
|
# ls is read-only, but $(rm -rf /) is dangerous
|
|
cmd = "ls $(rm -rf /)"
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
# Should be blocked by injection check, not allowed as read-only
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
self.assertIn("command_substitution", decision.message)
|
|
|
|
async def test_safe_commands_pass(self) -> None:
|
|
"""Test that safe commands pass injection check."""
|
|
|
|
safe_commands = [
|
|
"ls -la",
|
|
"cat file.txt",
|
|
"git status",
|
|
"echo 'hello world'",
|
|
]
|
|
for cmd in safe_commands:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
# Should pass injection check (either ALLOW or PASSTHROUGH)
|
|
self.assertNotEqual(decision.behavior, PermissionBehavior.ASK)
|
|
if decision.behavior == PermissionBehavior.ASK:
|
|
self.assertNotIn(
|
|
"cannot be statically analyzed",
|
|
decision.message,
|
|
)
|
|
|
|
async def asyncTearDown(self) -> None:
|
|
"""Clean up test fixtures."""
|
|
self.bash_tool = None
|
|
self.context = None
|
|
|
|
|
|
@unittest.skipIf(
|
|
sys.platform == "win32",
|
|
"Bash tool is not supported on Windows",
|
|
)
|
|
class BashToolMatchRuleTest(IsolatedAsyncioTestCase):
|
|
"""Test cases for Bash tool match_rule and generate_suggestions."""
|
|
|
|
async def asyncSetUp(self) -> None:
|
|
"""Set up test fixtures."""
|
|
self.bash_tool = Bash()
|
|
|
|
async def test_match_rule_prefix_pattern(self) -> None:
|
|
"""Test match_rule with prefix patterns (e.g., git:*)."""
|
|
# Test exact command match
|
|
self.assertTrue(
|
|
await self.bash_tool.match_rule(
|
|
"git:*",
|
|
{"command": "git"},
|
|
),
|
|
)
|
|
|
|
# Test command with arguments
|
|
self.assertTrue(
|
|
await self.bash_tool.match_rule(
|
|
"git:*",
|
|
{"command": "git status"},
|
|
),
|
|
)
|
|
|
|
# Test non-matching command
|
|
self.assertFalse(
|
|
await self.bash_tool.match_rule(
|
|
"git:*",
|
|
{"command": "npm install"},
|
|
),
|
|
)
|
|
|
|
async def test_match_rule_wildcard_pattern(self) -> None:
|
|
"""Test match_rule with wildcard patterns."""
|
|
# Test wildcard matching
|
|
self.assertTrue(
|
|
await self.bash_tool.match_rule(
|
|
"git * -m *",
|
|
{"command": "git commit -m 'test'"},
|
|
),
|
|
)
|
|
|
|
# Test non-matching wildcard
|
|
self.assertFalse(
|
|
await self.bash_tool.match_rule(
|
|
"git * -m *",
|
|
{"command": "git status"},
|
|
),
|
|
)
|
|
|
|
async def test_match_rule_substring_pattern(self) -> None:
|
|
"""Test match_rule with substring patterns."""
|
|
# Test substring matching
|
|
self.assertTrue(
|
|
await self.bash_tool.match_rule(
|
|
"install",
|
|
{"command": "npm install package"},
|
|
),
|
|
)
|
|
|
|
# Test non-matching substring
|
|
self.assertFalse(
|
|
await self.bash_tool.match_rule(
|
|
"install",
|
|
{"command": "npm run build"},
|
|
),
|
|
)
|
|
|
|
async def test_match_rule_escaped_characters(self) -> None:
|
|
"""Test match_rule with escaped characters."""
|
|
# Test escaped asterisk
|
|
self.assertTrue(
|
|
await self.bash_tool.match_rule(
|
|
r"echo \*",
|
|
{"command": "echo *"},
|
|
),
|
|
)
|
|
|
|
# Test escaped backslash
|
|
self.assertTrue(
|
|
await self.bash_tool.match_rule(
|
|
r"echo \\",
|
|
{"command": "echo \\"},
|
|
),
|
|
)
|
|
|
|
async def test_generate_suggestions(self) -> None:
|
|
"""Test generate_suggestions for bash commands."""
|
|
|
|
# Test two-word command
|
|
suggestions = await self.bash_tool.generate_suggestions(
|
|
{"command": "git commit -m 'test'"},
|
|
)
|
|
|
|
self.assertIsInstance(suggestions, list)
|
|
self.assertGreater(len(suggestions), 0)
|
|
self.assertIsInstance(suggestions[0], PermissionRule)
|
|
|
|
# Should suggest "git commit:*"
|
|
suggestion_contents = [s.rule_content for s in suggestions]
|
|
self.assertIn("git commit:*", suggestion_contents)
|
|
|
|
async def test_generate_suggestions_single_word(self) -> None:
|
|
"""Test generate_suggestions for single-word commands."""
|
|
suggestions = await self.bash_tool.generate_suggestions(
|
|
{"command": "npm install"},
|
|
)
|
|
|
|
self.assertGreater(len(suggestions), 0)
|
|
|
|
# Should suggest "npm install:*"
|
|
suggestion_contents = [s.rule_content for s in suggestions]
|
|
self.assertIn("npm install:*", suggestion_contents)
|
|
|
|
async def asyncTearDown(self) -> None:
|
|
"""Clean up test fixtures."""
|
|
self.bash_tool = None
|
|
|
|
|
|
@unittest.skipIf(
|
|
sys.platform == "win32",
|
|
"Bash tool is not supported on Windows",
|
|
)
|
|
class BashToolDangerousRemovalTest(IsolatedAsyncioTestCase):
|
|
"""Test dangerous removal path detection in Bash tool."""
|
|
|
|
async def asyncSetUp(self) -> None:
|
|
"""Set up test fixtures."""
|
|
self.bash_tool = Bash()
|
|
self.context = PermissionContext()
|
|
|
|
async def test_rm_root_blocked(self) -> None:
|
|
"""Test that rm -rf / is blocked."""
|
|
|
|
cmd = "rm -rf /"
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
# Can be blocked by either dangerous command pattern or dangerous
|
|
# removal path check
|
|
self.assertTrue(
|
|
"Dangerous removal operation" in decision.message
|
|
or "dangerous pattern" in decision.message,
|
|
)
|
|
|
|
async def test_rm_root_children_blocked(self) -> None:
|
|
"""Test that rm -rf /usr, /etc, etc. are blocked."""
|
|
|
|
test_cases = [
|
|
"rm -rf /usr",
|
|
"rm -rf /etc",
|
|
"rm -rf /tmp",
|
|
"rm -rf /var",
|
|
"rm -rf /bin",
|
|
]
|
|
for cmd in test_cases:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
# Can be blocked by either dangerous command pattern or
|
|
# dangerous removal path check
|
|
self.assertTrue(
|
|
"Dangerous removal operation" in decision.message
|
|
or "dangerous pattern" in decision.message,
|
|
)
|
|
|
|
async def test_rm_home_blocked(self) -> None:
|
|
"""Test that rm -rf ~ is blocked."""
|
|
|
|
cmd = "rm -rf ~"
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
# Can be blocked by either dangerous command pattern or dangerous
|
|
# removal path check
|
|
self.assertTrue(
|
|
"Dangerous removal operation" in decision.message
|
|
or "dangerous pattern" in decision.message,
|
|
)
|
|
|
|
async def test_rm_wildcard_blocked(self) -> None:
|
|
"""Test that rm -rf * and rm -rf /* are blocked."""
|
|
|
|
test_cases = [
|
|
"rm -rf *",
|
|
"rm -rf /*",
|
|
"rm -rf /tmp/*",
|
|
]
|
|
for cmd in test_cases:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
# Can be blocked by either dangerous command pattern or
|
|
# dangerous removal path check
|
|
self.assertTrue(
|
|
"Dangerous removal operation" in decision.message
|
|
or "dangerous pattern" in decision.message,
|
|
)
|
|
|
|
async def test_rmdir_dangerous_paths_blocked(self) -> None:
|
|
"""Test that rmdir on dangerous paths is blocked."""
|
|
|
|
test_cases = [
|
|
"rmdir /",
|
|
"rmdir /usr",
|
|
"rmdir ~",
|
|
]
|
|
for cmd in test_cases:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
self.assertIn("Dangerous removal operation", decision.message)
|
|
|
|
async def test_safe_rm_commands_pass(self) -> None:
|
|
"""Test that safe rm commands pass dangerous removal check."""
|
|
|
|
safe_commands = [
|
|
"rm file.txt",
|
|
"rm -f temp.log",
|
|
"rm -rf /tmp/my_project/build",
|
|
"rm -rf ./node_modules",
|
|
]
|
|
for cmd in safe_commands:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
# Should not be blocked by dangerous removal check
|
|
# (may still be blocked by other checks)
|
|
if decision.behavior == PermissionBehavior.ASK:
|
|
self.assertNotIn(
|
|
"Dangerous removal operation",
|
|
decision.message,
|
|
)
|
|
|
|
async def test_compound_commands_with_dangerous_removal(self) -> None:
|
|
"""Test compound commands containing dangerous removal."""
|
|
|
|
test_cases = [
|
|
"ls && rm -rf /",
|
|
"cd /tmp && rm -rf /usr",
|
|
"echo start; rm -rf ~; echo end",
|
|
]
|
|
for cmd in test_cases:
|
|
with self.subTest(cmd=cmd):
|
|
decision = await self.bash_tool.check_permissions(
|
|
{"command": cmd},
|
|
self.context,
|
|
)
|
|
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
|
|
# Can be blocked by either dangerous command pattern or
|
|
# dangerous removal path check
|
|
self.assertTrue(
|
|
"Dangerous removal operation" in decision.message
|
|
or "dangerous pattern" in decision.message,
|
|
)
|
|
|
|
async def asyncTearDown(self) -> None:
|
|
"""Clean up test fixtures."""
|
|
self.bash_tool = None
|
|
self.context = None
|