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.
147 lines
5.8 KiB
Python
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
|