diff --git a/strix.spec b/strix.spec
index 987d8b96..b32be665 100644
--- a/strix.spec
+++ b/strix.spec
@@ -36,6 +36,10 @@ for asset in viewer_static.rglob('*'):
rel_path = asset.relative_to(project_root)
datas.append((str(asset), str(rel_path.parent)))
+for json_locale in strix_root.rglob('locales/*.json'):
+ rel_path = json_locale.relative_to(project_root)
+ datas.append((str(json_locale), str(rel_path.parent)))
+
datas += collect_data_files('tiktoken')
datas += collect_data_files('tiktoken_ext')
@@ -112,6 +116,12 @@ hiddenimports = [
# Strix modules
'strix',
+ 'strix.i18n',
+ 'strix.i18n.catalog',
+ 'strix.i18n.context',
+ 'strix.i18n.detector',
+ 'strix.i18n.directive',
+ 'strix.i18n.translator',
'strix.interface',
'strix.interface.main',
'strix.interface.cli',
diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py
index c45831aa..2358bdcd 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
@@ -131,6 +132,7 @@ def render_system_prompt(
available_skills=get_available_skills(),
interactive=interactive,
is_root=is_root,
+ language_directive=get_language_directive(),
system_prompt_context=system_prompt_context or {},
include_scope=include_scope,
**skill_content,
diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja
index b13c9a4c..3158ebb8 100644
--- a/strix/agents/prompts/system_prompt.jinja
+++ b/strix/agents/prompts/system_prompt.jinja
@@ -1,4 +1,9 @@
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.
+{% if language_directive %}
+
+{{ language_directive }}
+
+{% endif %}
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
{% if is_root %}
diff --git a/strix/config/settings.py b/strix/config/settings.py
index 5aa6da06..438618f6 100644
--- a/strix/config/settings.py
+++ b/strix/config/settings.py
@@ -205,6 +205,10 @@ class ViewerSettings(BaseSettings):
class Settings(BaseSettings):
model_config = _BASE_CONFIG
+ language: str = Field(
+ default="en",
+ validation_alias=AliasChoices("STRIX_LANGUAGE", "language"),
+ )
llm: LlmSettings = Field(default_factory=LlmSettings)
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
diff --git a/strix/i18n/__init__.py b/strix/i18n/__init__.py
new file mode 100644
index 00000000..10d5e8b1
--- /dev/null
+++ b/strix/i18n/__init__.py
@@ -0,0 +1,52 @@
+"""Robust Internationalization (i18n) Engine for Strix."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from strix.i18n.catalog import clear_locales_cache, get_available_languages
+from strix.i18n.context import reset_context_language, set_context_language
+from strix.i18n.detector import DEFAULT_LANGUAGE, detect_language, normalize_language
+from strix.i18n.directive import LANGUAGE_NAMES, get_language_directive
+from strix.i18n.translator import translate
+
+
+t = translate
+
+
+def set_language(lang: str | None) -> str:
+ """Set language preference for the current async execution context."""
+ normalized = normalize_language(lang)
+ set_context_language(normalized)
+ return normalized
+
+
+def get_language() -> str:
+ """Get active language preference or auto-detect from environment/config."""
+ return detect_language()
+
+
+def reset_language() -> None:
+ """Reset the execution context language to default/unspecified."""
+ reset_context_language()
+
+
+def __getattr__(name: str) -> Any:
+ if name == "SUPPORTED_LANGUAGES":
+ return get_available_languages()
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+__all__ = [
+ "DEFAULT_LANGUAGE",
+ "LANGUAGE_NAMES",
+ "clear_locales_cache",
+ "get_available_languages",
+ "get_language",
+ "get_language_directive",
+ "normalize_language",
+ "reset_language",
+ "set_language",
+ "t",
+ "translate",
+]
diff --git a/strix/i18n/catalog.py b/strix/i18n/catalog.py
new file mode 100644
index 00000000..0700c949
--- /dev/null
+++ b/strix/i18n/catalog.py
@@ -0,0 +1,87 @@
+"""Locale catalogue discovery, validation, and thread-safe caching."""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import logging
+import threading
+from pathlib import Path
+from typing import Any
+
+from strix.utils.resource_paths import get_strix_resource_path
+
+
+logger = logging.getLogger(__name__)
+
+_locales_lock = threading.RLock()
+_locales_cache: dict[str, dict[str, str]] = {}
+
+
+def get_builtin_locales_dir() -> Path:
+ """Return the filesystem path to bundled locale catalogs."""
+ return get_strix_resource_path("locales")
+
+
+def get_user_locales_dir() -> Path:
+ """Return the filesystem path to user-defined community locale catalogs."""
+ return Path.home() / ".strix" / "locales"
+
+
+def get_available_languages() -> set[str]:
+ """Discover all available language codes across built-in and user directories."""
+ languages: set[str] = {"en"}
+
+ builtin_dir = get_builtin_locales_dir()
+ if builtin_dir.is_dir():
+ for path in builtin_dir.glob("*.json"):
+ languages.add(path.stem.lower())
+
+ user_dir = get_user_locales_dir()
+ if user_dir.is_dir():
+ for path in user_dir.glob("*.json"):
+ languages.add(path.stem.lower())
+
+ return languages
+
+
+def _safe_merge(target: dict[str, str], source_data: Any) -> None:
+ """Safely merge key-value translation strings from source into target."""
+ if not isinstance(source_data, dict):
+ return
+ target.update(
+ {k: v for k, v in source_data.items() if isinstance(k, str) and isinstance(v, str)}
+ )
+
+
+def load_locale(lang: str) -> dict[str, str]:
+ """Load locale catalog JSON with thread-safe caching, type validation, and user overrides."""
+ with _locales_lock:
+ if lang in _locales_cache:
+ return _locales_cache[lang]
+
+ translations: dict[str, str] = {}
+
+ # 1. Built-in locale catalog
+ builtin_file = get_builtin_locales_dir() / f"{lang}.json"
+ if builtin_file.is_file():
+ with contextlib.suppress(OSError, json.JSONDecodeError):
+ raw_data = json.loads(builtin_file.read_text(encoding="utf-8"))
+ _safe_merge(translations, raw_data)
+
+ # 2. User community override catalog (~/.strix/locales/{lang}.json)
+ user_file = get_user_locales_dir() / f"{lang}.json"
+ if user_file.is_file():
+ with contextlib.suppress(OSError, json.JSONDecodeError):
+ raw_data = json.loads(user_file.read_text(encoding="utf-8"))
+ _safe_merge(translations, raw_data)
+
+ with _locales_lock:
+ _locales_cache[lang] = translations
+ return translations
+
+
+def clear_locales_cache() -> None:
+ """Evict all cached locale catalogs (useful for testing or hot-reload)."""
+ with _locales_lock:
+ _locales_cache.clear()
diff --git a/strix/i18n/context.py b/strix/i18n/context.py
new file mode 100644
index 00000000..4b7b1aac
--- /dev/null
+++ b/strix/i18n/context.py
@@ -0,0 +1,26 @@
+"""Execution-context and task isolation for Strix i18n."""
+
+from __future__ import annotations
+
+import contextvars
+
+
+_current_language: contextvars.ContextVar[str | None] = contextvars.ContextVar(
+ "strix_current_language",
+ default=None,
+)
+
+
+def get_context_language() -> str | None:
+ """Return the active language code bound to the current async context, or None."""
+ return _current_language.get()
+
+
+def set_context_language(lang: str | None) -> None:
+ """Bind a normalized language code to the active async execution context."""
+ _current_language.set(lang)
+
+
+def reset_context_language() -> None:
+ """Clear the active language code from the current async execution context."""
+ _current_language.set(None)
diff --git a/strix/i18n/detector.py b/strix/i18n/detector.py
new file mode 100644
index 00000000..4e58278c
--- /dev/null
+++ b/strix/i18n/detector.py
@@ -0,0 +1,64 @@
+"""Language detection, normalization, and precedence policy."""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import os
+from pathlib import Path
+
+from strix.i18n.catalog import get_available_languages
+from strix.i18n.context import get_context_language
+
+
+DEFAULT_LANGUAGE: str = "en"
+
+
+def normalize_language(lang: str | None, supported: set[str] | None = None) -> str:
+ """Normalize language tag (e.g. 'es_ES.UTF-8' -> 'es') and validate.
+
+ Checks against supported locales and returns DEFAULT_LANGUAGE if unrecognized.
+ """
+ if not lang:
+ return DEFAULT_LANGUAGE
+
+ clean = lang.strip().lower().split(".")[0].split("_")[0].split("-")[0]
+ available = supported if supported is not None else get_available_languages()
+
+ return clean if clean in available else DEFAULT_LANGUAGE
+
+
+def detect_language() -> str:
+ """Detect language following the canonical precedence chain:
+
+ 1. Active execution context (ContextVar)
+ 2. STRIX_LANGUAGE environment variable
+ 3. User CLI configuration file (~/.strix/cli-config.json)
+ 4. System locale (LC_ALL takes precedence over LANG)
+ 5. Default fallback language ('en')
+ """
+ ctx_lang = get_context_language()
+ if ctx_lang:
+ return ctx_lang
+
+ # 1. Environment Variable STRIX_LANGUAGE
+ env_lang = os.getenv("STRIX_LANGUAGE")
+ if env_lang:
+ return normalize_language(env_lang)
+
+ # 2. Config File ~/.strix/cli-config.json
+ cfg_path = Path.home() / ".strix" / "cli-config.json"
+ if cfg_path.is_file():
+ with contextlib.suppress(OSError, json.JSONDecodeError):
+ data = json.loads(cfg_path.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ cfg_lang = data.get("env", {}).get("STRIX_LANGUAGE") or data.get("language")
+ if isinstance(cfg_lang, str) and cfg_lang.strip():
+ return normalize_language(cfg_lang)
+
+ # 3. System Locale (LC_ALL takes precedence over LANG)
+ sys_lang = os.getenv("LC_ALL") or os.getenv("LANG")
+ if sys_lang:
+ return normalize_language(sys_lang)
+
+ return DEFAULT_LANGUAGE
diff --git a/strix/i18n/directive.py b/strix/i18n/directive.py
new file mode 100644
index 00000000..ac124f11
--- /dev/null
+++ b/strix/i18n/directive.py
@@ -0,0 +1,43 @@
+"""Agent prompt language directive generator for localized security findings."""
+
+from __future__ import annotations
+
+from strix.i18n.detector import detect_language
+
+
+LANGUAGE_NAMES: dict[str, str] = {
+ "en": "English",
+ "es": "Spanish",
+ "id": "Indonesian",
+ "ko": "Korean",
+ "pt": "Portuguese",
+ "fr": "French",
+ "de": "German",
+ "ja": "Japanese",
+ "zh": "Chinese",
+ "it": "Italian",
+ "ru": "Russian",
+ "tr": "Turkish",
+ "ar": "Arabic",
+ "vi": "Vietnamese",
+}
+
+
+def get_language_directive(lang: str | None = None) -> str:
+ """Generate prompt directive for LLM agents when target language is non-English.
+
+ Ensures technical security artifacts (CVE, CWE, CVSS, code, commands) remain unaltered.
+ """
+ effective_lang = lang if lang is not None else detect_language()
+ if effective_lang == "en":
+ return ""
+
+ target_name = LANGUAGE_NAMES.get(effective_lang, effective_lang.upper())
+
+ return (
+ f"IMPORTANT LANGUAGE INSTRUCTION:\n"
+ f"Write all human-readable findings, executive summaries, descriptions, "
+ f"and remediation steps in {target_name}.\n"
+ f"Do NOT translate technical identifiers, including CVE IDs, CWE IDs, CVSS scores, "
+ f"source code snippets, file paths, or shell commands."
+ )
diff --git a/strix/i18n/translator.py b/strix/i18n/translator.py
new file mode 100644
index 00000000..25838da3
--- /dev/null
+++ b/strix/i18n/translator.py
@@ -0,0 +1,29 @@
+"""Translation lookup, fallback resolution, and safe keyword interpolation."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from strix.i18n.catalog import load_locale
+from strix.i18n.detector import DEFAULT_LANGUAGE, detect_language
+
+
+def translate(key: str, **kwargs: Any) -> str:
+ """Translate key into the active language with fallback and safe interpolation."""
+ lang = detect_language()
+ catalog = load_locale(lang)
+
+ msg = catalog.get(key)
+ if msg is None and lang != DEFAULT_LANGUAGE:
+ msg = load_locale(DEFAULT_LANGUAGE).get(key)
+ if msg is None:
+ msg = key
+
+ if kwargs and "{" in msg and "}" in msg:
+ try:
+ msg = msg.format(**kwargs)
+ except (KeyError, ValueError, IndexError):
+ for k, v in kwargs.items():
+ msg = msg.replace(f"{{{k}}}", str(v))
+
+ return msg
diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py
index c9b6951b..4f0f4110 100644
--- a/strix/interface/cli_args.py
+++ b/strix/interface/cli_args.py
@@ -57,6 +57,14 @@ def _positive_int(value: str) -> int:
def parse_arguments() -> argparse.Namespace:
+ # Pre-resolve language before argparse if set in sys.argv
+ for idx, arg in enumerate(sys.argv[:-1]):
+ if arg in ("--language", "-l") and idx + 1 < len(sys.argv):
+ from strix.i18n import set_language
+
+ set_language(sys.argv[idx + 1])
+ break
+
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -129,6 +137,14 @@ Strix Cloud:
"matching upgrade command instead.",
)
+ parser.add_argument(
+ "--language",
+ "-l",
+ type=str,
+ default=None,
+ help="Language preference for CLI messages and audit reports (e.g. en, es, id)",
+ )
+
parser.add_argument(
"-t",
"--target",
@@ -323,6 +339,11 @@ Strix Cloud:
args.diff_scope = {"active": False}
args.run_name = None
+ if args.language:
+ from strix.i18n import set_language
+
+ set_language(args.language)
+
if args.config:
apply_config_override(validate_config_file(args.config))
diff --git a/strix/locales/en.json b/strix/locales/en.json
new file mode 100644
index 00000000..4d922acc
--- /dev/null
+++ b/strix/locales/en.json
@@ -0,0 +1,11 @@
+{
+ "cli.description": "Strix - Autonomous AI Security Penetration Testing Agent",
+ "cli.scan_started": "Starting scan on target: {target}",
+ "cli.scan_completed": "Scan completed for target: {target}",
+ "cli.vulnerabilities_found": "Found {count} vulnerability(ies)",
+ "report.title": "Security Penetration Test Report",
+ "report.description": "Description",
+ "report.evidence": "Evidence",
+ "report.impact": "Impact",
+ "report.remediation": "Remediation"
+}
diff --git a/strix/locales/es.json b/strix/locales/es.json
new file mode 100644
index 00000000..87c18405
--- /dev/null
+++ b/strix/locales/es.json
@@ -0,0 +1,11 @@
+{
+ "cli.description": "Strix - Agente Autónomo de Pruebas de Penetración de Seguridad con IA",
+ "cli.scan_started": "Iniciando escaneo en el objetivo: {target}",
+ "cli.scan_completed": "Escaneo completado para el objetivo: {target}",
+ "cli.vulnerabilities_found": "Se encontraron {count} vulnerabilidad(es)",
+ "report.title": "Informe de Prueba de Penetración de Seguridad",
+ "report.description": "Descripción",
+ "report.evidence": "Evidencia",
+ "report.impact": "Impacto",
+ "report.remediation": "Remediación"
+}
diff --git a/strix/locales/id.json b/strix/locales/id.json
new file mode 100644
index 00000000..92e599cd
--- /dev/null
+++ b/strix/locales/id.json
@@ -0,0 +1,11 @@
+{
+ "cli.description": "Strix - Agen Uji Penetrasi Keamanan Berbasis AI Otonom",
+ "cli.scan_started": "Memulai pemindaian pada target: {target}",
+ "cli.scan_completed": "Pemindaian selesai untuk target: {target}",
+ "cli.vulnerabilities_found": "Ditemukan {count} kerentanan",
+ "report.title": "Laporan Uji Penetrasi Keamanan",
+ "report.description": "Deskripsi",
+ "report.evidence": "Bukti",
+ "report.impact": "Dampak",
+ "report.remediation": "Langkah Perbaikan"
+}
diff --git a/strix/locales/ko.json b/strix/locales/ko.json
new file mode 100644
index 00000000..5043418b
--- /dev/null
+++ b/strix/locales/ko.json
@@ -0,0 +1,11 @@
+{
+ "cli.description": "Strix - 자율형 AI 보안 모의해킹 에이전트",
+ "cli.scan_started": "대상에 대한 스캔 시작: {target}",
+ "cli.scan_completed": "대상에 대한 스캔 완료: {target}",
+ "cli.vulnerabilities_found": "{count}개의 취약점이 발견되었습니다",
+ "report.title": "보안 침투 테스트 보고서",
+ "report.description": "설명",
+ "report.evidence": "증거",
+ "report.impact": "영향도",
+ "report.remediation": "조치 방안"
+}
diff --git a/strix/report/writer.py b/strix/report/writer.py
index cf858f7d..37ba2496 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:
@@ -139,7 +140,7 @@ 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"
with path.open("w", encoding="utf-8") as f:
- f.write("# Security Penetration Test Report\n\n")
+ f.write(f"# {t('report.title')}\n\n")
f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n")
f.write(f"{final_scan_result}\n")
logger.info("Saved final penetration test report to: %s", path)
@@ -259,17 +260,17 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append(f"**{label}:** {value}")
lines.append("")
- lines.append("## Description\n")
+ lines.append(f"## {t('report.description')}\n")
lines.append(report.get("description") or "No description provided.")
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("")
@@ -342,7 +343,7 @@ 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("")
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 00000000..b9cfef2f
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,298 @@
+"""Comprehensive test suite for Strix internationalization (i18n) engine."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from unittest.mock import patch
+
+import pytest
+
+from strix.agents.prompt import render_system_prompt
+from strix.i18n import (
+ DEFAULT_LANGUAGE,
+ clear_locales_cache,
+ get_available_languages,
+ get_language,
+ get_language_directive,
+ normalize_language,
+ reset_language,
+ set_language,
+ t,
+)
+from strix.i18n.catalog import _safe_merge, load_locale
+from strix.i18n.detector import detect_language
+from strix.interface.cli_args import parse_arguments
+from strix.report.writer import render_vulnerability_md, write_executive_report
+
+
+@pytest.fixture(autouse=True)
+def _reset_i18n_state():
+ """Ensure clean i18n context and cache for every test."""
+ reset_language()
+ clear_locales_cache()
+ yield
+ reset_language()
+ clear_locales_cache()
+
+
+# ---------------------------------------------------------------------------
+# 1. Core State & Normalization Tests
+# ---------------------------------------------------------------------------
+
+
+def test_set_and_get_language():
+ assert set_language("es") == "es"
+ assert get_language() == "es"
+
+ assert set_language("id") == "id"
+ assert get_language() == "id"
+
+ assert set_language("ko") == "ko"
+ assert get_language() == "ko"
+
+ reset_language()
+ assert get_language() == DEFAULT_LANGUAGE
+
+
+def test_normalize_language_tags():
+ assert normalize_language("es_ES.UTF-8") == "es"
+ assert normalize_language("id-ID") == "id"
+ assert normalize_language("ko_KR.eucKR") == "ko"
+ assert normalize_language("EN_us") == "en"
+ assert normalize_language(None) == DEFAULT_LANGUAGE
+ assert normalize_language("unsupported_xyz") == DEFAULT_LANGUAGE
+
+
+def test_dynamic_available_languages():
+ available = get_available_languages()
+ assert "en" in available
+ assert "es" in available
+ assert "id" in available
+ assert "ko" in available
+
+
+# ---------------------------------------------------------------------------
+# 2. Async Context Isolation Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_async_contextvar_isolation():
+ """Verify concurrent async tasks maintain strictly isolated language contexts."""
+
+ async def task_worker(lang: str, delay: float) -> str:
+ set_language(lang)
+ await asyncio.sleep(delay)
+ return get_language()
+
+ results = await asyncio.gather(
+ task_worker("es", 0.02),
+ task_worker("id", 0.01),
+ task_worker("ko", 0.015),
+ )
+
+ assert results == ["es", "id", "ko"]
+ # Outer context remains untouched
+ assert get_language() == DEFAULT_LANGUAGE
+
+
+# ---------------------------------------------------------------------------
+# 3. Precedence & Detection Tests
+# ---------------------------------------------------------------------------
+
+
+def test_precedence_context_over_env_and_system(monkeypatch):
+ monkeypatch.setenv("STRIX_LANGUAGE", "es")
+ monkeypatch.setenv("LC_ALL", "id")
+
+ # Context explicitly set wins over all
+ set_language("ko")
+ assert detect_language() == "ko"
+
+ # When context is reset, STRIX_LANGUAGE environment variable wins
+ reset_language()
+ assert detect_language() == "es"
+
+
+def test_precedence_env_over_config_and_system(monkeypatch, tmp_path):
+ monkeypatch.setenv("STRIX_LANGUAGE", "id")
+ fake_config = tmp_path / ".strix" / "cli-config.json"
+ fake_config.parent.mkdir(parents=True)
+ fake_config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8")
+
+ with patch("pathlib.Path.home", return_value=tmp_path):
+ assert detect_language() == "id"
+
+
+def test_precedence_config_over_system(monkeypatch, tmp_path):
+ monkeypatch.delenv("STRIX_LANGUAGE", raising=False)
+ monkeypatch.setenv("LC_ALL", "es")
+
+ fake_config = tmp_path / ".strix" / "cli-config.json"
+ fake_config.parent.mkdir(parents=True)
+ fake_config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "id"}}), encoding="utf-8")
+
+ with patch("pathlib.Path.home", return_value=tmp_path):
+ assert detect_language() == "id"
+
+
+def test_precedence_lc_all_over_lang(monkeypatch):
+ monkeypatch.delenv("STRIX_LANGUAGE", raising=False)
+ monkeypatch.setenv("LC_ALL", "es_ES.UTF-8")
+ monkeypatch.setenv("LANG", "id_ID.UTF-8")
+
+ assert detect_language() == "es"
+
+
+def test_fallback_to_default_when_empty(monkeypatch):
+ monkeypatch.delenv("STRIX_LANGUAGE", raising=False)
+ monkeypatch.delenv("LC_ALL", raising=False)
+ monkeypatch.delenv("LANG", raising=False)
+
+ assert detect_language() == DEFAULT_LANGUAGE
+
+
+# ---------------------------------------------------------------------------
+# 4. Catalog Loading, Caching & Override Tests
+# ---------------------------------------------------------------------------
+
+
+def test_catalog_load_builtin_and_caching():
+ catalog_en = load_locale("en")
+ assert "report.title" in catalog_en
+ assert catalog_en["report.title"] == "Security Penetration Test Report"
+
+ # Subsequent load hits cache
+ cached = load_locale("en")
+ assert cached is catalog_en
+
+
+def test_safe_merge_validates_string_types():
+ target = {"existing": "value"}
+ _safe_merge(target, {"valid_key": "valid_value", 123: "bad_key", "bad_val": 456})
+ assert target["valid_key"] == "valid_value"
+ assert 123 not in target
+ assert "bad_val" not in target
+
+
+def test_user_community_override_merging(tmp_path):
+ user_locales = tmp_path / ".strix" / "locales"
+ user_locales.mkdir(parents=True)
+ override_file = user_locales / "es.json"
+ override_file.write_text(
+ json.dumps({"custom.greeting": "Hola Mundo", "cli.description": "Custom Strix"}),
+ encoding="utf-8",
+ )
+
+ with patch("pathlib.Path.home", return_value=tmp_path):
+ catalog_es = load_locale("es")
+ assert catalog_es.get("custom.greeting") == "Hola Mundo"
+ assert catalog_es.get("cli.description") == "Custom Strix"
+ assert catalog_es.get("report.title") == "Informe de Prueba de Penetración de Seguridad"
+
+
+# ---------------------------------------------------------------------------
+# 5. Translation & Fallback Tests
+# ---------------------------------------------------------------------------
+
+
+def test_translation_formatting_interpolations():
+ set_language("id")
+ result = t("cli.scan_started", target="example.com")
+ assert "example.com" in result
+ assert "Memulai" in result
+
+
+def test_fallback_missing_key_in_target_language():
+ set_language("es")
+ # Non-existent key in all catalogs falls back to key itself
+ assert t("untranslated.nonexistent_key") == "untranslated.nonexistent_key"
+
+
+def test_safe_interpolation_on_missing_parameter():
+ set_language("en")
+ result = t("cli.scan_started") # 'target' parameter missing
+ assert "{target}" in result or isinstance(result, str)
+
+
+def test_safe_interpolation_with_special_characters():
+ set_language("en")
+ payload = "'; DROP TABLE users; -- {bad}"
+ result = t("cli.scan_started", target=payload)
+ assert payload in result
+
+
+# ---------------------------------------------------------------------------
+# 6. Prompt Language Directive Tests
+# ---------------------------------------------------------------------------
+
+
+def test_language_directive_empty_for_english():
+ set_language("en")
+ assert get_language_directive() == ""
+
+
+def test_language_directive_for_supported_languages():
+ set_language("es")
+ directive_es = get_language_directive()
+ assert "Spanish" in directive_es
+ assert "CVE IDs" in directive_es
+ assert "Do NOT translate technical identifiers" in directive_es
+
+ set_language("id")
+ directive_id = get_language_directive()
+ assert "Indonesian" in directive_id
+
+ set_language("ko")
+ directive_ko = get_language_directive()
+ assert "Korean" in directive_ko
+
+
+# ---------------------------------------------------------------------------
+# 7. End-to-End System & Integration Tests
+# ---------------------------------------------------------------------------
+
+
+def test_system_prompt_rendering_injects_language_directive():
+ set_language("es")
+ prompt_es = render_system_prompt(scan_mode="quick")
+ assert "" in prompt_es
+ assert "Spanish" in prompt_es
+
+ set_language("en")
+ prompt_en = render_system_prompt(scan_mode="quick")
+ assert "" not in prompt_en
+
+
+def test_markdown_report_writer_localization(tmp_path):
+ set_language("es")
+ sample_report = {
+ "id": "vuln-001",
+ "title": "SQL Injection",
+ "severity": "critical",
+ "timestamp": "2026-10-10 12:00:00 UTC",
+ "description": "Exploitable SQL query.",
+ "evidence": "Payload proof.",
+ "impact": "Full database read.",
+ "remediation_steps": "Use parameters.",
+ }
+
+ md = render_vulnerability_md(sample_report)
+ assert "## Descripción" in md
+ assert "## Evidencia" in md
+ assert "## Impacto" in md
+ assert "## Remediación" in md
+
+ # Executive report title
+ write_executive_report(tmp_path, "Scan findings summary.")
+ exec_file = tmp_path / "penetration_test_report.md"
+ content = exec_file.read_text(encoding="utf-8")
+ assert "# Informe de Prueba de Penetración de Seguridad" in content
+
+
+def test_cli_argument_parser_language_flag(monkeypatch):
+ monkeypatch.setattr("sys.argv", ["strix", "-t", "example.com", "--language", "id", "-n"])
+ args = parse_arguments()
+ assert args.language == "id"
+ assert get_language() == "id"