greydgl--pentestgpt
e238d701f2
* feat: 🎸 version 1.0 agentic workflow
Major rewrite of PentestGPT to use an agentic pipeline architecture:
Core Changes: - New event-driven architecture with EventBus for
TUI-agent decoupling - Implemented AgentController with 5-state
lifecycle (IDLE->RUNNING->PAUSED->COMPLETED->ERROR) - Added AgentBackend
interface with ClaudeCodeBackend implementation - Session management
with file-based persistence for resumable pentests - Langfuse
integration for observability and tracing Interface: - New Textual-based
TUI with real-time activity feed - Keyboard shortcuts: F1 help, Ctrl+P
pause, Ctrl+Q quit - Enhanced CLI with --target, --instruction,
--non-interactive, --debug flags Project Structure: - Moved legacy
multi-LLM version (v0.15) to legacy/ directory - New pentestgpt/core/
for agent, controller, events, session modules - New
pentestgpt/interface/ for TUI and CLI components - New
pentestgpt/benchmark/ for xbow benchmark integration - Comprehensive
test suite in tests/ with unit and integration tests DevOps: - Docker
support with Ubuntu 24.04 container - GitHub Actions CI/CD pipeline -
Makefile with dev commands (test, lint, format, typecheck) - Added
xbow-validation-benchmarks as submodule
* style: format code with Black
This commit fixes the style issues introduced in abe3be0 according to the output
from Black.
Details: https://github.com/GreyDGL/PentestGPT/pull/325
* fix: 🐛 fix test pipeline
* feat: 🎸 update format
* feat: 🎸 update
---------
Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
347 行
11 KiB
Python
347 行
11 KiB
Python
"""Agent controller with lifecycle management, pause/resume, and session persistence."""
|
|
|
|
import asyncio
|
|
import re
|
|
from enum import Enum
|
|
from typing import Any, ClassVar
|
|
|
|
from pentestgpt.core.backend import (
|
|
AgentBackend,
|
|
AgentMessage,
|
|
ClaudeCodeBackend,
|
|
MessageType,
|
|
)
|
|
from pentestgpt.core.config import PentestGPTConfig
|
|
from pentestgpt.core.events import Event, EventBus, EventType
|
|
from pentestgpt.core.session import SessionStatus, SessionStore
|
|
|
|
|
|
class AgentState(Enum):
|
|
"""Simple 5-state model for agent lifecycle."""
|
|
|
|
IDLE = "idle"
|
|
RUNNING = "running"
|
|
PAUSED = "paused"
|
|
COMPLETED = "completed"
|
|
ERROR = "error"
|
|
|
|
|
|
class AgentController:
|
|
"""
|
|
Central orchestrator with lifecycle management.
|
|
|
|
Features:
|
|
- Framework-agnostic via AgentBackend
|
|
- Pause/resume/stop control
|
|
- Instruction injection
|
|
- Session persistence
|
|
"""
|
|
|
|
# Flag detection patterns
|
|
FLAG_PATTERNS: ClassVar[list[str]] = [
|
|
r"flag\{[^\}]+\}", # flag{...}
|
|
r"FLAG\{[^\}]+\}", # FLAG{...}
|
|
r"HTB\{[^\}]+\}", # HTB{...}
|
|
r"CTF\{[^\}]+\}", # CTF{...}
|
|
r"[A-Za-z0-9_]+\{[^\}]+\}", # Generic CTF format
|
|
r"\b[a-f0-9]{32}\b", # 32-char hex (HTB user/root flags)
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
config: PentestGPTConfig,
|
|
backend: AgentBackend | None = None,
|
|
session_store: SessionStore | None = None,
|
|
events: EventBus | None = None,
|
|
):
|
|
"""Initialize controller.
|
|
|
|
Args:
|
|
config: PentestGPT configuration
|
|
backend: Optional custom backend (defaults to ClaudeCodeBackend)
|
|
session_store: Optional custom session store
|
|
events: Optional custom event bus
|
|
"""
|
|
self.config = config
|
|
self.backend = backend
|
|
self.sessions = session_store or SessionStore()
|
|
self.events = events or EventBus.get()
|
|
|
|
# State management
|
|
self._state = AgentState.IDLE
|
|
self._pause_requested = False
|
|
self._stop_requested = False
|
|
self._resume_event = asyncio.Event()
|
|
self._pending_instruction: str | None = None
|
|
|
|
# Subscribe to user events
|
|
self.events.subscribe(EventType.USER_COMMAND, self._on_user_command)
|
|
self.events.subscribe(EventType.USER_INPUT, self._on_user_input)
|
|
|
|
@property
|
|
def state(self) -> AgentState:
|
|
"""Get current agent state."""
|
|
return self._state
|
|
|
|
def _set_state(self, state: AgentState, details: str = "") -> None:
|
|
"""Update state and emit event."""
|
|
self._state = state
|
|
self.events.emit_state(state.value, details)
|
|
|
|
# === Control Methods (called from TUI) ===
|
|
|
|
def pause(self) -> bool:
|
|
"""Request pause at next safe point.
|
|
|
|
Returns:
|
|
True if pause request was accepted
|
|
"""
|
|
if self._state == AgentState.RUNNING:
|
|
self._pause_requested = True
|
|
return True
|
|
return False
|
|
|
|
def resume(self, instruction: str | None = None) -> bool:
|
|
"""Resume from paused state.
|
|
|
|
Args:
|
|
instruction: Optional instruction to inject on resume
|
|
|
|
Returns:
|
|
True if resume request was accepted
|
|
"""
|
|
if self._state == AgentState.PAUSED:
|
|
self._pending_instruction = instruction
|
|
self._pause_requested = False
|
|
self._resume_event.set()
|
|
return True
|
|
return False
|
|
|
|
def stop(self) -> bool:
|
|
"""Request stop.
|
|
|
|
Returns:
|
|
True (stop is always accepted)
|
|
"""
|
|
self._stop_requested = True
|
|
self._resume_event.set() # Unblock if paused
|
|
return True
|
|
|
|
def inject(self, instruction: str) -> bool:
|
|
"""Queue instruction for next pause point.
|
|
|
|
Args:
|
|
instruction: Instruction to inject
|
|
|
|
Returns:
|
|
True if instruction was queued
|
|
"""
|
|
if self._state in (AgentState.RUNNING, AgentState.PAUSED):
|
|
self._pending_instruction = instruction
|
|
if self._state == AgentState.RUNNING:
|
|
self._pause_requested = True
|
|
return True
|
|
return False
|
|
|
|
# === Event Handlers ===
|
|
|
|
def _on_user_command(self, event: Event) -> None:
|
|
"""Handle user command events."""
|
|
cmd = event.data.get("command")
|
|
if cmd == "pause":
|
|
self.pause()
|
|
elif cmd == "resume":
|
|
self.resume()
|
|
elif cmd == "stop":
|
|
self.stop()
|
|
|
|
def _on_user_input(self, event: Event) -> None:
|
|
"""Handle user input events."""
|
|
text = event.data.get("text", "")
|
|
if text:
|
|
self.inject(text)
|
|
|
|
# === Main Execution ===
|
|
|
|
async def run(self, task: str, resume_session_id: str | None = None) -> dict[str, Any]:
|
|
"""Run agent with full lifecycle management.
|
|
|
|
Args:
|
|
task: Task description for the agent
|
|
resume_session_id: Optional session ID to resume
|
|
|
|
Returns:
|
|
Result dictionary with success, output, flags, etc.
|
|
"""
|
|
# Reset state
|
|
self._pause_requested = False
|
|
self._stop_requested = False
|
|
self._resume_event.clear()
|
|
|
|
# Create or resume session
|
|
if resume_session_id:
|
|
session = self.sessions.load(resume_session_id)
|
|
if not session:
|
|
return {
|
|
"success": False,
|
|
"error": f"Session {resume_session_id} not found",
|
|
}
|
|
# Update task if resuming
|
|
if not task:
|
|
task = session.task
|
|
else:
|
|
session = self.sessions.create(
|
|
target=self.config.target,
|
|
task=task,
|
|
model=self.config.llm_model,
|
|
)
|
|
|
|
# Create backend if needed
|
|
if self.backend is None:
|
|
from pentestgpt.prompts.pentesting import get_ctf_prompt
|
|
|
|
self.backend = ClaudeCodeBackend(
|
|
working_directory=str(self.config.working_directory),
|
|
system_prompt=get_ctf_prompt(self.config.custom_instruction),
|
|
model=self.config.llm_model,
|
|
)
|
|
|
|
try:
|
|
self._set_state(AgentState.RUNNING, "Connecting...")
|
|
|
|
# Connect (or resume)
|
|
if resume_session_id and self.backend.supports_resume:
|
|
backend_session = session.backend_session_id or resume_session_id
|
|
await self.backend.resume(backend_session)
|
|
self.events.emit_message(f"Resumed session {resume_session_id}", "info")
|
|
else:
|
|
await self.backend.connect()
|
|
|
|
# Store backend session ID if available
|
|
if self.backend.session_id:
|
|
self.sessions.set_backend_session_id(self.backend.session_id)
|
|
|
|
# Send initial query
|
|
await self.backend.query(task)
|
|
self.sessions.update_status(SessionStatus.RUNNING)
|
|
|
|
# Process messages with pause/stop handling
|
|
output_parts: list[str] = []
|
|
flags_found: list[str] = []
|
|
|
|
async for msg in self.backend.receive_messages():
|
|
# Check stop request
|
|
if self._stop_requested:
|
|
self._set_state(AgentState.IDLE, "Stopped by user")
|
|
self.sessions.update_status(SessionStatus.PAUSED)
|
|
break
|
|
|
|
# Check pause request (between messages = safe point)
|
|
if self._pause_requested:
|
|
self._pause_requested = False
|
|
self._set_state(AgentState.PAUSED, "Paused - waiting for input")
|
|
self.sessions.update_status(SessionStatus.PAUSED)
|
|
|
|
# Wait for resume
|
|
await self._resume_event.wait()
|
|
self._resume_event.clear()
|
|
|
|
if self._stop_requested:
|
|
break
|
|
|
|
# Resume with pending instruction
|
|
self._set_state(AgentState.RUNNING, "Resumed")
|
|
self.sessions.update_status(SessionStatus.RUNNING)
|
|
|
|
if self._pending_instruction:
|
|
self.sessions.add_instruction(self._pending_instruction)
|
|
self.events.emit_message(
|
|
f"Injecting: {self._pending_instruction[:50]}...", "info"
|
|
)
|
|
await self.backend.query(self._pending_instruction)
|
|
self._pending_instruction = None
|
|
|
|
# Process message by type
|
|
await self._process_message(msg, output_parts, flags_found)
|
|
|
|
# Completed successfully
|
|
if not self._stop_requested:
|
|
self._set_state(AgentState.COMPLETED)
|
|
self.sessions.update_status(SessionStatus.COMPLETED)
|
|
|
|
return {
|
|
"success": True,
|
|
"output": "\n".join(output_parts),
|
|
"flags_found": flags_found,
|
|
"session_id": session.session_id,
|
|
"cost_usd": session.total_cost_usd,
|
|
}
|
|
|
|
except Exception as e:
|
|
self._set_state(AgentState.ERROR, str(e))
|
|
self.sessions.set_error(str(e))
|
|
self.sessions.update_status(SessionStatus.ERROR)
|
|
return {"success": False, "error": str(e)}
|
|
|
|
finally:
|
|
if self.backend:
|
|
await self.backend.disconnect()
|
|
|
|
async def _process_message(
|
|
self, msg: AgentMessage, output_parts: list[str], flags_found: list[str]
|
|
) -> None:
|
|
"""Process a single agent message.
|
|
|
|
Args:
|
|
msg: Message to process
|
|
output_parts: List to append text output to
|
|
flags_found: List to append found flags to
|
|
"""
|
|
if msg.type == MessageType.TEXT:
|
|
output_parts.append(msg.content)
|
|
self.events.emit_message(msg.content)
|
|
|
|
# Detect flags
|
|
detected = self._detect_flags(msg.content)
|
|
for flag in detected:
|
|
if flag not in flags_found:
|
|
flags_found.append(flag)
|
|
self.sessions.add_flag(flag, msg.content[:200])
|
|
self.events.emit_flag(flag, msg.content[:200])
|
|
|
|
elif msg.type == MessageType.TOOL_START:
|
|
self.events.emit_tool(
|
|
status="start",
|
|
name=msg.tool_name or "unknown",
|
|
args=msg.tool_args,
|
|
)
|
|
|
|
elif msg.type == MessageType.TOOL_RESULT:
|
|
self.events.emit_tool(
|
|
status="complete",
|
|
name=msg.tool_name or "unknown",
|
|
result=msg.content,
|
|
)
|
|
|
|
elif msg.type == MessageType.RESULT:
|
|
cost = msg.metadata.get("cost_usd", 0)
|
|
if cost > 0:
|
|
self.sessions.add_cost(cost)
|
|
|
|
def _detect_flags(self, text: str) -> list[str]:
|
|
"""Detect potential flags in text.
|
|
|
|
Args:
|
|
text: Text to search for flags
|
|
|
|
Returns:
|
|
List of detected flag strings
|
|
"""
|
|
flags = []
|
|
for pattern in self.FLAG_PATTERNS:
|
|
for match in re.finditer(pattern, text, re.IGNORECASE):
|
|
flag = match.group(0)
|
|
if flag not in flags:
|
|
flags.append(flag)
|
|
return flags
|