mirror of
https://github.com/usestrix/strix.git
synced 2026-10-08 03:08:08 +00:00
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:
parent
94a2586aaa
commit
4f964d7472
3 changed files with 299 additions and 0 deletions
181
strix/i18n.py
Normal file
181
strix/i18n.py
Normal 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
59
strix/locales/en.json
Normal 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
59
strix/locales/es.json
Normal 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"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue