diff --git a/strix/config/settings.py b/strix/config/settings.py index 42a2c97e..023154a3 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -1,156 +1,157 @@ -"""Strix application settings — pydantic-settings powered.""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import AliasChoices, Field -from pydantic_settings import BaseSettings, SettingsConfigDict - - -ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] - -DEFAULT_MAX_TURNS = 500 - -_BASE_CONFIG = SettingsConfigDict( - case_sensitive=False, - populate_by_name=True, - extra="ignore", -) - - -class LlmSettings(BaseSettings): - model_config = _BASE_CONFIG - - model: str | None = Field(default=None, alias="STRIX_LLM") - api_key: str | None = Field( - default=None, - validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"), - repr=False, - ) - api_base: str | None = Field( - default=None, - validation_alias=AliasChoices( - "LLM_API_BASE", - "OPENAI_API_BASE", - "OPENAI_BASE_URL", - "LITELLM_BASE_URL", - "OLLAMA_API_BASE", - ), - ) - extra_headers: dict[str, str] | None = Field( - default=None, - alias="LLM_EXTRA_HEADERS", - repr=False, - ) - reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT") - force_required_tool_choice: bool = Field( - default=False, - alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE", - ) - prompt_cache: bool = Field( - default=True, - alias="STRIX_PROMPT_CACHE", - ) - disable_streaming: bool = Field( - default=False, - alias="LLM_DISABLE_STREAMING", - ) - timeout: int = Field(default=300, alias="LLM_TIMEOUT") - stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT") - max_tool_calls_per_turn: int = Field( - default=32, - ge=0, - alias="LLM_MAX_TOOL_CALLS_PER_TURN", - ) - - -class DedupeSettings(BaseSettings): - model_config = _BASE_CONFIG - - model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL") - reasoning_effort: ReasoningEffort | None = Field( - default=None, - alias="STRIX_DEDUPE_REASONING_EFFORT", - ) - api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False) - api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE") - extra_headers: dict[str, str] | None = Field( - default=None, - alias="DEDUPE_LLM_EXTRA_HEADERS", - repr=False, - ) - - -class ContextSettings(BaseSettings): - """Context-window management: per-tool-output caps and history compaction.""" - - model_config = _BASE_CONFIG - - auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT") - compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS") - keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS") - fallback_context_tokens: int = Field( - default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS" - ) - summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS") - tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS") - tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES") - # Floor above the truncation-notice size so a preview always fits. - tool_output_max_bytes: int = Field( - default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES" - ) - - -class RuntimeSettings(BaseSettings): - model_config = _BASE_CONFIG - - image: str = Field( - default="ghcr.io/usestrix/strix-sandbox:1.3.0", - alias="STRIX_IMAGE", - ) - backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") - # Max screenshot/image tool outputs kept live per agent context (0 = none). - max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES") - - -class TelemetrySettings(BaseSettings): - model_config = _BASE_CONFIG - - enabled: bool = Field(default=True, alias="STRIX_TELEMETRY") - - -class IntegrationSettings(BaseSettings): - model_config = _BASE_CONFIG - - perplexity_api_key: str | None = Field( - default=None, - alias="PERPLEXITY_API_KEY", - repr=False, - ) - postman_api_key: str | None = Field( - default=None, - alias="POSTMAN_API_KEY", - repr=False, - ) - - -class ViewerSettings(BaseSettings): - model_config = _BASE_CONFIG - - # Base URL of the Strix relay the local viewer proxies to for email - # verification and encrypted report delivery. The browser never talks to - # the relay directly; the local server is the only caller. - app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL") - - -class Settings(BaseSettings): - model_config = _BASE_CONFIG - - llm: LlmSettings = Field(default_factory=LlmSettings) - dedupe: DedupeSettings = Field(default_factory=DedupeSettings) - runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) - context: ContextSettings = Field(default_factory=ContextSettings) - telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings) - integrations: IntegrationSettings = Field(default_factory=IntegrationSettings) - viewer: ViewerSettings = Field(default_factory=ViewerSettings) +"""Strix application settings — pydantic-settings powered.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] + +DEFAULT_MAX_TURNS = 500 + +_BASE_CONFIG = SettingsConfigDict( + case_sensitive=False, + populate_by_name=True, + extra="ignore", +) + + +class LlmSettings(BaseSettings): + model_config = _BASE_CONFIG + + model: str | None = Field(default=None, alias="STRIX_LLM") + api_key: str | None = Field( + default=None, + validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"), + repr=False, + ) + api_base: str | None = Field( + default=None, + validation_alias=AliasChoices( + "LLM_API_BASE", + "OPENAI_API_BASE", + "OPENAI_BASE_URL", + "LITELLM_BASE_URL", + "OLLAMA_API_BASE", + ), + ) + extra_headers: dict[str, str] | None = Field( + default=None, + alias="LLM_EXTRA_HEADERS", + repr=False, + ) + reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT") + force_required_tool_choice: bool = Field( + default=False, + alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE", + ) + prompt_cache: bool = Field( + default=True, + alias="STRIX_PROMPT_CACHE", + ) + disable_streaming: bool = Field( + default=False, + alias="LLM_DISABLE_STREAMING", + ) + timeout: int = Field(default=300, alias="LLM_TIMEOUT") + stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT") + max_tool_calls_per_turn: int = Field( + default=32, + ge=0, + alias="LLM_MAX_TOOL_CALLS_PER_TURN", + ) + + +class DedupeSettings(BaseSettings): + model_config = _BASE_CONFIG + + model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL") + reasoning_effort: ReasoningEffort | None = Field( + default=None, + alias="STRIX_DEDUPE_REASONING_EFFORT", + ) + api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False) + api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE") + extra_headers: dict[str, str] | None = Field( + default=None, + alias="DEDUPE_LLM_EXTRA_HEADERS", + repr=False, + ) + + +class ContextSettings(BaseSettings): + """Context-window management: per-tool-output caps and history compaction.""" + + model_config = _BASE_CONFIG + + auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT") + compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS") + keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS") + fallback_context_tokens: int = Field( + default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS" + ) + summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS") + tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS") + tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES") + # Floor above the truncation-notice size so a preview always fits. + tool_output_max_bytes: int = Field( + default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES" + ) + + +class RuntimeSettings(BaseSettings): + model_config = _BASE_CONFIG + + image: str = Field( + default="ghcr.io/usestrix/strix-sandbox:1.3.0", + alias="STRIX_IMAGE", + ) + backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") + # Max screenshot/image tool outputs kept live per agent context (0 = none). + max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES") + + +class TelemetrySettings(BaseSettings): + model_config = _BASE_CONFIG + + enabled: bool = Field(default=True, alias="STRIX_TELEMETRY") + + +class IntegrationSettings(BaseSettings): + model_config = _BASE_CONFIG + + perplexity_api_key: str | None = Field( + default=None, + alias="PERPLEXITY_API_KEY", + repr=False, + ) + postman_api_key: str | None = Field( + default=None, + alias="POSTMAN_API_KEY", + repr=False, + ) + + +class ViewerSettings(BaseSettings): + model_config = _BASE_CONFIG + + # Base URL of the Strix relay the local viewer proxies to for email + # verification and encrypted report delivery. The browser never talks to + # the relay directly; the local server is the only caller. + app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL") + + +class Settings(BaseSettings): + model_config = _BASE_CONFIG + + language: str = Field(default="en", alias="STRIX_LANGUAGE") + llm: LlmSettings = Field(default_factory=LlmSettings) + dedupe: DedupeSettings = Field(default_factory=DedupeSettings) + runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) + context: ContextSettings = Field(default_factory=ContextSettings) + telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings) + integrations: IntegrationSettings = Field(default_factory=IntegrationSettings) + viewer: ViewerSettings = Field(default_factory=ViewerSettings) diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index 6c672437..fc990b64 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -1,380 +1,394 @@ -"""Command-line argument parsing for the ``strix`` scan entrypoint.""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -from strix.config import apply_config_override -from strix.config.settings import DEFAULT_MAX_TURNS -from strix.core.paths import run_dir_for, runtime_state_dir -from strix.interface.scan_setup import attach_workspace_mount, build_targets_info -from strix.interface.update_check import self_update -from strix.interface.utils import ( - check_mountable_dir, - collect_local_sources, - validate_config_file, -) - - -def get_version() -> str: - try: - from importlib.metadata import version - - return version("strix-agent") - except Exception: - return "unknown" - - -def _positive_budget(value: str) -> float: - try: - budget = float(value) - 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", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Web application penetration test - strix --target https://example.com - - # GitHub repository analysis - strix --target https://github.com/user/repo - strix --target git@github.com:user/repo.git - - # Local code analysis - strix --target ./my-project - - # API spec test (OpenAPI/Swagger file or Postman collection export) - strix --target ./openapi.yaml --target https://api.example.com - strix --target ./collection.postman_collection.json - - # Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment - strix --target postman:// --target https://api.example.com - strix --target "postman://?env=" - - # Domain penetration test - strix --target example.com - - # IP address penetration test - strix --target 192.168.1.42 - - # Multiple targets (e.g., white-box testing with source and deployed app) - 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" - - # Custom instructions (from file) - strix --target example.com --instruction-file ./instructions.txt - strix --target https://app.com --instruction-file /path/to/detailed_instructions.md - """, - ) - - parser.add_argument( - "-v", - "--version", - action="version", - 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", - type=str, - action="append", - help="Target to test: URL, repository, local directory path, domain name, IP address, " - "an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a " - "Postman collection by id (postman://[?env=], needs " - "POSTMAN_API_KEY). Local directories are mounted into the sandbox writable. " - "Can be specified multiple times for multi-target scans. " - "Fresh runs require --target or --target-list.", - ) - 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( - "--instruction", - type=str, - help="Custom instructions for the penetration test. This can be " - "specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), " - "testing approaches (e.g., 'Perform thorough authentication testing'), " - "test credentials (e.g., 'Use the following credentials to access the app: " - "admin:password123'), " - "or areas of interest (e.g., 'Check login API endpoint for security issues').", - ) - - parser.add_argument( - "--instruction-file", - type=str, - help="Path to a file containing detailed custom instructions for the penetration test. " - "Use this option when you have lengthy or complex instructions saved in a file " - "(e.g., '--instruction-file ./detailed_instructions.txt').", - ) - - parser.add_argument( - "-n", - "--non-interactive", - action="store_true", - help=( - "Run in non-interactive mode (no TUI, exits on completion). " - "Default is interactive mode with TUI." - ), - ) - - parser.add_argument( - "-m", - "--scan-mode", - type=str, - choices=["quick", "standard", "deep"], - default="deep", - help=( - "Scan mode: " - "'quick' for fast CI/CD checks, " - "'standard' for routine testing, " - "'deep' for thorough security reviews (default). " - "Default: deep." - ), - ) - - parser.add_argument( - "--scope-mode", - type=str, - choices=["auto", "diff", "full"], - default="auto", - help=( - "Scope mode for code targets: " - "'auto' enables PR diff-scope in CI/headless runs, " - "'diff' forces changed-files scope, " - "'full' disables diff-scope." - ), - ) - - parser.add_argument( - "--diff-base", - type=str, - help=( - "Target branch or commit to compare against (e.g., origin/main). " - "Defaults to the repository's default branch." - ), - ) - - parser.add_argument( - "--config", - type=str, - help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json", - ) - - parser.add_argument( - "--max-budget", - "--max-budget-usd", - 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. " - "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( - "--resume", - type=str, - metavar="RUN_NAME", - help=( - "Resume a prior scan by its run name (the dir under ./strix_runs/). " - "Picks up the root + every non-terminal subagent's full LLM history " - "and agent topology. Skips fresh run-name generation." - ), - ) - - args = parser.parse_args() - # Startup-resolved state lives alongside the parsed flags. The full schema - # is established here so downstream code reads attributes directly. - args.needs_setup = False - args.targets_info = [] - args.local_sources = [] - args.diff_scope = {"active": False} - args.run_name = None - - if args.config: - apply_config_override(validate_config_file(args.config)) - - 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." - ) - - if args.instruction_file: - instruction_path = Path(args.instruction_file) - try: - with instruction_path.open(encoding="utf-8") as f: - args.instruction = f.read().strip() - if not args.instruction: - parser.error(f"Instruction file '{instruction_path}' is empty") - except Exception as e: - parser.error(f"Failed to read instruction file '{instruction_path}': {e}") - - args.user_explicit_instruction = args.instruction if args.resume else None - # What the user actually asked for, kept apart from args.instruction because - # prepare_run prepends the diff-scope preamble to that. This is the text the - # transcript shows as their opening message. - args.user_instruction = args.instruction or None - - if args.resume: - if args.target or args.target_list: - parser.error( - "Cannot combine --resume with --target/--target-list. " - "--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" - if not agents_path.exists(): - parser.error( - f"--resume {args.resume}: missing {agents_path}. The run was " - f"persisted but never reached its first agent snapshot — " - f"there's nothing to resume from. Pick a fresh --run-name " - f"or remove --resume to start over with the same targets." - ) - else: - if not args.target and not args.target_list: - if args.non_interactive: - parser.error( - "the following arguments are required: -t/--target or --target-list " - "(or use --resume to continue a prior scan)" - ) - # Interactive launch with no target: open the normal TUI on its - # start screen, where the user gives a target or a bare prompt - # before the scan starts. - args.needs_setup = True - return args - - try: - build_targets_info(args) - except ValueError as e: - parser.error(str(e)) - - return args - - -def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: - """Populate ``args.targets_info`` and friends from a prior run's run.json.""" - from strix.report.writer import read_run_record - - run_dir = run_dir_for(args.resume) - state_path = run_dir / "run.json" - if not state_path.exists(): - parser.error( - f"--resume {args.resume}: no such run " - f"(missing {state_path}; remove --resume for a fresh start)" - ) - try: - state = read_run_record(run_dir) - except RuntimeError as exc: - parser.error(f"--resume {args.resume}: run.json unreadable: {exc}") - - args.targets_info = state.get("targets_info") or [] - # A target-less run has no targets_info at all. It is driven by its - # instruction, over a mounted working directory or over nothing when the - # mount was declined, so either of those is enough to resume it. - workspace_mount = state.get("workspace_mount") or None - if not args.targets_info and not workspace_mount and not state.get("user_instruction"): - parser.error(f"--resume {args.resume}: run.json has no targets_info") - - for target in args.targets_info: - if not isinstance(target, dict): - continue - details = target.get("details") or {} - if target.get("type") == "local_code" and details.get("target_path"): - try: - check_mountable_dir(Path(details["target_path"]).expanduser()) - except ValueError as exc: - parser.error(f"--resume {args.resume}: {exc}") - continue - if target.get("type") != "repository": - continue - cloned = details.get("cloned_repo_path") - if not cloned: - continue - if not Path(cloned).expanduser().exists(): - parser.error( - f"--resume {args.resume}: cloned repo at {cloned} is missing. " - f"It was deleted between runs. Pick a fresh --run-name to " - f"re-clone, or restore the directory before resuming." - ) - - if args.instruction is None: - args.instruction = state.get("instruction") - if not getattr(args, "user_instruction", None): - args.user_instruction = state.get("user_instruction") or None - args.local_sources = collect_local_sources(args.targets_info) - # Remount the workspace the run was started with. The user already confirmed - # this directory, so the target mount guard does not apply to it; it only has - # to still be there. - args.workspace_mount = workspace_mount - if workspace_mount: - if not Path(workspace_mount).expanduser().is_dir(): - parser.error( - f"--resume {args.resume}: the working directory {workspace_mount} " - f"is missing. Restore it before resuming, or start a fresh run." - ) - attach_workspace_mount(args) - if state.get("diff_scope"): - args.diff_scope = state.get("diff_scope") - persisted_scan_mode = state.get("scan_mode") - if persisted_scan_mode and args.scan_mode == "deep": - args.scan_mode = persisted_scan_mode +"""Command-line argument parsing for the ``strix`` scan entrypoint.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from strix.config import apply_config_override +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.core.paths import run_dir_for, runtime_state_dir +from strix.interface.scan_setup import attach_workspace_mount, build_targets_info +from strix.interface.update_check import self_update +from strix.interface.utils import ( + check_mountable_dir, + collect_local_sources, + validate_config_file, +) + + +def get_version() -> str: + try: + from importlib.metadata import version + + return version("strix-agent") + except Exception: + return "unknown" + + +def _positive_budget(value: str) -> float: + try: + budget = float(value) + 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", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Web application penetration test + strix --target https://example.com + + # GitHub repository analysis + strix --target https://github.com/user/repo + strix --target git@github.com:user/repo.git + + # Local code analysis + strix --target ./my-project + + # API spec test (OpenAPI/Swagger file or Postman collection export) + strix --target ./openapi.yaml --target https://api.example.com + strix --target ./collection.postman_collection.json + + # Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment + strix --target postman:// --target https://api.example.com + strix --target "postman://?env=" + + # Domain penetration test + strix --target example.com + + # IP address penetration test + strix --target 192.168.1.42 + + # Multiple targets (e.g., white-box testing with source and deployed app) + 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" + + # Custom instructions (from file) + strix --target example.com --instruction-file ./instructions.txt + strix --target https://app.com --instruction-file /path/to/detailed_instructions.md + """, + ) + + parser.add_argument( + "-v", + "--version", + action="version", + 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( + "-l", + "--language", + type=str, + default=None, + help="Language for UI and agent responses (e.g., 'en', 'es'). " + "Default: auto-detect from environment.", + ) + + parser.add_argument( + "-t", + "--target", + type=str, + action="append", + help="Target to test: URL, repository, local directory path, domain name, IP address, " + "an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a " + "Postman collection by id (postman://[?env=], needs " + "POSTMAN_API_KEY). Local directories are mounted into the sandbox writable. " + "Can be specified multiple times for multi-target scans. " + "Fresh runs require --target or --target-list.", + ) + 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( + "--instruction", + type=str, + help="Custom instructions for the penetration test. This can be " + "specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), " + "testing approaches (e.g., 'Perform thorough authentication testing'), " + "test credentials (e.g., 'Use the following credentials to access the app: " + "admin:password123'), " + "or areas of interest (e.g., 'Check login API endpoint for security issues').", + ) + + parser.add_argument( + "--instruction-file", + type=str, + help="Path to a file containing detailed custom instructions for the penetration test. " + "Use this option when you have lengthy or complex instructions saved in a file " + "(e.g., '--instruction-file ./detailed_instructions.txt').", + ) + + parser.add_argument( + "-n", + "--non-interactive", + action="store_true", + help=( + "Run in non-interactive mode (no TUI, exits on completion). " + "Default is interactive mode with TUI." + ), + ) + + parser.add_argument( + "-m", + "--scan-mode", + type=str, + choices=["quick", "standard", "deep"], + default="deep", + help=( + "Scan mode: " + "'quick' for fast CI/CD checks, " + "'standard' for routine testing, " + "'deep' for thorough security reviews (default). " + "Default: deep." + ), + ) + + parser.add_argument( + "--scope-mode", + type=str, + choices=["auto", "diff", "full"], + default="auto", + help=( + "Scope mode for code targets: " + "'auto' enables PR diff-scope in CI/headless runs, " + "'diff' forces changed-files scope, " + "'full' disables diff-scope." + ), + ) + + parser.add_argument( + "--diff-base", + type=str, + help=( + "Target branch or commit to compare against (e.g., origin/main). " + "Defaults to the repository's default branch." + ), + ) + + parser.add_argument( + "--config", + type=str, + help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json", + ) + + parser.add_argument( + "--max-budget", + "--max-budget-usd", + 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. " + "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( + "--resume", + type=str, + metavar="RUN_NAME", + help=( + "Resume a prior scan by its run name (the dir under ./strix_runs/). " + "Picks up the root + every non-terminal subagent's full LLM history " + "and agent topology. Skips fresh run-name generation." + ), + ) + + args = parser.parse_args() + # Startup-resolved state lives alongside the parsed flags. The full schema + # is established here so downstream code reads attributes directly. + args.needs_setup = False + args.targets_info = [] + args.local_sources = [] + args.diff_scope = {"active": False} + args.run_name = None + + # Set language from CLI flag (highest priority in resolution chain) + if args.language: + from strix.i18n import set_language + set_language(args.language) + + if args.config: + apply_config_override(validate_config_file(args.config)) + + 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." + ) + + if args.instruction_file: + instruction_path = Path(args.instruction_file) + try: + with instruction_path.open(encoding="utf-8") as f: + args.instruction = f.read().strip() + if not args.instruction: + parser.error(f"Instruction file '{instruction_path}' is empty") + except Exception as e: + parser.error(f"Failed to read instruction file '{instruction_path}': {e}") + + args.user_explicit_instruction = args.instruction if args.resume else None + # What the user actually asked for, kept apart from args.instruction because + # prepare_run prepends the diff-scope preamble to that. This is the text the + # transcript shows as their opening message. + args.user_instruction = args.instruction or None + + if args.resume: + if args.target or args.target_list: + parser.error( + "Cannot combine --resume with --target/--target-list. " + "--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" + if not agents_path.exists(): + parser.error( + f"--resume {args.resume}: missing {agents_path}. The run was " + f"persisted but never reached its first agent snapshot — " + f"there's nothing to resume from. Pick a fresh --run-name " + f"or remove --resume to start over with the same targets." + ) + else: + if not args.target and not args.target_list: + if args.non_interactive: + parser.error( + "the following arguments are required: -t/--target or --target-list " + "(or use --resume to continue a prior scan)" + ) + # Interactive launch with no target: open the normal TUI on its + # start screen, where the user gives a target or a bare prompt + # before the scan starts. + args.needs_setup = True + return args + + try: + build_targets_info(args) + except ValueError as e: + parser.error(str(e)) + + return args + + +def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: + """Populate ``args.targets_info`` and friends from a prior run's run.json.""" + from strix.report.writer import read_run_record + + run_dir = run_dir_for(args.resume) + state_path = run_dir / "run.json" + if not state_path.exists(): + parser.error( + f"--resume {args.resume}: no such run " + f"(missing {state_path}; remove --resume for a fresh start)" + ) + try: + state = read_run_record(run_dir) + except RuntimeError as exc: + parser.error(f"--resume {args.resume}: run.json unreadable: {exc}") + + args.targets_info = state.get("targets_info") or [] + # A target-less run has no targets_info at all. It is driven by its + # instruction, over a mounted working directory or over nothing when the + # mount was declined, so either of those is enough to resume it. + workspace_mount = state.get("workspace_mount") or None + if not args.targets_info and not workspace_mount and not state.get("user_instruction"): + parser.error(f"--resume {args.resume}: run.json has no targets_info") + + for target in args.targets_info: + if not isinstance(target, dict): + continue + details = target.get("details") or {} + if target.get("type") == "local_code" and details.get("target_path"): + try: + check_mountable_dir(Path(details["target_path"]).expanduser()) + except ValueError as exc: + parser.error(f"--resume {args.resume}: {exc}") + continue + if target.get("type") != "repository": + continue + cloned = details.get("cloned_repo_path") + if not cloned: + continue + if not Path(cloned).expanduser().exists(): + parser.error( + f"--resume {args.resume}: cloned repo at {cloned} is missing. " + f"It was deleted between runs. Pick a fresh --run-name to " + f"re-clone, or restore the directory before resuming." + ) + + if args.instruction is None: + args.instruction = state.get("instruction") + if not getattr(args, "user_instruction", None): + args.user_instruction = state.get("user_instruction") or None + args.local_sources = collect_local_sources(args.targets_info) + # Remount the workspace the run was started with. The user already confirmed + # this directory, so the target mount guard does not apply to it; it only has + # to still be there. + args.workspace_mount = workspace_mount + if workspace_mount: + if not Path(workspace_mount).expanduser().is_dir(): + parser.error( + f"--resume {args.resume}: the working directory {workspace_mount} " + f"is missing. Restore it before resuming, or start a fresh run." + ) + attach_workspace_mount(args) + if state.get("diff_scope"): + args.diff_scope = state.get("diff_scope") + persisted_scan_mode = state.get("scan_mode") + if persisted_scan_mode and args.scan_mode == "deep": + args.scan_mode = persisted_scan_mode