Skip to content

feat: modernize legacy PentestGPT with native multi-LLM support - #469

Merged
GreyDGL merged 1 commit into
legacy-multi-llm-basefrom
feat/legacy-multi-llm
Jun 6, 2026
Merged

GreyDGL merged 1 commit into
legacy-multi-llm-basefrom
feat/legacy-multi-llm

Conversation

@GreyDGL

@GreyDGL GreyDGL commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

Rewrites and modernizes the legacy PentestGPT (the classic USENIX-2024 human-in-the-loop tool) so it works with the latest 2026 LLMs. It ships as a standalone pentestgpt_legacy package / pentestgpt-legacy CLI and does not touch the autonomous 3rd-gen agent.

The classic design is preserved — three cooperating LLM sessions (reasoning / generation / parsing) maintaining a Pentesting Task Tree through an interactive REPL (next, more, todo, discuss) — but the broken legacy LLM layer (mixed OpenAI SDK versions, tiktoken("gpt-4o") for every provider, stale hardcoded model ids, an unwired app.config) is replaced with a clean native per-provider layer.

What's included

  • llm/registry.py — single source of truth for supported models (web-verified June 2026 ids); powers --list-models and the README table.
  • llm/providers/OpenAICompatibleProvider (OpenAI + DeepSeek/Ollama/xAI/Qwen/Moonshot via base_url, with a Responses-API path for *-pro/*-codex), AnthropicProvider, GeminiProvider (new google-genai SDK).
  • llm/client.pyLLMClient bridges async providers to the core's synchronous send_new_message/send_message (drop-in for the old LLMAPI), with context-window-aware history trimming.
  • llm/factory.py / llm/config.py — model→provider construction and pydantic-settings credentials (per-provider keys + base-url overrides).
  • CLIpentestgpt-legacy with --list-models, --smoke-test, --reasoning-model, --parsing-model, --base-url.
  • Docs — README "Interactive Multi-LLM Mode" section + model table; CLAUDE.md architecture notes.

Supported providers/models (June 2026)

OpenAI (gpt-5.5, gpt-5.5-pro, gpt-5.4-mini/nano, gpt-5.2, gpt-5.3-codex, + legacy gpt-4o/o3/o4-mini) · Anthropic (claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5) · Google Gemini (gemini-3.1-pro, gemini-3.5-flash, gemini-3-pro, + 2.5) · DeepSeek (deepseek-v4-flash/pro) · xAI (grok-4.3) · Qwen (qwen3.7-max, qwen3.5-flash) · Moonshot (kimi-k2.6) · local Ollama (ollama:<model>).

Verification

  • Live smoke test: 22 passed, 0 failed, 6 skipped (pentestgpt-legacy --smoke-test) — every model with a configured key did a real round-trip; Gemini skipped (no key in this env). The first run surfaced 5 real issues, all fixed: gpt-5.5-pro/gpt-5.3-codex needed the Responses API; claude-3-7-sonnet-latest/grok-4.1-fast were retired/invalid ids (removed); Moonshot defaulted to the wrong region (now .cn, overridable).
  • End-to-end: real reasoning/generation/parsing sessions produce and update a PTT from simulated nmap output.
  • 25 new unit tests (mocked); full suite 151 passed, 2 skipped; ruff clean; mypy clean on the typed core; existing make typecheck unchanged; wheel builds with the new package.

Notes for reviewers

  • Scope is intentionally limited to the legacy modernization; the in-progress 3rd-gen refactor in the working tree is not part of this PR.
  • This branch is based on the current local main, which is a few commits ahead of origin/main, so the diff also shows those prior commits.
  • make typecheck stays scoped to pentestgpt/; the new package is covered by ruff (make lint) and tests/legacy/. Run uv run mypy pentestgpt_legacy/llm/ for its typed core.

🤖 Generated with Claude Code

Rebuild the classic USENIX-2024 interactive PentestGPT (reasoning / generation /
parsing sessions + Pentesting Task Tree + REPL) as a standalone
`pentestgpt_legacy` package on a native per-provider LLM layer that supports the
latest 2026 models.

- llm/: BaseProvider + OpenAI-compatible / Anthropic / Gemini connectors, a
  web-verified model registry (OpenAI, Anthropic, Gemini, DeepSeek, xAI, Qwen,
  Moonshot, local Ollama), a factory, and an LLMClient bridging async providers
  to the core's synchronous send_new_message/send_message session API.
- CLI `pentestgpt-legacy`: --list-models and --smoke-test (live per-model
  round-trip matrix), plus --reasoning-model / --parsing-model / --base-url.
- Tests: 25 unit tests (mocked, no network). Live smoke test verified 22/22
  models with a configured key respond.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request restructures the repository to separate the autonomous agent (pentestgpt/) from the modernized legacy interactive mode (pentestgpt_legacy/), introducing a multi-stage pipeline orchestrator and updating the Docker environment to use uv. The legacy mode is rebuilt with a native per-provider LLM layer supporting modern models. The code review identified several critical issues, including an unawaited coroutine in the backend process cleanup, potential type errors when handling flag dictionaries in the pipeline and stage prompts, and unhandled exceptions in the synchronous runner thread. Additionally, the reviewer recommended optimizing LLM client instantiation across the Anthropic, Gemini, and OpenAI-compatible providers to prevent connection overhead.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +148 to +150
# Non-async wait — safe even during event loop teardown
with contextlib.suppress(Exception):
proc.wait() # type: ignore[unused-coroutine]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In _kill_process, proc.wait() is called without await. Since proc.wait() is an asynchronous coroutine in asyncio.subprocess.Process, calling it without await does not wait for or reap the process, and it will raise a RuntimeWarning: coroutine 'Process.wait' was never awaited.

To perform a non-blocking synchronous wait safely during event loop teardown, we should wait on the underlying subprocess.Popen object directly.

Suggested change
# Non-async wait — safe even during event loop teardown
with contextlib.suppress(Exception):
proc.wait() # type: ignore[unused-coroutine]
# Non-async wait — safe even during event loop teardown
with contextlib.suppress(Exception):
if proc._transport:
popen = proc._transport.get_extra_info('subprocess')
if popen:
popen.wait()

Comment on lines +93 to +97
for f in r.flags_found:
if f not in seen:
seen.add(f)
flags.append(f)
return flags

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If r.flags_found contains dictionary objects (e.g., {"flag": "...", "context": "..."}), iterating over them and adding them directly to the seen set will raise TypeError: unhashable type: 'dict'.

We should extract the flag string from f if it is a dictionary before checking or adding it to the seen set.

Suggested change
for f in r.flags_found:
if f not in seen:
seen.add(f)
flags.append(f)
return flags
for r in self.stage_results:
for f in r.flags_found:
flag_str = f.get("flag", f) if isinstance(f, dict) else f
if flag_str not in seen:
seen.add(flag_str)
flags.append(flag_str)

Comment on lines +142 to +143
parts.append(f"Flags found: {', '.join(result.flags_found)}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If result.flags_found contains dictionary objects, calling ', '.join(result.flags_found) will raise TypeError: sequence item 0: expected str instance, dict found.

We should extract the flag string from each element in result.flags_found before joining them.

Suggested change
parts.append(f"Flags found: {', '.join(result.flags_found)}")
if result.flags_found:
flag_strs = [f.get("flag", f) if isinstance(f, dict) else f for f in result.flags_found]
parts.append(f"Flags found: {', '.join(flag_strs)}")

Comment on lines +39 to +47
box: dict[str, Any] = {}

def _runner() -> None:
box["value"] = asyncio.run(coro)

thread = threading.Thread(target=_runner)
thread.start()
thread.join()
return box["value"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In run_sync, the _runner thread function executes asyncio.run(coro) but does not capture or propagate any exceptions raised during execution. If an exception occurs (e.g., network timeout, authentication failure), the thread will terminate silently, and the main thread will raise a confusing KeyError: 'value' when trying to access box["value"].

We should catch exceptions in the thread, store them in box, and re-raise them in the main thread to preserve the original traceback and error message.

Suggested change
box: dict[str, Any] = {}
def _runner() -> None:
box["value"] = asyncio.run(coro)
thread = threading.Thread(target=_runner)
thread.start()
thread.join()
return box["value"]
box: dict[str, Any] = {}
def _runner() -> None:
try:
box["value"] = asyncio.run(coro)
except Exception as e:
box["error"] = e
thread = threading.Thread(target=_runner)
thread.start()
thread.join()
if "error" in box:
raise box["error"]
return box["value"]

Comment on lines +17 to +45
from pentestgpt_legacy.llm.registry import ModelSpec


class OpenAICompatibleProvider(BaseProvider):
"""Async OpenAI-compatible provider (Chat Completions, optional Responses API)."""

async def acomplete(
self,
messages: list[Message],
system: str | None,
spec: ModelSpec,
*,
max_output_tokens: int | None = None,
temperature: float | None = None,
) -> str:
try:
from openai import AsyncOpenAI
except ImportError as exc: # pragma: no cover - install-time guard
raise RuntimeError(
"The 'openai' package is required. Install with: uv add openai"
) from exc

# Ollama needs a non-empty key string but ignores its value.
api_key = self.api_key or "not-needed"
async with AsyncOpenAI(api_key=api_key, base_url=self.base_url) as client:
if spec.responses_api:
return await self._via_responses(
client, messages, system, spec, max_output_tokens, temperature
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instantiating a new AsyncOpenAI client on every call to acomplete is highly inefficient. It creates a new httpx.AsyncClient and connection pool each time, requiring a new TCP and TLS handshake for every single request, which adds significant latency and prevents connection reuse.

We should instantiate the client once in __init__ and reuse it across requests.

class OpenAICompatibleProvider(BaseProvider):
    """Async OpenAI-compatible provider (Chat Completions, optional Responses API)."""

    def __init__(self, info: ProviderInfo, api_key: str | None, base_url: str | None):
        super().__init__(info, api_key, base_url)
        from openai import AsyncOpenAI
        self.client = AsyncOpenAI(api_key=api_key or "not-needed", base_url=base_url)

    async def acomplete(
        self,
        messages: list[Message],
        system: str | None,
        spec: ModelSpec,
        *,
        max_output_tokens: int | None = None,
        temperature: float | None = None,
    ) -> str:
        if spec.responses_api:
            return await self._via_responses(
                self.client, messages, system, spec, max_output_tokens, temperature
            )
        return await self._via_chat(
            self.client, messages, system, spec, max_output_tokens, temperature
        )

Comment on lines +13 to +43
from pentestgpt_legacy.llm.registry import ModelSpec

# Anthropic requires an explicit output cap; generous enough for detailed steps.
_DEFAULT_MAX_TOKENS = 8192


class AnthropicProvider(BaseProvider):
"""Async Anthropic Messages API provider."""

async def acomplete(
self,
messages: list[Message],
system: str | None,
spec: ModelSpec,
*,
max_output_tokens: int | None = None,
temperature: float | None = None,
) -> str:
try:
from anthropic import AsyncAnthropic
except ImportError as exc: # pragma: no cover - install-time guard
raise RuntimeError(
"The 'anthropic' package is required. Install with: uv add anthropic"
) from exc

kwargs: dict[str, Any] = {
"model": spec.api_id,
"messages": list(messages),
"max_tokens": max_output_tokens or _DEFAULT_MAX_TOKENS,
}
if system:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instantiating a new AsyncAnthropic client on every call to acomplete is highly inefficient. It creates a new connection pool each time, requiring a new TCP and TLS handshake for every single request, which adds significant latency and prevents connection reuse.

We should instantiate the client once in __init__ and reuse it across requests.

class AnthropicProvider(BaseProvider):
    """Async Anthropic Messages API provider."""

    def __init__(self, info: ProviderInfo, api_key: str | None, base_url: str | None):
        super().__init__(info, api_key, base_url)
        from anthropic import AsyncAnthropic
        self.client = AsyncAnthropic(api_key=api_key or "")

    async def acomplete(
        self,
        messages: list[Message],
        system: str | None,
        spec: ModelSpec,
        *,
        max_output_tokens: int | None = None,
        temperature: float | None = None,
    ) -> str:
        kwargs: dict[str, Any] = {
            "model": spec.api_id,
            "messages": list(messages),
            "max_tokens": max_output_tokens or _DEFAULT_MAX_TOKENS,
        }
        if system:
            kwargs["system"] = system
        if temperature is not None:
            kwargs["temperature"] = temperature

        response = await self.client.messages.create(**kwargs)

Comment on lines +16 to +64

class GeminiProvider(BaseProvider):
"""Async Google Gemini provider (google-genai)."""

async def acomplete(
self,
messages: list[Message],
system: str | None,
spec: ModelSpec,
*,
max_output_tokens: int | None = None,
temperature: float | None = None,
) -> str:
try:
from google import genai
from google.genai import types
except ImportError as exc: # pragma: no cover - install-time guard
raise RuntimeError(
"The 'google-genai' package is required. Install with: uv add google-genai"
) from exc

contents: list[Any] = []
for message in messages:
role = "user" if message["role"] == "user" else "model"
contents.append(
types.Content(role=role, parts=[types.Part.from_text(text=message["content"])])
)

# Relax safety filters — security tooling output routinely trips defaults.
safety_categories = (
types.HarmCategory.HARM_CATEGORY_HARASSMENT,
types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
)
safety = [
types.SafetySetting(category=category, threshold=types.HarmBlockThreshold.BLOCK_NONE)
for category in safety_categories
]
config = types.GenerateContentConfig(
system_instruction=system or None,
temperature=temperature,
max_output_tokens=max_output_tokens,
safety_settings=safety,
)

client = genai.Client(api_key=self.api_key or "")
response = await client.aio.models.generate_content(
model=spec.api_id, contents=contents, config=config

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instantiating a new genai.Client on every call to acomplete is highly inefficient. It creates a new connection pool each time, requiring a new TCP and TLS handshake for every single request, which adds significant latency and prevents connection reuse.

We should instantiate the client once in __init__ and reuse it across requests.

class GeminiProvider(BaseProvider):
    """Async Google Gemini provider (google-genai)."""

    def __init__(self, info: ProviderInfo, api_key: str | None, base_url: str | None):
        super().__init__(info, api_key, base_url)
        from google import genai
        self.client = genai.Client(api_key=api_key or "")

    async def acomplete(
        self,
        messages: list[Message],
        system: str | None,
        spec: ModelSpec,
        *,
        max_output_tokens: int | None = None,
        temperature: float | None = None,
    ) -> str:
        try:
            from google.genai import types
        except ImportError as exc:  # pragma: no cover - install-time guard
            raise RuntimeError(
                "The 'google-genai' package is required. Install with: uv add google-genai"
            ) from exc

        contents: list[Any] = []
        for message in messages:
            role = "user" if message["role"] == "user" else "model"
            contents.append(
                types.Content(role=role, parts=[types.Part.from_text(text=message["content"])])
            )

        # Relax safety filters — security tooling output routinely trips defaults.
        safety_categories = (
            types.HarmCategory.HARM_CATEGORY_HARASSMENT,
            types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
            types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
            types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
        )
        safety = [
            types.SafetySetting(category=category, threshold=types.HarmBlockThreshold.BLOCK_NONE)
            for category in safety_categories
        ]
        config = types.GenerateContentConfig(
            system_instruction=system or None,
            temperature=temperature,
            max_output_tokens=max_output_tokens,
            safety_settings=safety,
        )

        response = await self.client.aio.models.generate_content(
            model=spec.api_id, contents=contents, config=config
        )

@shawkat-Mzury

shawkat-Mzury commented Jun 6, 2026 via email

Copy link
Copy Markdown

@GreyDGL
GreyDGL changed the base branch from main to legacy-multi-llm-base June 6, 2026 17:54
@GreyDGL
GreyDGL merged commit 577ac29 into legacy-multi-llm-base Jun 6, 2026
3 of 5 checks passed
GreyDGL added a commit that referenced this pull request Jun 7, 2026
* fix: 🐛 minor typo and build process

* feat: 🎸 [WIP] Pentest mode

* feat: 🎸 code abstraction

* feat: modernize legacy PentestGPT with native multi-LLM support (#469)

Rebuild the classic USENIX-2024 interactive PentestGPT (reasoning / generation /
parsing sessions + Pentesting Task Tree + REPL) as a standalone
`pentestgpt_legacy` package on a native per-provider LLM layer that supports the
latest 2026 models.

- llm/: BaseProvider + OpenAI-compatible / Anthropic / Gemini connectors, a
  web-verified model registry (OpenAI, Anthropic, Gemini, DeepSeek, xAI, Qwen,
  Moonshot, local Ollama), a factory, and an LLMClient bridging async providers
  to the core's synchronous send_new_message/send_message session API.
- CLI `pentestgpt-legacy`: --list-models and --smoke-test (live per-model
  round-trip matrix), plus --reasoning-model / --parsing-model / --base-url.
- Tests: 25 unit tests (mocked, no network). Live smoke test verified 22/22
  models with a configured key respond.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(backend): address review on ClaudeCodeBackend subprocess handling

- _build_env: pop ANTHROPIC_API_KEY instead of setting it to "", so an empty
  value can't shadow the CLI's own auth fallback (e.g. subscription login).
- _kill_process: reap the force-killed process with os.waitpid(.., WNOHANG)
  instead of calling the proc.wait() coroutine without awaiting it (removes the
  "coroutine was never awaited" warning).
- query/_drain_stderr: drain subprocess stderr in a background task so its pipe
  buffer can't fill and deadlock the child.

Also reformats backend.py, fixing the failing Lint (ruff format) check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(docker-test): assert uv instead of Poetry in container health check

The project migrated from Poetry to uv (the Dockerfile installs uv to
/home/pentester/.local/bin, which is on PATH), so test_poetry_installed failed
with exit 127. Replace it with test_uv_installed checking `uv --version`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants