mirror of
https://github.com/usestrix/strix.git
synced 2026-10-07 02:58:26 +00:00
feat(cli): add language configuration
- Add language field to Settings with STRIX_LANGUAGE env var alias - Add --language/-l CLI flag to argparse - Call set_language() after argument parsing - Language persists to ~/.strix/cli-config.json
This commit is contained in:
parent
4f964d7472
commit
a1f109c748
2 changed files with 551 additions and 536 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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://<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
|
||||
"""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(
|
||||
"-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://<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
|
||||
|
||||
# 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 <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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue