From d4cf20ce44dc7a051e2d88aca40dc67a03368a73 Mon Sep 17 00:00:00 2001 From: criss717 Date: Sun, 1 Nov 2026 17:53:12 +0100 Subject: [PATCH] feat(i18n): add Spanish localization for CLI, reports, and agent prompts --- strix.spec | 5 + strix/agents/prompt.py | 4 + strix/agents/prompts/system_prompt.jinja | 3 + strix/config/settings.py | 1 + strix/i18n.py | 210 +++++++++ strix/interface/cli.py | 19 +- strix/interface/cli_args.py | 278 +++++------ strix/interface/main.py | 72 +-- strix/locales/en.json | 158 +++++++ strix/locales/es.json | 158 +++++++ strix/report/writer.py | 70 +-- tests/test_i18n.py | 568 +++++++++++++++++++++++ 12 files changed, 1341 insertions(+), 205 deletions(-) create mode 100644 strix/i18n.py create mode 100644 strix/locales/en.json create mode 100644 strix/locales/es.json create mode 100644 tests/test_i18n.py diff --git a/strix.spec b/strix.spec index 987d8b96c..eccf2c664 100644 --- a/strix.spec +++ b/strix.spec @@ -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', diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index 2eb45bf18..a18ba1f86 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -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: diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index fd09d0774..7067b0daf 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -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 %} YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing. diff --git a/strix/config/settings.py b/strix/config/settings.py index 9b216aa99..90af82c32 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -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) diff --git a/strix/i18n.py b/strix/i18n.py new file mode 100644 index 000000000..e2d17a0b2 --- /dev/null +++ b/strix/i18n.py @@ -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""" diff --git a/strix/interface/cli.py b/strix/interface/cli.py index a8992ce49..5418090c5 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -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( diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index c9b6951be..f6b28ebfb 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -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:// --target https://api.example.com strix --target "postman://?env=" - # 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://[?env=], 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 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 `` 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)) diff --git a/strix/interface/main.py b/strix/interface/main.py index 846a35da9..b54233556 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -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("/", 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 -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 []` 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" diff --git a/strix/locales/en.json b/strix/locales/en.json new file mode 100644 index 000000000..7f4064dfb --- /dev/null +++ b/strix/locales/en.json @@ -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://[?env=], 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 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 -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" +} diff --git a/strix/locales/es.json b/strix/locales/es.json new file mode 100644 index 000000000..8470f4edd --- /dev/null +++ b/strix/locales/es.json @@ -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://[?env=], 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 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 -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" +} diff --git a/strix/report/writer.py b/strix/report/writer.py index cf858f7d7..aa2de1a56 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -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") diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 000000000..c64b428b9 --- /dev/null +++ b/tests/test_i18n.py @@ -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" in prompt + assert "at all times.\n\n\n" 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("") + + +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"