mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
Merge fe0ea5316f into cbb0f57058
This commit is contained in:
commit
d4e93ee2d8
18 changed files with 3075 additions and 1314 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -93,6 +93,8 @@ Thumbs.db
|
|||
schema.graphql
|
||||
|
||||
.opencode/
|
||||
.atl/
|
||||
.codegraph/
|
||||
|
||||
# Root-only local data and reference checkouts
|
||||
/.benchmarks/
|
||||
|
|
|
|||
81
openspec/changes/i18n-support/apply-progress.md
Normal file
81
openspec/changes/i18n-support/apply-progress.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Apply Progress: i18n Support
|
||||
|
||||
## Status: Phase 1 & 2 COMPLETE
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: CLI & Agent Responses ✅
|
||||
|
||||
### Task 1: Add language field to Settings ✅
|
||||
- **File**: `strix/config/settings.py`
|
||||
- **Status**: Complete
|
||||
- **Commit**: feat/i18n-spanish branch
|
||||
|
||||
### Task 2: Add --language CLI flag ✅
|
||||
- **File**: `strix/interface/cli_args.py`
|
||||
- **Status**: Complete
|
||||
- **Commit**: feat/i18n-spanish branch
|
||||
|
||||
### Task 3: Inject language directive into agent prompts ✅
|
||||
- **Files**: `strix/agents/prompt.py`, `strix/agents/prompts/system_prompt.jinja`
|
||||
- **Status**: Complete
|
||||
- **Commit**: feat/i18n-spanish branch
|
||||
|
||||
### Task 4: Integrate t() into main CLI messages ✅
|
||||
- **Files**: `strix/interface/main.py`, `strix/interface/cli.py`
|
||||
- **Status**: Complete
|
||||
- **Commit**: feat/i18n-spanish branch
|
||||
|
||||
### Task 5: Add tests ✅
|
||||
- **File**: `tests/test_i18n.py`
|
||||
- **Status**: Complete (34 tests passing)
|
||||
- **Commit**: feat/i18n-spanish branch
|
||||
|
||||
### Task 6: Verify with make check-all ✅
|
||||
- **Status**: Complete
|
||||
- **Result**: PASS (ruff, mypy, bandit, pytest)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Report Translations ✅
|
||||
|
||||
### Task 7: Add report translation keys ✅
|
||||
- **Files**: `strix/locales/en.json`, `strix/locales/es.json`
|
||||
- **Status**: Complete (18 keys added)
|
||||
- **Commit**: fc554a8
|
||||
|
||||
### Task 8: Translate report writer ✅
|
||||
- **File**: `strix/report/writer.py`
|
||||
- **Status**: Complete
|
||||
- **Commit**: fc554a8
|
||||
|
||||
### Task 9: Add Phase 2 tests ✅
|
||||
- **File**: `tests/test_i18n.py`
|
||||
- **Status**: Complete
|
||||
- **Commit**: fc554a8
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Go TUI (PENDING)
|
||||
|
||||
- **Status**: Not started
|
||||
- **Scope**: ~300 strings across 45 Go files
|
||||
- **Location**: `strix/interface/tui/internal/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: React Viewer (PENDING)
|
||||
|
||||
- **Status**: Not started
|
||||
- **Scope**: UI strings in `strix/interface/viewer/frontend/src/`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Status | Tasks | Tests |
|
||||
|-------|--------|-------|-------|
|
||||
| Phase 1 | ✅ Complete | 6/6 | 34/34 |
|
||||
| Phase 2 | ✅ Complete | 3/3 | ✅ |
|
||||
| Phase 3 | ⏳ Pending | 0 | - |
|
||||
| Phase 4 | ⏳ Pending | 0 | - |
|
||||
213
openspec/changes/i18n-support/design.md
Normal file
213
openspec/changes/i18n-support/design.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# Technical Design: i18n Support — Phase 1
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Language Resolution │
|
||||
│ --language > STRIX_LANGUAGE > config.json > LANG > en │
|
||||
└──────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ strix/i18n.py │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||||
│ │ set_language │ │ get_language │ │ t(key, **kw) │ │
|
||||
│ └─────────────┘ └──────────────┘ └─────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ _locales: dict[str, dict[str, str]] │ │
|
||||
│ │ (lazy-loaded, cached, thread-safe) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└──────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
strix/locales/ CLI args Agent prompt
|
||||
{en,es}.json argparse Jinja injection
|
||||
```
|
||||
|
||||
## Module Design: strix/i18n.py
|
||||
|
||||
```python
|
||||
"""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) as exc:
|
||||
logger.error("Failed to load locale %s: %s", lang, exc)
|
||||
_locales[lang] = {}
|
||||
return {}
|
||||
|
||||
|
||||
def set_language(lang: str | None) -> None:
|
||||
"""Set the active language. Called from CLI args parsing."""
|
||||
global _language
|
||||
_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"""
|
||||
61
openspec/changes/i18n-support/proposal.md
Normal file
61
openspec/changes/i18n-support/proposal.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Proposal: Internationalization (i18n) Support — Phase 1
|
||||
|
||||
## Intent
|
||||
|
||||
Strix is hardcoded to English across Python CLI, Go TUI, and React viewer. Non-English security teams can't consume findings in their language, slowing triage and adoption. This adds an `i18n` capability so scans run in Spanish (others follow the same plumbing) without altering the engine.
|
||||
|
||||
## Scope
|
||||
|
||||
**In (Phase 1):** `Settings.language: str = "en"` (pydantic-settings), env `STRIX_LANGUAGE`, CLI `--language`/`-l`, persisted in `~/.strix/cli-config.json`. Resolution: `--language` > env > config > `LANG`/`LC_ALL` > `"en"`. Flat JSON dicts in `strix/locales/{lang}.json`. `strix/i18n.py` with `t("key")`. Spanish for argparse help, scan progress, errors. One Jinja variable in `system_prompt.jinja` directing the LLM to write findings/descriptions/recommendations in the target language (CVE/CWE/CVSS/code/commands unchanged). Lazy wrapper for argparse `help=` so locale is resolved at parse time.
|
||||
|
||||
**Out (later PRs):** Phase 2 report headings, Phase 3 Go TUI (~300 strings, 45 files), Phase 4 React viewer. SARIF and `vulnerabilities.json` stay English.
|
||||
|
||||
## Capabilities
|
||||
|
||||
**New:** `internationalization` — locale loading, resolution chain, prompt injection, `t()`.
|
||||
|
||||
**Modified:** None.
|
||||
|
||||
## Approach
|
||||
|
||||
Flat JSON dicts are portable — same source later serves Go (backend socket) and React (copy/fetch). Python side: one `Settings.language` field, small `i18n.py` with cache, one Jinja variable prepending a language directive to the agent system prompt. LLM body returns in target language; static CLI strings use `t()`. argparse `help=` uses a deferred callable so locale is available at parse time.
|
||||
|
||||
## Affected Areas
|
||||
|
||||
| Area | Impact | Change |
|
||||
|------|--------|--------|
|
||||
| `strix/config/settings.py` | Mod | `Settings.language` + env |
|
||||
| `strix/interface/cli_args.py` | Mod | `--language` flag; lazy `help=` |
|
||||
| `strix/locales/{en,es}.json` | New | Flat locale dicts |
|
||||
| `strix/i18n.py` | New | `t()`, `set/get_language()` + cache |
|
||||
| `strix/agents/prompts/system_prompt.jinja` | Mod | Inject `{{ language_directive }}` |
|
||||
| `strix/agents/factory.py` | Mod | Pass language to Jinja |
|
||||
| `strix/interface/utils.py` | Mod | Wrap CLI msgs in `t()` |
|
||||
| `tests/` | Mod | Locale + prompt tests |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| LLM quality drops in non-English (M) | Directive preserves CVE/CWE/CVSS/code; model stays user-driven |
|
||||
| argparse help evaluated at import (H) | Lazy callable wrapper at parse time |
|
||||
| 3 ecosystems need coordinated i18n (M) | JSON portable; Phase 1 only touches Python |
|
||||
| Locale drift between languages (L) | Keys generated from `en.json`; missing → English + warning |
|
||||
| Locale leaks into SARIF/JSON (L) | Exports use fixed English keys; test covers |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the merge. `Settings.language` defaults `"en"` and `t()` returns the key when no locale loaded — removing files and unhooking the Jinja directive restores prior behavior, no migration.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`pydantic-settings` (already present). No new libs — stdlib `json` + existing Jinja2.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `--language es` → Spanish findings; CVE/CWE/CVSS/code unchanged
|
||||
- [ ] `STRIX_LANGUAGE=es` and `"language": "es"` in config match the flag
|
||||
- [ ] `t('cli.scan_started')` returns Spanish under env, English otherwise
|
||||
- [ ] `--help` shows translated text under `--language es`
|
||||
- [ ] SARIF and `vulnerabilities.json` stay English regardless of locale
|
||||
- [ ] `uv run pytest` and `make check-all` pass
|
||||
211
openspec/changes/i18n-support/specs/internationalization/spec.md
Normal file
211
openspec/changes/i18n-support/specs/internationalization/spec.md
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# Internationalization Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Enable Strix to operate in multiple languages. Phase 1 delivers Spanish alongside English for CLI strings and LLM-generated findings, while preserving English for all machine-consumed artifacts (SARIF, vulnerabilities.json).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Locale Loading
|
||||
|
||||
The system SHALL load locale data from flat JSON files at `strix/locales/{lang}.json`. Locale files MUST be loaded lazily on first `t()` call or language resolution, then cached in memory for the process lifetime. Missing keys MUST fall back to the English value and log a warning.
|
||||
|
||||
#### Scenario: Load Spanish locale on first t() call
|
||||
|
||||
- GIVEN `strix/locales/es.json` exists with key `"cli.scan_started": "Escaneo iniciado"`
|
||||
- WHEN `t("cli.scan_started")` is called with active language `"es"`
|
||||
- THEN the function returns `"Escaneo iniciado"`
|
||||
- AND the locale file is read from disk exactly once
|
||||
|
||||
#### Scenario: Missing key falls back to English
|
||||
|
||||
- GIVEN `strix/locales/es.json` does NOT contain key `"cli.unknown_key"`
|
||||
- AND `strix/locales/en.json` contains `"cli.unknown_key": "Unknown key"`
|
||||
- WHEN `t("cli.unknown_key")` is called with active language `"es"`
|
||||
- THEN the function returns `"Unknown key"`
|
||||
- AND a warning is logged
|
||||
|
||||
#### Scenario: Key missing from all locales
|
||||
|
||||
- GIVEN no locale file contains key `"cli.nonexistent"`
|
||||
- WHEN `t("cli.nonexistent")` is called
|
||||
- THEN the function returns the key string `"cli.nonexistent"`
|
||||
|
||||
### Requirement: Language Resolution Chain
|
||||
|
||||
The system SHALL determine the active language using this priority: `--language` CLI flag > `STRIX_LANGUAGE` env var > `~/.strix/cli-config.json` `"language"` field > `LANG`/`LC_ALL` system locale > `"en"` default. Unsupported languages MUST fall back to `"en"` with a warning. Initially supported: `en`, `es`.
|
||||
|
||||
#### Scenario: CLI flag takes highest priority
|
||||
|
||||
- GIVEN `STRIX_LANGUAGE=es` and `~/.strix/cli-config.json` contains `"language": "en"`
|
||||
- WHEN the user runs `strix --language es --target example.com`
|
||||
- THEN the active language is `"es"`
|
||||
|
||||
#### Scenario: Environment variable used when no CLI flag
|
||||
|
||||
- GIVEN no `--language` flag is provided
|
||||
- AND `STRIX_LANGUAGE=es` is set
|
||||
- WHEN strix starts
|
||||
- THEN the active language is `"es"`
|
||||
|
||||
#### Scenario: Config file used when no flag or env
|
||||
|
||||
- GIVEN no `--language` flag, no `STRIX_LANGUAGE` env var
|
||||
- AND `~/.strix/cli-config.json` contains `"language": "es"`
|
||||
- WHEN strix starts
|
||||
- THEN the active language is `"es"`
|
||||
|
||||
#### Scenario: System locale detection
|
||||
|
||||
- GIVEN no flag, env, or config language set
|
||||
- AND `LANG=es_ES.UTF-8`
|
||||
- WHEN strix starts
|
||||
- THEN the active language is `"es"`
|
||||
|
||||
#### Scenario: Unsupported language falls back to English
|
||||
|
||||
- GIVEN `STRIX_LANGUAGE=fr` (unsupported)
|
||||
- WHEN strix starts
|
||||
- THEN the active language is `"en"`
|
||||
- AND a warning is logged
|
||||
|
||||
### Requirement: CLI Integration
|
||||
|
||||
The system SHALL provide a `--language` / `-l` CLI flag via argparse. Help text for all argparse arguments MUST be evaluated lazily at parse time, not import time, so the active language is resolved before help strings are displayed. When `--language` is provided, the system SHOULD persist it to `~/.strix/cli-config.json`.
|
||||
|
||||
#### Scenario: --language flag sets active language
|
||||
|
||||
- GIVEN the user runs `strix --language es --target example.com`
|
||||
- WHEN arguments are parsed
|
||||
- THEN the active language is `"es"`
|
||||
|
||||
#### Scenario: Help text is translated
|
||||
|
||||
- GIVEN `STRIX_LANGUAGE=es`
|
||||
- WHEN the user runs `strix --help`
|
||||
- THEN help strings are displayed in Spanish
|
||||
|
||||
#### Scenario: Language persisted to config
|
||||
|
||||
- GIVEN the user runs `strix --language es --target example.com`
|
||||
- WHEN the scan completes
|
||||
- THEN `~/.strix/cli-config.json` contains `"language": "es"`
|
||||
|
||||
### Requirement: Agent Prompt Injection
|
||||
|
||||
The system SHALL inject a `{{ language_directive }}` Jinja variable into `system_prompt.jinja`. The directive MUST instruct the LLM to write findings, descriptions, and recommendations in the target language while preserving technical identifiers (CVE, CWE, CVSS, code snippets, commands) unchanged. The prompt factory MUST pass the resolved language context to the template.
|
||||
|
||||
#### Scenario: Spanish directive injected
|
||||
|
||||
- GIVEN active language is `"es"`
|
||||
- WHEN `render_system_prompt()` is called
|
||||
- THEN the rendered prompt contains an instruction to write in Spanish
|
||||
- AND technical identifiers are explicitly excluded from translation
|
||||
|
||||
#### Scenario: English directive is no-op
|
||||
|
||||
- GIVEN active language is `"en"`
|
||||
- WHEN `render_system_prompt()` is called
|
||||
- THEN the language directive is empty or absent
|
||||
|
||||
### Requirement: t() Helper Contract
|
||||
|
||||
The system SHALL provide a `t(key: str, **kwargs) -> str` function. It MUST support `{placeholder}` interpolation via kwargs. It MUST be thread-safe and cache loaded locales. If a key is not found, it MUST return the key itself (graceful degradation).
|
||||
|
||||
#### Scenario: Placeholder interpolation
|
||||
|
||||
- GIVEN `en.json` contains `"cli.scan_target": "Scanning {target}"`
|
||||
- WHEN `t("cli.scan_target", target="example.com")` is called
|
||||
- THEN the function returns `"Scanning example.com"`
|
||||
|
||||
#### Scenario: Thread-safe concurrent access
|
||||
|
||||
- GIVEN multiple threads call `t()` simultaneously
|
||||
- WHEN locales are not yet loaded
|
||||
- THEN the locale is loaded exactly once
|
||||
- AND all threads receive correct translations
|
||||
|
||||
### Requirement: Spanish Translations
|
||||
|
||||
The system SHALL ship `strix/locales/es.json` with Spanish translations for Phase 1 strings: CLI argparse help, scan progress messages, error messages, and auth flow messages. Keys MUST be dot-separated paths matching the English source.
|
||||
|
||||
#### Scenario: All Phase 1 keys translated
|
||||
|
||||
- GIVEN `strix/locales/en.json` contains N keys
|
||||
- WHEN `strix/locales/es.json` is loaded
|
||||
- THEN it contains translations for all N keys
|
||||
|
||||
#### Scenario: Key structure consistency
|
||||
|
||||
- GIVEN `en.json` has key `"cli.scan_started"`
|
||||
- THEN `es.json` MUST have the same key `"cli.scan_started"`
|
||||
|
||||
## Locale Key Structure
|
||||
|
||||
```json
|
||||
// en.json - CLI keys (Phase 1)
|
||||
{
|
||||
"cli.target_help": "Target to test: URL, repository, local directory path...",
|
||||
"cli.scan_started": "Starting scan against {target}",
|
||||
"cli.scan_completed": "Scan completed. {count} vulnerabilities found.",
|
||||
"cli.error_no_target": "No target specified. Use --target or --target-list.",
|
||||
"cli.test_initiated": "Penetration test initiated",
|
||||
"cli.test_in_progress": "Penetration test in progress",
|
||||
"cli.vulnerabilities_realtime": "Vulnerabilities will be displayed in real-time.",
|
||||
"cli.completion_title": "Penetration test completed",
|
||||
"cli.session_ended": "SESSION ENDED"
|
||||
}
|
||||
|
||||
// en.json - Report keys (Phase 2)
|
||||
{
|
||||
"report.title": "Security Penetration Test Report",
|
||||
"report.generated": "Generated:",
|
||||
"report.description": "Description",
|
||||
"report.evidence": "Evidence",
|
||||
"report.impact": "Impact",
|
||||
"report.technical_analysis": "Technical Analysis",
|
||||
"report.proof_of_concept": "Proof of Concept",
|
||||
"report.code_analysis": "Code Analysis",
|
||||
"report.remediation": "Remediation",
|
||||
"report.assumptions": "Assumptions",
|
||||
"report.severity": "Severity",
|
||||
"report.found": "Found",
|
||||
"report.target": "Target",
|
||||
"report.location": "Location",
|
||||
"report.suggested_fix": "Suggested Fix"
|
||||
}
|
||||
|
||||
// es.json - CLI keys (Phase 1)
|
||||
{
|
||||
"cli.target_help": "Objetivo a probar: URL, repositorio, directorio local...",
|
||||
"cli.scan_started": "Iniciando escaneo contra {target}",
|
||||
"cli.scan_completed": "Escaneo completado. {count} vulnerabilidades encontradas.",
|
||||
"cli.error_no_target": "No se especificó objetivo. Use --target o --target-list.",
|
||||
"cli.test_initiated": "Prueba de penetración iniciada",
|
||||
"cli.test_in_progress": "Prueba de penetración en progreso",
|
||||
"cli.vulnerabilities_realtime": "Las vulnerabilidades se mostrarán en tiempo real.",
|
||||
"cli.completion_title": "Prueba de penetración completada",
|
||||
"cli.session_ended": "SESIÓN FINALIZADA"
|
||||
}
|
||||
|
||||
// es.json - Report keys (Phase 2)
|
||||
{
|
||||
"report.title": "Informe de Prueba de Penetración de Seguridad",
|
||||
"report.generated": "Generado:",
|
||||
"report.description": "Descripción",
|
||||
"report.evidence": "Evidencia",
|
||||
"report.impact": "Impacto",
|
||||
"report.technical_analysis": "Análisis Técnico",
|
||||
"report.proof_of_concept": "Prueba de Concepto",
|
||||
"report.code_analysis": "Análisis de Código",
|
||||
"report.remediation": "Remediación",
|
||||
"report.assumptions": "Suposiciones",
|
||||
"report.severity": "Severidad",
|
||||
"report.found": "Encontrado",
|
||||
"report.target": "Objetivo",
|
||||
"report.location": "Ubicación",
|
||||
"report.suggested_fix": "Corrección Sugerida"
|
||||
}
|
||||
```
|
||||
|
||||
## Total Keys: 83 (65 CLI + 18 Report)
|
||||
301
openspec/changes/i18n-support/tasks.md
Normal file
301
openspec/changes/i18n-support/tasks.md
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
# Tasks: i18n Support — Phase 1 & 2
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
- **Estimated changed lines**: ~250 (well under 400-line budget)
|
||||
- **Chained PRs recommended**: No
|
||||
- **Decision needed before apply**: No
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: CLI & Agent Responses ✅ COMPLETE
|
||||
|
||||
## Task 1: Add language field to Settings ✅
|
||||
|
||||
**File**: `strix/config/settings.py`
|
||||
|
||||
**Description**: Add `language: str` field to the `Settings` class with `STRIX_LANGUAGE` env var alias.
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
class Settings(BaseSettings):
|
||||
# ... existing fields ...
|
||||
language: str = Field(default="en", alias="STRIX_LANGUAGE")
|
||||
```
|
||||
|
||||
**Acceptance**:
|
||||
- [ ] `Settings(language="es").language == "es"`
|
||||
- [ ] `STRIX_LANGUAGE=es` env var is picked up
|
||||
- [ ] Default is `"en"`
|
||||
|
||||
**Dependencies**: None
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add --language CLI flag ✅
|
||||
|
||||
**File**: `strix/interface/cli_args.py`
|
||||
|
||||
**Description**: Add `--language` / `-l` argument to argparse. Call `set_language()` after parsing.
|
||||
|
||||
**Changes**:
|
||||
1. Add argument before `parse_arguments()` returns:
|
||||
```python
|
||||
parser.add_argument(
|
||||
"-l", "--language",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Language for UI and agent responses (e.g., 'en', 'es'). Default: auto-detect.",
|
||||
)
|
||||
```
|
||||
|
||||
2. After `args = parser.parse_args()`, add:
|
||||
```python
|
||||
from strix.i18n import set_language
|
||||
if args.language:
|
||||
set_language(args.language)
|
||||
```
|
||||
|
||||
**Acceptance**:
|
||||
- [ ] `strix --language es --help` shows help in Spanish
|
||||
- [ ] `strix -l es` works
|
||||
- [ ] No `--language` flag → auto-detection from env/config/locale
|
||||
|
||||
**Dependencies**: Task 1
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Inject language directive into agent prompts ✅
|
||||
|
||||
**Files**:
|
||||
- `strix/agents/prompt.py`
|
||||
- `strix/agents/prompts/system_prompt.jinja`
|
||||
|
||||
**Description**: Pass `language_directive` to Jinja template and render it.
|
||||
|
||||
**Changes in prompt.py** (`render_system_prompt` function):
|
||||
```python
|
||||
from strix.i18n import get_language_directive
|
||||
|
||||
# Inside render_system_prompt(), before env.get_template().render():
|
||||
language_directive = get_language_directive()
|
||||
|
||||
# Add to render() call:
|
||||
rendered = env.get_template("system_prompt.jinja").render(
|
||||
# ... existing params ...
|
||||
language_directive=language_directive,
|
||||
)
|
||||
```
|
||||
|
||||
**Changes in system_prompt.jinja** (add near the top, after initial instructions):
|
||||
```jinja
|
||||
{% if language_directive %}
|
||||
{{ language_directive }}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Acceptance**:
|
||||
- [ ] With `language="es"`, rendered prompt contains "Spanish" instruction
|
||||
- [ ] With `language="en"`, no directive injected (empty string)
|
||||
- [ ] CVE/CWE/CVSS preservation mentioned in directive
|
||||
|
||||
**Dependencies**: None (parallel with Task 2)
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Integrate t() into main CLI messages ✅
|
||||
|
||||
**File**: `strix/interface/main.py`
|
||||
|
||||
**Description**: Replace hardcoded English strings with `t()` calls for key user-facing messages.
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
from strix.i18n import t
|
||||
|
||||
# Replace strings like:
|
||||
# print("Starting scan...")
|
||||
# With:
|
||||
# print(t("cli.scan_started", target=target))
|
||||
```
|
||||
|
||||
Key strings to translate:
|
||||
- Scan start/complete messages
|
||||
- Error messages for missing targets
|
||||
- Progress indicators
|
||||
|
||||
**Acceptance**:
|
||||
- [ ] `strix --language es -t example.com` shows Spanish progress messages
|
||||
- [ ] `strix -t example.com` shows English (default)
|
||||
- [ ] No runtime errors from t() calls
|
||||
|
||||
**Dependencies**: Task 1, Task 2
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add tests ✅
|
||||
|
||||
**File**: `tests/test_i18n.py`
|
||||
|
||||
**Description**: Test the i18n module: translation, fallback, language resolution, directive generation.
|
||||
|
||||
**Test cases**:
|
||||
```python
|
||||
def test_t_returns_english_by_default()
|
||||
def test_t_returns_spanish_when_language_set()
|
||||
def test_t_falls_back_to_english_for_missing_key()
|
||||
def test_t_returns_key_for_completely_missing_key()
|
||||
def test_t_interpolates_placeholders()
|
||||
def test_set_language_normalizes()
|
||||
def test_get_language_directive_empty_for_english()
|
||||
def test_get_language_directive_contains_language_name()
|
||||
def test_locale_files_are_valid_json()
|
||||
def test_all_en_keys_exist_in_es()
|
||||
```
|
||||
|
||||
**Acceptance**:
|
||||
- [ ] `uv run pytest tests/test_i18n.py -v` passes
|
||||
- [ ] All locale keys validated
|
||||
|
||||
**Dependencies**: Task 1-4
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Verify with make check-all ✅
|
||||
|
||||
**Description**: Run full quality suite to ensure no regressions.
|
||||
|
||||
**Commands**:
|
||||
```bash
|
||||
make check-all # ruff, mypy, bandit
|
||||
uv run pytest # all tests
|
||||
```
|
||||
|
||||
**Acceptance**:
|
||||
- [ ] `make check-all` passes
|
||||
- [ ] `uv run pytest` passes
|
||||
- [ ] No new warnings or errors
|
||||
|
||||
**Dependencies**: Task 1-5
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
Task 1 (Settings) ──┐
|
||||
├──> Task 2 (CLI flag) ──┐
|
||||
Task 3 (Jinja) ─────┘ ├──> Task 4 (Main.py) ──> Task 5 (Tests) ──> Task 6 (Verify)
|
||||
│
|
||||
└──> Task 3 (parallel)
|
||||
```
|
||||
|
||||
Tasks 1 and 3 can be done in parallel. Task 2 depends on Task 1. Task 4 depends on Task 2. Task 5 depends on all. Task 6 is final verification.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Report Translations ✅ COMPLETE
|
||||
|
||||
## Task 7: Add report translation keys ✅
|
||||
|
||||
**Files**:
|
||||
- `strix/locales/en.json`
|
||||
- `strix/locales/es.json`
|
||||
|
||||
**Description**: Add 18 translation keys for report headings and metadata labels.
|
||||
|
||||
**Keys added**:
|
||||
- `report.title` — Executive report title
|
||||
- `report.generated` — Generated timestamp label
|
||||
- `report.description` — Description section heading
|
||||
- `report.evidence` — Evidence section heading
|
||||
- `report.impact` — Impact section heading
|
||||
- `report.severity` — Severity metadata label
|
||||
- `report.target` — Target metadata label
|
||||
- `report.package` — Package metadata label
|
||||
- `report.remediation` — Remediation section heading
|
||||
- `report.references` — References section heading
|
||||
- `report.cvss_score` — CVSS score label
|
||||
- `report.cwe_id` — CWE ID label
|
||||
- `report.affected_versions` — Affected versions label
|
||||
- `report.fixed_versions` — Fixed versions label
|
||||
- `report.proof_of_concept` — Proof of concept heading
|
||||
- `report.steps_to_reproduce` — Steps to reproduce heading
|
||||
- `report.expected_result` — Expected result label
|
||||
- `report.actual_result` — Actual result label
|
||||
|
||||
**Acceptance**:
|
||||
- [x] All 18 keys exist in en.json
|
||||
- [x] All 18 keys exist in es.json with Spanish translations
|
||||
- [x] No missing keys between locales
|
||||
|
||||
**Dependencies**: Task 1 (Settings.language)
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Translate report writer ✅
|
||||
|
||||
**File**: `strix/report/writer.py`
|
||||
|
||||
**Description**: Replace hardcoded English report strings with `t()` calls.
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
from strix.i18n import t
|
||||
|
||||
# Replace strings like:
|
||||
# "Description"
|
||||
# With:
|
||||
# t("report.description")
|
||||
```
|
||||
|
||||
**Sections translated**:
|
||||
- Executive report title and metadata
|
||||
- Vulnerability detail headings (Description, Evidence, Impact, etc.)
|
||||
- CVSS/CWE labels
|
||||
- Remediation and references sections
|
||||
- Proof of concept sections
|
||||
|
||||
**Acceptance**:
|
||||
- [x] `--language es` → Spanish report headings
|
||||
- [x] `--language en` → English report headings (default)
|
||||
- [x] SARIF and vulnerabilities.json stay English
|
||||
|
||||
**Dependencies**: Task 7
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add Phase 2 tests ✅
|
||||
|
||||
**File**: `tests/test_i18n.py`
|
||||
|
||||
**Description**: Add tests for report translation keys.
|
||||
|
||||
**Test cases added**:
|
||||
```python
|
||||
def test_report_keys_exist_in_both_locales()
|
||||
def test_report_t_returns_spanish_when_language_set()
|
||||
def test_report_t_returns_english_by_default()
|
||||
```
|
||||
|
||||
**Acceptance**:
|
||||
- [x] `uv run pytest tests/test_i18n.py -v` passes
|
||||
- [x] All report keys validated
|
||||
|
||||
**Dependencies**: Task 7, Task 8
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Go TUI (PENDING)
|
||||
|
||||
**Status**: Not started
|
||||
**Scope**: ~300 strings across 45 Go files in `strix/interface/tui/internal/`
|
||||
**Approach**: Backend socket serves locale JSON; Go code calls translation function
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: React Viewer (PENDING)
|
||||
|
||||
**Status**: Not started
|
||||
**Scope**: UI strings in `strix/interface/viewer/frontend/src/`
|
||||
**Approach**: Fetch locale JSON; React hooks for translations
|
||||
70
openspec/changes/i18n-support/verify-report.md
Normal file
70
openspec/changes/i18n-support/verify-report.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Verify Report: i18n Support
|
||||
|
||||
## Status: PASS
|
||||
|
||||
---
|
||||
|
||||
## Verification Summary
|
||||
|
||||
| Phase | Status | Requirements | Tests |
|
||||
|-------|--------|--------------|-------|
|
||||
| Phase 1 | ✅ PASS | 6/6 | 34/34 |
|
||||
| Phase 2 | ✅ PASS | 3/3 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: CLI & Agent Responses
|
||||
|
||||
### Requirements Verification
|
||||
|
||||
| # | Requirement | Implementation | Status |
|
||||
|---|-------------|----------------|--------|
|
||||
| 1 | Settings.language field | `strix/config/settings.py` | ✅ PASS |
|
||||
| 2 | STRIX_LANGUAGE env var | pydantic-settings alias | ✅ PASS |
|
||||
| 3 | --language CLI flag | `strix/interface/cli_args.py` | ✅ PASS |
|
||||
| 4 | Language directive injection | Jinja template | ✅ PASS |
|
||||
| 5 | t() integration | main.py, cli.py | ✅ PASS |
|
||||
| 6 | Tests | test_i18n.py | ✅ PASS |
|
||||
|
||||
### Test Results
|
||||
```
|
||||
uv run pytest tests/test_i18n.py -v
|
||||
34 passed
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```
|
||||
make check-all
|
||||
✓ ruff (linting)
|
||||
✓ mypy (type checking)
|
||||
✓ bandit (security)
|
||||
✓ pytest (tests)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Report Translations
|
||||
|
||||
### Requirements Verification
|
||||
|
||||
| # | Requirement | Implementation | Status |
|
||||
|---|-------------|----------------|--------|
|
||||
| 1 | Report translation keys | en.json, es.json (18 keys) | ✅ PASS |
|
||||
| 2 | Report writer integration | strix/report/writer.py | ✅ PASS |
|
||||
| 3 | SARIF/JSON stay English | Exports unaffected | ✅ PASS |
|
||||
|
||||
### Test Results
|
||||
- Report keys exist in both locales: ✅
|
||||
- Translation function works correctly: ✅
|
||||
- No regressions: ✅
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS** — All Phase 1 & 2 requirements implemented and verified.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
- Phase 3: Go TUI (~300 strings, 45 files)
|
||||
- Phase 4: React viewer
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -103,12 +104,15 @@ def render_system_prompt(
|
|||
skill_content = load_skills(skills_to_load)
|
||||
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
|
||||
|
||||
language_directive = get_language_directive()
|
||||
|
||||
rendered = env.get_template("system_prompt.jinja").render(
|
||||
loaded_skill_names=list(skill_content.keys()),
|
||||
available_skills=get_available_skills(),
|
||||
interactive=interactive,
|
||||
is_root=is_root,
|
||||
system_prompt_context=system_prompt_context or {},
|
||||
language_directive=language_directive,
|
||||
**skill_content,
|
||||
)
|
||||
except Exception:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,156 +1,157 @@
|
|||
"""Strix application settings — pydantic-settings powered."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
DEFAULT_MAX_TURNS = 500
|
||||
|
||||
_BASE_CONFIG = SettingsConfigDict(
|
||||
case_sensitive=False,
|
||||
populate_by_name=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
class LlmSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_LLM")
|
||||
api_key: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
|
||||
repr=False,
|
||||
)
|
||||
api_base: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices(
|
||||
"LLM_API_BASE",
|
||||
"OPENAI_API_BASE",
|
||||
"OPENAI_BASE_URL",
|
||||
"LITELLM_BASE_URL",
|
||||
"OLLAMA_API_BASE",
|
||||
),
|
||||
)
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="LLM_EXTRA_HEADERS",
|
||||
repr=False,
|
||||
)
|
||||
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
|
||||
force_required_tool_choice: bool = Field(
|
||||
default=False,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
alias="STRIX_PROMPT_CACHE",
|
||||
)
|
||||
disable_streaming: bool = Field(
|
||||
default=False,
|
||||
alias="LLM_DISABLE_STREAMING",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
|
||||
max_tool_calls_per_turn: int = Field(
|
||||
default=32,
|
||||
ge=0,
|
||||
alias="LLM_MAX_TOOL_CALLS_PER_TURN",
|
||||
)
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
|
||||
reasoning_effort: ReasoningEffort | None = Field(
|
||||
default=None,
|
||||
alias="STRIX_DEDUPE_REASONING_EFFORT",
|
||||
)
|
||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False)
|
||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="DEDUPE_LLM_EXTRA_HEADERS",
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
"""Context-window management: per-tool-output caps and history compaction."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
|
||||
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
|
||||
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
|
||||
fallback_context_tokens: int = Field(
|
||||
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
|
||||
)
|
||||
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
|
||||
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
|
||||
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
|
||||
# Floor above the truncation-notice size so a preview always fits.
|
||||
tool_output_max_bytes: int = Field(
|
||||
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
image: str = Field(
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.3.0",
|
||||
alias="STRIX_IMAGE",
|
||||
)
|
||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
||||
# Max screenshot/image tool outputs kept live per agent context (0 = none).
|
||||
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
|
||||
|
||||
|
||||
class TelemetrySettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
|
||||
|
||||
|
||||
class IntegrationSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
perplexity_api_key: str | None = Field(
|
||||
default=None,
|
||||
alias="PERPLEXITY_API_KEY",
|
||||
repr=False,
|
||||
)
|
||||
postman_api_key: str | None = Field(
|
||||
default=None,
|
||||
alias="POSTMAN_API_KEY",
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
class ViewerSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
# Base URL of the Strix relay the local viewer proxies to for email
|
||||
# verification and encrypted report delivery. The browser never talks to
|
||||
# the relay directly; the local server is the only caller.
|
||||
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
|
||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||
context: ContextSettings = Field(default_factory=ContextSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
"""Strix application settings — pydantic-settings powered."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
DEFAULT_MAX_TURNS = 500
|
||||
|
||||
_BASE_CONFIG = SettingsConfigDict(
|
||||
case_sensitive=False,
|
||||
populate_by_name=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
class LlmSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_LLM")
|
||||
api_key: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
|
||||
repr=False,
|
||||
)
|
||||
api_base: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices(
|
||||
"LLM_API_BASE",
|
||||
"OPENAI_API_BASE",
|
||||
"OPENAI_BASE_URL",
|
||||
"LITELLM_BASE_URL",
|
||||
"OLLAMA_API_BASE",
|
||||
),
|
||||
)
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="LLM_EXTRA_HEADERS",
|
||||
repr=False,
|
||||
)
|
||||
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
|
||||
force_required_tool_choice: bool = Field(
|
||||
default=False,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
alias="STRIX_PROMPT_CACHE",
|
||||
)
|
||||
disable_streaming: bool = Field(
|
||||
default=False,
|
||||
alias="LLM_DISABLE_STREAMING",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
|
||||
max_tool_calls_per_turn: int = Field(
|
||||
default=32,
|
||||
ge=0,
|
||||
alias="LLM_MAX_TOOL_CALLS_PER_TURN",
|
||||
)
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
|
||||
reasoning_effort: ReasoningEffort | None = Field(
|
||||
default=None,
|
||||
alias="STRIX_DEDUPE_REASONING_EFFORT",
|
||||
)
|
||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False)
|
||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="DEDUPE_LLM_EXTRA_HEADERS",
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
"""Context-window management: per-tool-output caps and history compaction."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
|
||||
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
|
||||
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
|
||||
fallback_context_tokens: int = Field(
|
||||
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
|
||||
)
|
||||
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
|
||||
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
|
||||
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
|
||||
# Floor above the truncation-notice size so a preview always fits.
|
||||
tool_output_max_bytes: int = Field(
|
||||
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
image: str = Field(
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.3.0",
|
||||
alias="STRIX_IMAGE",
|
||||
)
|
||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
||||
# Max screenshot/image tool outputs kept live per agent context (0 = none).
|
||||
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
|
||||
|
||||
|
||||
class TelemetrySettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
|
||||
|
||||
|
||||
class IntegrationSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
perplexity_api_key: str | None = Field(
|
||||
default=None,
|
||||
alias="PERPLEXITY_API_KEY",
|
||||
repr=False,
|
||||
)
|
||||
postman_api_key: str | None = Field(
|
||||
default=None,
|
||||
alias="POSTMAN_API_KEY",
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
class ViewerSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
# Base URL of the Strix relay the local viewer proxies to for email
|
||||
# verification and encrypted report delivery. The browser never talks to
|
||||
# the relay directly; the local server is the only caller.
|
||||
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
|
||||
|
||||
|
||||
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)
|
||||
context: ContextSettings = Field(default_factory=ContextSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
|
|
|
|||
196
strix/i18n.py
Normal file
196
strix/i18n.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""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
|
||||
_config_path: Path | 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. Config file (--config override or ~/.strix/cli-config.json)
|
||||
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 (canonical format: {"env": {"STRIX_LANGUAGE": "es"}})
|
||||
try:
|
||||
config_path = _config_path or (Path.home() / ".strix" / "cli-config.json")
|
||||
if config_path.exists():
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
env_block = data.get("env", {})
|
||||
if isinstance(env_block, dict):
|
||||
config_lang = env_block.get("STRIX_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 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) 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"""
|
||||
|
|
@ -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(
|
||||
|
|
@ -138,11 +139,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):
|
||||
|
|
@ -208,14 +209,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(
|
||||
|
|
|
|||
|
|
@ -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 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 (
|
||||
|
|
@ -20,6 +21,61 @@ from strix.interface.utils import (
|
|||
)
|
||||
|
||||
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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. This pre-scans sys.argv for the flags.
|
||||
Priority matches ``_detect_language``: --language > STRIX_LANGUAGE env >
|
||||
--config file > default config > system locale.
|
||||
"""
|
||||
argv = sys.argv[1:]
|
||||
language: str | None = None
|
||||
config_path: str | None = None
|
||||
for i, arg in enumerate(argv):
|
||||
if arg in ("-l", "--language") and i + 1 < len(argv):
|
||||
language = argv[i + 1]
|
||||
elif arg.startswith("--language="):
|
||||
language = arg.split("=", 1)[1]
|
||||
elif arg == "--config" and i + 1 < len(argv):
|
||||
config_path = argv[i + 1]
|
||||
elif arg.startswith("--config="):
|
||||
config_path = arg.split("=", 1)[1]
|
||||
|
||||
from strix.i18n import set_config_path, set_language
|
||||
|
||||
if language:
|
||||
set_language(language)
|
||||
elif config_path:
|
||||
set_config_path(config_path)
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
|
@ -52,50 +108,53 @@ def _positive_int(value: str) -> int:
|
|||
|
||||
|
||||
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://<collection-uuid> --target https://api.example.com
|
||||
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
||||
|
||||
# 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
|
||||
""",
|
||||
|
|
@ -111,9 +170,15 @@ Examples:
|
|||
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(
|
||||
|
|
@ -121,38 +186,25 @@ Examples:
|
|||
"--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://<collection-uuid>[?env=<environment-uuid>], 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(
|
||||
|
|
@ -160,21 +212,14 @@ Examples:
|
|||
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'). The file is "
|
||||
"read-only inside the sandbox and lands outside every target directory.",
|
||||
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(
|
||||
|
|
@ -183,13 +228,7 @@ Examples:
|
|||
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(
|
||||
|
|
@ -197,27 +236,19 @@ Examples:
|
|||
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(
|
||||
|
|
@ -251,10 +282,7 @@ Examples:
|
|||
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(
|
||||
|
|
@ -263,21 +291,14 @@ Examples:
|
|||
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(
|
||||
"--resume",
|
||||
type=str,
|
||||
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."
|
||||
),
|
||||
help=t("cli.resume_help"),
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
117
strix/locales/en.json
Normal file
117
strix/locales/en.json
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
{
|
||||
"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.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'). The file is read-only inside the sandbox and lands outside every target directory.",
|
||||
"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.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.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.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.model_quality_warning": "MODEL QUALITY WARNING",
|
||||
"cli.model_quality_warning_body": " is not a recommended frontier model for Strix.\nSecurity scans work best with:\n",
|
||||
"cli.model_quality_warning_footer": "\nYou can continue, but weaker models may miss vulnerabilities or produce lower-quality findings.",
|
||||
"cli.interactive_setup_unavailable": "INTERACTIVE SETUP UNAVAILABLE",
|
||||
"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.technical_analysis": "Technical Analysis",
|
||||
"report.proof_of_concept": "Proof of Concept",
|
||||
"report.code_analysis": "Code Analysis",
|
||||
"report.remediation": "Remediation",
|
||||
"report.assumptions": "Assumptions",
|
||||
"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.fix_effort": "Fix Effort"
|
||||
}
|
||||
117
strix/locales/es.json
Normal file
117
strix/locales/es.json
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
{
|
||||
"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.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'). El archivo es de solo lectura dentro del sandbox y queda fuera de cada directorio de objetivo.",
|
||||
"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.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.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.unknown_model_body": " no es un modelo de OpenAI conocido. Los nombres simples se enrutan a OpenAI por defecto.\nSi te referías a un proveedor distinto de OpenAI, usa '",
|
||||
"cli.unknown_model_hint": "', por ejemplo 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
|
||||
"cli.model_quality_warning": "ADVERTENCIA DE CALIDAD DEL MODELO",
|
||||
"cli.model_quality_warning_body": " no es un modelo frontier recomendado para Strix.\nLos escaneos de seguridad funcionan mejor con:\n",
|
||||
"cli.model_quality_warning_footer": "\nPuedes continuar, pero los modelos más débiles pueden omitir vulnerabilidades o producir hallazgos de menor calidad.",
|
||||
"cli.interactive_setup_unavailable": "CONFIGURACIÓN INTERACTIVA NO DISPONIBLE",
|
||||
"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.technical_analysis": "Análisis Técnico",
|
||||
"report.proof_of_concept": "Prueba de Concepto",
|
||||
"report.code_analysis": "Análisis de Código",
|
||||
"report.remediation": "Remediación",
|
||||
"report.assumptions": "Suposiciones",
|
||||
"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.fix_effort": "Esfuerzo de Corrección"
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -115,9 +116,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)
|
||||
|
||||
|
|
@ -195,21 +197,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")),
|
||||
]
|
||||
|
|
@ -224,23 +226,23 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
|||
if report.get("confidence"):
|
||||
metadata.append(("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("")
|
||||
|
||||
|
|
@ -260,7 +262,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
|||
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("")
|
||||
|
||||
|
|
@ -270,7 +272,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
|||
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("")
|
||||
|
|
@ -284,7 +286,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 = ""
|
||||
|
|
@ -293,7 +295,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"):
|
||||
|
|
@ -303,7 +305,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())
|
||||
|
|
@ -313,7 +315,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("")
|
||||
|
||||
|
|
@ -323,7 +325,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
|||
lines.append("")
|
||||
|
||||
if report.get("assumptions"):
|
||||
lines.append("## Assumptions\n")
|
||||
lines.append(f"## {t('report.assumptions')}\n")
|
||||
lines.append(str(report["assumptions"]))
|
||||
lines.append("")
|
||||
|
||||
|
|
|
|||
359
tests/test_i18n.py
Normal file
359
tests/test_i18n.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"""Tests for the i18n internationalization module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import strix.i18n as mod
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_i18n_state():
|
||||
"""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):
|
||||
set_language("es")
|
||||
assert get_language() == "es"
|
||||
|
||||
def test_set_language_english(self):
|
||||
set_language("en")
|
||||
assert get_language() == "en"
|
||||
|
||||
def test_set_language_normalizes(self):
|
||||
set_language("ES")
|
||||
assert get_language() == "es"
|
||||
|
||||
def test_set_language_none_resets(self):
|
||||
set_language("es")
|
||||
set_language(None)
|
||||
# After reset, should fall back to detection
|
||||
assert get_language() == "en" # default
|
||||
|
||||
def test_set_language_unsupported_falls_back(self):
|
||||
set_language("fr")
|
||||
assert get_language() == "en"
|
||||
|
||||
|
||||
class TestNormalizeLang:
|
||||
def test_normalize_lowercase(self):
|
||||
assert _normalize_lang("es") == "es"
|
||||
|
||||
def test_normalize_uppercase(self):
|
||||
assert _normalize_lang("ES") == "es"
|
||||
|
||||
def test_normalize_strips(self):
|
||||
assert _normalize_lang(" es ") == "es"
|
||||
|
||||
def test_normalize_unsupported(self):
|
||||
assert _normalize_lang("fr") == "en"
|
||||
|
||||
def test_normalize_empty(self):
|
||||
assert _normalize_lang("") == "en"
|
||||
|
||||
|
||||
class TestDetectLanguage:
|
||||
def test_default_is_english(self):
|
||||
assert _detect_language() == "en"
|
||||
|
||||
def test_explicit_set_takes_priority(self):
|
||||
mod._language = "es"
|
||||
assert _detect_language() == "es"
|
||||
|
||||
def test_env_var_detected(self):
|
||||
with patch.dict(os.environ, {"STRIX_LANGUAGE": "es"}):
|
||||
assert _detect_language() == "es"
|
||||
|
||||
def test_env_var_overrides_config(self):
|
||||
with patch.dict(os.environ, {"STRIX_LANGUAGE": "es"}):
|
||||
assert _detect_language() == "es"
|
||||
|
||||
def test_system_locale_detected(self):
|
||||
with patch.dict(os.environ, {"LANG": "es_ES.UTF-8"}, clear=False):
|
||||
mod._language = None
|
||||
assert _detect_language() == "es"
|
||||
|
||||
def test_canonical_config_format(self):
|
||||
"""Test that config file reads canonical format {"env": {"STRIX_LANGUAGE": "es"}}."""
|
||||
# Verify the canonical format structure is valid
|
||||
config_data = {"env": {"STRIX_LANGUAGE": "es"}}
|
||||
assert isinstance(config_data["env"]["STRIX_LANGUAGE"], str)
|
||||
assert config_data["env"]["STRIX_LANGUAGE"] == "es"
|
||||
|
||||
|
||||
class TestLoadLocale:
|
||||
def test_load_english(self):
|
||||
locale = _load_locale("en")
|
||||
assert "cli.description" in locale
|
||||
assert isinstance(locale, dict)
|
||||
|
||||
def test_load_spanish(self):
|
||||
locale = _load_locale("es")
|
||||
assert "cli.description" in locale
|
||||
|
||||
def test_load_missing_returns_empty(self):
|
||||
locale = _load_locale("nonexistent")
|
||||
assert locale == {}
|
||||
|
||||
def test_cached_after_first_load(self):
|
||||
_load_locale("en")
|
||||
assert "en" in mod._locales
|
||||
|
||||
|
||||
class TestTranslationFunction:
|
||||
def test_t_returns_english_by_default(self):
|
||||
result = t("cli.description")
|
||||
assert "Strix" in result
|
||||
assert "Penetration" in result
|
||||
|
||||
def test_t_returns_spanish_when_set(self):
|
||||
set_language("es")
|
||||
result = t("cli.description")
|
||||
assert "Penetración" in result
|
||||
|
||||
def test_t_falls_back_to_english_for_missing_key(self):
|
||||
set_language("es")
|
||||
# Use a key that exists in en but not es
|
||||
result = t("cli.description")
|
||||
# Should still return something (English fallback)
|
||||
assert result != "cli.description"
|
||||
|
||||
def test_t_returns_key_for_missing(self):
|
||||
result = t("nonexistent.key.xyz")
|
||||
assert result == "nonexistent.key.xyz"
|
||||
|
||||
def test_t_interpolates_placeholders(self):
|
||||
result = t("cli.scan_started", target="example.com")
|
||||
assert "example.com" in result
|
||||
|
||||
def test_t_interpolates_spanish(self):
|
||||
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):
|
||||
# Should not raise, just return with missing placeholder
|
||||
result = t("cli.scan_started") # target not provided
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestLanguageDirective:
|
||||
def test_english_returns_empty(self):
|
||||
set_language("en")
|
||||
directive = get_language_directive()
|
||||
assert directive == ""
|
||||
|
||||
def test_spanish_returns_directive(self):
|
||||
set_language("es")
|
||||
directive = get_language_directive()
|
||||
assert "Spanish" in directive
|
||||
assert "CVE" in directive # preservation rule
|
||||
assert "CWE" in directive
|
||||
|
||||
def test_directive_preserves_identifiers(self):
|
||||
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
|
||||
|
||||
|
||||
class TestLocaleKeyConsistency:
|
||||
def test_all_en_keys_exist_in_es(self):
|
||||
en = _load_locale("en")
|
||||
es = _load_locale("es")
|
||||
missing = set(en.keys()) - set(es.keys())
|
||||
assert missing == set(), f"Missing Spanish keys: {missing}"
|
||||
|
||||
def test_locale_files_are_valid_json(self):
|
||||
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):
|
||||
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):
|
||||
set_language("en")
|
||||
en_value = t("cli.workspace_file_help")
|
||||
assert "sandbox workspace" in en_value
|
||||
set_language("es")
|
||||
es_value = t("cli.workspace_file_help")
|
||||
assert "workspace del sandbox" in es_value
|
||||
assert es_value != "cli.workspace_file_help"
|
||||
|
||||
def test_examples_header_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.examples_header") == "Ejemplos:"
|
||||
set_language("en")
|
||||
assert t("cli.examples_header") == "Examples:"
|
||||
|
||||
def test_model_quality_warning_body_translates(self):
|
||||
set_language("en")
|
||||
assert "frontier model" in t("cli.model_quality_warning_body")
|
||||
set_language("es")
|
||||
es_value = t("cli.model_quality_warning_body")
|
||||
assert "modelo frontier" in es_value
|
||||
assert es_value != "cli.model_quality_warning_body"
|
||||
|
||||
def test_model_quality_warning_footer_translates(self):
|
||||
set_language("en")
|
||||
assert "weaker models" in t("cli.model_quality_warning_footer")
|
||||
set_language("es")
|
||||
es_value = t("cli.model_quality_warning_footer")
|
||||
assert "modelos más débiles" in es_value
|
||||
|
||||
def test_unknown_model_body_translates(self):
|
||||
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):
|
||||
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):
|
||||
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")
|
||||
set_language("en")
|
||||
assert t("cli.example_web_app") == "Web application penetration test"
|
||||
|
||||
|
||||
class TestCustomConfigPath:
|
||||
"""``--config`` should drive language resolution like the default config."""
|
||||
|
||||
def test_custom_config_path_resolves_language(self, tmp_path):
|
||||
config = tmp_path / "custom.json"
|
||||
config.write_text(
|
||||
json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8"
|
||||
)
|
||||
set_config_path(config)
|
||||
assert get_language() == "es"
|
||||
|
||||
def test_custom_config_path_ignored_without_env_key(self, tmp_path):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_usage") == "uso: "
|
||||
set_language("en")
|
||||
assert t("cli.argparse_usage") == "usage: "
|
||||
|
||||
def test_options_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_options") == "opciones"
|
||||
set_language("en")
|
||||
assert t("cli.argparse_options") == "options"
|
||||
|
||||
def test_help_help_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_help") == "muestra este mensaje de ayuda y sale"
|
||||
|
||||
def test_version_help_translates(self):
|
||||
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):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_unrecognized") == "argumentos no reconocidos: %s"
|
||||
|
||||
def test_argparse_override_is_wired(self):
|
||||
# The override routes argparse's gettext calls through t().
|
||||
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: "
|
||||
|
||||
|
||||
class TestSettingsLanguageField:
|
||||
def test_settings_has_language_field(self):
|
||||
s = Settings()
|
||||
assert hasattr(s, "language")
|
||||
assert s.language == "en"
|
||||
|
||||
def test_settings_language_from_env(self):
|
||||
with patch.dict(os.environ, {"STRIX_LANGUAGE": "es"}):
|
||||
s = Settings()
|
||||
assert s.language == "es"
|
||||
Loading…
Add table
Reference in a new issue