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:
criss717 2026-08-10 17:55:58 +02:00
parent 4f964d7472
commit a1f109c748
2 changed files with 551 additions and 536 deletions

View file

@ -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)

View file

@ -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