|
3 | 3 | import pytest |
4 | 4 |
|
5 | 5 | from hackbot.modes.plan import PlanMode, PLAN_TEMPLATES |
6 | | -from hackbot.modes.agent import Severity, Finding |
| 6 | +from hackbot.modes.agent import AgentMode, Severity, Finding |
| 7 | +from hackbot.core.engine import AIEngine |
| 8 | +from hackbot.config import HackBotConfig, AgentConfig, AIConfig |
| 9 | +from hackbot.core.runner import ToolResult |
| 10 | +from unittest.mock import MagicMock, patch |
7 | 11 |
|
8 | 12 |
|
9 | 13 | def test_plan_templates_exist(): |
@@ -49,3 +53,95 @@ def test_finding_creation(): |
49 | 53 | d = finding.to_dict() |
50 | 54 | assert d["severity"] == "High" |
51 | 55 | assert d["title"] == "SQL Injection" |
| 56 | + |
| 57 | + |
| 58 | +@pytest.fixture |
| 59 | +def mock_agent(): |
| 60 | + """Create an AgentMode instance with mocked dependencies for testing loop logic.""" |
| 61 | + config = HackBotConfig() |
| 62 | + config.ai = AIConfig(provider="mock", model="mock") |
| 63 | + config.agent = AgentConfig(allowed_tools=["echo", "nmap"], safe_mode=False) |
| 64 | + |
| 65 | + engine = MagicMock(spec=AIEngine) |
| 66 | + engine.chat.return_value = "Mocked AI response" |
| 67 | + |
| 68 | + with patch("hackbot.core.vulndb.VulnDB"), \ |
| 69 | + patch("hackbot.core.cve.CVELookup"): |
| 70 | + agent = AgentMode(engine=engine, config=config) |
| 71 | + agent.target = "127.0.0.1" |
| 72 | + agent.conversation = MagicMock() |
| 73 | + agent.is_running = True |
| 74 | + return agent |
| 75 | + |
| 76 | +def test_process_actions_loop_skips_same_round_duplicates(mock_agent): |
| 77 | + """Test that identical commands within one round are skipped.""" |
| 78 | + mock_agent._execute_action = MagicMock(return_value=("output", ToolResult( |
| 79 | + tool="echo", command="echo test", stdout="test", stderr="", |
| 80 | + return_code=0, duration=0.1, success=True |
| 81 | + ))) |
| 82 | + |
| 83 | + actions = [ |
| 84 | + {"action": "execute", "command": "echo test"}, |
| 85 | + {"action": "execute", "command": "echo test"}, |
| 86 | + {"action": "execute", "command": "echo test"}, |
| 87 | + ] |
| 88 | + |
| 89 | + # We only care about the single-round processing here, so mock Chat to stop the loop |
| 90 | + mock_agent.engine.chat.return_value = '{"action": "complete"}' |
| 91 | + |
| 92 | + mock_agent._process_actions_loop(actions, max_rounds=2) |
| 93 | + |
| 94 | + # execute_action should only be called once, the others are deduplicated in same round |
| 95 | + assert mock_agent._execute_action.call_count == 1 |
| 96 | + |
| 97 | +def test_process_actions_loop_skips_session_duplicates(mock_agent): |
| 98 | + """Test that commands executed >=2 times in a session are skipped.""" |
| 99 | + mock_agent._command_history = {"echo test": 2} # Already run twice |
| 100 | + |
| 101 | + mock_agent._execute_action = MagicMock() |
| 102 | + mock_agent.engine.chat.return_value = '{"action": "complete"}' |
| 103 | + |
| 104 | + actions = [ |
| 105 | + {"action": "execute", "command": "echo test"}, |
| 106 | + ] |
| 107 | + mock_agent._process_actions_loop(actions, max_rounds=2) |
| 108 | + |
| 109 | + # Should be skipped entirely |
| 110 | + mock_agent._execute_action.assert_not_called() |
| 111 | + |
| 112 | +def test_command_history_increments(mock_agent): |
| 113 | + """Test that executing an action increments command history.""" |
| 114 | + # Ensure runner is a mock that won't actually execute |
| 115 | + mock_agent.runner = MagicMock() |
| 116 | + mock_agent.runner.execute.return_value = ToolResult( |
| 117 | + tool="echo", command="echo hello", stdout="hello", stderr="", |
| 118 | + return_code=0, duration=0.1, success=True |
| 119 | + ) |
| 120 | + |
| 121 | + action = {"action": "execute", "command": "echo hello"} |
| 122 | + |
| 123 | + assert "echo hello" not in mock_agent._command_history |
| 124 | + mock_agent._execute_action(action) |
| 125 | + assert mock_agent._command_history["echo hello"] == 1 |
| 126 | + |
| 127 | + mock_agent._execute_action(action) |
| 128 | + assert mock_agent._command_history["echo hello"] == 2 |
| 129 | + |
| 130 | +def test_should_nudge_capped_per_step(mock_agent): |
| 131 | + """Test that _should_nudge only returns True once per step.""" |
| 132 | + response_with_tool = "I will run nmap now." |
| 133 | + |
| 134 | + # First check should be True |
| 135 | + assert mock_agent._should_nudge(response_with_tool) is True |
| 136 | + # Second check should be False (already nudged) |
| 137 | + assert mock_agent._should_nudge(response_with_tool) is False |
| 138 | + |
| 139 | + # Reset count (as happens at top of step()) |
| 140 | + mock_agent._nudge_count = 0 |
| 141 | + assert mock_agent._should_nudge(response_with_tool) is True |
| 142 | + |
| 143 | +def test_should_nudge_ignores_non_tools(mock_agent): |
| 144 | + """Test that _should_nudge returns False if no tool names are present.""" |
| 145 | + response_no_tool = "I have begun assessing the target based on the scope provided." |
| 146 | + # Should be false since it doesn't mention nmap/sqlmap/etc |
| 147 | + assert mock_agent._should_nudge(response_no_tool) is False |
0 commit comments