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.
Files changed (95) hide show
  1. agent/__init__.py +17 -0
  2. agent/benchmark/__init__.py +11 -0
  3. agent/benchmark/cli.py +179 -0
  4. agent/benchmark/config.py +15 -0
  5. agent/benchmark/docker.py +192 -0
  6. agent/benchmark/registry.py +99 -0
  7. agent/core/__init__.py +0 -0
  8. agent/core/agent.py +362 -0
  9. agent/core/backend.py +1667 -0
  10. agent/core/config.py +106 -0
  11. agent/core/controller.py +638 -0
  12. agent/core/events.py +177 -0
  13. agent/core/langfuse.py +320 -0
  14. agent/core/phantom.py +2327 -0
  15. agent/core/planner.py +493 -0
  16. agent/core/profiling.py +58 -0
  17. agent/core/sanitizer.py +104 -0
  18. agent/core/session.py +228 -0
  19. agent/core/tracer.py +137 -0
  20. agent/interface/__init__.py +0 -0
  21. agent/interface/components/__init__.py +0 -0
  22. agent/interface/components/activity_feed.py +202 -0
  23. agent/interface/components/renderers.py +149 -0
  24. agent/interface/components/splash.py +112 -0
  25. agent/interface/main.py +1126 -0
  26. agent/interface/styles.tcss +421 -0
  27. agent/interface/tui.py +508 -0
  28. agent/parsing/html_distiller.py +230 -0
  29. agent/parsing/tool_parser.py +115 -0
  30. agent/prompts/__init__.py +0 -0
  31. agent/prompts/pentesting.py +238 -0
  32. agent/rag_module/knowledge_base.py +116 -0
  33. agent/tests/benchmark_phantom.py +455 -0
  34. agent/tools/__init__.py +14 -0
  35. agent/tools/base.py +99 -0
  36. agent/tools/executor.py +345 -0
  37. agent/tools/registry.py +47 -0
  38. backend/README.md +244 -0
  39. backend/__init__.py +10 -0
  40. backend/api/__init__.py +1 -0
  41. backend/api/routes_scan.py +932 -0
  42. backend/authz.py +75 -0
  43. backend/cli.py +261 -0
  44. backend/compliance/__init__.py +1 -0
  45. backend/compliance/pdpa_mapping.py +16 -0
  46. backend/core/__init__.py +1 -0
  47. backend/core/coverage.py +93 -0
  48. backend/core/events.py +166 -0
  49. backend/core/llm_client.py +614 -0
  50. backend/core/orchestrator.py +402 -0
  51. backend/core/scan_memory.py +236 -0
  52. backend/crawler/__init__.py +1 -0
  53. backend/crawler/csrf.py +75 -0
  54. backend/crawler/headless.py +232 -0
  55. backend/crawler/js_analyzer.py +133 -0
  56. backend/crawler/spider.py +550 -0
  57. backend/crawler/subdomains.py +279 -0
  58. backend/daemon.py +252 -0
  59. backend/db/README.md +86 -0
  60. backend/db/__init__.py +17 -0
  61. backend/db/engine.py +87 -0
  62. backend/db/models.py +206 -0
  63. backend/db/repository.py +316 -0
  64. backend/db/schema.sql +247 -0
  65. backend/modes/__init__.py +5 -0
  66. backend/modes/base.py +178 -0
  67. backend/modes/ctf.py +39 -0
  68. backend/modes/enterprise.py +210 -0
  69. backend/modes/general.py +226 -0
  70. backend/modes/registry.py +34 -0
  71. backend/reporting/__init__.py +0 -0
  72. backend/reporting/generator.py +285 -0
  73. backend/schemas/__init__.py +1 -0
  74. backend/schemas/api.py +423 -0
  75. backend/tools/__init__.py +62 -0
  76. backend/tools/dirbrute_tool.py +304 -0
  77. backend/tools/general_report_tool.py +135 -0
  78. backend/tools/http_tool.py +351 -0
  79. backend/tools/nuclei_tool.py +20 -0
  80. backend/tools/report_tool.py +145 -0
  81. backend/tools/shell_tools.py +23 -0
  82. backend/verification/__init__.py +1 -0
  83. backend/verification/evidence_store.py +125 -0
  84. backend/verification/general_oracle.py +369 -0
  85. backend/verification/idor_oracle.py +131 -0
  86. backend/waf/__init__.py +0 -0
  87. backend/waf/detector.py +147 -0
  88. backend/waf/evasion.py +117 -0
  89. backend/webui/index.html +713 -0
  90. backend/workspace.py +182 -0
  91. shadowcat-2.0.0.dist-info/METADATA +360 -0
  92. shadowcat-2.0.0.dist-info/RECORD +95 -0
  93. shadowcat-2.0.0.dist-info/WHEEL +4 -0
  94. shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
  95. shadowcat-2.0.0.dist-info/licenses/LICENSE.md +21 -0
@@ -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