# -*- coding: utf-8 -*- """Tests for pineagents.security.tool_guard.utils.""" # pylint: disable=redefined-outer-name from __future__ import annotations from unittest.mock import patch from pineagents.security.tool_guard.models import ( GuardFinding, GuardSeverity, GuardThreatCategory, ToolGuardResult, ) from pineagents.security.tool_guard.utils import ( _DEFAULT_GUARDED_TOOLS, _parse_guarded_tokens, log_findings, logger, resolve_auto_denied_rules, resolve_denied_tools, resolve_guarded_tools, ) # --------------------------------------------------------------------------- # _parse_guarded_tokens # --------------------------------------------------------------------------- class TestParseGuardedTokens: """Tests for the _parse_guarded_tokens helper.""" def test_all_returns_none(self): """The token 'all' signals guard-everything (returns None).""" assert _parse_guarded_tokens(["all"]) is None def test_star_returns_none(self): """The token '*' also signals guard-everything.""" assert _parse_guarded_tokens(["*"]) is None def test_none_returns_empty_set(self): """The token 'none' disables guarding (returns empty set).""" assert _parse_guarded_tokens(["none"]) == set() def test_off_returns_empty_set(self): """'off' disables guarding.""" assert _parse_guarded_tokens(["off"]) == set() def test_false_returns_empty_set(self): """'false' disables guarding.""" assert _parse_guarded_tokens(["false"]) == set() def test_zero_returns_empty_set(self): """'0' disables guarding.""" assert _parse_guarded_tokens(["0"]) == set() def test_comma_separated_tool_names(self): """Comma-separated tool names produce the expected set.""" tokens = ["execute_shell_command", "read_file", "write_file"] result = _parse_guarded_tokens(tokens) assert result == {"execute_shell_command", "read_file", "write_file"} def test_whitespace_handling(self): """Leading/trailing whitespace is stripped from tokens.""" tokens = [" execute_shell_command ", " read_file ", "write_file"] result = _parse_guarded_tokens(tokens) assert result == {"execute_shell_command", "read_file", "write_file"} def test_empty_tokens_ignored(self): """Empty strings and whitespace-only tokens are dropped.""" tokens = ["execute_shell_command", "", " ", "read_file"] result = _parse_guarded_tokens(tokens) assert result == {"execute_shell_command", "read_file"} def test_all_empty_returns_empty_set(self): """If all tokens are empty/whitespace, return empty set.""" assert _parse_guarded_tokens(["", " "]) == set() def test_mixed_disable_tokens_only(self): """A mix of only disable-tokens returns empty set.""" assert _parse_guarded_tokens(["none", "off", "false", "0"]) == set() def test_mixed_case_all(self): """ALL in uppercase is recognised as the 'all' sentinel.""" assert _parse_guarded_tokens(["ALL"]) is None def test_tool_names_preserved_case(self): """Real tool names keep their original casing.""" tokens = ["Execute_Shell_Command"] result = _parse_guarded_tokens(tokens) assert "Execute_Shell_Command" in result # --------------------------------------------------------------------------- # resolve_guarded_tools # --------------------------------------------------------------------------- class TestResolveGuardedTools: """Tests for resolve_guarded_tools.""" def test_user_defined_takes_priority(self): """When user_defined is provided, it wins over env and config.""" result = resolve_guarded_tools( user_defined=["execute_shell_command", "read_file"], ) assert result == {"execute_shell_command", "read_file"} def test_user_defined_none_means_guard_all(self): """user_defined=['all'] resolves to None (guard everything).""" result = resolve_guarded_tools(user_defined=["all"]) assert result is None def test_user_defined_none_token_means_empty(self): """user_defined=['none'] resolves to empty set.""" result = resolve_guarded_tools(user_defined=["none"]) assert result == set() def test_env_var_qwenpaw_tool_guard_tools(self, mock_env_loader): """Env var QWENPAW_TOOL_GUARD_TOOLS is consulted when user_defined is None.""" mock_env_loader.return_value = "execute_shell_command,write_file" result = resolve_guarded_tools() assert result == {"execute_shell_command", "write_file"} def test_env_var_all_means_none(self, mock_env_loader): """Env var value 'all' resolves to None (guard everything).""" mock_env_loader.return_value = "all" result = resolve_guarded_tools() assert result is None def test_env_var_star_means_none(self, mock_env_loader): """Env var value '*' resolves to None.""" mock_env_loader.return_value = "*" result = resolve_guarded_tools() assert result is None def test_env_var_off_means_empty(self, mock_env_loader): """Env var value 'off' resolves to empty set.""" mock_env_loader.return_value = "off" result = resolve_guarded_tools() assert result == set() def test_env_var_empty_string_falls_through( self, mock_env_loader, mock_config, # pylint: disable=unused-argument ): """Empty env var falls through to config, then default.""" mock_env_loader.return_value = "" # Config also returns None for guarded_tools by default result = resolve_guarded_tools() assert result == set(_DEFAULT_GUARDED_TOOLS) def test_config_guarded_tools_used(self, mock_env_loader, mock_config): """When env is empty, config's guarded_tools is used.""" mock_env_loader.return_value = "" mock_config.return_value.security.tool_guard.guarded_tools = [ "execute_shell_command", ] result = resolve_guarded_tools() assert result == {"execute_shell_command"} def test_default_set_returned_when_nothing_specified( self, mock_env_loader, mock_config, # pylint: disable=unused-argument ): """When user, env, and config are all empty, default set is returned.""" mock_env_loader.return_value = "" # mock_config returns guarded_tools=None by default result = resolve_guarded_tools() assert result == set(_DEFAULT_GUARDED_TOOLS) def test_none_user_defined_triggers_env_lookup(self, mock_env_loader): """None user_defined causes the env var to be checked.""" mock_env_loader.return_value = "read_file" result = resolve_guarded_tools(user_defined=None) assert "read_file" in result def test_config_load_failure_falls_to_default( self, mock_env_loader, ): """If config loading fails, the default set is returned.""" mock_env_loader.return_value = "" with patch( "pineagents.security.tool_guard.utils._load_config_tool_guard", return_value=None, ): result = resolve_guarded_tools() assert result == set(_DEFAULT_GUARDED_TOOLS) # --------------------------------------------------------------------------- # resolve_denied_tools # --------------------------------------------------------------------------- class TestResolveDeniedTools: """Tests for resolve_denied_tools.""" def test_user_defined_takes_priority(self): """When user_defined is provided, it wins over env and config.""" result = resolve_denied_tools(user_defined=["dangerous_tool"]) assert result == {"dangerous_tool"} def test_user_defined_none_pass_through(self): """user_defined=None triggers env/config/default chain.""" # This test just verifies the function doesn't crash with None with patch( "pineagents.security.tool_guard.utils.EnvVarLoader.get_str", return_value="", ): result = resolve_denied_tools(user_defined=None) # Default is empty assert isinstance(result, set) def test_env_var_qwenpaw_tool_guard_denied_tools(self, mock_env_loader): """Env var QWENPAW_TOOL_GUARD_DENIED_TOOLS is consulted.""" mock_env_loader.return_value = "tool_a,tool_b" result = resolve_denied_tools() assert result == {"tool_a", "tool_b"} def test_env_var_whitespace_stripped(self, mock_env_loader): """Whitespace is stripped from env var tokens.""" mock_env_loader.return_value = " tool_a , tool_b " result = resolve_denied_tools() assert result == {"tool_a", "tool_b"} def test_default_is_empty_set( self, mock_env_loader, mock_config, # pylint: disable=unused-argument ): """When nothing is specified, the default is an empty set.""" mock_env_loader.return_value = "" result = resolve_denied_tools() assert result == set() def test_config_denied_tools_used(self, mock_env_loader, mock_config): """Config's denied_tools is used when env is empty.""" mock_env_loader.return_value = "" mock_config.return_value.security.tool_guard.denied_tools = [ "blocked_tool", ] result = resolve_denied_tools() assert result == {"blocked_tool"} def test_config_empty_denied_tools_falls_through( self, mock_env_loader, mock_config, ): """If config.denied_tools is falsy, fall through to default empty set.""" mock_env_loader.return_value = "" mock_config.return_value.security.tool_guard.denied_tools = [] result = resolve_denied_tools() assert result == set() # --------------------------------------------------------------------------- # resolve_auto_denied_rules # --------------------------------------------------------------------------- class TestResolveAutoDeniedRules: """Tests for resolve_auto_denied_rules. Mirrors the priority chain of resolve_denied_tools: user_defined > env var > config.json > default({SAFETY_CHECKS_DESTRUCTIVE_COMMAND}). Legacy persisted ``[]`` keeps the built-in default; opt out via env ``none`` / ``-`` / ``off``. """ def test_user_defined_takes_priority(self): """User-supplied set wins over env and config.""" result = resolve_auto_denied_rules( user_defined=["RULE_A", "RULE_B"], ) assert result == {"RULE_A", "RULE_B"} def test_user_defined_strips_whitespace_and_empty(self): """Whitespace is stripped and empty/whitespace tokens dropped.""" result = resolve_auto_denied_rules( user_defined=[" RULE_A ", "", " ", "RULE_B"], ) assert result == {"RULE_A", "RULE_B"} def test_user_defined_empty_iterable_yields_empty_set(self): """An empty user-supplied iterable short-circuits to empty set and does NOT fall through to env/config.""" with patch( "pineagents.security.tool_guard.utils.EnvVarLoader.get_str", return_value="SHOULD_NOT_BE_READ", ): result = resolve_auto_denied_rules(user_defined=[]) assert result == set() def test_env_var_consulted_when_user_defined_is_none( self, mock_env_loader, ): """Env var QWENPAW_TOOL_GUARD_AUTO_DENIED_RULES is read next.""" mock_env_loader.return_value = "RULE_X,RULE_Y" result = resolve_auto_denied_rules() assert result == {"RULE_X", "RULE_Y"} def test_env_var_whitespace_stripped(self, mock_env_loader): """Whitespace around comma-separated env tokens is stripped.""" mock_env_loader.return_value = " RULE_X , RULE_Y " result = resolve_auto_denied_rules() assert result == {"RULE_X", "RULE_Y"} def test_env_var_empty_string_falls_through_to_config( self, mock_env_loader, mock_config, ): """Empty env-var falls through to the config-derived value.""" mock_env_loader.return_value = "" mock_config.return_value.security.tool_guard.auto_denied_rules = [ "RULE_CFG", ] result = resolve_auto_denied_rules() assert result == {"RULE_CFG"} def test_config_strips_whitespace_and_drops_empty( self, mock_env_loader, mock_config, ): """Whitespace stripped and empty entries dropped from config list.""" mock_env_loader.return_value = "" mock_config.return_value.security.tool_guard.auto_denied_rules = [ " RULE_CFG ", "", " ", ] result = resolve_auto_denied_rules() assert result == {"RULE_CFG"} def test_config_empty_list_keeps_built_in_default( self, mock_env_loader, mock_config, ): """Legacy persisted [] must not silently opt out of hard-deny.""" mock_env_loader.return_value = "" mock_config.return_value.security.tool_guard.auto_denied_rules = [] result = resolve_auto_denied_rules() assert result == {"SAFETY_CHECKS_DESTRUCTIVE_COMMAND"} def test_default_includes_shared_destructive_rule( self, mock_env_loader, mock_config, # pylint: disable=unused-argument ): """With unset config (None), shared catastrophic findings auto-deny.""" mock_env_loader.return_value = "" # conftest mock leaves auto_denied_rules as None (unset). result = resolve_auto_denied_rules() assert result == {"SAFETY_CHECKS_DESTRUCTIVE_COMMAND"} def test_env_none_opts_out_of_auto_deny(self, mock_env_loader): """QWENPAW_TOOL_GUARD_AUTO_DENIED_RULES=none disables auto-deny.""" mock_env_loader.return_value = "none" result = resolve_auto_denied_rules() assert result == set() def test_config_load_failure_falls_to_default(self, mock_env_loader): """If config loading fails, fall through to built-in default.""" mock_env_loader.return_value = "" with patch( "pineagents.security.tool_guard.utils._load_config_tool_guard", return_value=None, ): result = resolve_auto_denied_rules() assert result == {"SAFETY_CHECKS_DESTRUCTIVE_COMMAND"} # --------------------------------------------------------------------------- # DEFAULT_GUARDED_TOOLS # --------------------------------------------------------------------------- class TestDefaultGuardedTools: """Tests for the _DEFAULT_GUARDED_TOOLS frozenset.""" def test_is_frozenset(self): assert isinstance(_DEFAULT_GUARDED_TOOLS, frozenset) def test_contains_expected_tools(self): """The default set includes the expected high-risk tools.""" expected = { "execute_shell_command", "read_file", "write_file", "edit_file", "append_file", "send_file_to_user", "view_text_file", "write_text_file", } assert _DEFAULT_GUARDED_TOOLS == expected # --------------------------------------------------------------------------- # log_findings # --------------------------------------------------------------------------- def _format_call(mock_call): """Format a logger mock call into its rendered message string. The logger uses %-style formatting: ``logger.info(fmt, *args)``. ``mock_call`` is a ``call(fmt, arg1, arg2, ...)`` tuple. """ fmt = mock_call[0][0] args = mock_call[0][1:] if args: return fmt % args return fmt class TestLogFindings: """Tests for the log_findings structured logging function. The project's custom logging sets propagate=False on the ``qwenpaw`` logger, so caplog cannot capture records. Instead we patch the module-level logger and inspect the mock calls directly. """ def test_empty_findings_logs_summary_only(self): """When findings list is empty, only a summary line is logged.""" result = ToolGuardResult( tool_name="read_file", params={}, findings=[], guard_duration_seconds=0.01, ) with patch.object(logger, "info") as mock_info, patch.object( logger, "warning", ): log_findings("read_file", result) # Summary for safe result uses info mock_info.assert_called() last_msg = _format_call(mock_info.call_args_list[-1]) assert "Summary for tool 'read_file'" in last_msg assert "max_severity=SAFE" in last_msg def test_high_severity_uses_warning(self): """HIGH severity findings are logged at WARNING level.""" finding = GuardFinding( id="f-1", rule_id="R-001", category=GuardThreatCategory.PATH_TRAVERSAL, severity=GuardSeverity.HIGH, title="Path traversal", description="Escapes workspace", tool_name="read_file", param_name="path", matched_value="../../etc/passwd", ) result = ToolGuardResult( tool_name="read_file", params={"path": "../../etc/passwd"}, findings=[finding], guard_duration_seconds=0.02, ) with patch.object(logger, "warning") as mock_warn: log_findings("read_file", result) # HIGH finding uses warning, summary also uses warning assert mock_warn.call_count >= 2 first_msg = _format_call(mock_warn.call_args_list[0]) assert "HIGH" in first_msg def test_low_severity_uses_info(self): """LOW severity findings are logged at INFO level.""" finding = GuardFinding( id="f-1", rule_id="R-001", category=GuardThreatCategory.RESOURCE_ABUSE, severity=GuardSeverity.LOW, title="Resource usage", description="High memory usage", tool_name="execute_shell_command", ) result = ToolGuardResult( tool_name="execute_shell_command", params={}, findings=[finding], guard_duration_seconds=0.01, ) with patch.object(logger, "info") as mock_info: log_findings("execute_shell_command", result) first_msg = _format_call(mock_info.call_args_list[0]) assert "LOW" in first_msg def test_critical_severity_uses_warning(self): """CRITICAL severity findings are logged at WARNING level.""" finding = GuardFinding( id="f-1", rule_id="R-001", category=GuardThreatCategory.COMMAND_INJECTION, severity=GuardSeverity.CRITICAL, title="Command injection", description="Destructive command", tool_name="execute_shell_command", matched_value="rm -rf /", ) result = ToolGuardResult( tool_name="execute_shell_command", params={"command": "rm -rf /"}, findings=[finding], guard_duration_seconds=0.03, ) with patch.object(logger, "warning") as mock_warn: log_findings("execute_shell_command", result) first_msg = _format_call(mock_warn.call_args_list[0]) assert "CRITICAL" in first_msg def test_log_includes_tool_guard_prefix(self): """Log messages include the [TOOL GUARD] prefix.""" result = ToolGuardResult( tool_name="read_file", params={}, findings=[], guard_duration_seconds=0.01, ) with patch.object(logger, "info") as mock_info: log_findings("read_file", result) msg = _format_call(mock_info.call_args_list[0]) assert "[TOOL GUARD]" in msg