shadowcat 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent/__init__.py +17 -0
- agent/benchmark/__init__.py +11 -0
- agent/benchmark/cli.py +179 -0
- agent/benchmark/config.py +15 -0
- agent/benchmark/docker.py +192 -0
- agent/benchmark/registry.py +99 -0
- agent/core/__init__.py +0 -0
- agent/core/agent.py +362 -0
- agent/core/backend.py +1667 -0
- agent/core/config.py +106 -0
- agent/core/controller.py +638 -0
- agent/core/events.py +177 -0
- agent/core/langfuse.py +320 -0
- agent/core/phantom.py +2327 -0
- agent/core/planner.py +493 -0
- agent/core/profiling.py +58 -0
- agent/core/sanitizer.py +104 -0
- agent/core/session.py +228 -0
- agent/core/tracer.py +137 -0
- agent/interface/__init__.py +0 -0
- agent/interface/components/__init__.py +0 -0
- agent/interface/components/activity_feed.py +202 -0
- agent/interface/components/renderers.py +149 -0
- agent/interface/components/splash.py +112 -0
- agent/interface/main.py +1126 -0
- agent/interface/styles.tcss +421 -0
- agent/interface/tui.py +508 -0
- agent/parsing/html_distiller.py +230 -0
- agent/parsing/tool_parser.py +115 -0
- agent/prompts/__init__.py +0 -0
- agent/prompts/pentesting.py +238 -0
- agent/rag_module/knowledge_base.py +116 -0
- agent/tests/benchmark_phantom.py +455 -0
- agent/tools/__init__.py +14 -0
- agent/tools/base.py +99 -0
- agent/tools/executor.py +345 -0
- agent/tools/registry.py +47 -0
- backend/README.md +244 -0
- backend/__init__.py +10 -0
- backend/api/__init__.py +1 -0
- backend/api/routes_scan.py +932 -0
- backend/authz.py +75 -0
- backend/cli.py +261 -0
- backend/compliance/__init__.py +1 -0
- backend/compliance/pdpa_mapping.py +16 -0
- backend/core/__init__.py +1 -0
- backend/core/coverage.py +93 -0
- backend/core/events.py +166 -0
- backend/core/llm_client.py +614 -0
- backend/core/orchestrator.py +402 -0
- backend/core/scan_memory.py +236 -0
- backend/crawler/__init__.py +1 -0
- backend/crawler/csrf.py +75 -0
- backend/crawler/headless.py +232 -0
- backend/crawler/js_analyzer.py +133 -0
- backend/crawler/spider.py +550 -0
- backend/crawler/subdomains.py +279 -0
- backend/daemon.py +252 -0
- backend/db/README.md +86 -0
- backend/db/__init__.py +17 -0
- backend/db/engine.py +87 -0
- backend/db/models.py +206 -0
- backend/db/repository.py +316 -0
- backend/db/schema.sql +247 -0
- backend/modes/__init__.py +5 -0
- backend/modes/base.py +178 -0
- backend/modes/ctf.py +39 -0
- backend/modes/enterprise.py +210 -0
- backend/modes/general.py +226 -0
- backend/modes/registry.py +34 -0
- backend/reporting/__init__.py +0 -0
- backend/reporting/generator.py +285 -0
- backend/schemas/__init__.py +1 -0
- backend/schemas/api.py +423 -0
- backend/tools/__init__.py +62 -0
- backend/tools/dirbrute_tool.py +304 -0
- backend/tools/general_report_tool.py +135 -0
- backend/tools/http_tool.py +351 -0
- backend/tools/nuclei_tool.py +20 -0
- backend/tools/report_tool.py +145 -0
- backend/tools/shell_tools.py +23 -0
- backend/verification/__init__.py +1 -0
- backend/verification/evidence_store.py +125 -0
- backend/verification/general_oracle.py +369 -0
- backend/verification/idor_oracle.py +131 -0
- backend/waf/__init__.py +0 -0
- backend/waf/detector.py +147 -0
- backend/waf/evasion.py +117 -0
- backend/webui/index.html +713 -0
- backend/workspace.py +182 -0
- shadowcat-2.0.0.dist-info/METADATA +360 -0
- shadowcat-2.0.0.dist-info/RECORD +95 -0
- shadowcat-2.0.0.dist-info/WHEEL +4 -0
- shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
- shadowcat-2.0.0.dist-info/licenses/LICENSE.md +21 -0
agent/core/controller.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
"""Agent controller with lifecycle management, pause/resume, and session persistence."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
|
|
12
|
+
from agent.core.backend import (
|
|
13
|
+
AgentBackend,
|
|
14
|
+
AgentMessage,
|
|
15
|
+
AnthropicBackend,
|
|
16
|
+
ClaudeSDKBackend,
|
|
17
|
+
MessageType,
|
|
18
|
+
OpenRouterBackend,
|
|
19
|
+
)
|
|
20
|
+
from agent.core.config import ShadowCatConfig
|
|
21
|
+
from agent.core.events import Event, EventBus, EventType
|
|
22
|
+
from agent.core.profiling import mark, span
|
|
23
|
+
from agent.core.session import SessionStatus, SessionStore
|
|
24
|
+
from agent.parsing.tool_parser import ToolOutputParser
|
|
25
|
+
|
|
26
|
+
# RAG is optional — the module pulls in sentence-transformers / chromadb /
|
|
27
|
+
# langchain at import time, none of which ship in the default install. If any
|
|
28
|
+
# of them is missing, set CTFKnowledgeBase=None and let the existing null-checks
|
|
29
|
+
# below skip the RAG path.
|
|
30
|
+
try:
|
|
31
|
+
from agent.rag_module.knowledge_base import CTFKnowledgeBase
|
|
32
|
+
except ImportError:
|
|
33
|
+
CTFKnowledgeBase = None # type: ignore[assignment,misc]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentState(Enum):
|
|
37
|
+
"""Simple 5-state model for agent lifecycle."""
|
|
38
|
+
|
|
39
|
+
IDLE = "idle"
|
|
40
|
+
RUNNING = "running"
|
|
41
|
+
PAUSED = "paused"
|
|
42
|
+
COMPLETED = "completed"
|
|
43
|
+
ERROR = "error"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentController:
|
|
47
|
+
"""
|
|
48
|
+
Central orchestrator with lifecycle management.
|
|
49
|
+
|
|
50
|
+
Features:
|
|
51
|
+
- Framework-agnostic via AgentBackend
|
|
52
|
+
- Pause/resume/stop control
|
|
53
|
+
- Instruction injection
|
|
54
|
+
- Session persistence
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# Flag detection patterns
|
|
58
|
+
FLAG_PATTERNS: ClassVar[list[str]] = [
|
|
59
|
+
r"(?:flag|FLAG|HTB|THM|CTF|picoCTF|HackTheBox)\{[^\}\s]+\}",
|
|
60
|
+
r"\b[a-f0-9]{32}\b", # 32-char hex (HTB user/root flags)
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
config: ShadowCatConfig,
|
|
66
|
+
backend: AgentBackend | None = None,
|
|
67
|
+
session_store: SessionStore | None = None,
|
|
68
|
+
events: EventBus | None = None,
|
|
69
|
+
):
|
|
70
|
+
"""Initialize controller.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
config: ShadowCat configuration
|
|
74
|
+
backend: Optional custom backend (defaults to ClaudeCodeBackend)
|
|
75
|
+
session_store: Optional custom session store
|
|
76
|
+
events: Optional custom event bus
|
|
77
|
+
"""
|
|
78
|
+
self.config = config
|
|
79
|
+
self.backend = backend
|
|
80
|
+
self.sessions = session_store or SessionStore()
|
|
81
|
+
self.events = events or EventBus.get()
|
|
82
|
+
|
|
83
|
+
# State management
|
|
84
|
+
self._state = AgentState.IDLE
|
|
85
|
+
self._pause_requested = False
|
|
86
|
+
self._stop_requested = False
|
|
87
|
+
self._resume_event = asyncio.Event()
|
|
88
|
+
self._pending_instruction: str | None = None
|
|
89
|
+
self._tool_history: list[tuple[str, str]] = []
|
|
90
|
+
|
|
91
|
+
self._tool_parser = ToolOutputParser()
|
|
92
|
+
if CTFKnowledgeBase is None:
|
|
93
|
+
self._kb = None # RAG deps not installed
|
|
94
|
+
else:
|
|
95
|
+
try:
|
|
96
|
+
# Reuse the process-wide singleton instead of constructing a
|
|
97
|
+
# second KB: the SentenceTransformer load is "heavy (hundreds of
|
|
98
|
+
# ms to several seconds)" (see knowledge_base.get_shared_kb), and
|
|
99
|
+
# the http_request tool in backend.py already loads it via
|
|
100
|
+
# get_shared_kb(). Two instances = the embedding model loaded
|
|
101
|
+
# twice at startup. Share one.
|
|
102
|
+
from agent.rag_module.knowledge_base import get_shared_kb
|
|
103
|
+
|
|
104
|
+
self._kb = get_shared_kb()
|
|
105
|
+
except Exception:
|
|
106
|
+
self._kb = None # RAG init failed (e.g. embedding model download), degrade
|
|
107
|
+
|
|
108
|
+
# Subscribe to user events
|
|
109
|
+
self.events.subscribe(EventType.USER_COMMAND, self._on_user_command)
|
|
110
|
+
self.events.subscribe(EventType.USER_INPUT, self._on_user_input)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def state(self) -> AgentState:
|
|
114
|
+
"""Get current agent state."""
|
|
115
|
+
return self._state
|
|
116
|
+
|
|
117
|
+
def _set_state(
|
|
118
|
+
self,
|
|
119
|
+
state: AgentState,
|
|
120
|
+
details: str = "",
|
|
121
|
+
target: str | None = None,
|
|
122
|
+
task: str | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Update state and emit event.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
state: New agent state
|
|
128
|
+
details: Optional details about the state
|
|
129
|
+
target: Optional target for session tracking (used by Langfuse)
|
|
130
|
+
task: Optional full task description for session tracking (used by Langfuse)
|
|
131
|
+
"""
|
|
132
|
+
self._state = state
|
|
133
|
+
self.events.emit_state(state.value, details, target=target, task=task)
|
|
134
|
+
|
|
135
|
+
def _looks_like_loop(self) -> bool:
|
|
136
|
+
"""Detect if the exact same tool with the exact same arguments was called 3 times in a row."""
|
|
137
|
+
if len(self._tool_history) < 3:
|
|
138
|
+
return False
|
|
139
|
+
last_three = self._tool_history[-3:]
|
|
140
|
+
return last_three[0] == last_three[1] == last_three[2]
|
|
141
|
+
|
|
142
|
+
# === Control Methods (called from TUI) ===
|
|
143
|
+
|
|
144
|
+
def pause(self) -> bool:
|
|
145
|
+
"""Request pause at next safe point.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
True if pause request was accepted
|
|
149
|
+
"""
|
|
150
|
+
if self._state == AgentState.RUNNING:
|
|
151
|
+
self._pause_requested = True
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def resume(self, instruction: str | None = None) -> bool:
|
|
156
|
+
"""Resume from paused state.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
instruction: Optional instruction to inject on resume
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if resume request was accepted
|
|
163
|
+
"""
|
|
164
|
+
if self._state == AgentState.PAUSED:
|
|
165
|
+
self._pending_instruction = instruction
|
|
166
|
+
self._pause_requested = False
|
|
167
|
+
self._resume_event.set()
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def stop(self) -> bool:
|
|
172
|
+
"""Request stop.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True (stop is always accepted)
|
|
176
|
+
"""
|
|
177
|
+
self._stop_requested = True
|
|
178
|
+
self._resume_event.set() # Unblock if paused
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
def inject(self, instruction: str) -> bool:
|
|
182
|
+
"""Queue instruction for next pause point.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
instruction: Instruction to inject
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True if instruction was queued
|
|
189
|
+
"""
|
|
190
|
+
if self._state in (AgentState.RUNNING, AgentState.PAUSED):
|
|
191
|
+
self._pending_instruction = instruction
|
|
192
|
+
if self._state == AgentState.RUNNING:
|
|
193
|
+
self._pause_requested = True
|
|
194
|
+
return True
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
# === Event Handlers ===
|
|
198
|
+
|
|
199
|
+
def _on_user_command(self, event: Event) -> None:
|
|
200
|
+
"""Handle user command events."""
|
|
201
|
+
cmd = event.data.get("command")
|
|
202
|
+
if cmd == "pause":
|
|
203
|
+
self.pause()
|
|
204
|
+
elif cmd == "resume":
|
|
205
|
+
self.resume()
|
|
206
|
+
elif cmd == "stop":
|
|
207
|
+
self.stop()
|
|
208
|
+
|
|
209
|
+
def _on_user_input(self, event: Event) -> None:
|
|
210
|
+
"""Handle user input events."""
|
|
211
|
+
text = event.data.get("text", "")
|
|
212
|
+
if text:
|
|
213
|
+
self.inject(text)
|
|
214
|
+
|
|
215
|
+
# === Main Execution ===
|
|
216
|
+
|
|
217
|
+
async def run(self, task: str, resume_session_id: str | None = None) -> dict[str, Any]:
|
|
218
|
+
"""Run agent with full lifecycle management.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
task: Task description for the agent
|
|
222
|
+
resume_session_id: Optional session ID to resume
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Result dictionary with success, output, flags, etc.
|
|
226
|
+
"""
|
|
227
|
+
# Reset state
|
|
228
|
+
self._pause_requested = False
|
|
229
|
+
self._stop_requested = False
|
|
230
|
+
self._resume_event.clear()
|
|
231
|
+
|
|
232
|
+
# Create or resume session
|
|
233
|
+
if resume_session_id:
|
|
234
|
+
session = self.sessions.load(resume_session_id)
|
|
235
|
+
if not session:
|
|
236
|
+
return {
|
|
237
|
+
"success": False,
|
|
238
|
+
"error": f"Session {resume_session_id} not found",
|
|
239
|
+
}
|
|
240
|
+
# Update task if resuming
|
|
241
|
+
if not task:
|
|
242
|
+
task = session.task
|
|
243
|
+
else:
|
|
244
|
+
session = self.sessions.create(
|
|
245
|
+
target=self.config.target,
|
|
246
|
+
task=task,
|
|
247
|
+
model=self.config.llm_model,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Create backend if needed.
|
|
251
|
+
# Auth-mode-driven backend selection:
|
|
252
|
+
# anthropic -> AnthropicBackend (Option 2, direct API key)
|
|
253
|
+
# claude_subscription -> ClaudeSDKBackend (Option 4, OAuth via subscription)
|
|
254
|
+
# anything else -> OpenRouterBackend (Option 1 default; Option 3 local
|
|
255
|
+
# also goes here — point MODEL at
|
|
256
|
+
# the local OpenAI-compatible URL)
|
|
257
|
+
# See scripts/config.sh for how SHADOWCAT_AUTH_MODE gets set.
|
|
258
|
+
if self.backend is None:
|
|
259
|
+
from agent.prompts.pentesting import get_ctf_prompt
|
|
260
|
+
|
|
261
|
+
auth_mode = (os.getenv("SHADOWCAT_AUTH_MODE") or "openrouter").lower()
|
|
262
|
+
system_prompt = get_ctf_prompt(self.config.custom_instruction)
|
|
263
|
+
working_directory = str(self.config.working_directory)
|
|
264
|
+
model = self.config.llm_model
|
|
265
|
+
|
|
266
|
+
if auth_mode == "claude_subscription":
|
|
267
|
+
self.backend = ClaudeSDKBackend(
|
|
268
|
+
working_directory=working_directory,
|
|
269
|
+
system_prompt=system_prompt,
|
|
270
|
+
model=model,
|
|
271
|
+
)
|
|
272
|
+
elif auth_mode == "anthropic":
|
|
273
|
+
self.backend = AnthropicBackend(
|
|
274
|
+
working_directory=working_directory,
|
|
275
|
+
system_prompt=system_prompt,
|
|
276
|
+
model=model,
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
self.backend = OpenRouterBackend(
|
|
280
|
+
working_directory=working_directory,
|
|
281
|
+
system_prompt=system_prompt,
|
|
282
|
+
model=model,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
self._set_state(
|
|
287
|
+
AgentState.RUNNING,
|
|
288
|
+
"Connecting...",
|
|
289
|
+
target=self.config.target,
|
|
290
|
+
task=task,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Connect (or resume)
|
|
294
|
+
with span("backend.connect/resume"):
|
|
295
|
+
if resume_session_id and self.backend.supports_resume:
|
|
296
|
+
backend_session = session.backend_session_id or resume_session_id
|
|
297
|
+
await self.backend.resume(backend_session)
|
|
298
|
+
self.events.emit_message(f"Resumed session {resume_session_id}", "info")
|
|
299
|
+
else:
|
|
300
|
+
await self.backend.connect()
|
|
301
|
+
|
|
302
|
+
# Store backend session ID if available
|
|
303
|
+
if self.backend.session_id:
|
|
304
|
+
self.sessions.set_backend_session_id(self.backend.session_id)
|
|
305
|
+
|
|
306
|
+
# ═══════════════════════════════════════════════════════════════
|
|
307
|
+
# Stage A: Planner pre-pass
|
|
308
|
+
# ───────────────────────────────────────────────────────────────
|
|
309
|
+
# One cheap LLM call BEFORE the Senior loop starts. Augments the
|
|
310
|
+
# task with a JSON exploit chain. Falls back silently on any
|
|
311
|
+
# failure so the Senior runs exactly as today if anything breaks.
|
|
312
|
+
#
|
|
313
|
+
# Heavy logging at every decision point so the next "Planner is
|
|
314
|
+
# silent" incident is diagnosable from a single log file. After
|
|
315
|
+
# the previous absent-file incident, observability beats clean
|
|
316
|
+
# code: every branch traces to stderr AND to logging.info.
|
|
317
|
+
# ═══════════════════════════════════════════════════════════════
|
|
318
|
+
planner_model = os.getenv("PLANNER_MODEL", "").strip()
|
|
319
|
+
|
|
320
|
+
def _planner_trace(msg: str) -> None:
|
|
321
|
+
logging.getLogger(__name__).info("[PLANNER] %s", msg)
|
|
322
|
+
print(f"[PLANNER] {msg}", file=sys.stderr, flush=True)
|
|
323
|
+
|
|
324
|
+
# f-strings can't combine !r with a conditional inside a single
|
|
325
|
+
# placeholder, so compute the display strings first.
|
|
326
|
+
_planner_model_display = repr(planner_model) if planner_model else "(UNSET - will skip)"
|
|
327
|
+
_resume_display = (
|
|
328
|
+
repr(resume_session_id) if resume_session_id else "(none - fresh session)"
|
|
329
|
+
)
|
|
330
|
+
_planner_trace(f"Planner Model detected: {_planner_model_display}")
|
|
331
|
+
_planner_trace(f"resume_session_id: {_resume_display}")
|
|
332
|
+
|
|
333
|
+
if not planner_model:
|
|
334
|
+
_planner_trace(
|
|
335
|
+
"Planner attempt: SKIPPED (PLANNER_MODEL env var is empty "
|
|
336
|
+
"or unset). To enable, set PLANNER_MODEL in .env.auth or "
|
|
337
|
+
"export it before `make connect`. Continuing un-planned."
|
|
338
|
+
)
|
|
339
|
+
elif resume_session_id:
|
|
340
|
+
_planner_trace(
|
|
341
|
+
"Planner attempt: SKIPPED (resuming existing session; "
|
|
342
|
+
"original plan is already in the conversation history). "
|
|
343
|
+
"Continuing un-planned."
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
_planner_trace(f"Planner attempt: STARTING with model={planner_model!r}")
|
|
347
|
+
try:
|
|
348
|
+
from agent.core.planner import (
|
|
349
|
+
format_plan_for_injection,
|
|
350
|
+
gather_planning_context,
|
|
351
|
+
generate_plan,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Pre-plan enrichment: fingerprint a web target (PHANTOM)
|
|
355
|
+
# and retrieve framework CVEs (RAG) so the FIRST plan is
|
|
356
|
+
# grounded in the real stack, not parametric guessing.
|
|
357
|
+
#
|
|
358
|
+
# PERF: enrichment adds a SECOND serial LLM round-trip
|
|
359
|
+
# (crystallize_fingerprint) before generate_plan. On a slow
|
|
360
|
+
# reasoning model that doubles pre-plan latency, so it's now
|
|
361
|
+
# opt-in via PLANNER_ENRICHMENT (default off) — the common
|
|
362
|
+
# path makes a single planner call. Set PLANNER_ENRICHMENT=1
|
|
363
|
+
# to trade ~one extra round-trip for a stack-grounded plan.
|
|
364
|
+
# Best-effort either way — returns (None, None) on failure.
|
|
365
|
+
enrichment_on = bool(os.getenv("PLANNER_ENRICHMENT", "").strip())
|
|
366
|
+
if enrichment_on:
|
|
367
|
+
with span("planner.enrichment (PHANTOM+RAG)"):
|
|
368
|
+
phantom_findings, rag_context = await gather_planning_context(
|
|
369
|
+
target=self.config.target,
|
|
370
|
+
fingerprint_model=planner_model,
|
|
371
|
+
kb=self._kb,
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
phantom_findings, rag_context = None, None
|
|
375
|
+
_planner_trace(
|
|
376
|
+
"Pre-plan enrichment: SKIPPED (PLANNER_ENRICHMENT "
|
|
377
|
+
"unset). Single-call planning. Set PLANNER_ENRICHMENT=1 "
|
|
378
|
+
"to fingerprint the target + RAG before planning "
|
|
379
|
+
"(adds one LLM round-trip)."
|
|
380
|
+
)
|
|
381
|
+
_planner_trace(
|
|
382
|
+
"Pre-plan enrichment: "
|
|
383
|
+
f"phantom={'yes' if phantom_findings else 'no'}, "
|
|
384
|
+
f"rag={'yes' if rag_context else 'no'}"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
with span(f"planner.generate_plan [{planner_model}]"):
|
|
388
|
+
plan, plan_cost = await generate_plan(
|
|
389
|
+
task=task,
|
|
390
|
+
model=planner_model,
|
|
391
|
+
phantom_findings=phantom_findings,
|
|
392
|
+
rag_context=rag_context,
|
|
393
|
+
)
|
|
394
|
+
if plan_cost > 0:
|
|
395
|
+
self.sessions.add_cost(plan_cost)
|
|
396
|
+
injection = format_plan_for_injection(plan)
|
|
397
|
+
# Append (not prepend) so operator hint at top of task
|
|
398
|
+
# outranks the planner on conflict.
|
|
399
|
+
task = task + "\n\n" + injection
|
|
400
|
+
|
|
401
|
+
snippet = json.dumps(
|
|
402
|
+
{
|
|
403
|
+
"hypothesis": plan.overall_hypothesis[:120],
|
|
404
|
+
"step_count": len(plan.steps),
|
|
405
|
+
"first_step": plan.steps[0].goal[:120] if plan.steps else None,
|
|
406
|
+
},
|
|
407
|
+
default=str,
|
|
408
|
+
)
|
|
409
|
+
_planner_trace(f"Planner attempt: SUCCESS — {snippet}")
|
|
410
|
+
_planner_trace(f"Injection output (first 300 chars): {injection[:300]!r}")
|
|
411
|
+
_planner_trace(
|
|
412
|
+
f"Augmented task: original_len={len(task) - len(injection) - 2}, "
|
|
413
|
+
f"new_len={len(task)}, plan_cost=${plan_cost:.4f}"
|
|
414
|
+
)
|
|
415
|
+
self.events.emit_message(
|
|
416
|
+
f"Planner produced a {len(plan.steps)}-step plan "
|
|
417
|
+
f"(model={planner_model}, cost=${plan_cost:.4f})",
|
|
418
|
+
"info",
|
|
419
|
+
)
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
# Planner is best-effort — never fatal. Log loudly so the
|
|
422
|
+
# operator sees WHY it skipped (vs. silent failure).
|
|
423
|
+
_planner_trace(
|
|
424
|
+
f"Planner attempt: FAILURE — {exc.__class__.__name__}: {exc}. "
|
|
425
|
+
f"Falling back to un-planned baseline."
|
|
426
|
+
)
|
|
427
|
+
self.events.emit_message(
|
|
428
|
+
f"Planner skipped ({exc.__class__.__name__}: {exc}); running un-planned",
|
|
429
|
+
"info",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Send initial query (now potentially augmented with the plan)
|
|
433
|
+
mark("agent loop: dispatching first query (pre-pass complete)")
|
|
434
|
+
await self.backend.query(task)
|
|
435
|
+
self.sessions.update_status(SessionStatus.RUNNING)
|
|
436
|
+
|
|
437
|
+
# Process messages with pause/stop handling
|
|
438
|
+
output_parts: list[str] = []
|
|
439
|
+
flags_found: list[str] = []
|
|
440
|
+
backend_error: str | None = None
|
|
441
|
+
|
|
442
|
+
async for msg in self.backend.receive_messages():
|
|
443
|
+
# Backend-reported failure (timeout, rate limit, oversized
|
|
444
|
+
# prompt, auth failure). Capture the message, break out of
|
|
445
|
+
# the loop, and let the post-loop check below raise so the
|
|
446
|
+
# outer try/except sets ERROR state and the spinner stops.
|
|
447
|
+
if msg.type == MessageType.ERROR:
|
|
448
|
+
backend_error = (
|
|
449
|
+
msg.content if isinstance(msg.content, str) else str(msg.content)
|
|
450
|
+
)
|
|
451
|
+
self.events.emit_message(backend_error, "error")
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
# Check stop request
|
|
455
|
+
if self._stop_requested:
|
|
456
|
+
self._set_state(AgentState.IDLE, "Stopped by user")
|
|
457
|
+
self.sessions.update_status(SessionStatus.PAUSED)
|
|
458
|
+
break
|
|
459
|
+
|
|
460
|
+
# Check pause request (between messages = safe point)
|
|
461
|
+
if self._pause_requested:
|
|
462
|
+
self._pause_requested = False
|
|
463
|
+
self._set_state(AgentState.PAUSED, "Paused - waiting for input")
|
|
464
|
+
self.sessions.update_status(SessionStatus.PAUSED)
|
|
465
|
+
|
|
466
|
+
# Wait for resume
|
|
467
|
+
await self._resume_event.wait()
|
|
468
|
+
self._resume_event.clear()
|
|
469
|
+
|
|
470
|
+
if self._stop_requested:
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
# Resume with pending instruction
|
|
474
|
+
self._set_state(AgentState.RUNNING, "Resumed")
|
|
475
|
+
self.sessions.update_status(SessionStatus.RUNNING)
|
|
476
|
+
|
|
477
|
+
if self._pending_instruction:
|
|
478
|
+
self.sessions.add_instruction(self._pending_instruction)
|
|
479
|
+
self.events.emit_message(
|
|
480
|
+
f"Injecting: {self._pending_instruction[:50]}...", "info"
|
|
481
|
+
)
|
|
482
|
+
await self.backend.query(self._pending_instruction)
|
|
483
|
+
self._pending_instruction = None
|
|
484
|
+
|
|
485
|
+
# Process message by type
|
|
486
|
+
await self._process_message(msg, output_parts, flags_found)
|
|
487
|
+
|
|
488
|
+
# Backend signalled failure (timeout/auth/rate-limit/etc.) via an
|
|
489
|
+
# ERROR message. Surface as a clean failure rather than reporting
|
|
490
|
+
# success with whatever partial output we accumulated.
|
|
491
|
+
if backend_error:
|
|
492
|
+
self._set_state(AgentState.ERROR, backend_error)
|
|
493
|
+
self.sessions.set_error(backend_error)
|
|
494
|
+
self.sessions.update_status(SessionStatus.ERROR)
|
|
495
|
+
return {
|
|
496
|
+
"success": False,
|
|
497
|
+
"error": backend_error,
|
|
498
|
+
"output": "\n".join(output_parts),
|
|
499
|
+
"flags_found": flags_found,
|
|
500
|
+
"session_id": session.session_id,
|
|
501
|
+
"cost_usd": session.total_cost_usd,
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
# Completed successfully
|
|
505
|
+
if not self._stop_requested:
|
|
506
|
+
self._set_state(AgentState.COMPLETED)
|
|
507
|
+
self.sessions.update_status(SessionStatus.COMPLETED)
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
"success": True,
|
|
511
|
+
"output": "\n".join(output_parts),
|
|
512
|
+
"flags_found": flags_found,
|
|
513
|
+
"session_id": session.session_id,
|
|
514
|
+
"cost_usd": session.total_cost_usd,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
except Exception as e:
|
|
518
|
+
self._set_state(AgentState.ERROR, str(e))
|
|
519
|
+
self.sessions.set_error(str(e))
|
|
520
|
+
self.sessions.update_status(SessionStatus.ERROR)
|
|
521
|
+
return {"success": False, "error": str(e)}
|
|
522
|
+
|
|
523
|
+
finally:
|
|
524
|
+
if self.backend:
|
|
525
|
+
await self.backend.disconnect()
|
|
526
|
+
|
|
527
|
+
async def _process_message(
|
|
528
|
+
self, msg: AgentMessage, output_parts: list[str], flags_found: list[str]
|
|
529
|
+
) -> None:
|
|
530
|
+
"""Process a single agent message.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
msg: Message to process
|
|
534
|
+
output_parts: List to append text output to
|
|
535
|
+
flags_found: List to append found flags to
|
|
536
|
+
"""
|
|
537
|
+
if msg.type == MessageType.TEXT:
|
|
538
|
+
output_parts.append(msg.content)
|
|
539
|
+
self.events.emit_message(msg.content)
|
|
540
|
+
|
|
541
|
+
# Detect flags
|
|
542
|
+
detected = self._detect_flags(msg.content)
|
|
543
|
+
for flag in detected:
|
|
544
|
+
if flag not in flags_found:
|
|
545
|
+
flags_found.append(flag)
|
|
546
|
+
self.sessions.add_flag(flag, msg.content[:200])
|
|
547
|
+
self.events.emit_flag(flag, msg.content[:200])
|
|
548
|
+
|
|
549
|
+
elif msg.type == MessageType.TOOL_START:
|
|
550
|
+
tool_name = msg.tool_name or "unknown"
|
|
551
|
+
tool_args = msg.tool_args or {}
|
|
552
|
+
|
|
553
|
+
self.events.emit_tool(
|
|
554
|
+
status="start",
|
|
555
|
+
name=tool_name,
|
|
556
|
+
args=tool_args,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# Loop Detection & Replan
|
|
560
|
+
import json
|
|
561
|
+
|
|
562
|
+
args_str = json.dumps(tool_args, sort_keys=True)
|
|
563
|
+
self._tool_history.append((tool_name, args_str))
|
|
564
|
+
|
|
565
|
+
if self._looks_like_loop():
|
|
566
|
+
self.inject(
|
|
567
|
+
"LOOP DETECTED: you have run the same command 3 times. "
|
|
568
|
+
'Output a JSON object: {"why_stuck": "...", "new_hypothesis": "..."}. '
|
|
569
|
+
"Then execute a new chain."
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
elif msg.type == MessageType.TOOL_RESULT:
|
|
573
|
+
tool_name = msg.tool_name or "unknown"
|
|
574
|
+
raw_content = msg.content or ""
|
|
575
|
+
try:
|
|
576
|
+
summarized = self._tool_parser.summarize_output(tool_name, raw_content)
|
|
577
|
+
except Exception as e:
|
|
578
|
+
logging.getLogger(__name__).warning(
|
|
579
|
+
"Tool parser failed for %s: %s. Falling back to raw output.",
|
|
580
|
+
tool_name,
|
|
581
|
+
e,
|
|
582
|
+
)
|
|
583
|
+
summarized = raw_content
|
|
584
|
+
|
|
585
|
+
# Scan raw tool output for flags — the LLM may not echo a flag it
|
|
586
|
+
# already saw in `cat flag.txt` output, so we'd otherwise miss it
|
|
587
|
+
# and trigger a needless retry cycle. Use raw_content (not the
|
|
588
|
+
# summarized JSON) so we don't depend on summarizer behaviour.
|
|
589
|
+
for flag in self._detect_flags(raw_content):
|
|
590
|
+
if flag not in flags_found:
|
|
591
|
+
flags_found.append(flag)
|
|
592
|
+
context_snippet = raw_content[:200] if isinstance(raw_content, str) else ""
|
|
593
|
+
self.sessions.add_flag(flag, context_snippet)
|
|
594
|
+
self.events.emit_flag(flag, context_snippet)
|
|
595
|
+
|
|
596
|
+
self.events.emit_tool(
|
|
597
|
+
status="complete",
|
|
598
|
+
name=tool_name,
|
|
599
|
+
result=summarized,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if tool_name.lower() == "nmap" and self._kb is not None:
|
|
603
|
+
try:
|
|
604
|
+
parsed = self._tool_parser.parse_nmap(raw_content)
|
|
605
|
+
for entry in parsed.get("open_ports", []):
|
|
606
|
+
service = entry.get("service", "unknown")
|
|
607
|
+
port = str(entry.get("port", ""))
|
|
608
|
+
chunks = await self._kb.query_technique(service, port)
|
|
609
|
+
if chunks:
|
|
610
|
+
rag_context = (
|
|
611
|
+
f"[RAG] Techniques for {service} on port {port}:\n"
|
|
612
|
+
+ "\n---\n".join(chunks)
|
|
613
|
+
)
|
|
614
|
+
self.inject(rag_context)
|
|
615
|
+
except Exception as exc:
|
|
616
|
+
logging.getLogger(__name__).debug("RAG query failed: %s", exc)
|
|
617
|
+
|
|
618
|
+
elif msg.type == MessageType.RESULT:
|
|
619
|
+
cost = msg.metadata.get("cost_usd", 0)
|
|
620
|
+
if cost > 0:
|
|
621
|
+
self.sessions.add_cost(cost)
|
|
622
|
+
|
|
623
|
+
def _detect_flags(self, text: str) -> list[str]:
|
|
624
|
+
"""Detect potential flags in text.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
text: Text to search for flags
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
List of detected flag strings
|
|
631
|
+
"""
|
|
632
|
+
flags = []
|
|
633
|
+
for pattern in self.FLAG_PATTERNS:
|
|
634
|
+
for match in re.finditer(pattern, text, re.IGNORECASE):
|
|
635
|
+
flag = match.group(0)
|
|
636
|
+
if flag not in flags:
|
|
637
|
+
flags.append(flag)
|
|
638
|
+
return flags
|