feat(i18n): add Spanish localization for CLI, reports, and agent prompts

This commit is contained in:
criss717 2026-11-01 17:53:12 +01:00
parent 6b7ae2ebe4
commit d4cf20ce44
12 changed files with 1341 additions and 205 deletions

View file

@ -29,6 +29,10 @@ for xml_file in strix_root.rglob('*.xml'):
rel_path = xml_file.relative_to(project_root)
datas.append((str(xml_file), str(rel_path.parent)))
for locale_file in strix_root.rglob('locales/*.json'):
rel_path = locale_file.relative_to(project_root)
datas.append((str(locale_file), str(rel_path.parent)))
# Prebuilt local-viewer SPA (served by `strix view`).
viewer_static = strix_root / 'interface' / 'viewer' / 'static'
for asset in viewer_static.rglob('*'):
@ -112,6 +116,7 @@ hiddenimports = [
# Strix modules
'strix',
'strix.i18n',
'strix.interface',
'strix.interface.main',
'strix.interface.cli',

View file

@ -7,6 +7,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from strix.i18n import get_language_directive
from strix.skills import get_available_skills, load_skills, skill_search_dirs
from strix.utils.resource_paths import get_strix_resource_path
@ -128,6 +129,8 @@ def render_system_prompt(
)
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
language_directive = get_language_directive()
# Skills every agent of this kind loads come first, so siblings share them
# as a cached prefix; the ones the caller asked for vary and go after.
rendered = env.get_template("system_prompt.jinja").render(
@ -141,6 +144,7 @@ def render_system_prompt(
include_scope=include_scope,
report_tool=report_tool,
coverage_tools=coverage_tools,
language_directive=language_directive,
**skill_content,
)
except Exception:

View file

@ -1,5 +1,8 @@
You are an advanced AI application security validation agent. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
{% if language_directive %}
{{ language_directive }}
{% endif -%}
{% if is_root %}
<root_agent_directive>
YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing.

View file

@ -229,6 +229,7 @@ class ViewerSettings(BaseSettings):
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)

210
strix/i18n.py Normal file
View file

@ -0,0 +1,210 @@
"""Internationalization support for Strix."""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__)
# Supported languages — add new ones here + create matching JSON file
SUPPORTED_LANGUAGES: frozenset[str] = frozenset({"en", "es"})
# Module-level state
_language: str | None = None
_config_path: Path | None = None
_locales: dict[str, dict[str, str]] = {}
_lock = threading.Lock()
_locales_dir: Path = get_strix_resource_path("locales")
def _read_config_language(config_path: Path) -> str | None:
"""Read ``STRIX_LANGUAGE`` from a CLI JSON config file, if present."""
try:
if not config_path.exists():
return None
data = json.loads(config_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
env_block = data.get("env", {}) if isinstance(data, dict) else {}
if not isinstance(env_block, dict):
return None
for key, raw_val in env_block.items():
if str(key).upper() == "STRIX_LANGUAGE" and isinstance(raw_val, str):
config_lang = raw_val.strip().lower()
if config_lang:
return _normalize_lang(config_lang)
return None
def _detect_language() -> str:
"""Resolve language from the priority chain.
Priority:
1. _language (set by --language CLI flag or set_language())
2. STRIX_LANGUAGE env var
3. Config file (--config override or ~/.strix/cli-config.json)
4. LANG / LC_ALL / LC_MESSAGES system locale (first 2 chars)
5. "en" default
"""
# 1. Explicitly set (CLI flag)
if _language is not None:
return _language
# 2. Environment variable (case-insensitive to match pydantic Settings)
for env_key, env_raw in os.environ.items():
if env_key.upper() == "STRIX_LANGUAGE" and env_raw.strip():
return _normalize_lang(env_raw)
# 3. Config file (canonical format: {"env": {"STRIX_LANGUAGE": "es"}})
config_lang = _read_config_language(
_config_path or (Path.home() / ".strix" / "cli-config.json")
)
if config_lang is not None:
return config_lang
# 4. System locale
for var in ("LANG", "LC_ALL", "LC_MESSAGES"):
locale_val = os.environ.get(var, "")
if len(locale_val) >= 2:
candidate = locale_val[:2].lower()
if candidate in SUPPORTED_LANGUAGES:
return candidate
# 5. Default
return "en"
def _normalize_lang(lang: str) -> str:
"""Normalize and validate a language code."""
lang = lang.strip().lower()[:2]
if lang not in SUPPORTED_LANGUAGES:
logger.warning("Unsupported language %r, falling back to 'en'", lang)
return "en"
return lang
def _load_locale(lang: str) -> dict[str, str]:
"""Load a locale JSON file. Thread-safe, cached."""
with _lock:
if lang in _locales:
return _locales[lang]
locale_file = _locales_dir / f"{lang}.json"
if not locale_file.exists():
logger.warning("Locale file not found: %s", locale_file)
_locales[lang] = {}
return {}
try:
data = json.loads(locale_file.read_text(encoding="utf-8"))
_locales[lang] = data if isinstance(data, dict) else {}
return _locales[lang]
except (json.JSONDecodeError, OSError):
logger.exception("Failed to load locale %s", lang)
_locales[lang] = {}
return {}
def set_language(lang: str | None) -> None:
"""Set the active language. Called from CLI args parsing."""
global _language # noqa: PLW0603
_language = _normalize_lang(lang) if lang else None
def set_config_path(path: str | Path | None) -> None:
"""Override the config file used for language resolution.
``--config`` selects a custom config file; its ``env.STRIX_LANGUAGE``
should drive localization just like the default ``~/.strix/cli-config.json``.
Pass ``None`` to clear the override.
"""
global _config_path # noqa: PLW0603
_config_path = Path(path).expanduser() if path else None
def get_language() -> str:
"""Get the currently resolved language."""
return _detect_language()
def t(key: str, **kwargs: Any) -> str:
"""Translate a key to the active language.
Args:
key: Dot-separated translation key (e.g., "cli.scan_started")
**kwargs: Placeholder values for {name} interpolation
Returns:
Translated string with placeholders filled, or the key itself if not found.
"""
lang = get_language()
# Try active language first
locale = _load_locale(lang)
value = locale.get(key)
# Fallback to English
if value is None and lang != "en":
en_locale = _load_locale("en")
value = en_locale.get(key)
if value is not None:
logger.debug("Key %r not found in %s, using English fallback", key, lang)
# Last resort: return the key itself
if value is None:
logger.warning("Translation key not found: %s", key)
return key
# Interpolate placeholders
if kwargs:
try:
return value.format(**kwargs)
except KeyError as exc:
logger.warning("Missing placeholder %s in key %s", exc, key)
return value
return value
def get_language_directive() -> str:
"""Get the language directive for agent system prompts.
Returns empty string for English (no directive needed).
Returns an instruction block for other languages.
"""
lang = get_language()
if lang == "en":
return ""
lang_names = {
"es": "Spanish",
"fr": "French",
"de": "German",
"pt": "Portuguese",
"it": "Italian",
}
lang_name = lang_names.get(lang, lang)
return f"""LANGUAGE DIRECTIVE:
The user's preferred language is {lang_name}.
Write all natural-language findings, explanations, descriptions, impact assessments,
remediation steps, and recommendations in {lang_name}.
Keep the following UNCHANGED (do not translate):
- CVE identifiers (e.g., CVE-2025-XXXX)
- CWE identifiers (e.g., CWE-79)
- CVSS scores
- HTTP requests and headers
- URLs and domains
- Source code snippets
- Shell commands and payloads
- Technical product names
- File paths"""

View file

@ -15,6 +15,7 @@ from rich.text import Text
from strix.config import load_settings
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.core.runner import run_strix_scan
from strix.i18n import t
from strix.report.state import ReportState, set_global_report_state
from strix.runtime import session_manager
@ -42,27 +43,27 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
console = Console()
start_text = Text()
start_text.append("Penetration test initiated", style="bold #22c55e")
start_text.append(t("cli.test_initiated"), style="bold #22c55e")
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(t("cli.target_label"), style="dim")
target_text.append(" ")
if len(args.targets_info) == 1:
target_text.append(args.targets_info[0]["original"], style="bold white")
else:
target_text.append(f"{len(args.targets_info)} targets", style="bold white")
target_text.append(t("cli.targets_label", count=len(args.targets_info)), style="bold white")
for target_info in args.targets_info:
target_text.append("\n ")
target_text.append(target_info["original"], style="white")
results_text = Text()
results_text.append("Output", style="dim")
results_text.append(t("cli.output_label"), style="dim")
results_text.append(" ")
results_text.append(f"strix_runs/{args.run_name}", style="#60a5fa")
note_text = Text()
note_text.append("\n\n", style="dim")
note_text.append("Vulnerabilities will be displayed in real-time.", style="dim")
note_text.append(t("cli.vulnerabilities_realtime"), style="dim")
startup_panel = Panel(
Text.assemble(
@ -164,11 +165,11 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
set_global_report_state(report_state)
startup_phase: list[str] = ["Starting up"]
startup_phase: list[str] = [t("cli.starting_up")]
def create_live_status() -> Panel:
status_text = Text()
status_text.append("Penetration test in progress", style="bold #22c55e")
status_text.append(t("cli.test_in_progress"), style="bold #22c55e")
status_text.append("\n\n")
if not has_model_response(report_state):
@ -234,14 +235,14 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
await session_manager.cleanup(args.run_name)
except Exception as e:
console.print(f"[bold red]Error during penetration test:[/] {e}")
console.print(f"[bold red]{t('cli.error_during_test')}[/] {e}")
raise
if report_state.final_scan_result:
console.print()
final_report_text = Text()
final_report_text.append("Penetration test summary", style="bold #60a5fa")
final_report_text.append(t("cli.test_summary"), style="bold #60a5fa")
final_report_panel = Panel(
Text.assemble(

View file

@ -10,6 +10,7 @@ from pathlib import Path
from strix.config import apply_config_override
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.core.paths import RUNS_DIR_NAME, run_dir_for, runtime_state_dir
from strix.i18n import t
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 (
@ -24,6 +25,78 @@ from strix.report.runs import list_run_summaries
# Severities ``--fail-on`` accepts, most severe first.
FAIL_ON_SEVERITIES = ("critical", "high", "medium", "low", "info")
# Translate argparse's built-in strings (usage, options, error, help/version)
# through the i18n layer. argparse routes these through ``gettext.gettext``,
# so replacing ``argparse._`` with a ``t()``-backed lookup localizes them.
_ARGPARSE_MESSAGE_KEYS: dict[str, str] = {
"usage: ": "cli.argparse_usage",
"options": "cli.argparse_options",
"show this help message and exit": "cli.argparse_help",
"show program's version number and exit": "cli.argparse_version",
"%(prog)s: error: %(message)s\n": "cli.argparse_error",
"unrecognized arguments: %s": "cli.argparse_unrecognized",
"the following arguments are required: %s": "cli.argparse_required",
"invalid choice: %(value)r (choose from %(choices)s)": "cli.argparse_invalid_choice",
"argument %(argument_name)s: %(message)s": "cli.argparse_argument_error",
"expected one argument": "cli.argparse_expected_one",
"expected at most one argument": "cli.argparse_expected_at_most",
"expected at least one argument": "cli.argparse_expected_at_least",
"unexpected option string: %s": "cli.argparse_unexpected_option",
}
def _translate_argparse(message: str) -> str:
"""Return the localized form of an argparse built-in string."""
key = _ARGPARSE_MESSAGE_KEYS.get(message)
if key is None:
return message
translated = t(key)
return translated if translated != key else message
argparse._ = _translate_argparse # type: ignore[attr-defined]
# A minimal parser used to resolve --language/--config BEFORE the full parser
# renders help text. It reuses argparse's own abbreviation rules (e.g.
# ``--conf`` -> ``--config``), so language resolution accepts exactly what the
# real parse accepts. ``exit_on_error=False`` makes a malformed known option
# raise instead of printing, so the full parser reports it once.
_ABBREV_PARSER = argparse.ArgumentParser(add_help=False, allow_abbrev=True, exit_on_error=False)
_ABBREV_PARSER.add_argument("-l", "--language")
_ABBREV_PARSER.add_argument("--config")
def _pre_resolve_language() -> None:
"""Resolve language from --language/-l and --config before argparse runs.
Argparse evaluates help text at parse time, so we must resolve the language
BEFORE parse_args() is called. A minimal parser resolves the same option
abbreviations the real parse accepts (e.g. ``--conf`` -> ``--config``).
Priority matches ``_detect_language``: --language > STRIX_LANGUAGE env >
--config file > default config > system locale.
"""
from strix.i18n import set_config_path, set_language
# Reset prior CLI overrides before re-evaluating argv so repeated calls stay clean.
set_language(None)
set_config_path(None)
raw_args = sys.argv[1:]
# Options after ``--`` are positional arguments, never flags.
pre_args = raw_args[: raw_args.index("--")] if "--" in raw_args else raw_args
try:
namespace, _ = _ABBREV_PARSER.parse_known_args(pre_args)
except argparse.ArgumentError:
# A malformed --language/--config; the full parser reports it.
return
if namespace.language:
set_language(namespace.language)
if namespace.config:
set_config_path(namespace.config)
def get_version() -> str:
try:
@ -38,11 +111,11 @@ 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
raise argparse.ArgumentTypeError(t("cli.invalid_float", value=value)) from exc
import math
if not math.isfinite(budget) or budget <= 0:
raise argparse.ArgumentTypeError("must be a finite number greater than 0")
raise argparse.ArgumentTypeError(t("cli.finite_number"))
return budget
@ -50,67 +123,70 @@ 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
raise argparse.ArgumentTypeError(t("cli.invalid_int", value=value)) from exc
if parsed <= 0:
raise argparse.ArgumentTypeError("must be an integer greater than 0")
raise argparse.ArgumentTypeError(t("cli.positive_integer"))
return parsed
def parse_arguments() -> argparse.Namespace:
# Pre-scan for --language before argparse runs so help text can be translated
_pre_resolve_language()
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
description=t("cli.description"),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Web application penetration test
epilog=f"""
{t("cli.examples_header")}
# {t("cli.example_web_app")}
strix --target https://example.com
# GitHub repository analysis
# {t("cli.example_github")}
strix --target https://github.com/user/repo
strix --target git@github.com:user/repo.git
# Local code analysis
# {t("cli.example_local_code")}
strix --target ./my-project
# API spec test (OpenAPI/Swagger file or Postman collection export)
# {t("cli.example_api_spec")}
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
# {t("cli.example_postman")}
strix --target postman://<collection-uuid> --target https://api.example.com
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
# Domain penetration test
# {t("cli.example_domain")}
strix --target example.com
# IP address penetration test
# {t("cli.example_ip")}
strix --target 192.168.1.42
# Multiple targets (e.g., white-box testing with source and deployed app)
# {t("cli.example_multiple")}
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
# {t("cli.example_file")}
strix --target-list ./targets.txt
# Custom instructions (inline)
# {t("cli.example_instruction_inline")}
strix --target example.com --instruction "Focus on authentication vulnerabilities"
# Custom instructions (from file)
# {t("cli.example_instruction_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
# {t("cli.example_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
strix cloud # {t("cli.example_cloud_list")}
Run a pentest in Strix Cloud https://app.strix.ai
Try Strix Enterprise https://strix.ai/demo
{t("cli.example_cloud_run"):<32} https://app.strix.ai
{t("cli.example_cloud_enterprise"):<32} https://strix.ai/demo
""",
)
@ -124,9 +200,15 @@ Strix Cloud:
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.",
help=t("cli.update_help"),
)
parser.add_argument(
"-l",
"--language",
type=str,
default=None,
help=t("cli.language_help"),
)
parser.add_argument(
@ -134,38 +216,25 @@ Strix Cloud:
"--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.",
help=t("cli.target_help"),
)
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.",
help=t("cli.target_list_help"),
)
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').",
help=t("cli.instruction_help"),
)
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').",
help=t("cli.instruction_file_help"),
)
parser.add_argument(
@ -173,22 +242,14 @@ Strix Cloud:
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.",
help=t("cli.workspace_file_help"),
)
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."
),
help=t("cli.non_interactive_help"),
)
parser.add_argument(
@ -198,12 +259,7 @@ Strix Cloud:
choices=FAIL_ON_SEVERITIES,
default=None,
metavar="SEVERITY",
help=(
"Headless mode only: exit 2 only when a finding is at or above this severity "
"(critical, high, medium, low, info). Lower findings are still written to every "
"report artifact. A finding with an unrecognized severity always counts. "
"Default: any finding exits 2."
),
help=t("cli.fail_on_help"),
)
parser.add_argument(
@ -212,13 +268,7 @@ Strix Cloud:
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."
),
help=t("cli.scan_mode_help"),
)
parser.add_argument(
@ -226,34 +276,26 @@ Strix Cloud:
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."
),
help=t("cli.scope_mode_help"),
)
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."
),
help=t("cli.diff_base_help"),
)
parser.add_argument(
"--config",
type=str,
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
help=t("cli.config_help"),
)
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.",
help=t("cli.mcp_config_help"),
)
parser.add_argument(
@ -261,8 +303,7 @@ Strix Cloud:
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.",
help=t("cli.mcp_server_help"),
)
parser.add_argument(
@ -270,7 +311,7 @@ Strix Cloud:
dest="mcp_exclude",
action="append",
metavar="NAME",
help="Skip this MCP connection for the run, by its config name (repeatable).",
help=t("cli.mcp_exclude_help"),
)
parser.add_argument(
@ -280,10 +321,7 @@ Strix Cloud:
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."
),
help=t("cli.max_budget_help"),
)
parser.add_argument(
@ -292,10 +330,7 @@ Strix Cloud:
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."
),
help=t("cli.max_turns_help"),
)
parser.add_argument(
@ -305,12 +340,7 @@ Strix Cloud:
nargs="?",
const="",
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. Without a "
"name, opens a picker of the prior runs."
),
help=t("cli.resume_help"),
)
args = parser.parse_args()
@ -329,7 +359,7 @@ Strix Cloud:
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}")
parser.error(t("cli.error_mcp_config_not_found", path=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)
@ -346,12 +376,10 @@ Strix Cloud:
if args.fail_on and not args.non_interactive and terminal_attached():
# Without a terminal main() switches to headless anyway, so the
# flag is only out of place when the TUI would actually open.
parser.error("--fail-on only applies to headless runs; add -n/--non-interactive.")
parser.error(t("cli.error_fail_on_headless_only"))
if args.instruction and args.instruction_file:
parser.error(
"Cannot specify both --instruction and --instruction-file. Use one or the other."
)
parser.error(t("cli.error_instruction_conflict"))
if args.instruction_file:
instruction_path = Path(args.instruction_file)
@ -359,9 +387,9 @@ Strix Cloud:
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")
parser.error(t("cli.error_empty_instruction_file", path=instruction_path))
except Exception as e:
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
parser.error(t("cli.error_read_instruction_file", path=instruction_path, error=e))
try:
args.workspace_files = resolve_workspace_files(getattr(args, "workspace_file", None))
@ -376,11 +404,7 @@ Strix Cloud:
if args.resume is not None:
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."
)
parser.error(t("cli.error_resume_with_target"))
if not args.resume.strip():
# A bare --resume: main() opens the inline picker of prior runs
# before anything launches; headless has nobody to pick, so it
@ -388,9 +412,9 @@ Strix Cloud:
args.resume = None
args.resume_picker = True
if args.non_interactive:
parser.error(resume_run_list_message("--resume needs a run name in headless mode."))
parser.error(resume_run_list_message(t("cli.error_resume_headless_needs_name")))
if not list_run_summaries():
parser.error(f"--resume: no runs in ./{RUNS_DIR_NAME} to resume")
parser.error(t("cli.error_resume_no_runs", dir=RUNS_DIR_NAME))
return args
try:
load_resume_state(args)
@ -399,10 +423,7 @@ Strix Cloud:
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)"
)
parser.error(t("cli.error_no_target_headless"))
# 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.
@ -432,10 +453,10 @@ def resume_run_list_message(lead: str) -> str:
"""``lead`` followed by the runs that ``--resume <name>`` would accept."""
runs = list_run_summaries()
if not runs:
return f"{lead} There are no runs in ./{RUNS_DIR_NAME}."
return t("cli.resume_no_runs_available", lead=lead, dir=RUNS_DIR_NAME)
name_width = max(len(run.run_name) for run in runs)
status_width = max(len(run.status) for run in runs)
lines = [f"{lead} Runs in ./{RUNS_DIR_NAME}:"]
lines = [t("cli.resume_runs_in_dir", lead=lead, dir=RUNS_DIR_NAME)]
lines.extend(
f" {run.run_name:<{name_width}} {run.status:<{status_width}} "
f"{run.started_at[:19]:<19} {run.target}".rstrip()
@ -455,14 +476,11 @@ def load_resume_state(args: argparse.Namespace) -> None:
run_dir = run_dir_for(args.resume)
state_path = run_dir / "run.json"
if not state_path.exists():
raise ResumeError(
f"--resume {args.resume}: no such run "
f"(missing {state_path}; remove --resume for a fresh start)"
)
raise ResumeError(t("cli.error_resume_missing_run", name=args.resume, path=state_path))
try:
state = read_run_record(run_dir)
except (RuntimeError, TypeError) as exc:
raise ResumeError(f"--resume {args.resume}: run.json unreadable: {exc}") from exc
raise ResumeError(t("cli.error_resume_unreadable", name=args.resume, error=exc)) from exc
args.targets_info = state.get("targets_info") or []
# A target-less run has no targets_info at all. It is driven by its
@ -470,7 +488,7 @@ def load_resume_state(args: argparse.Namespace) -> None:
# 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"):
raise ResumeError(f"--resume {args.resume}: run.json has no targets_info")
raise ResumeError(t("cli.error_resume_no_targets", name=args.resume))
for target in args.targets_info:
if not isinstance(target, dict):
@ -488,11 +506,7 @@ def load_resume_state(args: argparse.Namespace) -> None:
if not cloned:
continue
if not Path(cloned).expanduser().exists():
raise ResumeError(
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."
)
raise ResumeError(t("cli.error_resume_missing_repo", name=args.resume, path=cloned))
if args.instruction is None:
args.instruction = state.get("instruction")
@ -519,12 +533,13 @@ def load_resume_state(args: argparse.Namespace) -> None:
try:
args.workspace_files = resolve_workspace_files(restored)
except ValueError as error:
raise ResumeError(f"--resume {args.resume}: invalid workspace file: {error}") from error
raise ResumeError(
t("cli.error_resume_invalid_workspace_file", name=args.resume, error=error)
) from error
if workspace_mount:
if not Path(workspace_mount).expanduser().is_dir():
raise ResumeError(
f"--resume {args.resume}: the working directory {workspace_mount} "
f"is missing. Restore it before resuming, or start a fresh run."
t("cli.error_resume_missing_workdir", name=args.resume, path=workspace_mount)
)
attach_workspace_mount(args)
if state.get("diff_scope"):
@ -534,9 +549,4 @@ def load_resume_state(args: argparse.Namespace) -> None:
args.scan_mode = persisted_scan_mode
agents_path = runtime_state_dir(run_dir) / "agents.json"
if not agents_path.exists():
raise ResumeError(
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."
)
raise ResumeError(t("cli.error_resume_missing_agents", name=args.resume, path=agents_path))

View file

@ -17,6 +17,7 @@ from rich.text import Text
from strix.config import codex, load_settings, persist_current
from strix.core.paths import RUNS_DIR_NAME, run_dir_for
from strix.i18n import t
from strix.interface.cli_args import (
FAIL_ON_SEVERITIES,
ResumeError,
@ -68,6 +69,16 @@ Additional commands:
"""
def _format_root_subcommand_help() -> str:
return (
f"{t('cli.subcommands_header')}\n"
f" strix cloud ... {t('cli.subcommand_cloud')}\n"
f" strix auth ... {t('cli.subcommand_auth')}\n"
f" strix view [RUN] {t('cli.subcommand_view')}\n"
f" strix completions SHELL {t('cli.subcommand_completions')}"
)
def _exception_messages(exc: BaseException) -> tuple[str, ...]:
messages: list[str] = []
seen: set[int] = set()
@ -130,17 +141,16 @@ async def warm_up_llm() -> None:
and not llm.api_base
):
warn_text = Text()
warn_text.append("UNKNOWN MODEL NAME", style="bold yellow")
warn_text.append(t("cli.unknown_model"), style="bold yellow")
warn_text.append("\n\n", style="white")
warn_text.append(f"'{raw_model}'", style="bold cyan")
warn_text.append(
" is not a known OpenAI model. Bare names route to OpenAI by default.\n"
"If you meant a non-OpenAI provider, use the '",
t("cli.unknown_model_body"),
style="white",
)
warn_text.append("<provider>/<model>", style="bold cyan")
warn_text.append(
"' form, e.g. 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
t("cli.unknown_model_hint"),
style="white",
)
console.print(
@ -196,17 +206,17 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
completion_text = Text()
if scan_completed:
completion_text.append("Penetration test completed", style="bold #22c55e")
completion_text.append(t("cli.completion_title"), style="bold #22c55e")
else:
completion_text.append("SESSION ENDED", style="bold #eab308")
completion_text.append(t("cli.session_ended"), style="bold #eab308")
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(t("cli.target_label"), style="dim")
target_text.append(" ")
if len(args.targets_info) == 1:
target_text.append(args.targets_info[0]["original"], style="bold white")
else:
target_text.append(f"{len(args.targets_info)} targets", style="bold white")
target_text.append(t("cli.targets_label", count=len(args.targets_info)), style="bold white")
for target_info in args.targets_info:
target_text.append("\n ")
target_text.append(target_info["original"], style="white")
@ -220,14 +230,14 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
results_text = Text()
results_text.append("\n")
results_text.append("Output", style="dim")
results_text.append(t("cli.output_label"), style="dim")
results_text.append(" ")
results_text.append(str(results_path), style="#60a5fa")
panel_parts.extend(["\n", results_text])
view_text = Text()
view_text.append("\n")
view_text.append("View", style="dim")
view_text.append(t("cli.view_label"), style="dim")
view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text])
@ -235,7 +245,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
if not scan_completed:
resume_text = Text()
resume_text.append("\n")
resume_text.append("Resume", style="dim")
resume_text.append(t("cli.resume_label"), style="dim")
resume_text.append(" ")
resume_text.append(f"strix --resume {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", resume_text])
@ -318,16 +328,16 @@ def _print_model_connection_error(exc: BaseException) -> None:
sub_hint = _subscription_error_hint(exc)
if sub_hint is not None:
border_style = "yellow"
error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow")
error_text.append(t("cli.model_not_available"), style="bold yellow")
error_text.append("\n\n", style="white")
error_text.append(f"{sub_hint}\n", style="white")
error_text.append(f"\nDetails: {exc}", style="dim white")
else:
border_style = "red"
error_text.append("LLM CONNECTION FAILED", style="bold red")
error_text.append(t("cli.llm_connection_failed"), style="bold red")
error_text.append("\n\n", style="white")
error_text.append("Could not establish connection to the language model.\n", style="white")
error_text.append("Please check your configuration and try again.\n", style="white")
error_text.append(t("cli.llm_connection_error") + "\n", style="white")
error_text.append(t("cli.llm_check_config") + "\n", style="white")
error_text.append(f"\nError: {exc}", style="dim white")
panel = Panel(
@ -365,13 +375,12 @@ def _fall_back_to_headless(args: argparse.Namespace) -> None:
if args.needs_setup:
report_error("no_terminal_for_setup")
_print_error_panel(
"NO TERMINAL ATTACHED",
"The interactive interface needs a terminal and no target was given.\n"
"Pass -t <target> -n to run headless.",
t("cli.no_terminal_attached"),
t("cli.no_terminal_attached_body"),
)
sys.exit(1)
args.non_interactive = True
Console().print("No terminal attached, running headless (same as -n).", style="dim")
Console().print(t("cli.running_headless_notice"), style="dim")
def _pick_run_to_resume(args: argparse.Namespace) -> None:
@ -413,7 +422,7 @@ def _bootstrap_scan(args: argparse.Namespace) -> None:
prepare_run(args)
except ValueError as e:
report_error("scan_preparation_failed", e)
_print_error_panel("SCAN PREPARATION FAILED", str(e))
_print_error_panel(t("cli.scan_preparation_failed"), str(e))
sys.exit(1)
telemetry_start(args)
@ -434,12 +443,17 @@ def main() -> None:
setup_console_logging()
if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"):
try:
parse_arguments()
except SystemExit as exc:
Console().print(_ROOT_SUBCOMMAND_HELP.strip(), markup=False)
raise SystemExit(exc.code) from None
raw_argv = sys.argv[1:]
pre_dd_argv = raw_argv[: raw_argv.index("--")] if "--" in raw_argv else raw_argv
if "-h" in pre_dd_argv or "--help" in pre_dd_argv:
subcommands = ("view", "auth", "completion", "completions", "cloud")
if len(sys.argv) < 2 or sys.argv[1] not in subcommands:
try:
parse_arguments()
except SystemExit as exc:
if exc.code == 0:
Console().print(_format_root_subcommand_help(), markup=False)
raise SystemExit(exc.code) from None
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
# scan argument parser (which requires a target) and before any scan setup.
@ -509,12 +523,14 @@ def main() -> None:
except InteractiveSetupUnavailableError as exc:
exit_reason = "error"
report_error("interactive_setup_unavailable", exc)
_print_error_panel("INTERACTIVE SETUP UNAVAILABLE", str(exc))
_print_error_panel(t("cli.interactive_setup_unavailable"), str(exc))
sys.exit(1)
except InteractiveInterfaceExitedError as exc:
exit_reason = "error"
report_error("interactive_interface_exited", exc)
_print_error_panel("INTERACTIVE INTERFACE STOPPED", f"{exc}.\n{HEADLESS_HINT}")
_print_error_panel(
t("cli.interactive_interface_stopped"), f"{exc}.\n{t('cli.headless_hint')}"
)
sys.exit(1)
except KeyboardInterrupt:
exit_reason = "interrupted"

158
strix/locales/en.json Normal file
View file

@ -0,0 +1,158 @@
{
"cli.description": "Strix Multi-Agent Cybersecurity Penetration Testing Tool",
"cli.target_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.",
"cli.target_list_help": "Path to a file containing targets, one per non-empty, non-comment line. Can be specified multiple times and combined with --target.",
"cli.instruction_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').",
"cli.instruction_file_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').",
"cli.non_interactive_help": "Run in non-interactive mode (no TUI, exits on completion). Default is interactive mode with TUI.",
"cli.fail_on_help": "Headless mode only: exit 2 only when a finding is at or above this severity (critical, high, medium, low, info). Lower findings are still written to every report artifact. A finding with an unrecognized severity always counts. Default: any finding exits 2.",
"cli.scan_mode_help": "Scan mode: 'quick' for fast CI/CD checks, 'standard' for routine testing, 'deep' for thorough security reviews (default). Default: deep.",
"cli.scope_mode_help": "Scope mode for code targets: 'auto' enables PR diff-scope in CI/headless runs, 'diff' forces changed-files scope, 'full' disables diff-scope.",
"cli.diff_base_help": "Target branch or commit to compare against (e.g., origin/main). Defaults to the repository's default branch.",
"cli.config_help": "Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
"cli.mcp_config_help": "Path to an MCP servers JSON file to use instead of ~/.strix/mcp-servers.json.",
"cli.mcp_server_help": "Use only this MCP connection for the run, by its config name (repeatable). Every other configured connection is skipped.",
"cli.mcp_exclude_help": "Skip this MCP connection for the run, by its config name (repeatable).",
"cli.max_budget_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.",
"cli.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.",
"cli.resume_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. Without a name, opens a picker of the prior runs.",
"cli.language_help": "Language for UI and agent responses (e.g., 'en', 'es'). Default: auto-detect from environment.",
"cli.workspace_file_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.",
"cli.examples_header": "Examples:",
"cli.argparse_usage": "usage: ",
"cli.argparse_options": "options",
"cli.argparse_help": "show this help message and exit",
"cli.argparse_version": "show program's version number and exit",
"cli.argparse_error": "%(prog)s: error: %(message)s\n",
"cli.argparse_unrecognized": "unrecognized arguments: %s",
"cli.argparse_required": "the following arguments are required: %s",
"cli.argparse_invalid_choice": "invalid choice: %(value)r (choose from %(choices)s)",
"cli.argparse_argument_error": "argument %(argument_name)s: %(message)s",
"cli.argparse_expected_one": "expected one argument",
"cli.argparse_expected_at_most": "expected at most one argument",
"cli.argparse_expected_at_least": "expected at least one argument",
"cli.argparse_unexpected_option": "unexpected option string: %s",
"cli.invalid_float": "invalid float value: {value!r}",
"cli.finite_number": "must be a finite number greater than 0",
"cli.invalid_int": "invalid int value: {value!r}",
"cli.positive_integer": "must be an integer greater than 0",
"cli.example_web_app": "Web application penetration test",
"cli.example_github": "GitHub repository analysis",
"cli.example_local_code": "Local code analysis",
"cli.example_api_spec": "API spec test (OpenAPI/Swagger file or Postman collection export)",
"cli.example_postman": "Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment",
"cli.example_domain": "Domain penetration test",
"cli.example_ip": "IP address penetration test",
"cli.example_multiple": "Multiple targets (e.g., white-box testing with source and deployed app)",
"cli.example_file": "Targets from a file, one target per non-empty, non-comment line",
"cli.example_instruction_inline": "Custom instructions (inline)",
"cli.example_instruction_file": "Custom instructions (from file)",
"cli.example_workspace": "Extra files placed in the sandbox workspace",
"cli.example_cloud_list": "list every cloud resource",
"cli.example_cloud_run": "Run a pentest in Strix Cloud",
"cli.example_cloud_enterprise": "Try Strix Enterprise",
"cli.subcommands_header": "Additional commands:",
"cli.subcommand_cloud": "Use the managed Strix platform",
"cli.subcommand_auth": "Manage model-subscription sign-in",
"cli.subcommand_view": "View a completed or running scan",
"cli.subcommand_completions": "Generate zsh, bash, or fish tab completion",
"cli.update_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.",
"cli.version_help": "Show version and exit.",
"cli.error_no_target": "No target specified. Use --target or --target-list.",
"cli.error_no_target_headless": "the following arguments are required: -t/--target or --target-list (or use --resume <run_name> to continue a prior scan)",
"cli.error_invalid_target": "Invalid target: {target}",
"cli.error_mcp_config_not_found": "--mcp-config file not found: {path}",
"cli.error_fail_on_headless_only": "--fail-on only applies to headless runs; add -n/--non-interactive.",
"cli.error_instruction_conflict": "Cannot specify both --instruction and --instruction-file. Use one or the other.",
"cli.error_empty_instruction_file": "Instruction file '{path}' is empty",
"cli.error_read_instruction_file": "Failed to read instruction file '{path}': {error}",
"cli.error_resume_with_target": "Cannot combine --resume with --target/--target-list. --resume picks up where the prior run left off, including the original target list.",
"cli.error_resume_headless_needs_name": "--resume needs a run name in headless mode.",
"cli.error_resume_no_runs": "--resume: no runs in ./{dir} to resume",
"cli.resume_no_runs_available": "{lead} There are no runs in ./{dir}.",
"cli.resume_runs_in_dir": "{lead} Runs in ./{dir}:",
"cli.error_resume_invalid_workspace_file": "--resume {name}: invalid workspace file: {error}",
"cli.error_resume_missing_agents": "--resume {name}: missing {path}. The run was persisted but never reached its first agent snapshot — there's nothing to resume from. Pick a fresh --run-name or remove --resume to start over with the same targets.",
"cli.error_resume_no_targets": "--resume {name}: run.json has no targets_info",
"cli.error_resume_missing_run": "--resume {name}: no such run (missing {path}; remove --resume for a fresh start)",
"cli.error_resume_unreadable": "--resume {name}: run.json unreadable: {error}",
"cli.error_resume_missing_repo": "--resume {name}: cloned repo at {path} is missing. It was deleted between runs. Pick a fresh --run-name to re-clone, or restore the directory before resuming.",
"cli.error_resume_missing_workdir": "--resume {name}: the working directory {path} is missing. Restore it before resuming, or start a fresh run.",
"cli.scan_started": "Starting scan against {target}",
"cli.scan_completed": "Scan completed. {count} vulnerabilities found.",
"cli.auth_login_prompt": "Enter your API key",
"cli.auth_login_success": "Authentication successful",
"cli.auth_login_failure": "Authentication failed: {reason}",
"cli.progress_recon": "Performing reconnaissance...",
"cli.progress_scanning": "Scanning {target}...",
"cli.progress_reporting": "Generating report...",
"cli.vulnerability_found": "Vulnerability found",
"cli.severity_critical": "Critical",
"cli.severity_high": "High",
"cli.severity_medium": "Medium",
"cli.severity_low": "Low",
"cli.severity_info": "Info",
"cli.completion_title": "Penetration test completed",
"cli.session_ended": "SESSION ENDED",
"cli.target_label": "Target",
"cli.targets_label": "{count} targets",
"cli.output_label": "Output",
"cli.view_label": "View",
"cli.resume_label": "Resume",
"cli.llm_connection_failed": "LLM CONNECTION FAILED",
"cli.llm_connection_error": "Could not establish connection to the language model.",
"cli.llm_check_config": "Please check your configuration and try again.",
"cli.model_not_available": "MODEL NOT AVAILABLE ON SUBSCRIPTION",
"cli.unknown_model": "UNKNOWN MODEL NAME",
"cli.unknown_model_body": " is not a known OpenAI model. Bare names route to OpenAI by default.\nIf you meant a non-OpenAI provider, use the '",
"cli.unknown_model_hint": "' form, e.g. 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
"cli.interactive_setup_unavailable": "INTERACTIVE SETUP UNAVAILABLE",
"cli.interactive_interface_stopped": "INTERACTIVE INTERFACE STOPPED",
"cli.headless_hint": "If Strix runs without a terminal (CI, nohup, pipes), pass -n to run headless.",
"cli.no_terminal_attached": "NO TERMINAL ATTACHED",
"cli.no_terminal_attached_body": "The interactive interface needs a terminal and no target was given.\nPass -t <target> -n to run headless.",
"cli.running_headless_notice": "No terminal attached, running headless (same as -n).",
"cli.scan_preparation_failed": "SCAN PREPARATION FAILED",
"cli.test_initiated": "Penetration test initiated",
"cli.test_in_progress": "Penetration test in progress",
"cli.test_summary": "Penetration test summary",
"cli.vulnerabilities_realtime": "Vulnerabilities will be displayed in real-time.",
"cli.starting_up": "Starting up",
"cli.error_during_test": "Error during penetration test:",
"cli.vulnerabilities_count": "Vulnerabilities",
"cli.no_exploitable": "No exploitable vulnerabilities detected",
"report.title": "Security Penetration Test Report",
"report.generated": "Generated:",
"report.description": "Description",
"report.evidence": "Evidence",
"report.impact": "Impact",
"report.counterevidence": "Counterevidence",
"report.confidence_rationale": "Confidence Rationale",
"report.severity_change_conditions": "What Would Change This Severity",
"report.technical_analysis": "Technical Analysis",
"report.contextual_cvss": "Contextual CVSS",
"report.proof_of_concept": "Proof of Concept",
"report.code_analysis": "Code Analysis",
"report.remediation": "Remediation",
"report.fix_verification": "Fix Verification",
"report.assumptions": "Assumptions",
"report.update_history": "Update History",
"report.no_description": "No description provided.",
"report.location": "Location",
"report.suggested_fix": "Suggested Fix",
"report.severity": "Severity",
"report.found": "Found",
"report.target": "Target",
"report.package": "Package",
"report.ecosystem": "Ecosystem",
"report.installed_version": "Installed Version",
"report.fixed_version": "Fixed Version",
"report.introduced_by": "Introduced By",
"report.dependency_chain": "Dependency Chain",
"report.endpoint": "Endpoint",
"report.method": "Method",
"report.advisory_cvss": "Advisory CVSS",
"report.contextual_cvss_vector": "Contextual CVSS Vector",
"report.confidence": "Confidence",
"report.fix_effort": "Fix Effort"
}

158
strix/locales/es.json Normal file
View file

@ -0,0 +1,158 @@
{
"cli.description": "Herramienta de Pruebas de Penetración Multi-Agente con IA de Strix",
"cli.target_help": "Objetivo a probar: URL, repositorio, ruta de directorio local, nombre de dominio, dirección IP, un archivo de especificación API (OpenAPI/Swagger .json/.yaml o exportación de colección Postman), o una colección Postman por id (postman://<collection-uuid>[?env=<environment-uuid>], requiere POSTMAN_API_KEY). Los directorios locales se montan en el sandbox con permisos de escritura. Se puede especificar múltiples veces para escaneos multi-objetivo. Las ejecuciones nuevas requieren --target o --target-list.",
"cli.target_list_help": "Ruta a un archivo con objetivos, uno por línea no vacía y no comentario. Se puede especificar múltiples veces y combinar con --target.",
"cli.instruction_help": "Instrucciones personalizadas para la prueba de penetración. Pueden ser tipos de vulnerabilidad específicos en los que enfocarse (ej: 'Focus on IDOR and XSS'), enfoques de prueba (ej: 'Perform thorough authentication testing'), credenciales de prueba (ej: 'Use the following credentials to access the app: admin:password123'), o áreas de interés (ej: 'Check login API endpoint for security issues').",
"cli.instruction_file_help": "Ruta a un archivo con instrucciones personalizadas detalladas para la prueba de penetración. Use esta opción cuando tenga instrucciones extensas o complejas guardadas en un archivo (ej: '--instruction-file ./detailed_instructions.txt').",
"cli.non_interactive_help": "Ejecutar en modo no interactivo (sin TUI, sale al completar). El modo por defecto es interactivo con TUI.",
"cli.fail_on_help": "Solo en modo headless: salir con código 2 únicamente cuando un hallazgo tenga esta severidad o superior (critical, high, medium, low, info). Los hallazgos menores se escriben igualmente en todos los artefactos de informe. Un hallazgo con severidad no reconocida siempre cuenta. Por defecto: cualquier hallazgo sale con código 2.",
"cli.scan_mode_help": "Modo de escaneo: 'quick' para verificaciones rápidas CI/CD, 'standard' para pruebas rutinarias, 'deep' para revisiones de seguridad exhaustivas (por defecto). Por defecto: deep.",
"cli.scope_mode_help": "Modo de alcance para objetivos de código: 'auto' habilita alcance diff en PR en ejecuciones CI/headless, 'diff' fuerza alcance de archivos cambiados, 'full' deshabilita alcance diff.",
"cli.diff_base_help": "Rama o commit objetivo para comparar (ej: origin/main). Por defecto usa la rama principal del repositorio.",
"cli.config_help": "Ruta a un archivo de configuración personalizado (JSON) en lugar de ~/.strix/cli-config.json",
"cli.mcp_config_help": "Ruta a un archivo JSON de servidores MCP para usar en lugar de ~/.strix/mcp-servers.json.",
"cli.mcp_server_help": "Usar únicamente esta conexión MCP para la ejecución, por su nombre de configuración (repetible). Todas las demás conexiones configuradas se omiten.",
"cli.mcp_exclude_help": "Omitir esta conexión MCP para la ejecución, por su nombre de configuración (repetible).",
"cli.max_budget_help": "Costo máximo de LLM en USD (> 0). El escaneo se detiene limpiamente al alcanzar este límite. Se envían advertencias graduales a todos los agentes al acercarse.",
"cli.max_turns_help": "Máximo de turnos por agente (> 0, por defecto %(default)s). Cada agente se detiene forzosamente al alcanzar este límite, con advertencias graduales al acercarse.",
"cli.resume_help": "Reanudar un escaneo anterior por nombre de ejecución (el directorio bajo ./strix_runs/). Retoma el historial LLM completo del agente raíz y subagentes no terminados junto con su topología. Omite la generación de un nombre nuevo. Sin nombre, abre un selector con las ejecuciones anteriores.",
"cli.language_help": "Idioma para la interfaz y respuestas de agentes (ej: 'en', 'es'). Por defecto: auto-detectar del entorno.",
"cli.workspace_file_help": "Coloca un archivo de esta máquina en el workspace del sandbox antes de que comience el escaneo, por ejemplo una wordlist, una especificación de API o notas. Repite la opción para más archivos. DEST es la ruta dentro de /workspace y por defecto usa el nombre del archivo (por ejemplo '--workspace-file ./wordlist.txt:lists/wordlist.txt'). Strix copia el archivo en el sandbox, fuera de cada directorio de objetivo. El agente puede editar la copia. El archivo en esta máquina no cambia.",
"cli.examples_header": "Ejemplos:",
"cli.argparse_usage": "uso: ",
"cli.argparse_options": "opciones",
"cli.argparse_help": "muestra este mensaje de ayuda y sale",
"cli.argparse_version": "muestra el número de versión del programa y sale",
"cli.argparse_error": "%(prog)s: error: %(message)s\n",
"cli.argparse_unrecognized": "argumentos no reconocidos: %s",
"cli.argparse_required": "se requieren los siguientes argumentos: %s",
"cli.argparse_invalid_choice": "opción inválida: %(value)r (elija entre %(choices)s)",
"cli.argparse_argument_error": "argumento %(argument_name)s: %(message)s",
"cli.argparse_expected_one": "se espera un argumento",
"cli.argparse_expected_at_most": "se espera como máximo un argumento",
"cli.argparse_expected_at_least": "se espera al menos un argumento",
"cli.argparse_unexpected_option": "opción inesperada: %s",
"cli.invalid_float": "valor float inválido: {value!r}",
"cli.finite_number": "debe ser un número finito mayor que 0",
"cli.invalid_int": "valor entero inválido: {value!r}",
"cli.positive_integer": "debe ser un entero mayor que 0",
"cli.example_web_app": "Prueba de penetración de aplicación web",
"cli.example_github": "Análisis de repositorio de GitHub",
"cli.example_local_code": "Análisis de código local",
"cli.example_api_spec": "Prueba de spec API (archivo OpenAPI/Swagger o export de colección Postman)",
"cli.example_postman": "Colección Postman obtenida en vivo por id (requiere POSTMAN_API_KEY); entorno opcional",
"cli.example_domain": "Prueba de penetración de dominio",
"cli.example_ip": "Prueba de penetración de dirección IP",
"cli.example_multiple": "Múltiples objetivos (ej., prueba white-box con código fuente y app desplegada)",
"cli.example_file": "Objetivos desde un archivo, uno por línea no vacía y no comentario",
"cli.example_instruction_inline": "Instrucciones personalizadas (en línea)",
"cli.example_instruction_file": "Instrucciones personalizadas (desde archivo)",
"cli.example_workspace": "Archivos extra colocados en el workspace del sandbox",
"cli.example_cloud_list": "listar todos los recursos cloud",
"cli.example_cloud_run": "Ejecutar un pentest en Strix Cloud",
"cli.example_cloud_enterprise": "Probar Strix Enterprise",
"cli.subcommands_header": "Comandos adicionales:",
"cli.subcommand_cloud": "Usar la plataforma gestionada de Strix",
"cli.subcommand_auth": "Gestionar el inicio de sesión de suscripción de modelos",
"cli.subcommand_view": "Ver un escaneo completado o en ejecución",
"cli.subcommand_completions": "Generar autocompletado de terminal para zsh, bash o fish",
"cli.update_help": "Actualizar strix a la última versión y salir. Auto-actualiza la instalación binaria; para instalaciones pip/pipx/uv, muestra el comando de actualización correspondiente.",
"cli.version_help": "Mostrar versión y salir.",
"cli.error_no_target": "No se especificó objetivo. Use --target o --target-list.",
"cli.error_no_target_headless": "se requieren los siguientes argumentos: -t/--target o --target-list (o use --resume <run_name> para continuar un escaneo anterior)",
"cli.error_invalid_target": "Objetivo inválido: {target}",
"cli.error_mcp_config_not_found": "archivo --mcp-config no encontrado: {path}",
"cli.error_fail_on_headless_only": "--fail-on solo se aplica a ejecuciones headless; agregue -n/--non-interactive.",
"cli.error_instruction_conflict": "No se puede especificar --instruction y --instruction-file juntos. Use uno u otro.",
"cli.error_empty_instruction_file": "El archivo de instrucciones '{path}' está vacío",
"cli.error_read_instruction_file": "Error al leer archivo de instrucciones '{path}': {error}",
"cli.error_resume_with_target": "No se puede combinar --resume con --target/--target-list. --resume retoma donde quedó la ejecución anterior, incluyendo la lista original de objetivos.",
"cli.error_resume_headless_needs_name": "--resume requiere un nombre de ejecución en modo headless.",
"cli.error_resume_no_runs": "--resume: no hay ejecuciones en ./{dir} para reanudar",
"cli.resume_no_runs_available": "{lead} No hay ejecuciones en ./{dir}.",
"cli.resume_runs_in_dir": "{lead} Ejecuciones en ./{dir}:",
"cli.error_resume_invalid_workspace_file": "--resume {name}: archivo de workspace inválido: {error}",
"cli.error_resume_missing_agents": "--resume {name}: falta {path}. La ejecución se persistió pero nunca alcanzó su primer snapshot de agente — no hay nada desde donde reanudar. Elija un --run-name nuevo o quite --resume para comenzar de nuevo con los mismos objetivos.",
"cli.error_resume_no_targets": "--resume {name}: run.json no tiene targets_info",
"cli.error_resume_missing_run": "--resume {name}: no existe tal ejecución (falta {path}; quite --resume para un inicio nuevo)",
"cli.error_resume_unreadable": "--resume {name}: run.json ilegible: {error}",
"cli.error_resume_missing_repo": "--resume {name}: el repositorio clonado en {path} no existe. Se eliminó entre ejecuciones. Use un --run-name nuevo para re-clonar, o restaure el directorio antes de reanudar.",
"cli.error_resume_missing_workdir": "--resume {name}: el directorio de trabajo {path} no existe. Restáurelo antes de reanudar, o inicie una ejecución nueva.",
"cli.scan_started": "Iniciando escaneo contra {target}",
"cli.scan_completed": "Escaneo completado. {count} vulnerabilidades encontradas.",
"cli.auth_login_prompt": "Ingrese su clave API",
"cli.auth_login_success": "Autenticación exitosa",
"cli.auth_login_failure": "Autenticación fallida: {reason}",
"cli.progress_recon": "Realizando reconocimiento...",
"cli.progress_scanning": "Escaneando {target}...",
"cli.progress_reporting": "Generando informe...",
"cli.vulnerability_found": "Vulnerabilidad encontrada",
"cli.severity_critical": "Crítica",
"cli.severity_high": "Alta",
"cli.severity_medium": "Media",
"cli.severity_low": "Baja",
"cli.severity_info": "Info",
"cli.completion_title": "Prueba de penetración completada",
"cli.session_ended": "SESIÓN FINALIZADA",
"cli.target_label": "Objetivo",
"cli.targets_label": "{count} objetivos",
"cli.output_label": "Salida",
"cli.view_label": "Ver",
"cli.resume_label": "Reanudar",
"cli.llm_connection_failed": "FALLO DE CONEXIÓN LLM",
"cli.llm_connection_error": "No se pudo establecer conexión con el modelo de lenguaje.",
"cli.llm_check_config": "Verifique su configuración e intente nuevamente.",
"cli.model_not_available": "MODELO NO DISPONIBLE EN SUSCRIPCIÓN",
"cli.unknown_model": "NOMBRE DE MODELO DESCONOCIDO",
"cli.unknown_model_body": " no es un modelo de OpenAI conocido. Los nombres simples se enrutan a OpenAI por defecto.\nSi se refería a un proveedor distinto de OpenAI, use el formato '",
"cli.unknown_model_hint": "', por ejemplo 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
"cli.interactive_setup_unavailable": "CONFIGURACIÓN INTERACTIVA NO DISPONIBLE",
"cli.interactive_interface_stopped": "INTERFAZ INTERACTIVA DETENIDA",
"cli.headless_hint": "Si Strix se ejecuta sin una terminal (CI, nohup, pipes), pase -n para ejecutar en modo headless.",
"cli.no_terminal_attached": "SIN TERMINAL CONECTADA",
"cli.no_terminal_attached_body": "La interfaz interactiva requiere una terminal y no se indicó ningún objetivo.\nPase -t <target> -n para ejecutar en modo headless.",
"cli.running_headless_notice": "Sin terminal conectada, ejecutando en modo headless (igual que -n).",
"cli.scan_preparation_failed": "FALLO EN PREPARACIÓN DEL ESCANEO",
"cli.test_initiated": "Prueba de penetración iniciada",
"cli.test_in_progress": "Prueba de penetración en progreso",
"cli.test_summary": "Resumen de prueba de penetración",
"cli.vulnerabilities_realtime": "Las vulnerabilidades se mostrarán en tiempo real.",
"cli.starting_up": "Iniciando",
"cli.error_during_test": "Error durante la prueba de penetración:",
"cli.vulnerabilities_count": "Vulnerabilidades",
"cli.no_exploitable": "No se detectaron vulnerabilidades explotables",
"report.title": "Informe de Prueba de Penetración de Seguridad",
"report.generated": "Generado:",
"report.description": "Descripción",
"report.evidence": "Evidencia",
"report.impact": "Impacto",
"report.counterevidence": "Contraevidencia",
"report.confidence_rationale": "Justificación de Confianza",
"report.severity_change_conditions": "Qué Cambiaría Esta Severidad",
"report.technical_analysis": "Análisis Técnico",
"report.contextual_cvss": "CVSS Contextual",
"report.proof_of_concept": "Prueba de Concepto",
"report.code_analysis": "Análisis de Código",
"report.remediation": "Remediación",
"report.fix_verification": "Verificación de Corrección",
"report.assumptions": "Suposiciones",
"report.update_history": "Historial de Actualizaciones",
"report.no_description": "No se proporcionó descripción.",
"report.location": "Ubicación",
"report.suggested_fix": "Corrección Sugerida",
"report.severity": "Severidad",
"report.found": "Encontrado",
"report.target": "Objetivo",
"report.package": "Paquete",
"report.ecosystem": "Ecosistema",
"report.installed_version": "Versión Instalada",
"report.fixed_version": "Versión Corregida",
"report.introduced_by": "Introducido Por",
"report.dependency_chain": "Cadena de Dependencias",
"report.endpoint": "Endpoint",
"report.method": "Método",
"report.advisory_cvss": "CVSS del Aviso",
"report.contextual_cvss_vector": "Vector CVSS Contextual",
"report.confidence": "Confianza",
"report.fix_effort": "Esfuerzo de Corrección"
}

View file

@ -17,6 +17,7 @@ from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound
from strix.core.paths import run_record_path
from strix.i18n import t
if TYPE_CHECKING:
@ -138,9 +139,10 @@ def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
def write_executive_report(run_dir: Path, final_scan_result: str) -> None:
path = run_dir / "penetration_test_report.md"
timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
with path.open("w", encoding="utf-8") as f:
f.write("# Security Penetration Test Report\n\n")
f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n")
f.write(f"# {t('report.title')}\n\n")
f.write(f"**{t('report.generated')}** {timestamp}\n\n")
f.write(f"{final_scan_result}\n")
logger.info("Saved final penetration test report to: %s", path)
@ -224,21 +226,21 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines: list[str] = [
f"# {report.get('title', 'Untitled Vulnerability')}\n",
f"**ID:** {report.get('id', 'unknown')}",
f"**Severity:** {report.get('severity', 'unknown').upper()}",
f"**Found:** {report.get('timestamp', 'unknown')}",
f"**{t('report.severity')}:** {report.get('severity', 'unknown').upper()}",
f"**{t('report.found')}:** {report.get('timestamp', 'unknown')}",
]
dep_meta = report.get("dependency_metadata") or {}
metadata: list[tuple[str, Any]] = [
("Target", report.get("target")),
("Package", dep_meta.get("package_name")),
("Ecosystem", dep_meta.get("package_ecosystem")),
("Installed Version", dep_meta.get("installed_version")),
("Fixed Version", dep_meta.get("fixed_version")),
("Introduced By", dep_meta.get("introduced_by")),
("Dependency Chain", dep_meta.get("dependency_path")),
("Endpoint", report.get("endpoint")),
("Method", report.get("method")),
(t("report.target"), report.get("target")),
(t("report.package"), dep_meta.get("package_name")),
(t("report.ecosystem"), dep_meta.get("package_ecosystem")),
(t("report.installed_version"), dep_meta.get("installed_version")),
(t("report.fixed_version"), dep_meta.get("fixed_version")),
(t("report.introduced_by"), dep_meta.get("introduced_by")),
(t("report.dependency_chain"), dep_meta.get("dependency_path")),
(t("report.endpoint"), report.get("endpoint")),
(t("report.method"), report.get("method")),
("CVE", report.get("cve")),
("CWE", report.get("cwe")),
]
@ -247,59 +249,59 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
metadata.append(("CVSS", cvss))
advisory_cvss = dep_meta.get("advisory_cvss")
if advisory_cvss is not None and advisory_cvss != cvss:
metadata.append(("Advisory CVSS", advisory_cvss))
metadata.append((t("report.advisory_cvss"), advisory_cvss))
if dep_meta.get("contextual_cvss_vector"):
metadata.append(("Contextual CVSS Vector", dep_meta["contextual_cvss_vector"]))
metadata.append((t("report.contextual_cvss_vector"), dep_meta["contextual_cvss_vector"]))
if report.get("confidence"):
metadata.append(("Confidence", str(report["confidence"]).title()))
metadata.append((t("report.confidence"), str(report["confidence"]).title()))
if report.get("fix_effort"):
metadata.append(("Fix Effort", str(report["fix_effort"]).title()))
metadata.append((t("report.fix_effort"), str(report["fix_effort"]).title()))
for label, value in metadata:
if value:
lines.append(f"**{label}:** {value}")
lines.append("")
lines.append("## Description\n")
lines.append(report.get("description") or "No description provided.")
lines.append(f"## {t('report.description')}\n")
lines.append(report.get("description") or t("report.no_description"))
lines.append("")
if report.get("evidence"):
lines.append("## Evidence\n")
lines.append(f"## {t('report.evidence')}\n")
lines.append(str(report["evidence"]))
lines.append("")
if report.get("impact"):
lines.append("## Impact\n")
lines.append(f"## {t('report.impact')}\n")
lines.append(str(report["impact"]))
lines.append("")
if report.get("counterevidence"):
lines.append("## Counterevidence\n")
lines.append(f"## {t('report.counterevidence')}\n")
lines.append(str(report["counterevidence"]))
lines.append("")
if report.get("confidence_rationale"):
lines.append("## Confidence Rationale\n")
lines.append(f"## {t('report.confidence_rationale')}\n")
lines.append(str(report["confidence_rationale"]))
lines.append("")
if report.get("severity_change_conditions"):
lines.append("## What Would Change This Severity\n")
lines.append(f"## {t('report.severity_change_conditions')}\n")
lines.append(str(report["severity_change_conditions"]))
lines.append("")
if report.get("technical_analysis"):
lines.append("## Technical Analysis\n")
lines.append(f"## {t('report.technical_analysis')}\n")
lines.append(str(report["technical_analysis"]))
lines.append("")
if dep_meta.get("contextual_cvss_reasoning"):
lines.append("## Contextual CVSS\n")
lines.append(f"## {t('report.contextual_cvss')}\n")
lines.append(str(dep_meta["contextual_cvss_reasoning"]))
lines.append("")
if report.get("poc_description") or report.get("poc_script_code"):
lines.append("## Proof of Concept\n")
lines.append(f"## {t('report.proof_of_concept')}\n")
if report.get("poc_description"):
lines.append(str(report["poc_description"]))
lines.append("")
@ -313,7 +315,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append("")
if report.get("code_locations"):
lines.append("## Code Analysis\n")
lines.append(f"## {t('report.code_analysis')}\n")
for i, loc in enumerate(report["code_locations"]):
file_ref = loc.get("file", "unknown")
line_ref = ""
@ -322,7 +324,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
line_ref = f" (lines {loc['start_line']}-{loc['end_line']})"
else:
line_ref = f" (line {loc['start_line']})"
lines.append(f"**Location {i + 1}:** `{file_ref}`{line_ref}")
lines.append(f"**{t('report.location')} {i + 1}:** `{file_ref}`{line_ref}")
if loc.get("label"):
lines.append(f" {loc['label']}")
if loc.get("snippet"):
@ -332,7 +334,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.extend(f" {ln}" for ln in snippet.splitlines())
lines.append(f" {fence}")
if loc.get("fix_before") or loc.get("fix_after"):
lines.append("\n **Suggested Fix:**")
lines.append(f"\n **{t('report.suggested_fix')}:**")
lines.append("```diff")
if loc.get("fix_before"):
lines.extend(f"- {ln}" for ln in str(loc["fix_before"]).splitlines())
@ -342,17 +344,17 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append("")
if report.get("remediation_steps"):
lines.append("## Remediation\n")
lines.append(f"## {t('report.remediation')}\n")
lines.append(str(report["remediation_steps"]))
lines.append("")
if report.get("fix_verification"):
lines.append("## Fix Verification\n")
lines.append(f"## {t('report.fix_verification')}\n")
lines.append(str(report["fix_verification"]))
lines.append("")
if report.get("assumptions"):
lines.append("## Assumptions\n")
lines.append(f"## {t('report.assumptions')}\n")
lines.append(str(report["assumptions"]))
lines.append("")
@ -371,7 +373,7 @@ def render_update_history(history: Any) -> list[str]:
if not entries:
return []
lines = ["## Update History\n"]
lines = [f"## {t('report.update_history')}\n"]
for entry in entries:
author = str(entry.get("agent_name") or entry.get("agent_id") or "an agent")
raw_fields = entry.get("fields")

568
tests/test_i18n.py Normal file
View file

@ -0,0 +1,568 @@
"""Tests for the i18n internationalization module."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
import strix.i18n as mod
from strix.agents.prompt import render_system_prompt
from strix.config.settings import Settings
from strix.i18n import (
SUPPORTED_LANGUAGES,
_detect_language,
_load_locale,
_normalize_lang,
get_language,
get_language_directive,
set_config_path,
set_language,
t,
)
from strix.interface import cli_args
from strix.report.writer import render_vulnerability_md, write_executive_report
if TYPE_CHECKING:
from collections.abc import Generator
@pytest.fixture(autouse=True)
def _reset_i18n_state() -> Generator[None, None, None]:
"""Reset module-level state between tests."""
mod._language = None
mod._config_path = None
mod._locales.clear()
yield
mod._language = None
mod._config_path = None
mod._locales.clear()
class TestSetLanguage:
def test_set_language_spanish(self) -> None:
set_language("es")
assert get_language() == "es"
def test_set_language_english(self) -> None:
set_language("en")
assert get_language() == "en"
def test_set_language_normalizes(self) -> None:
set_language("ES")
assert get_language() == "es"
def test_set_language_none_resets(self, tmp_path: Path) -> None:
set_config_path(tmp_path / "missing.json")
with patch.dict(os.environ, {}, clear=True):
set_language("es")
set_language(None)
assert get_language() == "en"
def test_set_language_unsupported_falls_back(self) -> None:
set_language("fr")
assert get_language() == "en"
class TestNormalizeLang:
def test_normalize_lowercase(self) -> None:
assert _normalize_lang("es") == "es"
def test_normalize_uppercase(self) -> None:
assert _normalize_lang("ES") == "es"
def test_normalize_strips(self) -> None:
assert _normalize_lang(" es ") == "es"
def test_normalize_unsupported(self) -> None:
assert _normalize_lang("fr") == "en"
def test_normalize_empty(self) -> None:
assert _normalize_lang("") == "en"
class TestDetectLanguage:
def test_default_is_english(self, tmp_path: Path) -> None:
set_config_path(tmp_path / "missing.json")
with patch.dict(os.environ, {}, clear=True):
assert _detect_language() == "en"
def test_explicit_set_takes_priority(self) -> None:
mod._language = "es"
assert _detect_language() == "es"
def test_env_var_detected(self) -> None:
with patch.dict(os.environ, {"STRIX_LANGUAGE": "es"}):
assert _detect_language() == "es"
def test_env_var_case_insensitive(self) -> None:
with patch.dict(os.environ, {"strix_language": "es"}, clear=True):
assert _detect_language() == "es"
def test_env_var_overrides_config(self, tmp_path: Path) -> None:
config = tmp_path / "cli-config.json"
config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "en"}}), encoding="utf-8")
set_config_path(config)
with patch.dict(os.environ, {"STRIX_LANGUAGE": "es"}):
assert _detect_language() == "es"
def test_system_locale_detected(self, tmp_path: Path) -> None:
set_config_path(tmp_path / "missing.json")
with patch.dict(os.environ, {"LANG": "es_ES.UTF-8"}, clear=True):
mod._language = None
assert _detect_language() == "es"
def test_canonical_config_format(self, tmp_path: Path) -> None:
"""Test that config file reads canonical format {"env": {"STRIX_LANGUAGE": "es"}}."""
config = tmp_path / "cli-config.json"
config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8")
set_config_path(config)
with patch.dict(os.environ, {}, clear=True):
assert _detect_language() == "es"
class TestLoadLocale:
def test_load_english(self) -> None:
locale = _load_locale("en")
assert "cli.description" in locale
assert isinstance(locale, dict)
def test_load_spanish(self) -> None:
locale = _load_locale("es")
assert "cli.description" in locale
def test_load_missing_returns_empty(self) -> None:
locale = _load_locale("nonexistent")
assert locale == {}
def test_cached_after_first_load(self) -> None:
_load_locale("en")
assert "en" in mod._locales
class TestTranslationFunction:
def test_t_returns_english_by_default(self, tmp_path: Path) -> None:
set_config_path(tmp_path / "missing.json")
with patch.dict(os.environ, {}, clear=True):
result = t("cli.description")
assert "Strix" in result
assert "Penetration" in result
def test_t_returns_spanish_when_set(self) -> None:
set_language("es")
result = t("cli.description")
assert "Penetración" in result
def test_t_falls_back_to_english_for_missing_key(self) -> None:
set_language("es")
_load_locale("es")
mod._locales["es"] = {}
result = t("cli.description")
assert result == "Strix Multi-Agent Cybersecurity Penetration Testing Tool"
def test_t_returns_key_for_missing(self) -> None:
result = t("nonexistent.key.xyz")
assert result == "nonexistent.key.xyz"
def test_t_interpolates_placeholders(self) -> None:
set_language("en")
result = t("cli.scan_started", target="example.com")
assert "example.com" in result
def test_t_interpolates_spanish(self) -> None:
set_language("es")
result = t("cli.scan_started", target="example.com")
assert "example.com" in result
assert "escaneo" in result.lower()
def test_t_handles_missing_placeholder_gracefully(self) -> None:
set_language("en")
result = t("cli.scan_started")
assert isinstance(result, str)
class TestLanguageDirective:
def test_english_returns_empty(self) -> None:
set_language("en")
directive = get_language_directive()
assert directive == ""
def test_spanish_returns_directive(self) -> None:
set_language("es")
directive = get_language_directive()
assert "Spanish" in directive
assert "CVE" in directive
assert "CWE" in directive
def test_directive_preserves_identifiers(self) -> None:
set_language("es")
directive = get_language_directive()
assert "CVE identifiers" in directive
assert "CWE identifiers" in directive
assert "CVSS scores" in directive
assert "Source code" in directive
assert "Shell commands" in directive
def test_system_prompt_english_omits_directive_without_extra_blank_lines(self) -> None:
set_language("en")
prompt = render_system_prompt(include_scope=False, is_root=True)
assert "LANGUAGE DIRECTIVE:" not in prompt
assert "at all times.\n\n<root_agent_directive>" in prompt
assert "at all times.\n\n\n<root_agent_directive>" not in prompt
def test_system_prompt_spanish_injects_directive(self) -> None:
set_language("es")
prompt = render_system_prompt(include_scope=False, is_root=True)
assert "LANGUAGE DIRECTIVE:" in prompt
assert prompt.index("LANGUAGE DIRECTIVE:") < prompt.index("<root_agent_directive>")
class TestLocaleKeyConsistency:
def test_all_en_keys_exist_in_es_and_vice_versa(self) -> None:
en = _load_locale("en")
es = _load_locale("es")
missing_in_es = set(en.keys()) - set(es.keys())
missing_in_en = set(es.keys()) - set(en.keys())
assert missing_in_es == set(), f"Missing Spanish keys: {missing_in_es}"
assert missing_in_en == set(), f"Extra Spanish keys missing in English: {missing_in_en}"
def test_locale_files_are_valid_json(self) -> None:
locales_dir = Path(__file__).parent.parent / "strix" / "locales"
for json_file in locales_dir.glob("*.json"):
data = json.loads(json_file.read_text(encoding="utf-8"))
assert isinstance(data, dict), f"{json_file.name} is not a dict"
def test_supported_languages_match_files(self) -> None:
locales_dir = Path(__file__).parent.parent / "strix" / "locales"
for lang in SUPPORTED_LANGUAGES:
locale_file = locales_dir / f"{lang}.json"
assert locale_file.exists(), f"Missing locale file: {locale_file}"
class TestCliHelpKeys:
"""The CLI help and model-warning strings must translate in both locales."""
def test_workspace_file_help_translates(self) -> None:
set_language("en")
en_value = t("cli.workspace_file_help")
assert (
en_value == "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."
)
assert "(...)" not in en_value
set_language("es")
es_value = t("cli.workspace_file_help")
assert "workspace del sandbox" in es_value
assert "(por ejemplo '--workspace-file ./wordlist.txt:lists/wordlist.txt')" in es_value
assert "Strix copia el archivo en el sandbox" in es_value
assert "El agente puede editar la copia" in es_value
assert "El archivo en esta máquina no cambia" in es_value
assert "(...)" not in es_value
assert "Place a file from this machine" not in es_value
assert es_value != "cli.workspace_file_help"
def test_new_upstream_cli_flags_translate(self) -> None:
for key in (
"cli.fail_on_help",
"cli.mcp_config_help",
"cli.mcp_server_help",
"cli.mcp_exclude_help",
"cli.resume_help",
):
set_language("en")
en_val = t(key)
set_language("es")
es_val = t(key)
assert en_val != key
assert es_val != key
assert es_val != en_val
def test_examples_header_translates(self) -> None:
set_language("es")
assert t("cli.examples_header") == "Ejemplos:"
set_language("en")
assert t("cli.examples_header") == "Examples:"
def test_unknown_model_body_translates(self) -> None:
set_language("en")
assert "known OpenAI model" in t("cli.unknown_model_body")
set_language("es")
es_value = t("cli.unknown_model_body")
assert "modelo de OpenAI conocido" in es_value
def test_unknown_model_hint_translates(self) -> None:
set_language("en")
assert "form, e.g." in t("cli.unknown_model_hint")
set_language("es")
es_value = t("cli.unknown_model_hint")
assert "por ejemplo" in es_value
def test_example_titles_translate(self) -> None:
set_language("es")
assert "aplicación web" in t("cli.example_web_app")
assert "repositorio" in t("cli.example_github")
assert "código local" in t("cli.example_local_code")
assert "spec API" in t("cli.example_api_spec")
assert "dirección IP" in t("cli.example_ip")
assert "archivo" in t("cli.example_file")
assert "recursos cloud" in t("cli.example_cloud_list")
set_language("en")
assert t("cli.example_web_app") == "Web application penetration test"
assert t("cli.example_cloud_list") == "list every cloud resource"
def test_parse_arguments_spanish_help_output(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "argv", ["strix", "--language", "es", "--help"])
with pytest.raises(SystemExit) as exc_info:
cli_args.parse_arguments()
assert exc_info.value.code == 0
out = capsys.readouterr().out
assert "uso: " in out
assert "opciones:" in out
assert "Ejemplos:" in out
assert "workspace del sandbox" in out
assert "Solo en modo headless" in out
class TestCustomConfigPath:
"""``--config`` should drive language resolution like the default config."""
def test_custom_config_path_resolves_language(self, tmp_path: Path) -> None:
config = tmp_path / "custom.json"
config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8")
set_config_path(config)
with patch.dict(os.environ, {}, clear=True):
assert get_language() == "es"
def test_custom_config_path_ignored_without_env_key(self, tmp_path: Path) -> None:
config = tmp_path / "custom.json"
config.write_text(json.dumps({"env": {}}), encoding="utf-8")
set_config_path(config)
with patch.dict(os.environ, {}, clear=True):
assert get_language() == "en"
def test_custom_config_missing_file_falls_back(self, tmp_path: Path) -> None:
set_config_path(tmp_path / "does-not-exist.json")
with patch.dict(os.environ, {}, clear=True):
assert get_language() == "en"
def test_env_var_overrides_custom_config(self, tmp_path: Path) -> None:
config = tmp_path / "custom.json"
config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8")
set_config_path(config)
with patch.dict(os.environ, {"STRIX_LANGUAGE": "en"}):
assert get_language() == "en"
def test_explicit_language_overrides_custom_config(self, tmp_path: Path) -> None:
config = tmp_path / "custom.json"
config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8")
set_config_path(config)
set_language("en")
assert get_language() == "en"
class TestArgparseBuiltins:
"""argparse's built-in strings must translate through the i18n layer."""
def test_usage_translates(self) -> None:
set_language("es")
assert t("cli.argparse_usage") == "uso: "
set_language("en")
assert t("cli.argparse_usage") == "usage: "
def test_options_translates(self) -> None:
set_language("es")
assert t("cli.argparse_options") == "opciones"
set_language("en")
assert t("cli.argparse_options") == "options"
def test_help_help_translates(self) -> None:
set_language("es")
assert t("cli.argparse_help") == "muestra este mensaje de ayuda y sale"
def test_version_help_translates(self) -> None:
set_language("es")
assert t("cli.argparse_version") == "muestra el número de versión del programa y sale"
def test_unrecognized_arguments_translates(self) -> None:
set_language("es")
assert t("cli.argparse_unrecognized") == "argumentos no reconocidos: %s"
def test_argparse_override_is_wired(self) -> None:
set_language("es")
assert cli_args._translate_argparse("usage: ") == "uso: "
assert cli_args._translate_argparse("options") == "opciones"
assert cli_args._translate_argparse("unknown string") == "unknown string"
assert argparse._("usage: ") == "uso: " # type: ignore[attr-defined]
def test_invalid_choice_translates(self) -> None:
set_language("es")
assert "inválida" in t("cli.argparse_invalid_choice")
assert "elija entre" in t("cli.argparse_invalid_choice")
def test_argument_error_translates(self) -> None:
set_language("es")
assert t("cli.argparse_argument_error") == "argumento %(argument_name)s: %(message)s"
def test_expected_arguments_translate(self) -> None:
set_language("es")
assert t("cli.argparse_expected_one") == "se espera un argumento"
assert "como máximo" in t("cli.argparse_expected_at_most")
assert "al menos" in t("cli.argparse_expected_at_least")
def test_unexpected_option_translates(self) -> None:
set_language("es")
assert t("cli.argparse_unexpected_option") == "opción inesperada: %s"
def test_validation_errors_translate(self) -> None:
set_language("es")
assert t("cli.invalid_float", value="abc") == "valor float inválido: 'abc'"
assert t("cli.invalid_int", value="abc") == "valor entero inválido: 'abc'"
assert t("cli.finite_number") == "debe ser un número finito mayor que 0"
assert t("cli.positive_integer") == "debe ser un entero mayor que 0"
def test_config_abbreviation_resolves(self) -> None:
ns, _ = cli_args._ABBREV_PARSER.parse_known_args(["--conf", "x.json"])
assert ns.config == "x.json"
assert ns.language is None
def test_language_abbreviation_resolves(self) -> None:
ns, _ = cli_args._ABBREV_PARSER.parse_known_args(["--lang", "es"])
assert ns.language == "es"
assert ns.config is None
def test_pre_resolve_inline_equals_and_clustered_short_flag(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = tmp_path / "custom.json"
config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["strix", f"--conf={config}", "--help"])
with patch.dict(os.environ, {}, clear=True):
cli_args._pre_resolve_language()
assert get_language() == "es"
monkeypatch.setattr(sys, "argv", ["strix", "-les", "--help"])
cli_args._pre_resolve_language()
assert get_language() == "es"
monkeypatch.setattr(sys, "argv", ["strix", "--lang=es", "--help"])
cli_args._pre_resolve_language()
assert get_language() == "es"
def test_pre_resolve_handles_unknown_flags_and_double_dash(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Unknown flag before --language es still resolves Spanish cleanly
monkeypatch.setattr(sys, "argv", ["strix", "--unknown", "--language", "es", "--help"])
cli_args._pre_resolve_language()
assert get_language() == "es"
# Flags after `--` are positional arguments and must NOT set language
monkeypatch.setattr(sys, "argv", ["strix", "--", "--language", "es"])
with patch.dict(os.environ, {}, clear=True):
set_config_path(tmp_path / "missing.json")
# Re-run pre-resolve and ensure --language after `--` is ignored
cli_args._pre_resolve_language()
set_config_path(tmp_path / "missing.json")
assert get_language() == "en"
def test_pre_resolve_resets_stale_language_on_subsequent_english_call(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(sys, "argv", ["strix", "--language", "es", "--help"])
cli_args._pre_resolve_language()
assert get_language() == "es"
monkeypatch.setattr(sys, "argv", ["strix", "--help"])
with patch.dict(os.environ, {}, clear=True):
cli_args._pre_resolve_language()
set_config_path(tmp_path / "missing.json")
assert get_language() == "en"
def test_missing_en_locale_falls_back_cleanly(self, tmp_path: Path) -> None:
with patch.object(mod, "_locales_dir", tmp_path / "empty_locales"):
mod._locales.clear()
set_language("es")
assert t("cli.description") == "cli.description"
assert cli_args._translate_argparse("usage: ") == "usage: "
assert cli_args._translate_argparse("options") == "options"
class TestPyInstallerSpecPackaging:
def test_strix_spec_bundles_locales_and_hiddenimport(self) -> None:
spec_path = Path(__file__).parent.parent / "strix.spec"
spec_content = spec_path.read_text(encoding="utf-8")
assert "strix_root.rglob('locales/*.json')" in spec_content
assert "'strix.i18n'" in spec_content
# Verify the glob pattern actually matches both en.json and es.json on disk
strix_root = Path(__file__).parent.parent / "strix"
matched = {p.name for p in strix_root.rglob("locales/*.json")}
assert {"en.json", "es.json"}.issubset(matched)
class TestReportWriterLocalization:
def test_vulnerability_md_translates_sections_in_spanish(self) -> None:
set_language("es")
md = render_vulnerability_md(
{
"id": "vuln-0001",
"title": "SQL Injection",
"severity": "high",
"timestamp": "2026-07-02 10:00:00 UTC",
"description": "Unsanitized SQL query.",
"impact": "Database read.",
"counterevidence": "Egress filtered.",
"confidence": "medium",
"confidence_rationale": "Verified once.",
"severity_change_conditions": "Critical if filter removed.",
"fix_verification": "Retested.",
"remediation_steps": "Parameterize queries.",
"update_history": [
{"timestamp": "2026-07-02 11:00:00 UTC", "fields": ["severity"]}
],
}
)
assert "**Severidad:** HIGH" in md
assert "**Confianza:** Medium" in md
assert "## Descripción" in md
assert "## Impacto" in md
assert "## Contraevidencia" in md
assert "## Justificación de Confianza" in md
assert "## Qué Cambiaría Esta Severidad" in md
assert "## Remediación" in md
assert "## Verificación de Corrección" in md
assert "## Historial de Actualizaciones" in md
def test_executive_report_translates_in_spanish(self, tmp_path: Path) -> None:
set_language("es")
write_executive_report(tmp_path, "Resumen ejecutivo.")
content = (tmp_path / "penetration_test_report.md").read_text(encoding="utf-8")
assert "# Informe de Prueba de Penetración de Seguridad" in content
assert "**Generado:**" in content
class TestSettingsLanguageField:
def test_settings_has_language_field(self) -> None:
with patch.dict(os.environ, {}, clear=True):
s = Settings()
assert hasattr(s, "language")
assert s.language == "en"
def test_settings_language_from_env(self) -> None:
with patch.dict(os.environ, {"STRIX_LANGUAGE": "es"}):
s = Settings()
assert s.language == "es"