Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,51 @@ It is a planning note, not a promise.
- Add a lightweight launch smoke test (boot the app offscreen and exit cleanly) so CI confirms the GUI actually starts, not just that mocked logic passes
- Export transcribed text as a shareable audio file (e.g. OGG/Opus or MP3) so longer transcripts can be sent as a voice message via WhatsApp or similar (builds on the TTS pipeline)

## Paket H — Audit hardening (2026-06-21)

Derived from an external code audit, cross-verified by three independent
reviews (Claude, `security-reviewer`, Codex). Verdict: neither of the two
findings flagged "critical" is actually critical for this single-user,
no-network desktop app. The items below are real-but-lower-severity hardening
and quality improvements, ordered for step-by-step execution.

Phase 1 — worthwhile hardening (recommended):

- [x] H1 `config.py` `_load()`: split the broad `except Exception` (lines
~115-117) into `json.JSONDecodeError` and `PermissionError`/`OSError`,
each logged with `exc_info=True` so failures are debuggable. (MEDIUM)
- [x] H2 `run.sh` secrets sourcing (lines ~24-30): add an ownership/permission
check before `source` (`[[ -O "$SECRETS_FILE" ]]` + warn if perms are
looser than 600); keep `source`. Do NOT use the audit's
`export $(grep ... | xargs)` fix — it corrupts keys with spaces/special
chars via word-splitting. (MEDIUM, defense-in-depth)

Phase 2 — optional cleanups (LOW):

- [x] H3 `transcribe.py` (line ~62): `Path(wav_file).resolve()` as
defense-in-depth (no real traversal vector exists; path is app-generated).
- [x] H4 `llm_service.py`: replace the `MagicMock` production fallback with a
small `_NullLLMClient` stub so `unittest.mock` is not on the prod path.
- [ ] H5 `hotkey_service.py` (line ~319): optional short `time.sleep` after the
inner `break` as cheap insurance (no real busy-loop — `select(...,1.0)`
and fd-pop already prevent CPU spin). Deferred intentionally: optional only,
no reproduced bug and no strong red test yet.

Phase 3 — only if desired / verify first:

- [x] H6 Make `PasteService` key delay configurable via config and wire it through app init/runtime updates.
- [x] H7 `transcribe._transcribe_openai`: avoid leaking `CUDA_VISIBLE_DEVICES`
mutations beyond the OpenAI Whisper call; local override restored after use.
- [x] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs
`config.py` default) to a single source of truth.
- [x] H9 Verify (do not blindly remove) the `tts_openai_voice` validation set:
"marin"/"cedar" appear to be real newer OpenAI voices — the audit is
likely wrong here. Verified against the installed OpenAI client; no code
change needed.

Explicitly out: treating finding 2 (path traversal) as critical; adopting the
audit's flawed `run.sh` xargs fix.

## Not in scope

- Hosted backend services
Expand Down
7 changes: 4 additions & 3 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR)

from app.config import Config, VALID_HOTKEY_KEYS
from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS
from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, LLMServiceError
from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index
from app.hotkey_service import HotkeyWorker
Expand Down Expand Up @@ -273,7 +273,7 @@ def init_ui(self) -> None:

self.edit_llm_model = QLineEdit()
self.edit_llm_model.setText(self.config.llm_model)
self.edit_llm_model.setPlaceholderText("gpt-4o-mini")
self.edit_llm_model.setPlaceholderText(DEFAULTS["llm_model"])

self.combo_tone = QComboBox()
self.combo_tone.addItems(["formal", "neutral", "locker"])
Expand Down Expand Up @@ -604,7 +604,7 @@ def __init__(self, app: QApplication) -> None:

self.llm_service = self._build_llm_service()
self.audio_recorder = AudioRecorder()
self.paste_service = PasteService(autopaste=self.config.autopaste)
self.paste_service = PasteService(autopaste=self.config.autopaste, key_delay_ms=self.config.paste_key_delay_ms)

# State machine state: "IDLE", "RECORDING", "TRANSCRIBING", "LLM_REWRITING"
self.state = "IDLE"
Expand Down Expand Up @@ -964,6 +964,7 @@ def _stop_recording_and_process(self) -> None:

# Ensure PasteService has the latest autopaste configuration
self.paste_service.autopaste = self.config.autopaste
self.paste_service.key_delay_ms = self.config.paste_key_delay_ms

# Create the transcribe worker
worker = _TranscribeWorker(
Expand Down
21 changes: 19 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"llm_base_url": "",
"llm_model": "gpt-4o-mini",
"autopaste": True,
"paste_key_delay_ms": 80,
"audio_device": "@DEFAULT_SOURCE@",
"notes_folder": str(Path.home() / "Blitztext-Notizen"),
"history_size": 50,
Expand Down Expand Up @@ -112,8 +113,11 @@ def _load(self) -> dict[str, Any]:
sanitized = dict(data)
sanitized.pop("openai_api_key", None)
return _deep_merge(DEFAULTS, sanitized)
except Exception:
logger.warning("Config could not be loaded, using defaults")
except json.JSONDecodeError:
logger.warning("Config file is not valid JSON, using defaults", exc_info=True)
return _deep_merge(DEFAULTS, {})
except OSError:
logger.warning("Config file could not be read, using defaults", exc_info=True)
return _deep_merge(DEFAULTS, {})
Comment on lines +119 to 121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch non-UTF-8 config files during fallback

If config.json exists but contains invalid UTF-8 bytes, Path.read_text(encoding="utf-8") raises UnicodeDecodeError, which is not a json.JSONDecodeError or OSError. Before this change the broad handler fell back to defaults, but now app startup via BlitztextConfig(...) crashes instead of recovering from a corrupt hand-edited config file.

Useful? React with 👍 / 👎.


def save(self) -> None:
Expand Down Expand Up @@ -253,6 +257,19 @@ def autopaste(self) -> bool:
def autopaste(self, value: bool) -> None:
self._data["autopaste"] = bool(value)

@property
def paste_key_delay_ms(self) -> int:
raw = self._data.get("paste_key_delay_ms", DEFAULTS["paste_key_delay_ms"])
try:
value = int(raw)
except (TypeError, ValueError):
return DEFAULTS["paste_key_delay_ms"]
return max(0, min(1000, value))

@paste_key_delay_ms.setter
def paste_key_delay_ms(self, value: int) -> None:
self._data["paste_key_delay_ms"] = max(0, min(1000, int(value)))

@property
def audio_device(self) -> str:
return self._data.get("audio_device", "@DEFAULT_SOURCE@")
Expand Down
27 changes: 19 additions & 8 deletions app/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import logging
from typing import Any, Optional

from app.config import DEFAULTS
from app.workflows import WorkflowType
from app.writing_presets import DEFAULT_PRESET_KEY, get_preset

logger = logging.getLogger("blitztext.llm_service")

LLM_WORKFLOWS = {WorkflowType.TEXT_IMPROVER, WorkflowType.DAMPF_ABLASSEN, WorkflowType.EMOJI_TEXT}
MODEL = "gpt-4o-mini"
DEFAULT_LLM_MODEL = DEFAULTS["llm_model"]

_DAMPF_SYSTEM = (
"Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche "
Expand Down Expand Up @@ -40,6 +41,20 @@ class LLMServiceError(Exception):
"""Raised when an LLM call fails."""


class _NullLLMClient:
"""Minimaler Platzhalter, damit der Produktionspfad kein unittest.mock nutzt."""

class _Chat:
class _Completions:
@staticmethod
def create(*args, **kwargs):
raise RuntimeError("Null client cannot create completions")

completions = _Completions()

chat = _Chat()


class LLMService:
"""Wraps OpenAI API calls for BlitztextLinux rewrite workflows."""

Expand All @@ -59,7 +74,7 @@ def __init__(
self.api_key = api_key or ""
self.api_key_env = api_key_env or "OPENAI_API_KEY"
self.base_url = (base_url or "").strip()
self.model = (model or "").strip() or MODEL
self.model = (model or "").strip() or DEFAULT_LLM_MODEL
self.tone = tone
self.emoji_density = emoji_density
self.dampf_system_prompt = dampf_system_prompt
Expand All @@ -76,9 +91,7 @@ def __init__(
except ImportError:
self._openai_installed = False
self._client_is_fallback_mock = True
from unittest.mock import MagicMock

self.client = MagicMock()
self.client = _NullLLMClient()
else:
if self.api_key and self.api_key.strip():
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url or None)
Expand All @@ -87,9 +100,7 @@ def __init__(
# werfen bereits im Konstruktor bei leerem Key. Der eigentliche
# Fehler wird zur Aufrufzeit über _check_openai() klar gemeldet,
# damit die App auch ohne gesetzten Key startet.
from unittest.mock import MagicMock

self.client = MagicMock()
self.client = _NullLLMClient()

def is_available(self) -> bool:
return bool(self.api_key and self.api_key.strip())
Expand Down
6 changes: 4 additions & 2 deletions app/paste_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ class PasteService:
svc.paste("Hallo Welt")
"""

def __init__(self, autopaste: bool = True) -> None:
def __init__(self, autopaste: bool = True, key_delay_ms: int = _KEY_DELAY_MS) -> None:
"""
Args:
autopaste: True = nach wl-copy automatisch Ctrl+V via ydotool senden.
key_delay_ms: Verzögerung zwischen ydotool-Keyevents in Millisekunden.
"""
self.autopaste = autopaste
self.key_delay_ms = max(0, int(key_delay_ms))

def paste(self, text: str) -> None:
"""Text ins Clipboard schreiben und optional einfuegen.
Expand Down Expand Up @@ -154,7 +156,7 @@ def _ydotool_paste(self) -> None:
time.sleep(_PASTE_DELAY)
try:
result = subprocess.run(
["ydotool", "key", "--key-delay", str(_KEY_DELAY_MS), *_CTRL_V_KEYCODES],
["ydotool", "key", "--key-delay", str(self.key_delay_ms), *_CTRL_V_KEYCODES],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
Expand Down
27 changes: 18 additions & 9 deletions app/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def transcribe(
TranscribeError: Bei fehlender Abhaengigkeit, fehlendem File oder
Modell-Fehler.
"""
wav_file = Path(wav_file)
wav_file = Path(wav_file).resolve()

if model not in VALID_MODEL_NAMES:
raise TranscribeError(
Expand Down Expand Up @@ -150,16 +150,25 @@ def _load_faster_whisper_model_class():

def _transcribe_openai(wav_file: str, model_name: str, language: str, hint: str | None = None) -> str:
warnings.filterwarnings("ignore", message="FP16 is not supported on CPU")
if _should_force_cpu_for_openai():
original_cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
should_force_cpu = _should_force_cpu_for_openai()
if should_force_cpu:
os.environ["CUDA_VISIBLE_DEVICES"] = ""
whisper = _load_openai_whisper_module()
try:
whisper = _load_openai_whisper_module()

model = whisper.load_model(model_name)
result = model.transcribe(
wav_file,
language=_normalize_language(language),
initial_prompt=hint,
)
model = whisper.load_model(model_name)
result = model.transcribe(
wav_file,
language=_normalize_language(language),
initial_prompt=hint,
)
finally:
if should_force_cpu:
if original_cuda_visible_devices is None:
os.environ.pop("CUDA_VISIBLE_DEVICES", None)
else:
os.environ["CUDA_VISIBLE_DEVICES"] = original_cuda_visible_devices
if not isinstance(result, dict):
logger.warning("Transcription result is not a dict: %r", type(result).__name__)
return ""
Expand Down
16 changes: 12 additions & 4 deletions run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,18 @@ trap 'rm -f "${LOCKFILE}"' EXIT INT TERM

# --- secrets.env (optional) ---
if [[ -f "${SECRETS_FILE}" ]]; then
set -a
# shellcheck disable=SC1090
source "${SECRETS_FILE}"
set +a
if [[ ! -O "${SECRETS_FILE}" ]]; then
echo "WARNUNG: ${SECRETS_FILE} gehört nicht dem aktuellen Nutzer und wird nicht geladen." >&2
else
SECRETS_PERMS=$(stat -c '%a' "${SECRETS_FILE}" 2>/dev/null || true)
if [[ -n "${SECRETS_PERMS}" ]] && (( 10#${SECRETS_PERMS} > 600 )); then
echo "WARNUNG: ${SECRETS_FILE} hat zu offene Rechte (${SECRETS_PERMS}); erwartet 600 oder restriktiver." >&2
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check group/other bits instead of numeric mode order

For permission modes like 0440 or 0040, the secrets file is group-readable but 10#${SECRETS_PERMS} > 600 is false, so run.sh sources it without the intended warning. Since the hardening is meant to warn for anything looser than owner-only access, this needs to test group/other permission bits rather than compare the octal string as a decimal number.

Useful? React with 👍 / 👎.

fi
set -a
# shellcheck disable=SC1090
source "${SECRETS_FILE}"
set +a
fi
fi

# --- venv-Prüfung ---
Expand Down
38 changes: 38 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import json
import logging
from pathlib import Path

import pytest
Expand Down Expand Up @@ -262,6 +263,43 @@ def test_env_value_wins_over_legacy_fallback(self, config_dir, monkeypatch):
assert loaded.resolve_openai_api_key() == "env-placeholder"


class TestLoadFailures:
def test_invalid_json_logs_json_decode_error_with_exc_info(self, config_dir, caplog):
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.json").write_text("{kaputt", encoding="utf-8")

with caplog.at_level(logging.WARNING, logger="blitztext.config"):
loaded = BlitztextConfig(config_dir=config_dir)

assert loaded.model == "base"
records = [r for r in caplog.records if "valid JSON" in r.message]
assert len(records) == 1
assert records[0].exc_info is not None
assert records[0].exc_info[0] is json.JSONDecodeError

def test_read_error_logs_os_error_with_exc_info(self, config_dir, caplog, monkeypatch):
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.json"
config_file.write_text("{}", encoding="utf-8")
original_read_text = Path.read_text

def fake_read_text(self, *args, **kwargs):
if self == config_file:
raise PermissionError("denied")
return original_read_text(self, *args, **kwargs)

monkeypatch.setattr(Path, "read_text", fake_read_text)

with caplog.at_level(logging.WARNING, logger="blitztext.config"):
loaded = BlitztextConfig(config_dir=config_dir)

assert loaded.model == "base"
records = [r for r in caplog.records if "could not be read" in r.message]
assert len(records) == 1
assert records[0].exc_info is not None
assert records[0].exc_info[0] is PermissionError


class TestUILanguage:
"""Tests für ui_language Config-Property."""

Expand Down
3 changes: 3 additions & 0 deletions tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def test_defaults(self, tmp_path):
assert cfg.tts_openai_model == "gpt-4o-mini-tts"
assert cfg.tts_openai_voice == "marin"
assert cfg.notes_folder.endswith("Blitztext-Notizen")
assert cfg.paste_key_delay_ms == 80

def test_history_size_clamped(self, tmp_path):
cfg = Config.load(tmp_path / "config.json")
Expand All @@ -59,6 +60,7 @@ def test_roundtrip_save_load(self, tmp_path):
cfg.tts_openai_voice = "nova"
cfg.notes_folder = str(Path.home() / "Notizen")
cfg.history_size = 25
cfg.paste_key_delay_ms = 135
cfg.save()
cfg2 = Config.load(path)
assert cfg2.tts_voice == "de_DE-thorsten-medium.onnx"
Expand All @@ -67,6 +69,7 @@ def test_roundtrip_save_load(self, tmp_path):
assert cfg2.tts_openai_voice == "nova"
assert cfg2.history_size == 25
assert cfg2.notes_folder.endswith("Notizen")
assert cfg2.paste_key_delay_ms == 135

def test_sanitize_bad_values(self, tmp_path):
path = tmp_path / "config.json"
Expand Down
Loading