Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

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