Files
hermes-webui/tests/test_redact_large_string_cache.py
Anthony Ruiz fc7ae2ddd1 fix(redaction): fail closed on large strings, byte-bound the prefilter cache
Gate found two blocking defects in the new caches:

1. The large-string LRU was keyed on input text only. The installed
   agent.redact registry can extend the secret matcher at runtime via
   register_redaction_patterns() and exposes no policy generation or
   invalidation hook, so a >16k string primed before a registration was
   served with the new secret intact (reproduced against the installed
   registry). Drop the large-string cache: strings above 16,384 chars run
   the redactor uncached again. Regression primes a large string, registers
   a runtime pattern on the real registry and proves the next
   redact_session_data() response is redacted.

2. The prefilter LRU was count-bounded (8192) but its per-entry gate was a
   character cap, which is not a byte bound: 16k four-byte code points is
   ~64 KiB per key, ~512 MiB at capacity, and the workload died with
   MemoryError under 96 MiB of headroom. Gate each entry on
   sys.getsizeof(text) <= 2048 so the retained-key ceiling is a hard
   16 MiB. Regressions cover max-width Unicode, sensitive verdict pairs,
   eviction past the cap, and a low-memory RLIMIT_AS subprocess.
2026-09-17 23:35:54 +00:00

147 lines
5.8 KiB
Python

"""Large strings must NOT be memoized by input text alone.
``_redact_fn_cached`` memoizes strings up to 16,384 characters. An earlier
revision of this branch added a second LRU for larger strings, keyed only on
the text. That is unsafe: ``agent.redact.register_redaction_patterns()`` lets
a plugin extend the secret matcher at runtime, and the installed registry
exposes no policy generation the cache key could include and no hook that
could clear the cache. A large blob primed BEFORE such a registration would
keep being served with the newly-registered secret intact through session,
SSE and public-share projections.
Until the agent registry exposes a generation, strings above the small-cache
threshold fail closed: they run the full redactor on every call.
Contract verified here:
1. a large string is redacted exactly like the uncached redactor;
2. no module-level cache retains a large string (only the small LRU exists
and it never sees a string above the threshold);
3. production-registry regression: prime a large string carrying a token
the redactor does not know yet, register a runtime pattern for it, and
the NEXT ``redact_session_data`` response is redacted.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from api import helpers # noqa: E402
from api.helpers import ( # noqa: E402
_REDACT_CACHE_MAX_TEXT_LEN,
_redact_fn_cached,
_redact_fn_uncached,
_redact_text,
redact_session_data,
)
SECRET = "gh" + "p_" + "0123456789abcdefghijklmnopqrstuvwxyzAB"
@pytest.fixture(autouse=True)
def _isolate_small_cache():
"""Start and finish with an empty small-string LRU."""
helpers._redact_fn_lru.cache_clear()
yield
helpers._redact_fn_lru.cache_clear()
def _big(secret: str, size: int) -> str:
"""String of exactly ``size`` characters carrying ``secret``."""
unit = "lorem ipsum dolor sit amet "
filler = unit * (size // len(unit) + 1)
text = (filler + secret + filler)[:size]
assert len(text) == size
return text
def test_large_string_redaction_matches_uncached():
text = _big(SECRET, _REDACT_CACHE_MAX_TEXT_LEN + 5000)
assert len(text) > _REDACT_CACHE_MAX_TEXT_LEN
assert _redact_fn_cached(text) == _redact_fn_uncached(text)
assert SECRET not in _redact_fn_cached(text)
def test_large_string_is_never_memoized():
"""Above the threshold the redactor runs every time: no cache hit, no
cache entry, on any module-level LRU."""
text = _big(SECRET, _REDACT_CACHE_MAX_TEXT_LEN + 7000)
first = _redact_fn_cached(text)
second = _redact_fn_cached(text)
assert second == first
info = helpers._redact_fn_lru.cache_info()
assert info.hits == 0 and info.misses == 0 and info.currsize == 0, (
"large string reached the small-string LRU"
)
assert not hasattr(helpers, "_redact_fn_large_lru"), (
"a text-keyed large-string cache is back; it must carry a redaction "
"policy generation before it can exist"
)
def test_small_strings_still_use_the_small_cache():
small = f"small text {SECRET}"
assert len(small) <= _REDACT_CACHE_MAX_TEXT_LEN
first = _redact_fn_cached(small)
second = _redact_fn_cached(small)
assert first == second
assert SECRET not in first
assert helpers._redact_fn_lru.cache_info().hits >= 1
# ── Production-registry regression ─────────────────────────────────────────
# Uses the REAL installed agent.redact registry (skipped when the agent is not
# importable): the module-level `_redact_fn_uncached` is `_combined_redact`
# only when `agent.redact` imported at helpers import time.
_RUNTIME_TOKEN = "nvapi-" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0U1v2W3x4Y5z6A7b8C9d0"
_RUNTIME_PATTERN = r"nvapi-[A-Za-z0-9]{60}"
@pytest.fixture
def agent_registry():
redact = pytest.importorskip("agent.redact", reason="hermes-agent not installed")
if not hasattr(redact, "register_redaction_patterns"):
pytest.skip("installed agent.redact has no runtime pattern registry")
if helpers._redact_fn_uncached.__name__ != "_combined_redact":
pytest.skip("api.helpers was built without the agent redactor")
reset = getattr(redact, "_reset_plugin_redaction_patterns", None)
if reset is None:
pytest.skip("installed agent.redact has no registry reset seam")
reset()
yield redact
reset()
def test_runtime_pattern_registration_is_honored_for_large_strings(agent_registry):
"""Prime → register → the next response must be redacted (fail-closed)."""
# A marker the WebUI prefilter already trips on, so the large string goes
# through the redactor both before and after registration.
text = ("see https://example.invalid/docs lorem ipsum " * 500) + _RUNTIME_TOKEN + " tail"
assert len(text) > _REDACT_CACHE_MAX_TEXT_LEN
assert helpers._might_contain_sensitive_text(text)
# Sanity: the installed registry does not know this token shape yet.
# If it ever does, the test still proves the cache path stays honest.
primed = _redact_text(text, _enabled=True)
session_before = redact_session_data(
{"messages": [{"role": "assistant", "content": text}]}
)
assert session_before["messages"][0]["content"] == primed
accepted = agent_registry.register_redaction_patterns([_RUNTIME_PATTERN], source="test-7276")
assert accepted == 1, "the registry rejected the probe pattern"
oracle = _redact_fn_uncached(text)
assert _RUNTIME_TOKEN not in oracle, "the registered pattern is not effective"
assert _redact_text(text, _enabled=True) == oracle
session_after = redact_session_data(
{"messages": [{"role": "assistant", "content": text}]}
)
assert _RUNTIME_TOKEN not in session_after["messages"][0]["content"]
assert session_after["messages"][0]["content"] == oracle