mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
* make the working-directory prompt answer the mouse Its Confirm and Cancel were drawn as buttons and did nothing when clicked: the modal mouse handler had a case for every dialog except this one, so a click fell through and the scan sat waiting on an answer the user believed they had given. Only the keyboard could answer it. The prompt is docked in a corner rather than centered, so it also needs its own bounds; the centered ones every other dialog uses would have put the buttons in the wrong place. Those bounds now come from the same placement cornerOverlay draws with. Two returns that hand back the model alongside a call that mutates it are now sequenced explicitly. They work, but only because the compiler happens to evaluate the call first, and one of them is what puts the prompt back in the composer when the mount is declined. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skip the mount instead of abandoning the scan Declining the working-directory prompt threw the whole launch away and dropped back to the start screen, which is a lot to lose for answering one question about one directory. The two answers are now about the directory alone: mount it, or run without it. The prompt is the whole of the input either way. The buttons say which is which - Mount and Skip rather than Confirm and Cancel - and the prompt says what skipping costs. A run with neither target nor directory is a real run, so two things follow it. It can be resumed: its instruction is what drives it, and that is in the run record. And it tells the agent plainly that it has neither, because an agent given no scope goes looking for the one it assumes it was meant to have. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""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://<collection-uuid> --target https://api.example.com
|
|
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
|
|
|
# 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://<collection-uuid>[?env=<environment-uuid>], 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 <run_name> 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
|