mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
501 lines
17 KiB
Python
501 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Strix Agent Interface
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
from strix.config import codex, load_settings, persist_current
|
|
from strix.core.paths import run_dir_for
|
|
from strix.interface.cli_args import parse_arguments
|
|
from strix.interface.environment import (
|
|
check_docker_installed,
|
|
pull_docker_image,
|
|
validate_environment,
|
|
)
|
|
from strix.interface.interactive import (
|
|
InteractiveSetupUnavailableError,
|
|
run_tui,
|
|
)
|
|
from strix.interface.scan_setup import (
|
|
ModelConnectionError,
|
|
preflight_model_connection,
|
|
prepare_run,
|
|
telemetry_start,
|
|
)
|
|
from strix.interface.update_check import (
|
|
is_binary_install,
|
|
notify_update,
|
|
prompt_update_if_available,
|
|
start_background_check,
|
|
)
|
|
from strix.interface.utils import (
|
|
build_final_stats_text,
|
|
)
|
|
from strix.telemetry import posthog, scarf
|
|
from strix.telemetry.logging import configure_dependency_logging
|
|
|
|
|
|
BEDROCK_MODEL_PREFIX = "bedrock/"
|
|
BEDROCK_MISSING_MODULE_ERROR = "No module named 'boto3'"
|
|
BEDROCK_EXTRA_HINT = (
|
|
'Bedrock support is optional. Install it with: pipx install "strix-agent[bedrock]"'
|
|
)
|
|
VERTEX_MODEL_MARKER = "vertex"
|
|
VERTEX_MISSING_MODULE_ERROR = "No module named 'google"
|
|
VERTEX_EXTRA_HINT = (
|
|
'Vertex AI support is optional. Install it with: pipx install "strix-agent[vertex]"'
|
|
)
|
|
|
|
|
|
import logging # noqa: E402
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _exception_messages(exc: BaseException) -> tuple[str, ...]:
|
|
messages: list[str] = []
|
|
seen: set[int] = set()
|
|
stack: list[BaseException] = [exc]
|
|
while stack:
|
|
current = stack.pop()
|
|
if id(current) in seen:
|
|
continue
|
|
seen.add(id(current))
|
|
messages.append(str(current))
|
|
if current.__cause__ is not None:
|
|
stack.append(current.__cause__)
|
|
if current.__context__ is not None:
|
|
stack.append(current.__context__)
|
|
return tuple(messages)
|
|
|
|
|
|
def _provider_import_hint(exc: BaseException, model: str) -> str | None:
|
|
"""Return an install hint when *exc* is a missing provider dependency.
|
|
|
|
Bedrock and Vertex AI ship as optional extras: Bedrock needs ``boto3`` and
|
|
Vertex AI needs ``google-auth``. When either is absent, litellm may raise an
|
|
``ImportError``/``ModuleNotFoundError`` directly or wrap it in a connection
|
|
error. Map the missing module back to the matching extra so the user knows
|
|
what to install. Returns ``None`` for any unrelated error.
|
|
"""
|
|
model_name = model.lower()
|
|
messages = _exception_messages(exc)
|
|
if any(
|
|
BEDROCK_MISSING_MODULE_ERROR in message for message in messages
|
|
) and model_name.startswith(BEDROCK_MODEL_PREFIX):
|
|
return BEDROCK_EXTRA_HINT
|
|
if (
|
|
any(VERTEX_MISSING_MODULE_ERROR in message for message in messages)
|
|
and VERTEX_MODEL_MARKER in model_name
|
|
):
|
|
return VERTEX_EXTRA_HINT
|
|
return None
|
|
|
|
|
|
def _subscription_error_hint(exc: BaseException) -> str | None:
|
|
"""Return an actionable hint for a known ChatGPT-subscription error, or None."""
|
|
if not codex.subscription_model(load_settings().llm.model):
|
|
return None
|
|
joined = " ".join(_exception_messages(exc)).lower()
|
|
if "not supported when using codex with a chatgpt account" in joined:
|
|
return (
|
|
"This model isn't available on your ChatGPT subscription. "
|
|
"Set STRIX_LLM to a model your plan includes (e.g. chatgpt/gpt-5.4)."
|
|
)
|
|
if (
|
|
"error code: 401" in joined
|
|
or "http 401" in joined
|
|
or "unauthorized" in joined
|
|
or "invalid_grant" in joined
|
|
):
|
|
return (
|
|
"Your ChatGPT sign-in has expired or was revoked. Sign in again:\n"
|
|
" strix auth login chatgpt"
|
|
)
|
|
return None
|
|
|
|
|
|
async def warm_up_llm(show_model_warning: bool = True) -> None:
|
|
from agents.model_settings import ModelSettings
|
|
from agents.models.interface import ModelTracing
|
|
|
|
from strix.config.models import (
|
|
RECOMMENDED_MODEL_NAMES,
|
|
StrixProvider,
|
|
configure_sdk_model_defaults,
|
|
is_known_openai_bare_model,
|
|
is_recommended_or_frontier_model,
|
|
)
|
|
from strix.core.inputs import make_model_settings
|
|
|
|
console = Console()
|
|
logger.info("Warming up LLM connection")
|
|
|
|
raw_model = ""
|
|
try:
|
|
settings = load_settings()
|
|
configure_sdk_model_defaults(settings)
|
|
llm = settings.llm
|
|
raw_model = (llm.model or "").strip()
|
|
if (
|
|
raw_model
|
|
and "/" not in raw_model
|
|
and not is_known_openai_bare_model(raw_model)
|
|
and not llm.api_base
|
|
):
|
|
warn_text = Text()
|
|
warn_text.append("UNKNOWN MODEL NAME", style="bold yellow")
|
|
warn_text.append("\n\n", style="white")
|
|
warn_text.append(f"'{raw_model}'", style="bold cyan")
|
|
warn_text.append(
|
|
" is not a known OpenAI model. Bare names route to OpenAI by default.\n"
|
|
"If you meant a non-OpenAI provider, use the '",
|
|
style="white",
|
|
)
|
|
warn_text.append("<provider>/<model>", style="bold cyan")
|
|
warn_text.append(
|
|
"' form, e.g. 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
|
|
style="white",
|
|
)
|
|
console.print(
|
|
Panel(
|
|
warn_text,
|
|
title="[bold white]STRIX",
|
|
title_align="left",
|
|
border_style="yellow",
|
|
padding=(1, 2),
|
|
),
|
|
)
|
|
sys.exit(1)
|
|
|
|
if show_model_warning and raw_model and not is_recommended_or_frontier_model(raw_model):
|
|
warn_text = Text()
|
|
warn_text.append("MODEL QUALITY WARNING", style="bold yellow")
|
|
warn_text.append("\n\n", style="white")
|
|
warn_text.append(f"'{raw_model}'", style="bold cyan")
|
|
warn_text.append(
|
|
" is not a recommended frontier model for Strix.\nSecurity scans work best with:\n",
|
|
style="white",
|
|
)
|
|
for recommended_model in RECOMMENDED_MODEL_NAMES:
|
|
warn_text.append(f"• {recommended_model}\n", style="bold cyan")
|
|
warn_text.append(
|
|
"\nYou can continue, but weaker models may miss vulnerabilities "
|
|
"or produce lower-quality findings.",
|
|
style="white",
|
|
)
|
|
console.print(
|
|
Panel(
|
|
warn_text,
|
|
title="[bold white]STRIX",
|
|
title_align="left",
|
|
border_style="yellow",
|
|
padding=(1, 2),
|
|
),
|
|
)
|
|
|
|
await preflight_model_connection(raw_model, settings=settings)
|
|
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
|
|
|
if settings.dedupe.model:
|
|
from strix.report.dedupe import _dedupe_extra_args
|
|
|
|
dedupe_model = settings.dedupe.model.strip()
|
|
raw_model = dedupe_model
|
|
deduper = StrixProvider().get_model(dedupe_model)
|
|
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
|
# A dedicated dedupe model may route to another provider, which must
|
|
# never receive the main endpoint's headers; it has its own
|
|
# DEDUPE_LLM_EXTRA_HEADERS.
|
|
deduper_settings = make_model_settings(
|
|
None,
|
|
model_name=dedupe_model,
|
|
request_timeout=llm.timeout,
|
|
prompt_cache=False,
|
|
extra_headers=settings.dedupe.extra_headers,
|
|
)
|
|
if deduper_extra:
|
|
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
|
|
deduper_settings = deduper_settings.resolve(ModelSettings(extra_args=merged))
|
|
await asyncio.wait_for(
|
|
deduper.get_response(
|
|
system_instructions="You are a helpful assistant.",
|
|
input="Reply with just 'OK'.",
|
|
model_settings=deduper_settings,
|
|
tools=[],
|
|
output_schema=None,
|
|
handoffs=[],
|
|
tracing=ModelTracing.DISABLED,
|
|
previous_response_id=None,
|
|
conversation_id=None,
|
|
prompt=None,
|
|
),
|
|
timeout=llm.timeout,
|
|
)
|
|
logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model)
|
|
|
|
except ModelConnectionError:
|
|
logger.debug("Model route warm-up failed", exc_info=True)
|
|
raise
|
|
except Exception as exc:
|
|
logger.debug("LLM warm-up failed", exc_info=True)
|
|
raise ModelConnectionError(raw_model, exc) from exc
|
|
|
|
|
|
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
|
from strix.report.state import get_global_report_state
|
|
|
|
console = Console()
|
|
report_state = get_global_report_state()
|
|
|
|
scan_completed = False
|
|
if report_state:
|
|
scan_completed = report_state.run_record.get("status") == "completed"
|
|
|
|
completion_text = Text()
|
|
if scan_completed:
|
|
completion_text.append("Penetration test completed", style="bold #22c55e")
|
|
else:
|
|
completion_text.append("SESSION ENDED", style="bold #eab308")
|
|
|
|
target_text = Text()
|
|
target_text.append("Target", style="dim")
|
|
target_text.append(" ")
|
|
if len(args.targets_info) == 1:
|
|
target_text.append(args.targets_info[0]["original"], style="bold white")
|
|
else:
|
|
target_text.append(f"{len(args.targets_info)} targets", style="bold white")
|
|
for target_info in args.targets_info:
|
|
target_text.append("\n ")
|
|
target_text.append(target_info["original"], style="white")
|
|
|
|
stats_text = build_final_stats_text(report_state)
|
|
|
|
panel_parts: list[Text | str] = [completion_text, "\n\n", target_text]
|
|
|
|
if stats_text.plain:
|
|
panel_parts.extend(["\n", stats_text])
|
|
|
|
results_text = Text()
|
|
results_text.append("\n")
|
|
results_text.append("Output", style="dim")
|
|
results_text.append(" ")
|
|
results_text.append(str(results_path), style="#60a5fa")
|
|
panel_parts.extend(["\n", results_text])
|
|
|
|
view_text = Text()
|
|
view_text.append("\n")
|
|
view_text.append("View", style="dim")
|
|
view_text.append(" ")
|
|
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
|
panel_parts.extend(["\n", view_text])
|
|
|
|
if not scan_completed:
|
|
resume_text = Text()
|
|
resume_text.append("\n")
|
|
resume_text.append("Resume", style="dim")
|
|
resume_text.append(" ")
|
|
resume_text.append(f"strix --resume {args.run_name}", style="#22c55e")
|
|
panel_parts.extend(["\n", resume_text])
|
|
|
|
panel_content = Text.assemble(*panel_parts)
|
|
|
|
border_style = "#22c55e" if scan_completed else "#eab308"
|
|
|
|
panel = Panel(
|
|
panel_content,
|
|
title="[bold white]STRIX",
|
|
title_align="left",
|
|
border_style=border_style,
|
|
padding=(1, 2),
|
|
)
|
|
|
|
console.print("\n")
|
|
console.print(panel)
|
|
console.print()
|
|
console.print(
|
|
"[#60a5fa]strix.ai[/] [dim]·[/] "
|
|
"[#60a5fa]docs.strix.ai[/] [dim]·[/] "
|
|
"[#60a5fa]discord.gg/strix-ai[/]"
|
|
)
|
|
console.print()
|
|
if not args.non_interactive:
|
|
notify_update(console)
|
|
|
|
|
|
def _print_error_panel(title: str, message: str) -> None:
|
|
console = Console()
|
|
error_text = Text()
|
|
error_text.append(title, style="bold red")
|
|
error_text.append("\n\n", style="white")
|
|
error_text.append(message, style="white")
|
|
panel = Panel(
|
|
error_text,
|
|
title="[bold white]STRIX",
|
|
title_align="left",
|
|
border_style="red",
|
|
padding=(1, 2),
|
|
)
|
|
console.print("\n")
|
|
console.print(panel)
|
|
console.print()
|
|
|
|
|
|
def _print_model_connection_error(exc: BaseException, model_name: str) -> None:
|
|
console = Console()
|
|
error_text = Text()
|
|
sub_hint = _subscription_error_hint(exc)
|
|
if sub_hint is not None:
|
|
border_style = "yellow"
|
|
error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow")
|
|
error_text.append("\n\n", style="white")
|
|
error_text.append(f"{sub_hint}\n", style="white")
|
|
error_text.append(f"\nDetails: {exc}", style="dim white")
|
|
else:
|
|
border_style = "red"
|
|
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
|
error_text.append("\n\n", style="white")
|
|
error_text.append("Could not establish connection to the language model.\n", style="white")
|
|
error_text.append("Please check your configuration and try again.\n", style="white")
|
|
hint = _provider_import_hint(exc, model_name)
|
|
if hint is not None:
|
|
error_text.append(f"\n{hint}\n", style="bold yellow")
|
|
error_text.append(f"\nError: {exc}", style="dim white")
|
|
|
|
panel = Panel(
|
|
error_text,
|
|
title="[bold white]STRIX",
|
|
title_align="left",
|
|
border_style=border_style,
|
|
padding=(1, 2),
|
|
)
|
|
console.print("\n")
|
|
console.print(panel)
|
|
console.print()
|
|
|
|
|
|
def _bootstrap_scan(args: argparse.Namespace) -> None:
|
|
"""Warm up the model and prepare the run for a non-interactive scan.
|
|
|
|
Interactive launches only validate the environment here; the model
|
|
preflight and run preparation happen inside the TUI so the interface
|
|
paints immediately instead of waiting on a model round trip.
|
|
"""
|
|
validate_environment()
|
|
if not args.non_interactive:
|
|
return
|
|
try:
|
|
asyncio.run(warm_up_llm(show_model_warning=True))
|
|
except ModelConnectionError as exc:
|
|
_print_model_connection_error(exc, exc.model_name)
|
|
sys.exit(1)
|
|
persist_current()
|
|
try:
|
|
prepare_run(args)
|
|
except ValueError as e:
|
|
_print_error_panel("SCAN PREPARATION FAILED", str(e))
|
|
sys.exit(1)
|
|
telemetry_start(args)
|
|
|
|
|
|
def main() -> None:
|
|
configure_dependency_logging()
|
|
|
|
if sys.platform == "win32":
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
|
|
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
|
# scan argument parser (which requires a target) and before any scan setup.
|
|
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
|
from strix.interface.viewer.cli import run_view
|
|
|
|
run_view(sys.argv[2:])
|
|
return
|
|
|
|
# `strix auth …` manages model-subscription sign-in and exits; it needs no
|
|
# target, Docker, or scan setup.
|
|
if len(sys.argv) > 1 and sys.argv[1] == "auth":
|
|
from strix.interface.auth_cli import run_auth
|
|
|
|
sys.exit(run_auth(sys.argv[2:]))
|
|
|
|
args = parse_arguments()
|
|
|
|
start_background_check()
|
|
if not args.non_interactive and prompt_update_if_available(Console()):
|
|
if is_binary_install() and sys.platform != "win32":
|
|
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
|
|
sys.exit(0)
|
|
|
|
check_docker_installed()
|
|
pull_docker_image()
|
|
|
|
# In setup mode the TUI collects the target, then runs prepare_run(),
|
|
# warm-up, and telemetry itself once the user starts the scan.
|
|
if not args.needs_setup:
|
|
_bootstrap_scan(args)
|
|
|
|
from strix.report.state import get_global_report_state
|
|
|
|
exit_reason = "user_exit"
|
|
try:
|
|
if args.non_interactive:
|
|
from strix.interface.cli import run_cli
|
|
|
|
asyncio.run(run_cli(args))
|
|
else:
|
|
asyncio.run(run_tui(args))
|
|
except InteractiveSetupUnavailableError as exc:
|
|
exit_reason = "error"
|
|
_print_error_panel("INTERACTIVE SETUP UNAVAILABLE", str(exc))
|
|
sys.exit(1)
|
|
except KeyboardInterrupt:
|
|
exit_reason = "interrupted"
|
|
except Exception:
|
|
exit_reason = "error"
|
|
posthog.error("unhandled_exception")
|
|
scarf.error("unhandled_exception")
|
|
raise
|
|
finally:
|
|
report_state = get_global_report_state()
|
|
if report_state:
|
|
status = {"interrupted": "interrupted", "error": "failed"}.get(
|
|
exit_reason,
|
|
"stopped",
|
|
)
|
|
report_state.cleanup(status=status)
|
|
# Best-effort beacons on the way out. They reach the network, so a
|
|
# second Ctrl-C lands here; abandon them rather than trading a clean
|
|
# exit for a traceback.
|
|
with contextlib.suppress(KeyboardInterrupt, Exception):
|
|
posthog.end(report_state, exit_reason=exit_reason)
|
|
scarf.end(report_state, exit_reason=exit_reason)
|
|
|
|
if not args.run_name:
|
|
# Setup mode where the user quit before starting a scan: nothing ran.
|
|
return
|
|
|
|
results_path = run_dir_for(args.run_name)
|
|
|
|
display_completion_message(args, results_path)
|
|
|
|
if args.non_interactive:
|
|
report_state = get_global_report_state()
|
|
if report_state and report_state.vulnerability_reports:
|
|
sys.exit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|