chore: apply ruff fixes to project files; exclude third_party from ruff

This commit is contained in:
giveen
2026-01-19 10:31:57 -07:00
parent acb5ca021e
commit 08e9d53dd8
24 changed files with 960 additions and 447 deletions

View File

@@ -5,11 +5,8 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, AsyncIterator, List, Optional
from ..config.constants import AGENT_MAX_ITERATIONS
from ..workspaces.manager import TargetManager, WorkspaceManager
from .state import AgentState, AgentStateManager
from types import MappingProxyType
from ..workspaces.manager import WorkspaceManager, TargetManager, WorkspaceError
from ..workspaces.utils import resolve_knowledge_paths
if TYPE_CHECKING:
from ..llm import LLM
@@ -83,94 +80,56 @@ class BaseAgent(ABC):
tools: List["Tool"],
runtime: "Runtime",
max_iterations: int = AGENT_MAX_ITERATIONS,
**kwargs,
):
"""
Initialize the base agent.
Initialize base agent state.
Args:
llm: The LLM instance for generating responses
tools: List of tools available to the agent
runtime: The runtime environment for tool execution
max_iterations: Maximum iterations before forcing stop (safety limit)
llm: LLM instance used for generation
tools: Available tool list
runtime: Runtime used for tool execution
max_iterations: Safety limit for iterations
"""
self.llm = llm
self.tools = tools
self.runtime = runtime
self.max_iterations = max_iterations
# Agent runtime state
self.state_manager = AgentStateManager()
self.conversation_history: List[AgentMessage] = []
# Each agent gets its own plan instance
from ..tools.finish import TaskPlan
# Task planning structure (used by finish tool)
try:
from ..tools.finish import TaskPlan
self._task_plan = TaskPlan()
self._task_plan = TaskPlan()
except Exception:
# Fallback simple plan structure
class _SimplePlan:
def __init__(self):
self.steps = []
self.original_request = ""
# Attach plan to runtime so finish tool can access it
self.runtime.plan = self._task_plan
def clear(self):
self.steps.clear()
# Use tools as-is (finish accesses plan via runtime)
self.tools = list(tools)
def is_complete(self):
return True
@property
def workspace_context(self):
"""Return a read-only workspace context built at access time.
def has_failure(self):
return False
Uses WorkspaceManager.get_active() as the single source of truth
and does not cache state between calls.
"""
wm = WorkspaceManager()
active = wm.get_active()
if not active:
return None
self._task_plan = _SimplePlan()
targets = wm.list_targets(active)
# Expose plan to runtime so tools like `finish` can access it
try:
self.runtime.plan = self._task_plan
except Exception:
pass
kp = resolve_knowledge_paths()
knowledge_scope = "workspace" if kp.get("using_workspace") else "global"
ctx = {
"name": active,
"targets": list(targets),
"has_targets": bool(targets),
"knowledge_scope": knowledge_scope,
}
return MappingProxyType(ctx)
@property
def state(self) -> AgentState:
"""Get current agent state."""
return self.state_manager.current_state
@state.setter
def state(self, value: AgentState):
"""Set agent state."""
self.state_manager.transition_to(value)
def cleanup_after_cancel(self) -> None:
"""
Clean up agent state after a cancellation.
Removes the cancelled request and any pending tool calls from
conversation history to prevent stale responses from contaminating
the next conversation.
"""
# Remove incomplete messages from the end of conversation
while self.conversation_history:
last_msg = self.conversation_history[-1]
# Remove assistant message with tool calls (incomplete tool execution)
if last_msg.role == "assistant" and last_msg.tool_calls:
self.conversation_history.pop()
# Remove orphaned tool_result messages
elif last_msg.role == "tool":
self.conversation_history.pop()
# Remove the user message that triggered the cancelled request
elif last_msg.role == "user":
self.conversation_history.pop()
break # Stop after removing the user message
else:
break
# Reset state to idle
# Ensure agent starts idle
self.state_manager.transition_to(AgentState.IDLE)
@abstractmethod
@@ -529,8 +488,16 @@ class BaseAgent(ABC):
if cand_net.subnet_of(an) or cand_net == an:
return True
else:
# allowed is IP/hostname
if ipaddress.ip_address(a) == list(cand_net.hosts())[0]:
# allowed is IP or hostname; only accept if allowed is
# a single IP that exactly matches a single-address candidate
try:
allowed_ip = ipaddress.ip_address(a)
except Exception:
# not an IP (likely hostname) - skip
continue
# If candidate network represents exactly one address,
# allow it when that address equals the allowed IP
if cand_net.num_addresses == 1 and cand_net.network_address == allowed_ip:
return True
except Exception:
continue

View File

@@ -2,6 +2,7 @@
import argparse
import asyncio
from pathlib import Path
from ..config.constants import AGENT_MAX_ITERATIONS, DEFAULT_MODEL
from .cli import run_cli
@@ -325,10 +326,13 @@ def handle_mcp_command(args: argparse.Namespace):
def handle_workspace_command(args: argparse.Namespace):
"""Handle workspace lifecycle commands and actions."""
import shutil
from pentestagent.workspaces.manager import WorkspaceManager, WorkspaceError
from pentestagent.workspaces.utils import export_workspace, import_workspace, resolve_knowledge_paths
from pentestagent.workspaces.manager import WorkspaceError, WorkspaceManager
from pentestagent.workspaces.utils import (
export_workspace,
import_workspace,
resolve_knowledge_paths,
)
wm = WorkspaceManager()
@@ -400,14 +404,32 @@ def handle_workspace_command(args: argparse.Namespace):
return
if action == "note":
# Append operator note to active workspace (or specified)
name = rest[0] if rest and not rest[0].startswith("--") else wm.get_active()
# Append operator note to active workspace (or specified via --workspace/-w)
active = wm.get_active()
name = active
text_parts = rest or []
i = 0
# Parse optional workspace selector flags before the note text.
while i < len(text_parts):
part = text_parts[i]
if part in ("--workspace", "-w"):
if i + 1 >= len(text_parts):
print("Usage: workspace note [--workspace NAME] <text>")
return
name = text_parts[i + 1]
i += 2
continue
# First non-option token marks the start of the note text
break
if not name:
print("No active workspace. Set one with /workspace <name>.")
return
text = " ".join(rest[1:]) if rest and rest[0] == name else " ".join(rest)
text = " ".join(text_parts[i:])
if not text:
print("Usage: workspace note <text>")
print("Usage: workspace note [--workspace NAME] <text>")
return
try:
wm.set_operator_note(name, text)
@@ -501,7 +523,7 @@ def handle_workspaces_list():
def handle_target_command(args: argparse.Namespace):
"""Handle target add/list commands."""
from pentestagent.workspaces.manager import WorkspaceManager, WorkspaceError
from pentestagent.workspaces.manager import WorkspaceError, WorkspaceManager
wm = WorkspaceManager()
active = wm.get_active()

View File

@@ -0,0 +1,40 @@
"""Simple notifier bridge for UI notifications.
Modules can call `notify(level, message)` to emit operator-visible
notifications. A UI (TUI) may register a callback via `register_callback()`
to receive notifications and display them. If no callback is registered,
notifications are logged.
"""
import logging
from typing import Callable, Optional
_callback: Optional[Callable[[str, str], None]] = None
def register_callback(cb: Callable[[str, str], None]) -> None:
"""Register a callback to receive notifications.
Callback receives (level, message).
"""
global _callback
_callback = cb
def notify(level: str, message: str) -> None:
"""Emit a notification. If UI callback registered, call it; otherwise log."""
global _callback
if _callback:
try:
_callback(level, message)
return
except Exception:
logging.getLogger(__name__).exception("Notifier callback failed")
# Fallback to logging
log = logging.getLogger("pentestagent.notifier")
if level.lower() in ("error", "critical"):
log.error(message)
elif level.lower() in ("warn", "warning"):
log.warning(message)
else:
log.info(message)

View File

@@ -1194,6 +1194,14 @@ class PentestAgentTUI(App):
async def on_mount(self) -> None:
"""Initialize on mount"""
# Register notifier callback so other modules can emit operator-visible messages
try:
from .notifier import register_callback
register_callback(self._notifier_callback)
except Exception:
pass
# Call the textual worker - decorator returns a Worker, not a coroutine
_ = cast(Any, self._initialize_agent())
@@ -1340,6 +1348,37 @@ class PentestAgentTUI(App):
except Exception:
pass
def _show_notification(self, level: str, message: str) -> None:
"""Display a short operator-visible notification in the chat area."""
try:
# Prepend a concise system message so it is visible in the chat
prefix = "[!]" if level.lower() in ("error", "critical") else "[!]"
self._add_system(f"{prefix} {message}")
# Set status bar to error briefly for emphasis
if level.lower() in ("error", "critical"):
self._set_status("error")
except Exception:
pass
def _notifier_callback(self, level: str, message: str) -> None:
"""Callback wired to `pentestagent.interface.notifier`.
This will be registered on mount so other modules can emit notifications.
"""
try:
# textual apps typically run in the main thread; try to schedule update
# using call_from_thread if available, otherwise call directly.
if hasattr(self, "call_from_thread"):
try:
self.call_from_thread(self._show_notification, level, message)
return
except Exception:
# Fall through to direct call
pass
self._show_notification(level, message)
except Exception:
pass
def _add_message(self, widget: Static) -> None:
"""Add a message widget to chat"""
try:
@@ -1798,9 +1837,12 @@ Be concise. Use the actual data from notes."""
elif cmd_original.startswith("/workspace"):
# Support lightweight workspace management from the TUI
try:
from pentestagent.workspaces.manager import WorkspaceManager, WorkspaceError
from pentestagent.workspaces.manager import (
WorkspaceError,
WorkspaceManager,
)
from pentestagent.workspaces.utils import resolve_knowledge_paths
from pathlib import Path
wm = WorkspaceManager()
rest = cmd_original[len("/workspace") :].strip()
@@ -1869,13 +1911,27 @@ Be concise. Use the actual data from notes."""
return
if verb == "note":
name = parts[1] if len(parts) > 1 and not parts[1].startswith("--") else wm.get_active()
# By default, use the active workspace; allow explicit override via --workspace/-w.
name = wm.get_active()
i = 1
# Parse optional workspace selector flags before the note text.
while i < len(parts):
part = parts[i]
if part in ("--workspace", "-w"):
if i + 1 >= len(parts):
self._add_system("Usage: /workspace note [--workspace NAME] <text>")
return
name = parts[i + 1]
i += 2
continue
# First non-option token marks the start of the note text
break
if not name:
self._add_system("No active workspace. Set one with /workspace <name>.")
return
text = " ".join(parts[1:]) if len(parts) > 1 and parts[1] == name else " ".join(parts[1:])
text = " ".join(parts[i:])
if not text:
self._add_system("Usage: /workspace note <text>")
self._add_system("Usage: /workspace note [--workspace NAME] <text>")
return
try:
wm.set_operator_note(name, text)

View File

@@ -5,8 +5,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, List
from .rag import Document
from ..workspaces.utils import resolve_knowledge_paths
from .rag import Document
@dataclass

View File

@@ -1,14 +1,15 @@
"""RAG (Retrieval Augmented Generation) engine for PentestAgent."""
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
from .embeddings import get_embeddings
from ..workspaces.utils import resolve_knowledge_paths
from .embeddings import get_embeddings
@dataclass
@@ -84,12 +85,26 @@ class RAGEngine:
try:
self.load_index(idx_path)
return
except Exception:
# Fall through to re-index if loading fails
pass
except Exception:
# Non-fatal — continue to index from sources
pass
except Exception as e:
logging.getLogger(__name__).exception(
"Failed to load persisted RAG index at %s, will re-index: %s",
idx_path,
e,
)
try:
from ..interface.notifier import notify
notify(
"warning",
f"Failed to load persisted RAG index at {idx_path}: {e}",
)
except Exception:
pass
except Exception as e:
# Non-fatal — continue to index from sources, but log the error
logging.getLogger(__name__).exception(
"Error while checking for persisted workspace index: %s", e
)
# Process all files in knowledge directory
if sources_base.exists():
@@ -133,7 +148,9 @@ class RAGEngine:
)
)
except Exception as e:
print(f"[RAG] Error processing {file}: {e}")
logging.getLogger(__name__).exception(
"[RAG] Error processing %s: %s", file, e
)
self.documents = chunks
@@ -161,11 +178,26 @@ class RAGEngine:
idx_path = emb_dir / "index.pkl"
try:
self.save_index(idx_path)
except Exception as e:
logging.getLogger(__name__).exception(
"Failed to save RAG index to %s: %s", idx_path, e
)
try:
from ..interface.notifier import notify
notify("warning", f"Failed to save RAG index to {idx_path}: {e}")
except Exception:
pass
except Exception as e:
logging.getLogger(__name__).exception(
"Error while attempting to persist RAG index: %s", e
)
try:
from ..interface.notifier import notify
notify("warning", f"Error while attempting to persist RAG index: {e}")
except Exception:
# ignore save failures
pass
except Exception:
pass
def _chunk_text(
self, text: str, source: str, chunk_size: int = 1000, overlap: int = 200

View File

@@ -12,11 +12,10 @@ operates.
import asyncio
import os
import shutil
import sys
from pathlib import Path
from typing import Optional
import signal
import time
from pathlib import Path
from typing import Optional
try:
import aiohttp
@@ -27,7 +26,6 @@ except Exception:
from ..workspaces.utils import get_loot_file
class HexstrikeAdapter:
"""Manage a vendored HexStrike server under `third_party/hexstrike`.

View File

@@ -13,9 +13,9 @@ Uses standard MCP configuration format:
"""
import asyncio
import atexit
import json
import os
import atexit
import signal
from dataclasses import dataclass, field
from pathlib import Path
@@ -223,7 +223,7 @@ class MCPManager:
async def _stop_started_adapters_and_disconnect(self) -> None:
# Stop any adapters we started
for name, adapter in list(self._started_adapters.items()):
for _name, adapter in list(self._started_adapters.items()):
try:
stop = getattr(adapter, "stop", None)
if stop:

View File

@@ -10,10 +10,10 @@ health check on a configurable port.
import asyncio
import os
import shutil
import signal
import time
from pathlib import Path
from typing import Optional
import time
import signal
try:
import aiohttp
@@ -141,7 +141,8 @@ class MetasploitAdapter:
if not self._msfrpcd_proc or not self._msfrpcd_proc.stdout:
return
try:
with LOG_FILE.open("ab") as fh:
log_file = get_loot_file("artifacts/msfrpcd.log")
with log_file.open("ab") as fh:
while True:
line = await self._msfrpcd_proc.stdout.readline()
if not line:

View File

@@ -371,11 +371,11 @@ class SSETransport(MCPTransport):
# End of event; process accumulated lines
event_name = None
data_lines: list[str] = []
for l in event_lines:
if l.startswith("event:"):
event_name = l.split(":", 1)[1].strip()
elif l.startswith("data:"):
data_lines.append(l.split(":", 1)[1].lstrip())
for evt_line in event_lines:
if evt_line.startswith("event:"):
event_name = evt_line.split(":", 1)[1].strip()
elif evt_line.startswith("data:"):
data_lines.append(evt_line.split(":", 1)[1].lstrip())
if data_lines:
data_text = "\n".join(data_lines)

View File

@@ -4,7 +4,6 @@ import platform
import shutil
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional
if TYPE_CHECKING:
@@ -654,7 +653,6 @@ class LocalRuntime(Runtime):
elif action == "screenshot":
import time
import uuid
from pathlib import Path
# Navigate first if URL provided
if kwargs.get("url"):

View File

@@ -1,3 +1,3 @@
from .manager import WorkspaceManager, TargetManager, WorkspaceError
from .manager import TargetManager, WorkspaceError, WorkspaceManager
__all__ = ["WorkspaceManager", "TargetManager", "WorkspaceError"]

View File

@@ -6,10 +6,11 @@ Design goals:
- No in-memory caching: all operations read/write files directly
- Lightweight hostname validation; accept IPs, CIDRs, hostnames
"""
from pathlib import Path
import ipaddress
import logging
import re
import time
import ipaddress
from pathlib import Path
from typing import List
import yaml
@@ -50,7 +51,7 @@ class TargetManager:
# fallback to hostname validation (light)
if TargetManager.HOST_RE.match(v) and ".." not in v:
return v.lower()
raise WorkspaceError(f"Invalid target: {value}")
raise WorkspaceError(f"Invalid target: {value}") from None
@staticmethod
def validate(value: str) -> bool:
@@ -118,7 +119,7 @@ class WorkspaceManager:
data.setdefault("targets", [])
return data
except Exception as e:
raise WorkspaceError(f"Failed to read meta for {name}: {e}")
raise WorkspaceError(f"Failed to read meta for {name}: {e}") from e
def _write_meta(self, name: str, meta: dict):
mp = self.meta_path(name)
@@ -138,9 +139,19 @@ class WorkspaceManager:
meta.setdefault("operator_notes", "")
meta.setdefault("tool_runs", [])
self._write_meta(name, meta)
except Exception:
# Non-fatal - don't block activation on meta write errors
pass
except Exception as e:
# Non-fatal - don't block activation on meta write errors, but log for visibility
logging.getLogger(__name__).exception(
"Failed to update meta.yaml for workspace '%s': %s", name, e
)
try:
# Emit operator-visible notification if UI present
from ..interface.notifier import notify
notify("warning", f"Failed to update workspace meta for '{name}': {e}")
except Exception:
# ignore notifier failures
pass
def set_operator_note(self, name: str, note: str) -> dict:
"""Append or set operator_notes for a workspace (plain text)."""

View File

@@ -3,8 +3,9 @@
All functions are file-backed and do not cache the active workspace selection.
This module will emit a single warning per run if no active workspace is set.
"""
from pathlib import Path
import logging
import shutil
from pathlib import Path
from typing import Optional
from .manager import WorkspaceManager
@@ -59,9 +60,16 @@ def resolve_knowledge_paths(root: Optional[Path] = None) -> dict:
if workspace_base and workspace_base.exists():
# prefer workspace if it has any content (explicit opt-in)
try:
if any(workspace_base.rglob("*")):
# Use a non-recursive check to avoid walking the entire directory tree
if any(workspace_base.iterdir()):
use_workspace = True
except Exception:
# Also allow an explicit opt-in marker file .use_workspace
elif (workspace_base / ".use_workspace").exists():
use_workspace = True
except Exception as e:
logging.getLogger(__name__).exception(
"Error while checking workspace knowledge directory: %s", e
)
use_workspace = False
if use_workspace:
@@ -162,14 +170,20 @@ def import_workspace(archive: Path, root: Optional[Path] = None) -> str:
candidate_root = p / name
break
if candidate_root and candidate_root.exists():
# move candidate_root to dest
# move candidate_root to dest (use shutil.move to support cross-filesystem)
dest.parent.mkdir(parents=True, exist_ok=True)
candidate_root.replace(dest)
try:
shutil.move(str(candidate_root), str(dest))
except Exception as e:
raise RuntimeError(f"Failed to move workspace subtree into place: {e}") from e
else:
# Otherwise, assume contents are directly the workspace folder
# move the parent of meta_file (or its containing dir)
src = meta_file.parent
dest.parent.mkdir(parents=True, exist_ok=True)
src.replace(dest)
try:
shutil.move(str(src), str(dest))
except Exception as e:
raise RuntimeError(f"Failed to move extracted workspace into place: {e}") from e
return name

View File

@@ -0,0 +1,108 @@
"""Workspace target validation utilities.
Provides helpers to extract candidate targets from arbitrary tool arguments
and to determine whether a candidate target is covered by the allowed
workspace targets (IP, CIDR, hostname).
"""
import ipaddress
import logging
from typing import Any, List
from .manager import TargetManager
def gather_candidate_targets(obj: Any) -> List[str]:
"""Extract candidate target strings from arguments (shallow).
This intentionally performs a shallow inspection to keep the function
fast and predictable; nested structures should be handled by callers
if required.
"""
candidates: List[str] = []
if isinstance(obj, str):
candidates.append(obj)
elif isinstance(obj, dict):
for k, v in obj.items():
if k.lower() in (
"target",
"host",
"hostname",
"ip",
"address",
"url",
"hosts",
"targets",
):
if isinstance(v, (list, tuple)):
for it in v:
if isinstance(it, str):
candidates.append(it)
elif isinstance(v, str):
candidates.append(v)
return candidates
def is_target_in_scope(candidate: str, allowed: List[str]) -> bool:
"""Check whether `candidate` is covered by any entry in `allowed`.
Allowed entries may be IPs, CIDRs, or hostnames/labels. Candidate may
also be an IP, CIDR, or hostname. The function normalizes inputs and
performs robust comparisons for networks and addresses.
"""
try:
norm = TargetManager.normalize_target(candidate)
except Exception:
return False
# If candidate is a network (contains '/'), treat as network
try:
if "/" in norm:
cand_net = ipaddress.ip_network(norm, strict=False)
for a in allowed:
try:
if "/" in a:
an = ipaddress.ip_network(a, strict=False)
if cand_net.subnet_of(an) or cand_net == an:
return True
else:
# allowed is IP or hostname; accept only when candidate
# network represents exactly one address equal to allowed IP
try:
allowed_ip = ipaddress.ip_address(a)
except Exception:
# not an IP (likely hostname) - skip
continue
if cand_net.num_addresses == 1 and cand_net.network_address == allowed_ip:
return True
except Exception:
continue
return False
else:
# candidate is a single IP/hostname
try:
cand_ip = ipaddress.ip_address(norm)
for a in allowed:
try:
if "/" in a:
an = ipaddress.ip_network(a, strict=False)
if cand_ip in an:
return True
else:
if TargetManager.normalize_target(a) == norm:
return True
except Exception:
if isinstance(a, str) and a.lower() == norm.lower():
return True
return False
except Exception:
# candidate is likely a hostname; compare case-insensitively
for a in allowed:
try:
if a.lower() == norm.lower():
return True
except Exception:
continue
return False
except Exception as e:
logging.getLogger(__name__).exception("Error checking target scope: %s", e)
return False

View File

@@ -126,6 +126,8 @@ known_first_party = ["pentestagent"]
line-length = 88
target-version = "py310"
exclude = ["third_party/"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors

View File

@@ -0,0 +1,83 @@
import tarfile
from pathlib import Path
import pytest
from pentestagent.workspaces.utils import import_workspace
def make_tar_with_dir(source_dir: Path, archive_path: Path, store_subpath: Path = None):
# Create a tar.gz archive containing the contents of source_dir.
with tarfile.open(archive_path, "w:gz") as tf:
for p in source_dir.rglob("*"):
rel = p.relative_to(source_dir.parent)
# Optionally store paths under a custom subpath
arcname = str(rel)
if store_subpath:
# Prepend the store_subpath (e.g., workspaces/name/...)
arcname = str(store_subpath / p.relative_to(source_dir))
tf.add(str(p), arcname=arcname)
def test_import_workspace_nested(tmp_path):
# Create a workspace dir structure under a temporary dir
src_root = tmp_path / "src"
ws_name = "import-test"
ws_dir = src_root / "workspaces" / ws_name
ws_dir.mkdir(parents=True)
# write meta.yaml
meta = ws_dir / "meta.yaml"
meta.write_text("name: import-test\n")
# add a file
(ws_dir / "notes.txt").write_text("hello")
archive = tmp_path / "ws_nested.tar.gz"
# Create archive that stores workspaces/<name>/...
make_tar_with_dir(ws_dir, archive, store_subpath=Path("workspaces") / ws_name)
dest_root = tmp_path / "dest"
dest_root.mkdir()
name = import_workspace(archive, root=dest_root)
assert name == ws_name
dest_ws = dest_root / "workspaces" / ws_name
assert dest_ws.exists()
assert (dest_ws / "meta.yaml").exists()
def test_import_workspace_flat(tmp_path):
# Create a folder that is directly the workspace (not nested under workspaces/)
src = tmp_path / "srcflat"
src.mkdir()
(src / "meta.yaml").write_text("name: flat-test\n")
(src / "data.txt").write_text("x")
archive = tmp_path / "ws_flat.tar.gz"
# Archive the src folder contents directly (no workspaces/ prefix)
with tarfile.open(archive, "w:gz") as tf:
for p in src.rglob("*"):
tf.add(str(p), arcname=str(p.relative_to(src.parent)))
dest_root = tmp_path / "dest2"
dest_root.mkdir()
name = import_workspace(archive, root=dest_root)
assert name == "flat-test"
assert (dest_root / "workspaces" / "flat-test" / "meta.yaml").exists()
def test_import_workspace_missing_meta(tmp_path):
# Archive without meta.yaml
src = tmp_path / "empty"
src.mkdir()
(src / "file.txt").write_text("x")
archive = tmp_path / "no_meta.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
for p in src.rglob("*"):
tf.add(str(p), arcname=str(p.relative_to(src.parent)))
dest_root = tmp_path / "dest3"
dest_root.mkdir()
with pytest.raises(ValueError):
import_workspace(archive, root=dest_root)

View File

@@ -0,0 +1,82 @@
def test_workspace_meta_write_failure_emits_notification(tmp_path, monkeypatch):
"""Simulate a meta.yaml write failure and ensure notifier receives a warning."""
from pentestagent.interface import notifier
from pentestagent.workspaces.manager import WorkspaceManager
captured = []
def cb(level, message):
captured.append((level, message))
notifier.register_callback(cb)
wm = WorkspaceManager(root=tmp_path)
# Create workspace first so initial meta is written successfully
wm.create("testws")
# Patch _write_meta to raise when called during set_active's meta update
def bad_write(self, name, meta):
raise RuntimeError("disk error")
monkeypatch.setattr(WorkspaceManager, "_write_meta", bad_write)
# Calling set_active should attempt to update meta and trigger notification
wm.set_active("testws")
assert len(captured) >= 1
# Find a warning notification
assert any("Failed to update workspace meta" in m for _, m in captured)
def test_rag_index_save_failure_emits_notification(tmp_path, monkeypatch):
"""Simulate RAG save failure during index persistence and ensure notifier gets a warning."""
from pentestagent.interface import notifier
from pentestagent.knowledge.rag import RAGEngine
captured = []
def cb(level, message):
captured.append((level, message))
notifier.register_callback(cb)
# Prepare a small knowledge tree under tmp_path
ws = tmp_path / "workspaces" / "ws1"
src = ws / "knowledge" / "sources"
src.mkdir(parents=True, exist_ok=True)
f = src / "doc.txt"
f.write_text("hello world")
# Patch resolve_knowledge_paths in the RAG module to point to our tmp workspace
def fake_resolve(root=None):
return {
"using_workspace": True,
"sources": src,
"embeddings": ws / "knowledge" / "embeddings",
}
monkeypatch.setattr("pentestagent.knowledge.rag.resolve_knowledge_paths", fake_resolve)
# Ensure embeddings generation returns deterministic array (avoid external calls)
import numpy as np
monkeypatch.setattr(
"pentestagent.knowledge.rag.get_embeddings",
lambda texts, model=None: np.zeros((len(texts), 8)),
)
# Patch save_index to raise
def bad_save(self, path):
raise RuntimeError("write failed")
monkeypatch.setattr(RAGEngine, "save_index", bad_save)
rag = RAGEngine() # uses default knowledge_path -> resolve_knowledge_paths
# Force indexing which will attempt to save and trigger notifier
rag.index(force=True)
assert len(captured) >= 1
assert any("Failed to save RAG index" in m or "persist RAG index" in m for _, m in captured)

View File

@@ -1,11 +1,8 @@
import os
from pathlib import Path
import pytest
from pentestagent.workspaces.manager import WorkspaceManager
from pentestagent.knowledge.rag import RAGEngine
from pentestagent.knowledge.indexer import KnowledgeIndexer
from pentestagent.knowledge.rag import RAGEngine
from pentestagent.workspaces.manager import WorkspaceManager
def test_rag_and_indexer_use_workspace(tmp_path, monkeypatch):

View File

@@ -0,0 +1,63 @@
from types import SimpleNamespace
import pytest
from pentestagent.agents.base_agent import BaseAgent
from pentestagent.workspaces.manager import WorkspaceManager
class DummyTool:
def __init__(self, name="dummy"):
self.name = name
async def execute(self, arguments, runtime):
return "ok"
class SimpleAgent(BaseAgent):
def get_system_prompt(self, mode: str = "agent") -> str:
return ""
@pytest.mark.asyncio
async def test_ip_and_cidr_containment(tmp_path, monkeypatch):
# Use tmp_path as project root so WorkspaceManager writes here
monkeypatch.chdir(tmp_path)
wm = WorkspaceManager(root=tmp_path)
name = "scope-test"
wm.create(name)
wm.set_active(name)
tool = DummyTool("dummy")
agent = SimpleAgent(llm=object(), tools=[tool], runtime=SimpleNamespace())
# Helper to run execute_tools with a candidate target
async def run_with_candidate(candidate):
call = {"id": "1", "name": "dummy", "arguments": {"target": candidate}}
results = await agent._execute_tools([call])
return results[0]
# 1) Allowed single IP, candidate same IP
wm.add_targets(name, ["192.0.2.5"])
res = await run_with_candidate("192.0.2.5")
assert res.success is True
# 2) Allowed single IP, candidate single-address CIDR (/32) -> allowed
res = await run_with_candidate("192.0.2.5/32")
assert res.success is True
# 3) Allowed CIDR, candidate IP inside -> allowed
wm.add_targets(name, ["198.51.100.0/24"])
res = await run_with_candidate("198.51.100.25")
assert res.success is True
# 4) Allowed CIDR, candidate subnet inside -> allowed
wm.add_targets(name, ["203.0.113.0/24"])
res = await run_with_candidate("203.0.113.128/25")
assert res.success is True
# 5) Allowed single IP, candidate larger network -> not allowed
wm.add_targets(name, ["192.0.2.5"])
res = await run_with_candidate("192.0.2.0/24")
assert res.success is False

View File

@@ -0,0 +1,56 @@
from pentestagent.workspaces import validation
from pentestagent.workspaces.manager import TargetManager
def test_ip_in_cidr_containment():
allowed = ["10.0.0.0/8"]
assert validation.is_target_in_scope("10.1.2.3", allowed)
def test_cidr_within_cidr():
allowed = ["10.0.0.0/8"]
assert validation.is_target_in_scope("10.1.0.0/16", allowed)
def test_cidr_equal_allowed():
allowed = ["10.0.0.0/8"]
assert validation.is_target_in_scope("10.0.0.0/8", allowed)
def test_cidr_larger_than_allowed_is_out_of_scope():
allowed = ["10.0.0.0/24"]
assert not validation.is_target_in_scope("10.0.0.0/16", allowed)
def test_single_ip_vs_single_address_cidr():
allowed = ["192.168.1.5"]
# Candidate expressed as a /32 network should be allowed when it represents the same single address
assert validation.is_target_in_scope("192.168.1.5/32", allowed)
def test_hostname_case_insensitive_match():
allowed = ["example.com"]
assert validation.is_target_in_scope("Example.COM", allowed)
def test_hostname_vs_ip_not_match():
allowed = ["example.com"]
assert not validation.is_target_in_scope("93.184.216.34", allowed)
def test_gather_candidate_targets_shallow_behavior():
# shallow extraction: list of strings is extracted
args = {"targets": ["1.2.3.4", "example.com"]}
assert set(validation.gather_candidate_targets(args)) == {"1.2.3.4", "example.com"}
# nested dicts inside lists are NOT traversed by the shallow extractor
args2 = {"hosts": [{"ip": "5.6.7.8"}]}
assert validation.gather_candidate_targets(args2) == []
# direct string argument returns itself
assert validation.gather_candidate_targets("8.8.8.8") == ["8.8.8.8"]
def test_normalize_target_accepts_hostnames_and_ips():
assert TargetManager.normalize_target("example.com") == "example.com"
assert TargetManager.normalize_target("8.8.8.8") == "8.8.8.8"

View File

@@ -1,9 +1,8 @@
import os
from pathlib import Path
import pytest
from pentestagent.workspaces.manager import WorkspaceManager, WorkspaceError
from pentestagent.workspaces.manager import WorkspaceError, WorkspaceManager
def test_invalid_workspace_names(tmp_path: Path):
@@ -19,7 +18,7 @@ def test_invalid_workspace_names(tmp_path: Path):
def test_create_and_idempotent(tmp_path: Path):
wm = WorkspaceManager(root=tmp_path)
name = "eng1"
meta = wm.create(name)
wm.create(name)
assert (tmp_path / "workspaces" / name).exists()
assert (tmp_path / "workspaces" / name / "meta.yaml").exists()
# create again should not raise and should return meta

View File

@@ -17,17 +17,17 @@ Architecture: MCP Client for AI agent communication with HexStrike server
Framework: FastMCP integration for tool orchestration
"""
import sys
import os
import argparse
import logging
from typing import Dict, Any, Optional
import requests
import sys
import time
from datetime import datetime
from typing import Any, Dict, Optional
import requests
from mcp.server.fastmcp import FastMCP
class HexStrikeColors:
"""Enhanced color palette matching the server's ModernVisualEngine.COLORS"""
@@ -447,9 +447,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"☁️ Starting Prowler {provider} security assessment")
result = hexstrike_client.safe_post("api/tools/prowler", data)
if result.get("success"):
logger.info(f"✅ Prowler assessment completed")
logger.info("✅ Prowler assessment completed")
else:
logger.error(f"❌ Prowler assessment failed")
logger.error("❌ Prowler assessment failed")
return result
@mcp.tool()
@@ -517,9 +517,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"☁️ Starting Scout Suite {provider} assessment")
result = hexstrike_client.safe_post("api/tools/scout-suite", data)
if result.get("success"):
logger.info(f"✅ Scout Suite assessment completed")
logger.info("✅ Scout Suite assessment completed")
else:
logger.error(f"❌ Scout Suite assessment failed")
logger.error("❌ Scout Suite assessment failed")
return result
@mcp.tool()
@@ -575,12 +575,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"regions": regions,
"additional_args": additional_args
}
logger.info(f"☁️ Starting Pacu AWS exploitation")
logger.info("☁️ Starting Pacu AWS exploitation")
result = hexstrike_client.safe_post("api/tools/pacu", data)
if result.get("success"):
logger.info(f"✅ Pacu exploitation completed")
logger.info("✅ Pacu exploitation completed")
else:
logger.error(f"❌ Pacu exploitation failed")
logger.error("❌ Pacu exploitation failed")
return result
@mcp.tool()
@@ -611,12 +611,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"report": report,
"additional_args": additional_args
}
logger.info(f"☁️ Starting kube-hunter Kubernetes scan")
logger.info("☁️ Starting kube-hunter Kubernetes scan")
result = hexstrike_client.safe_post("api/tools/kube-hunter", data)
if result.get("success"):
logger.info(f"✅ kube-hunter scan completed")
logger.info("✅ kube-hunter scan completed")
else:
logger.error(f"❌ kube-hunter scan failed")
logger.error("❌ kube-hunter scan failed")
return result
@mcp.tool()
@@ -642,12 +642,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"output_format": output_format,
"additional_args": additional_args
}
logger.info(f"☁️ Starting kube-bench CIS benchmark")
logger.info("☁️ Starting kube-bench CIS benchmark")
result = hexstrike_client.safe_post("api/tools/kube-bench", data)
if result.get("success"):
logger.info(f"✅ kube-bench benchmark completed")
logger.info("✅ kube-bench benchmark completed")
else:
logger.error(f"❌ kube-bench benchmark failed")
logger.error("❌ kube-bench benchmark failed")
return result
@mcp.tool()
@@ -672,12 +672,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"output_file": output_file,
"additional_args": additional_args
}
logger.info(f"🐳 Starting Docker Bench Security assessment")
logger.info("🐳 Starting Docker Bench Security assessment")
result = hexstrike_client.safe_post("api/tools/docker-bench-security", data)
if result.get("success"):
logger.info(f"✅ Docker Bench Security completed")
logger.info("✅ Docker Bench Security completed")
else:
logger.error(f"❌ Docker Bench Security failed")
logger.error("❌ Docker Bench Security failed")
return result
@mcp.tool()
@@ -736,9 +736,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🛡️ Starting Falco runtime monitoring for {duration}s")
result = hexstrike_client.safe_post("api/tools/falco", data)
if result.get("success"):
logger.info(f"✅ Falco monitoring completed")
logger.info("✅ Falco monitoring completed")
else:
logger.error(f"❌ Falco monitoring failed")
logger.error("❌ Falco monitoring failed")
return result
@mcp.tool()
@@ -770,9 +770,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔍 Starting Checkov IaC scan: {directory}")
result = hexstrike_client.safe_post("api/tools/checkov", data)
if result.get("success"):
logger.info(f"✅ Checkov scan completed")
logger.info("✅ Checkov scan completed")
else:
logger.error(f"❌ Checkov scan failed")
logger.error("❌ Checkov scan failed")
return result
@mcp.tool()
@@ -804,9 +804,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔍 Starting Terrascan IaC scan: {iac_dir}")
result = hexstrike_client.safe_post("api/tools/terrascan", data)
if result.get("success"):
logger.info(f"✅ Terrascan scan completed")
logger.info("✅ Terrascan scan completed")
else:
logger.error(f"❌ Terrascan scan failed")
logger.error("❌ Terrascan scan failed")
return result
# ============================================================================
@@ -932,9 +932,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🎯 Generating {payload_type} payload: {size} bytes")
result = hexstrike_client.safe_post("api/payloads/generate", data)
if result.get("success"):
logger.info(f"✅ Payload generated successfully")
logger.info("✅ Payload generated successfully")
else:
logger.error(f"❌ Failed to generate payload")
logger.error("❌ Failed to generate payload")
return result
# ============================================================================
@@ -988,9 +988,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🐍 Executing Python script in env {env_name}")
result = hexstrike_client.safe_post("api/python/execute", data)
if result.get("success"):
logger.info(f"✅ Python script executed successfully")
logger.info("✅ Python script executed successfully")
else:
logger.error(f"❌ Python script execution failed")
logger.error("❌ Python script execution failed")
return result
# ============================================================================
@@ -1167,9 +1167,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔐 Starting John the Ripper: {hash_file}")
result = hexstrike_client.safe_post("api/tools/john", data)
if result.get("success"):
logger.info(f"✅ John the Ripper completed")
logger.info("✅ John the Ripper completed")
else:
logger.error(f"❌ John the Ripper failed")
logger.error("❌ John the Ripper failed")
return result
@mcp.tool()
@@ -1337,9 +1337,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔐 Starting Hashcat attack: mode {attack_mode}")
result = hexstrike_client.safe_post("api/tools/hashcat", data)
if result.get("success"):
logger.info(f"✅ Hashcat attack completed")
logger.info("✅ Hashcat attack completed")
else:
logger.error(f"❌ Hashcat attack failed")
logger.error("❌ Hashcat attack failed")
return result
@mcp.tool()
@@ -1690,9 +1690,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔍 Starting arp-scan: {target if target else 'local network'}")
result = hexstrike_client.safe_post("api/tools/arp-scan", data)
if result.get("success"):
logger.info(f"✅ arp-scan completed")
logger.info("✅ arp-scan completed")
else:
logger.error(f"❌ arp-scan failed")
logger.error("❌ arp-scan failed")
return result
@mcp.tool()
@@ -1727,9 +1727,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔍 Starting Responder on interface: {interface}")
result = hexstrike_client.safe_post("api/tools/responder", data)
if result.get("success"):
logger.info(f"✅ Responder completed")
logger.info("✅ Responder completed")
else:
logger.error(f"❌ Responder failed")
logger.error("❌ Responder failed")
return result
@mcp.tool()
@@ -1755,9 +1755,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🧠 Starting Volatility analysis: {plugin}")
result = hexstrike_client.safe_post("api/tools/volatility", data)
if result.get("success"):
logger.info(f"✅ Volatility analysis completed")
logger.info("✅ Volatility analysis completed")
else:
logger.error(f"❌ Volatility analysis failed")
logger.error("❌ Volatility analysis failed")
return result
@mcp.tool()
@@ -1787,9 +1787,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🚀 Starting MSFVenom payload generation: {payload}")
result = hexstrike_client.safe_post("api/tools/msfvenom", data)
if result.get("success"):
logger.info(f"✅ MSFVenom payload generated")
logger.info("✅ MSFVenom payload generated")
else:
logger.error(f"❌ MSFVenom payload generation failed")
logger.error("❌ MSFVenom payload generation failed")
return result
# ============================================================================
@@ -2071,9 +2071,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔧 Starting Pwntools exploit: {exploit_type}")
result = hexstrike_client.safe_post("api/tools/pwntools", data)
if result.get("success"):
logger.info(f"✅ Pwntools exploit completed")
logger.info("✅ Pwntools exploit completed")
else:
logger.error(f"❌ Pwntools exploit failed")
logger.error("❌ Pwntools exploit failed")
return result
@mcp.tool()
@@ -2097,9 +2097,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔧 Starting one_gadget analysis: {libc_path}")
result = hexstrike_client.safe_post("api/tools/one-gadget", data)
if result.get("success"):
logger.info(f"✅ one_gadget analysis completed")
logger.info("✅ one_gadget analysis completed")
else:
logger.error(f"❌ one_gadget analysis failed")
logger.error("❌ one_gadget analysis failed")
return result
@mcp.tool()
@@ -2157,9 +2157,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔧 Starting GDB-PEDA analysis: {binary or f'PID {attach_pid}' or core_file}")
result = hexstrike_client.safe_post("api/tools/gdb-peda", data)
if result.get("success"):
logger.info(f"✅ GDB-PEDA analysis completed")
logger.info("✅ GDB-PEDA analysis completed")
else:
logger.error(f"❌ GDB-PEDA analysis failed")
logger.error("❌ GDB-PEDA analysis failed")
return result
@mcp.tool()
@@ -2191,9 +2191,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔧 Starting angr analysis: {binary}")
result = hexstrike_client.safe_post("api/tools/angr", data)
if result.get("success"):
logger.info(f"✅ angr analysis completed")
logger.info("✅ angr analysis completed")
else:
logger.error(f"❌ angr analysis failed")
logger.error("❌ angr analysis failed")
return result
@mcp.tool()
@@ -2225,9 +2225,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔧 Starting ropper analysis: {binary}")
result = hexstrike_client.safe_post("api/tools/ropper", data)
if result.get("success"):
logger.info(f"✅ ropper analysis completed")
logger.info("✅ ropper analysis completed")
else:
logger.error(f"❌ ropper analysis failed")
logger.error("❌ ropper analysis failed")
return result
@mcp.tool()
@@ -2256,9 +2256,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔧 Starting pwninit setup: {binary}")
result = hexstrike_client.safe_post("api/tools/pwninit", data)
if result.get("success"):
logger.info(f"✅ pwninit setup completed")
logger.info("✅ pwninit setup completed")
else:
logger.error(f"❌ pwninit setup failed")
logger.error("❌ pwninit setup failed")
return result
@mcp.tool()
@@ -2667,9 +2667,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🎯 Starting Dalfox XSS scan: {url if url else 'pipe mode'}")
result = hexstrike_client.safe_post("api/tools/dalfox", data)
if result.get("success"):
logger.info(f"✅ Dalfox XSS scan completed")
logger.info("✅ Dalfox XSS scan completed")
else:
logger.error(f"❌ Dalfox XSS scan failed")
logger.error("❌ Dalfox XSS scan failed")
return result
@mcp.tool()
@@ -2922,7 +2922,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
if payload_info.get("risk_level") == "HIGH":
results["summary"]["high_risk_payloads"] += 1
logger.info(f"✅ Attack suite generated:")
logger.info("✅ Attack suite generated:")
logger.info(f" ├─ Total payloads: {results['summary']['total_payloads']}")
logger.info(f" ├─ High-risk payloads: {results['summary']['high_risk_payloads']}")
logger.info(f" └─ Test cases: {results['summary']['test_cases']}")
@@ -2967,7 +2967,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
endpoint_count = len(result.get("results", []))
logger.info(f"✅ API endpoint testing completed: {endpoint_count} endpoints tested")
else:
logger.info(f"✅ API endpoint discovery completed")
logger.info("✅ API endpoint discovery completed")
else:
logger.error("❌ API fuzzing failed")
@@ -3032,7 +3032,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"target_url": target_url
}
logger.info(f"🔍 Starting JWT security analysis")
logger.info("🔍 Starting JWT security analysis")
result = hexstrike_client.safe_post("api/tools/jwt_analyzer", data)
if result.get("success"):
@@ -3089,7 +3089,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.warning(f" ├─ [{severity}] {issue_type}")
if endpoint_count > 0:
logger.info(f"📊 Discovered endpoints:")
logger.info("📊 Discovered endpoints:")
for endpoint in analysis.get("endpoints_found", [])[:5]: # Show first 5
method = endpoint.get("method", "GET")
path = endpoint.get("path", "/")
@@ -3183,7 +3183,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"audit_coverage": "comprehensive" if len(audit_results["tests_performed"]) >= 3 else "partial"
}
logger.info(f"✅ Comprehensive API audit completed:")
logger.info("✅ Comprehensive API audit completed:")
logger.info(f" ├─ Tests performed: {audit_results['summary']['tests_performed']}")
logger.info(f" ├─ Total vulnerabilities: {audit_results['summary']['total_vulnerabilities']}")
logger.info(f" └─ Coverage: {audit_results['summary']['audit_coverage']}")
@@ -3220,9 +3220,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🧠 Starting Volatility3 analysis: {plugin}")
result = hexstrike_client.safe_post("api/tools/volatility3", data)
if result.get("success"):
logger.info(f"✅ Volatility3 analysis completed")
logger.info("✅ Volatility3 analysis completed")
else:
logger.error(f"❌ Volatility3 analysis failed")
logger.error("❌ Volatility3 analysis failed")
return result
@mcp.tool()
@@ -3248,9 +3248,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"📁 Starting Foremost file carving: {input_file}")
result = hexstrike_client.safe_post("api/tools/foremost", data)
if result.get("success"):
logger.info(f"✅ Foremost carving completed")
logger.info("✅ Foremost carving completed")
else:
logger.error(f"❌ Foremost carving failed")
logger.error("❌ Foremost carving failed")
return result
@mcp.tool()
@@ -3308,9 +3308,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"📷 Starting ExifTool analysis: {file_path}")
result = hexstrike_client.safe_post("api/tools/exiftool", data)
if result.get("success"):
logger.info(f"✅ ExifTool analysis completed")
logger.info("✅ ExifTool analysis completed")
else:
logger.error(f"❌ ExifTool analysis failed")
logger.error("❌ ExifTool analysis failed")
return result
@mcp.tool()
@@ -3335,12 +3335,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"append_data": append_data,
"additional_args": additional_args
}
logger.info(f"🔐 Starting HashPump attack")
logger.info("🔐 Starting HashPump attack")
result = hexstrike_client.safe_post("api/tools/hashpump", data)
if result.get("success"):
logger.info(f"✅ HashPump attack completed")
logger.info("✅ HashPump attack completed")
else:
logger.error(f"❌ HashPump attack failed")
logger.error("❌ HashPump attack failed")
return result
# ============================================================================
@@ -3383,9 +3383,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🕷️ Starting Hakrawler crawling: {url}")
result = hexstrike_client.safe_post("api/tools/hakrawler", data)
if result.get("success"):
logger.info(f"✅ Hakrawler crawling completed")
logger.info("✅ Hakrawler crawling completed")
else:
logger.error(f"❌ Hakrawler crawling failed")
logger.error("❌ Hakrawler crawling failed")
return result
@mcp.tool()
@@ -3416,12 +3416,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"output_file": output_file,
"additional_args": additional_args
}
logger.info(f"🌐 Starting HTTPx probing")
logger.info("🌐 Starting HTTPx probing")
result = hexstrike_client.safe_post("api/tools/httpx", data)
if result.get("success"):
logger.info(f"✅ HTTPx probing completed")
logger.info("✅ HTTPx probing completed")
else:
logger.error(f"❌ HTTPx probing failed")
logger.error("❌ HTTPx probing failed")
return result
@mcp.tool()
@@ -3449,9 +3449,9 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
logger.info(f"🔍 Starting ParamSpider discovery: {domain}")
result = hexstrike_client.safe_post("api/tools/paramspider", data)
if result.get("success"):
logger.info(f"✅ ParamSpider discovery completed")
logger.info("✅ ParamSpider discovery completed")
else:
logger.error(f"❌ ParamSpider discovery failed")
logger.error("❌ ParamSpider discovery failed")
return result
# ============================================================================
@@ -3486,12 +3486,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
"output_file": output_file,
"additional_args": additional_args
}
logger.info(f"🔍 Starting Burp Suite scan")
logger.info("🔍 Starting Burp Suite scan")
result = hexstrike_client.safe_post("api/tools/burpsuite", data)
if result.get("success"):
logger.info(f"✅ Burp Suite scan completed")
logger.info("✅ Burp Suite scan completed")
else:
logger.error(f"❌ Burp Suite scan failed")
logger.error("❌ Burp Suite scan failed")
return result
@mcp.tool()
@@ -3794,7 +3794,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
Returns:
Server health information with tool availability and telemetry
"""
logger.info(f"🏥 Checking HexStrike AI server health")
logger.info("🏥 Checking HexStrike AI server health")
result = hexstrike_client.check_health()
if result.get("status") == "healthy":
logger.info(f"✅ Server is healthy - {result.get('total_tools_available', 0)} tools available")
@@ -3810,7 +3810,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
Returns:
Cache performance statistics
"""
logger.info(f"💾 Getting cache statistics")
logger.info("💾 Getting cache statistics")
result = hexstrike_client.safe_get("api/cache/stats")
if "hit_rate" in result:
logger.info(f"📊 Cache hit rate: {result.get('hit_rate', 'unknown')}")
@@ -3824,12 +3824,12 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
Returns:
Cache clear operation results
"""
logger.info(f"🧹 Clearing server cache")
logger.info("🧹 Clearing server cache")
result = hexstrike_client.safe_post("api/cache/clear", {})
if result.get("success"):
logger.info(f"✅ Cache cleared successfully")
logger.info("✅ Cache cleared successfully")
else:
logger.error(f"❌ Failed to clear cache")
logger.error("❌ Failed to clear cache")
return result
@mcp.tool()
@@ -3840,7 +3840,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
Returns:
System performance and usage telemetry
"""
logger.info(f"📈 Getting system telemetry")
logger.info("📈 Getting system telemetry")
result = hexstrike_client.safe_get("api/telemetry")
if "commands_executed" in result:
logger.info(f"📊 Commands executed: {result.get('commands_executed', 0)}")
@@ -3993,7 +3993,7 @@ def setup_mcp_server(hexstrike_client: HexStrikeClient) -> FastMCP:
execution_time = result.get("execution_time", 0)
logger.info(f"✅ Command completed successfully in {execution_time:.2f}s")
else:
logger.warning(f"⚠️ Command completed with errors")
logger.warning("⚠️ Command completed with errors")
return result
except Exception as e:
@@ -5433,7 +5433,7 @@ def main():
logger.debug("🔍 Debug logging enabled")
# MCP compatibility: No banner output to avoid JSON parsing issues
logger.info(f"🚀 Starting HexStrike AI MCP Client v6.0")
logger.info("🚀 Starting HexStrike AI MCP Client v6.0")
logger.info(f"🔗 Connecting to: {args.server}")
try:

File diff suppressed because it is too large Load Diff