mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-21 00:23:52 +08:00
Extra files (knowledge trees, workspace files) reached the docker sandbox as per-file read-only bind mounts whose parent directories docker created as root, so the sandbox user could neither edit them nor create siblings. They now travel as one tar archive uploaded after bring-up and unpacked as the sandbox user, on every backend.
468 lines
17 KiB
Python
468 lines
17 KiB
Python
"""Command-line argument parsing for the ``strix`` scan entrypoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
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,
|
|
resolve_workspace_files,
|
|
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
|
|
|
|
# Extra files placed in the sandbox workspace
|
|
strix --target ./my-project --workspace-file ./wordlist.txt
|
|
strix --target https://app.com --workspace-file ./openapi.yaml:specs/openapi.yaml
|
|
|
|
Strix Cloud:
|
|
strix cloud login
|
|
strix cloud scans start --source . --yes --wait
|
|
strix cloud # list every cloud resource
|
|
|
|
Run a pentest in Strix Cloud https://app.strix.ai
|
|
Try Strix Enterprise https://strix.ai/demo
|
|
""",
|
|
)
|
|
|
|
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(
|
|
"--workspace-file",
|
|
type=str,
|
|
action="append",
|
|
metavar="PATH[:DEST]",
|
|
help="Place a file from this machine into the sandbox workspace before the scan "
|
|
"starts, for example a wordlist, an API specification, or notes. Repeat the option "
|
|
"for more files. DEST is the path inside /workspace and defaults to the file name "
|
|
"(for example '--workspace-file ./wordlist.txt:lists/wordlist.txt'). Strix copies "
|
|
"the file into the sandbox, outside every target directory. The agent can edit the "
|
|
"copy. The file on this machine does not change.",
|
|
)
|
|
|
|
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(
|
|
"--mcp-config",
|
|
type=str,
|
|
metavar="PATH",
|
|
help="Path to an MCP servers JSON file to use instead of ~/.strix/mcp-servers.json.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--mcp-server",
|
|
dest="mcp_server",
|
|
action="append",
|
|
metavar="NAME",
|
|
help="Use only this MCP connection for the run, by its config name "
|
|
"(repeatable). Every other configured connection is skipped.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--mcp-exclude",
|
|
dest="mcp_exclude",
|
|
action="append",
|
|
metavar="NAME",
|
|
help="Skip this MCP connection for the run, by its config name (repeatable).",
|
|
)
|
|
|
|
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.mcp_config:
|
|
mcp_config_path = Path(args.mcp_config).expanduser()
|
|
if not mcp_config_path.is_file():
|
|
parser.error(f"--mcp-config file not found: {args.mcp_config}")
|
|
# The MCP loader reads this env var as its config-path override, so
|
|
# setting it here makes the flag win over the default location.
|
|
os.environ["STRIX_MCP_CONFIG"] = str(mcp_config_path)
|
|
|
|
# The MCP loader reads these as its per-run include/exclude selection.
|
|
if args.mcp_server:
|
|
os.environ["STRIX_MCP_ONLY"] = ",".join(args.mcp_server)
|
|
if args.mcp_exclude:
|
|
os.environ["STRIX_MCP_EXCLUDE"] = ",".join(args.mcp_exclude)
|
|
|
|
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}")
|
|
|
|
try:
|
|
args.workspace_files = resolve_workspace_files(getattr(args, "workspace_file", None))
|
|
except ValueError as error:
|
|
parser.error(f"--workspace-file: {error}")
|
|
|
|
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, TypeError) 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
|
|
|
|
# Replace the workspace files the run started with, unless this resume names
|
|
# its own. The persisted record is revalidated like a fresh flag, so an
|
|
# edited run.json cannot widen what a resume places. A file deleted between
|
|
# runs is dropped rather than fatal: it is context for the agent, not scope.
|
|
if not getattr(args, "workspace_files", None):
|
|
restored = [
|
|
f"{source_path}:{workspace_path}"
|
|
for workspace_file in state.get("workspace_files") or []
|
|
if isinstance(workspace_file, dict)
|
|
and (source_path := Path(str(workspace_file.get("source_path") or ""))).is_file()
|
|
and (workspace_path := str(workspace_file.get("workspace_path") or ""))
|
|
]
|
|
try:
|
|
args.workspace_files = resolve_workspace_files(restored)
|
|
except ValueError as error:
|
|
parser.error(f"--resume {args.resume}: invalid workspace file: {error}")
|
|
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
|