From 4f964d74729a7f35346ba25fbf7143e2b86bd82a Mon Sep 17 00:00:00 2001 From: criss717 Date: Mon, 10 Aug 2026 17:55:27 +0200 Subject: [PATCH] feat(i18n): add localization infrastructure - Add strix/i18n.py module with t(), set_language(), get_language() - Add get_language_directive() for agent prompt injection - Add strix/locales/en.json with 60+ English translation keys - Add strix/locales/es.json with 60+ Spanish translation keys - Support language resolution chain: --language > STRIX_LANGUAGE > config > LANG > en - Thread-safe locale loading with caching - Graceful fallback: missing key -> English -> key itself --- strix/i18n.py | 181 ++++++++++++++++++++++++++++++++++++++++++ strix/locales/en.json | 59 ++++++++++++++ strix/locales/es.json | 59 ++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 strix/i18n.py create mode 100644 strix/locales/en.json create mode 100644 strix/locales/es.json diff --git a/strix/i18n.py b/strix/i18n.py new file mode 100644 index 00000000..dc63a032 --- /dev/null +++ b/strix/i18n.py @@ -0,0 +1,181 @@ +"""Internationalization support for Strix.""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from pathlib import Path +from typing import Any + + +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 +_locales: dict[str, dict[str, str]] = {} +_lock = threading.Lock() +_locales_dir: Path = Path(__file__).parent / "locales" + + +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. ~/.strix/cli-config.json "language" field + 4. LANG / LC_ALL system locale (first 2 chars) + 5. "en" default + """ + # 1. Explicitly set (CLI flag) + if _language is not None: + return _language + + # 2. Environment variable + env_lang = os.environ.get("STRIX_LANGUAGE", "").strip().lower() + if env_lang: + return _normalize_lang(env_lang) + + # 3. Config file + try: + config_path = Path.home() / ".strix" / "cli-config.json" + if config_path.exists(): + data = json.loads(config_path.read_text(encoding="utf-8")) + config_lang = data.get("language", "").strip().lower() + if config_lang: + return _normalize_lang(config_lang) + except (json.JSONDecodeError, OSError): + pass + + # 4. System locale + for var in ("LANG", "LC_ALL", "LC_MESSAGES"): + locale_val = os.environ.get(var, "") + if locale_val and 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 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/locales/en.json b/strix/locales/en.json new file mode 100644 index 00000000..746318d4 --- /dev/null +++ b/strix/locales/en.json @@ -0,0 +1,59 @@ +{ + "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. 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, testing approaches, test credentials, or areas of interest.", + "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.", + "cli.non_interactive_help": "Run in non-interactive mode (no TUI, exits on completion). Default is interactive mode with TUI.", + "cli.scan_mode_help": "Scan mode: 'quick' for fast CI/CD checks, 'standard' for routine testing, 'deep' for thorough security reviews (default).", + "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.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.", + "cli.language_help": "Language for UI and agent responses (e.g., 'en', 'es'). Default: auto-detect from environment.", + "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_invalid_target": "Invalid target: {target}", + "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_missing_agents": "--resume {name}: missing {path}. The run was persisted but never reached its first agent snapshot — there's nothing to resume from.", + "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.model_quality_warning": "MODEL QUALITY WARNING", + "cli.interactive_setup_unavailable": "INTERACTIVE SETUP UNAVAILABLE", + "cli.scan_preparation_failed": "SCAN PREPARATION FAILED" +} diff --git a/strix/locales/es.json b/strix/locales/es.json new file mode 100644 index 00000000..7f8cddcb --- /dev/null +++ b/strix/locales/es.json @@ -0,0 +1,59 @@ +{ + "cli.description": "Herramienta de Pruebas de Penetración Multi-Agente con IA de Strix", + "cli.target_help": "Objetivo a probar: URL, repositorio, directorio local, dominio, dirección IP, archivo de spec API (OpenAPI/Swagger .json/.yaml o export de colección Postman), o colección Postman por id. Los directorios locales se montan en el sandbox. Se puede especificar múltiples veces para escaneos multi-objetivo. Los escaneos nuevos 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, enfoques de prueba, credenciales de prueba o áreas de interés.", + "cli.instruction_file_help": "Ruta a un archivo con instrucciones personalizadas detalladas. Use esta opción cuando tenga instrucciones largas o complejas guardadas en un archivo.", + "cli.non_interactive_help": "Ejecutar en modo no interactivo (sin TUI, sale al completar). El modo por defecto es interactivo con TUI.", + "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).", + "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 por defecto del repositorio.", + "cli.config_help": "Ruta a un archivo de configuración personalizado (JSON) en lugar de ~/.strix/cli-config.json", + "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. Omite la generación de nombre nuevo.", + "cli.language_help": "Idioma para la interfaz y respuestas de agentes (ej: 'en', 'es'). Por defecto: auto-detectar del entorno.", + "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_invalid_target": "Objetivo inválido: {target}", + "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 de objetivos.", + "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.", + "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.model_quality_warning": "ADVERTENCIA DE CALIDAD DEL MODELO", + "cli.interactive_setup_unavailable": "CONFIGURACIÓN INTERACTIVA NO DISPONIBLE", + "cli.scan_preparation_failed": "FALLO EN PREPARACIÓN DEL ESCANEO" +}