Strix is connected to your ChatGPT subscription. Head back to your + terminal — your security test runs there.
+ +Autonomous AI hackers that find and fix your app's + vulnerabilities.
+ +Strix is connected to your ChatGPT subscription. Head back to your + terminal — your security test runs there.
+ +Autonomous AI hackers that find and fix your app's + vulnerabilities.
+ +You can close this tab.
+""" + + +__all__ = ["run_auth"] diff --git a/strix/interface/cli.py b/strix/interface/cli.py index f50791203..c03e8c86c 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -13,6 +13,7 @@ from rich.panel import Panel from rich.text import Text from strix.config import load_settings +from strix.core.inputs import DEFAULT_MAX_TURNS from strix.core.runner import run_strix_scan from strix.report.state import ReportState, set_global_report_state from strix.runtime import session_manager @@ -184,6 +185,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 local_sources=getattr(args, "local_sources", None) or [], interactive=bool(getattr(args, "interactive", False)), max_budget_usd=getattr(args, "max_budget_usd", None), + max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS), ) finally: stop_updates.set() diff --git a/strix/interface/main.py b/strix/interface/main.py index 25df61882..0fbcbf486 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -5,6 +5,7 @@ Strix Agent Interface import argparse import asyncio +import os import shutil import sys from datetime import UTC, datetime @@ -19,17 +20,28 @@ from rich.text import Text from strix.config import ( apply_config_override, + codex, load_settings, persist_current, ) 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 DEFAULT_MAX_TURNS from strix.core.paths import run_dir_for, runtime_state_dir from strix.interface.cli import run_cli from strix.interface.tui import run_tui +from strix.interface.update_check import ( + is_binary_install, + notify_update, + prompt_update_if_available, + self_update, + start_background_check, +) from strix.interface.utils import ( assign_workspace_subdirs, build_final_stats_text, @@ -44,6 +56,7 @@ from strix.interface.utils import ( infer_target_type, is_whitebox_scan, process_pull_line, + read_target_list_file, resolve_diff_scope_context, rewrite_localhost_targets, validate_config_file, @@ -55,6 +68,16 @@ from strix.telemetry.logging import configure_dependency_logging HOST_GATEWAY_HOSTNAME = "host.docker.internal" +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 @@ -71,6 +94,16 @@ def validate_environment() -> None: settings = load_settings() + if codex.subscription_model(settings.llm.model): + if not codex.is_authenticated(): + console.print( + f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, " + "but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first." + ) + sys.exit(1) + logger.info("Environment OK (ChatGPT subscription)") + return + if not settings.llm.model: missing_required_vars.append("STRIX_LLM") @@ -178,7 +211,7 @@ def validate_environment() -> None: padding=(1, 2), ) - logger.error("Missing required env vars: %s", missing_required_vars) + logger.debug("Missing required env vars: %s", missing_required_vars) console.print("\n") console.print(panel) console.print() @@ -191,7 +224,7 @@ def validate_environment() -> None: def check_docker_installed() -> None: if shutil.which("docker") is None: - logger.error("Docker CLI not found in PATH") + logger.debug("Docker CLI not found in PATH") console = Console() error_text = Text() error_text.append("DOCKER NOT INSTALLED", style="bold red") @@ -213,16 +246,80 @@ def check_docker_installed() -> None: logger.debug("Docker CLI present") -async def warm_up_llm() -> None: +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: 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 @@ -254,6 +351,32 @@ async def warm_up_llm() -> None: ) 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), + ), + ) + model = StrixProvider().get_model(raw_model) await asyncio.wait_for( model.get_response( @@ -272,20 +395,63 @@ async def warm_up_llm() -> None: ) 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) + # Match the runtime path: send the dedupe key/endpoint per call so a + # separate-provider dedupe model authenticates during warm-up too. + deduper_extra = _dedupe_extra_args(settings.dedupe) + deduper_settings = ModelSettings(extra_args=deduper_extra or None) + 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 Exception as e: - logger.exception("LLM warm-up failed") + logger.debug("LLM warm-up failed", exc_info=True) error_text = Text() - 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") - error_text.append(f"\nError: {e}", style="dim white") + sub_hint = _subscription_error_hint(e) + if sub_hint is not None: + # The model/backend answered with a clear, actionable rejection — + # show that instead of a generic "connection failed". + 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: {e}", 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(e, raw_model) + if hint is not None: + error_text.append(f"\n{hint}\n", style="bold yellow") + error_text.append(f"\nError: {e}", style="dim white") panel = Panel( error_text, title="[bold white]STRIX", title_align="left", - border_style="red", + border_style=border_style, padding=(1, 2), ) @@ -310,11 +476,22 @@ def _positive_budget(value: str) -> float: except ValueError as exc: raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc import math + if not math.isfinite(budget) or budget <= 0: raise argparse.ArgumentTypeError("must be a finite number greater than 0") return budget +def _positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc + if parsed <= 0: + raise argparse.ArgumentTypeError("must be an integer greater than 0") + return parsed + + def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Strix Multi-Agent Cybersecurity Penetration Testing Tool", @@ -344,6 +521,9 @@ Examples: strix --target https://github.com/user/repo --target https://example.com strix --target ./my-project --target https://staging.example.com --target https://prod.example.com + # Targets from a file, one target per non-empty, non-comment line + strix --target-list ./targets.txt + # Custom instructions (inline) strix --target example.com --instruction "Focus on authentication vulnerabilities" @@ -360,6 +540,14 @@ Examples: version=f"strix {get_version()}", ) + parser.add_argument( + "--update", + action="store_true", + help="Update strix to the latest version and exit. Self-updates the " + "standalone binary install; for pip/pipx/uv installs, prints the " + "matching upgrade command instead.", + ) + parser.add_argument( "-t", "--target", @@ -367,7 +555,15 @@ Examples: action="append", help="Target to test (URL, repository, local directory path, domain name, or IP address). " "Can be specified multiple times for multi-target scans. " - "Required for fresh runs; loaded from disk when ``--resume`` is set.", + "Fresh runs require at least one of --target, --target-list, or --mount.", + ) + parser.add_argument( + "--target-list", + type=str, + action="append", + metavar="PATH", + help="Path to a file containing targets, one per non-empty, non-comment line. " + "Can be specified multiple times and combined with --target.", ) parser.add_argument( "--mount", @@ -451,10 +647,27 @@ Examples: ) parser.add_argument( - "--max-budget-usd", + "--max-budget", + dest="max_budget_usd", + metavar="USD", type=_positive_budget, default=None, - help="Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached.", + help=( + "Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. " + "Graduated wrap-up warnings are sent to all agents as it is approached." + ), + ) + + parser.add_argument( + "--max-turns", + dest="max_turns", + metavar="N", + type=_positive_int, + default=DEFAULT_MAX_TURNS, + help=( + "Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped " + "when it reaches this limit, with graduated wrap-up warnings as it is approached." + ), ) parser.add_argument( @@ -470,6 +683,9 @@ Examples: args = parser.parse_args() + if args.update: + sys.exit(0 if self_update() else 1) + if args.instruction and args.instruction_file: parser.error( "Cannot specify both --instruction and --instruction-file. Use one or the other." @@ -488,10 +704,11 @@ Examples: args.user_explicit_instruction = args.instruction if args.resume else None if args.resume: - if args.target or args.mount: + if args.target or args.target_list or args.mount: parser.error( - "Cannot combine --resume with --target/--mount. --resume picks up where " - "the prior run left off, including the original target list." + "Cannot combine --resume with --target/--target-list/--mount. " + "--resume picks up where the prior run left off, including the " + "original target list." ) _load_resume_state(args, parser) agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json" @@ -503,13 +720,20 @@ Examples: f"or remove --resume to start over with the same targets." ) else: - if not args.target and not args.mount: + if not args.target and not args.target_list and not args.mount: parser.error( - "the following arguments are required: -t/--target or --mount " + "the following arguments are required: -t/--target, --target-list, or --mount " "(or use --resume
+ {error}
+Loading run data…
+Stay ahead of new exposures
++ Attack surface monitoring catches new exposures for your org over time. +
+No summary available for this run yet.
+ ) + )} + ++ Click an agent to open its full transcript. +
+Run this pentest with more depth
+Re-run this pentest on managed infra in the cloud.
++ {verifyOnly + ? "We send a one-time code to confirm it is you." + : "Verified by a one-time code sent to your email"} +
+ + {error && ( +{error}
+{notice}
+ )} + + {step === "disclosure" && ( ++ We email an encrypted PDF. Nothing else leaves your machine. +
++ Only you hold the password; Strix can't read it. +
+Sending to {auth.email}
+ )} +Generating and encrypting locally...
++ Sent to {confirmationEmail}. Open the attached PDF with this password. +
+{password}
+
+ + Save this now. Strix never stores it, so we cannot show it again. File:{" "} + {filename} +
+{error}
+{notice}
} + + {step === "email" ? ( + + ) : ( + + )} +Thanks, we got it.
++ We read every message. If it needs a reply, we'll reach out to the email you gave. +
+ ++ Bugs, feature requests, or anything else. Tell us what's on your mind. +
+ + {error && ( +{error}
++ You have {count} past {count === 1 ? "run" : "runs"} on this machine. +
+ {showVerify ? ( + <> ++ Verify your email with a one-time code to unlock the full history. +
+
+
+ Or open one from the CLI with{" "}
+ strix view <name>
+
Not available yet.
+ )} +{message}
+ +{description}
+ )} ++ {scanCompleted + ? "Agent trace data is not available for this pentest" + : "Waiting for agent data\u2026"} +
++ {msgCount} message{msgCount === 1 ? "" : "s"} · {toolCount} tool call + {toolCount === 1 ? "" : "s"} +
+ > + )} + + {mine.length === 0 ? ( +No recorded activity for this agent.
+ ) : ( +Live prompt
+Connected
+
+ {displayContent}
+
+ {needsTruncation && (
+
+ )}
+
+
+
+ {needsTruncation && (
+
+ )}
+ | + {row.leftNo} + | ++ {row.rightNo} + | ++ |
{children};
+ }
+
+ const meta = node?.data?.meta || node?.properties?.metastring || "";
+ const titleMatch = /title=["']?([^"'\s}]+)["']?/.exec(meta);
+ const startMatch = /startLineNumber=(\d+)/.exec(meta);
+ const fileName = titleMatch?.[1] || null;
+ const startLine = startMatch ? parseInt(startMatch[1], 10) : 1;
+ const headerLabel = fileName
+ ? startMatch
+ ? `${fileName}:${startLine}`
+ : fileName
+ : null;
+
+ const highlighted = highlightCode(raw, match?.[1]);
+
+ const lines = highlighted.split("\n");
+
+ const copy = () => {
+ copyToClipboard(raw);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+ | + {startLine + i} + | ++ |
+
+
+ + {banner.label}{bannerTime(vulnerability.status_changed_at)} +
+ {vulnerability.status_note && ( ++ “{vulnerability.status_note}” +
+ )} ++ Severity changed manually from{" "} + {vulnerability.original_severity} + {vulnerability.cvss != null ? ` (${vulnerability.cvss})` : ""} to{" "} + {vulnerability.severity} + {bannerTime(vulnerability.severity_changed_at)} +
+ {vulnerability.severity_override_reason && ( ++ “{vulnerability.severity_override_reason}” +
+ )} +