"""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 works in a mounted # directory, driven by its instruction. workspace_mount = state.get("workspace_mount") or None if not args.targets_info and not workspace_mount: 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