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
This commit is contained in:
criss717 2026-08-10 17:55:27 +02:00
parent 94a2586aaa
commit 4f964d7472
3 changed files with 299 additions and 0 deletions

181
strix/i18n.py Normal file
View file

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

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

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

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

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