Files
hermes-webui/tests/test_v050255_opus_followups.py
nesquena-hermes 5ce516ed38 v0.50.255: Opus follow-ups (4 fixes) + CHANGELOG
Opus pre-release advisor caught 4 issues in stage-255 (#1390 + #1405):

1. MUST-FIX: api/rollback.py path-traversal — _checkpoint_root() / ws_hash /
   checkpoint did NOT normalize Path() / "../escape", so an authenticated
   caller could read or restore from another allowlisted workspace via
   ../<other-ws-hash>/<sha>. New _validate_checkpoint_id() regex-guards
   with ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ and rejects . and .. literals.
   Both get_checkpoint_diff and restore_checkpoint validate.

2. SHOULD-FIX: redact_session_data perf cliff — the new api_redact_enabled
   toggle in #1405 called uncached load_settings() per string, recursed
   across messages[] and tool_calls[]. For a 50-message session: hundreds
   of disk reads per /api/session response. Now read once at the top and
   thread _enabled through via private kwarg.

3. SHOULD-FIX: voice-mode wrong-session TTS — the patched autoReadLastAssistant
   fires globally; if the user navigated to a different session between
   sending and stream completion, TTS would speak the wrong session\\s reply.
   New _voiceModeThinkingSid closure captures S.session.session_id at
   thinking-time; _speakResponse bails to _startListening() on mismatch.

4. NIT: rollback._inspect_checkpoint had bare Exception in the except tuple
   alongside specific catches, swallowing everything. Now (TimeoutExpired,
   OSError) only.

6 regression tests in test_v050255_opus_followups.py. Full suite: 3587 passed,
2 skipped, 3 xpassed.
2026-05-01 17:19:53 +00:00

221 lines
9.0 KiB
Python

"""Regression tests for v0.50.255 Opus pre-release follow-ups.
The v0.50.255 batch (#1390 + #1405) had four Opus advisor findings:
1. MUST-FIX — `api/rollback.py::checkpoint` parameter wasn't validated; the
path-join `_checkpoint_root() / ws_hash / checkpoint` does NOT normalize
`..`, so an authenticated caller could pass `../<other-ws-hash>/<sha>` and
read or restore from another allowlisted workspace's checkpoint store.
Fix: regex validation that rejects `/`, `..`, and `.`.
2. SHOULD-FIX — `api/helpers.py::_redact_text` called uncached `load_settings()`
per string, recursed across all messages and tool_calls. For a 50-message
session that's hundreds of disk reads per `/api/session?session_id=X`. Fix:
thread `_enabled` once through `redact_session_data()`.
3. SHOULD-FIX — `static/boot.js` voice mode: the patched `autoReadLastAssistant`
fires globally; if the user navigates to a different session between send
and stream completion, TTS would speak the wrong session's last assistant
message. Fix: capture the active session id in `_voiceModeSend` and bail
out in `_speakResponse` if it doesn't match.
4. NIT — `api/rollback.py::_inspect_checkpoint` had a bare `Exception` in the
except tuple alongside specific catches, swallowing everything (incl.
KeyboardInterrupt's siblings). Fix: drop to the specific tuple.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
# ── 1: rollback checkpoint id validation ─────────────────────────────────────
def test_rollback_validates_checkpoint_id_against_path_traversal():
"""The checkpoint param must reject `..`, `/`, and any path-component
traversal vector. Without this guard, a caller can join the checkpoint
root with `../<other-ws-hash>/<sha>` and escape the workspace allowlist
(Path() / '..' does NOT normalize)."""
src = (REPO / "api" / "rollback.py").read_text(encoding="utf-8")
# Validator function exists.
assert "def _validate_checkpoint_id(" in src, (
"_validate_checkpoint_id must exist as a defense-in-depth guard for "
"the checkpoint parameter; without it, ../<ws>/<sha> escapes the "
"workspace allowlist."
)
# Both diff + restore call it.
assert src.count("_validate_checkpoint_id(checkpoint)") >= 2, (
"both get_checkpoint_diff and restore_checkpoint must call "
"_validate_checkpoint_id() on their checkpoint parameter."
)
# Validator rejects '..' and '.' explicitly.
assert 'in (".", "..")' in src, (
"_validate_checkpoint_id must reject literal '.' and '..' explicitly "
"(not just rely on the regex)."
)
def test_rollback_validate_checkpoint_id_runtime_behavior():
"""End-to-end test of the validator: traversal attempts raise ValueError."""
import sys
sys.path.insert(0, str(REPO))
from api.rollback import _validate_checkpoint_id
# Valid SHA-style IDs pass.
assert _validate_checkpoint_id("abc123def456") == "abc123def456"
assert _validate_checkpoint_id("a1b2c3-d4e5") == "a1b2c3-d4e5"
assert _validate_checkpoint_id("checkpoint_2026-05-01") == "checkpoint_2026-05-01"
# Traversal attempts blocked.
for bad in (
"../escape",
"..",
".",
"../../../etc/passwd",
"abc/def",
"abc def", # space
"abc\x00def", # null byte
"",
" ",
".hidden", # leading dot → looks like dotfile escape
"/abs/path",
"x" * 65, # too long
):
with pytest.raises(ValueError):
_validate_checkpoint_id(bad)
# ── 2: redact_session_data settings.json read-once optimization ──────────────
def test_redact_session_data_reads_settings_once():
"""`redact_session_data()` must read `api_redact_enabled` ONCE per call
and thread it through the recursive walk via the `_enabled` keyword.
Calling load_settings per string was a hot-path perf regression."""
src = (REPO / "api" / "helpers.py").read_text(encoding="utf-8")
# The function reads settings once and threads _enabled through.
redact_fn_idx = src.find("def redact_session_data(")
assert redact_fn_idx != -1, "redact_session_data missing"
body = src[redact_fn_idx : redact_fn_idx + 1500]
assert "load_settings()" in body, (
"redact_session_data must read load_settings() once at the top"
)
assert body.count("_enabled=_enabled") >= 3, (
"redact_session_data must thread _enabled through to title, "
"messages, and tool_calls (3 call sites)"
)
# _redact_text and _redact_value accept _enabled kwarg.
assert "def _redact_text(text: str, *, _enabled" in src
assert "def _redact_value(v, *, _enabled" in src
def test_redact_session_data_threads_enabled_once_across_recursion():
"""End-to-end: a session payload with N strings should result in 1 read
of api_redact_enabled, not N. We verify by counting load_settings calls
via monkeypatch."""
import sys
sys.path.insert(0, str(REPO))
from api import helpers
call_count = [0]
real_load_settings = helpers.__dict__.get("load_settings")
def counting_load_settings():
call_count[0] += 1
return {"api_redact_enabled": True}
# The from-import inside redact_session_data resolves at call time, so
# patch in api.config where it lives.
from api import config
original = config.load_settings
config.load_settings = counting_load_settings
try:
# Simulate a session payload with many strings
session = {
"title": "Test session",
"messages": [
{"role": "user", "content": "hello world " * 10}
for _ in range(20)
],
"tool_calls": [
{"name": "tool", "args": {"x": "y", "z": ["a", "b", "c"]}}
for _ in range(10)
],
}
helpers.redact_session_data(session)
finally:
config.load_settings = original
# Should be called exactly once for the entire response, not per string.
assert call_count[0] == 1, (
f"redact_session_data called load_settings() {call_count[0]} times; "
f"expected exactly 1 (read-once + thread-through optimization)."
)
# ── 3: voice mode session-id capture ─────────────────────────────────────────
def test_voice_mode_speakresponse_guards_against_session_switch():
"""The `_speakResponse` callback fires from a global override of
`autoReadLastAssistant`. If the user navigates to a different session
between sending and stream completion, the callback would TTS-read the
new session's last assistant message instead of the one they sent to.
Fix: capture session_id at thinking-time, bail in _speakResponse if it
doesn't match the current S.session.session_id."""
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# Session-id capture state exists.
assert "let _voiceModeThinkingSid=" in src, (
"voice mode must declare _voiceModeThinkingSid to pin the active "
"session id at send-time"
)
# _voiceModeSend captures current session_id at thinking transition.
send_idx = src.find("function _voiceModeSend(")
assert send_idx != -1
send_body = src[send_idx : send_idx + 1200]
assert "_voiceModeThinkingSid=" in send_body, (
"_voiceModeSend must capture the current session_id at thinking-time"
)
assert "S.session.session_id" in send_body, (
"_voiceModeSend must read S.session.session_id"
)
# _speakResponse compares current sid to captured sid and bails on mismatch.
speak_idx = src.find("function _speakResponse(")
assert speak_idx != -1
speak_body = src[speak_idx : speak_idx + 1500]
assert "_voiceModeThinkingSid" in speak_body, (
"_speakResponse must consult _voiceModeThinkingSid"
)
assert "_startListening()" in speak_body, (
"_speakResponse mismatch path must drop back to listening, not silently exit"
)
# ── 4: rollback _inspect_checkpoint except tuple ─────────────────────────────
def test_rollback_inspect_checkpoint_except_no_bare_exception():
"""The bare `Exception` in `(subprocess.TimeoutExpired, OSError, Exception)`
swallowed everything including KeyboardInterrupt's siblings and made the
specific catches redundant. Should be the specific tuple only."""
src = (REPO / "api" / "rollback.py").read_text(encoding="utf-8")
# No bare Exception in the inspect-checkpoint except tuple.
assert "(subprocess.TimeoutExpired, OSError, Exception)" not in src, (
"_inspect_checkpoint must not catch bare Exception alongside specific "
"catches — the bare Exception swallows everything and makes the "
"specific ones redundant."
)
# The specific tuple is in place.
assert "(subprocess.TimeoutExpired, OSError)" in src