diff --git a/agent/redact.py b/agent/redact.py index 2512243dc0..496bbdcbce 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -1274,10 +1274,19 @@ def _has_known_prefix_substring(text: str) -> bool: # (``security.redact_secrets: false`` / HERMES_REDACT_SECRETS) applies to # plugin patterns exactly as it does to built-ins. -_PLUGIN_PREFIX_PATTERNS: list = [] +# Keyed by registration source (e.g. "plugin:my-plugin") so the plugin +# lifecycle/ownership-ledger work (#64229) has a clean seam to drop ONE +# plugin's patterns on unload. There is deliberately no public removal +# API — additive-only stands; unload is a host-owned lifecycle concern. +_PLUGIN_PREFIX_PATTERNS: dict = {} _registry_lock = threading.Lock() +def _plugin_patterns() -> list: + """All plugin-registered patterns in registration order.""" + return [p for patterns in _PLUGIN_PREFIX_PATTERNS.values() for p in patterns] + + def _rebuild_prefix_matcher() -> None: """Recompile the prefix alternation and pre-screen substrings. @@ -1286,7 +1295,7 @@ def _rebuild_prefix_matcher() -> None: under the GIL) propagates immediately to every caller. """ global _PREFIX_RE, _PREFIX_SUBSTRINGS - combined = _PREFIX_PATTERNS + _PLUGIN_PREFIX_PATTERNS + combined = _PREFIX_PATTERNS + _plugin_patterns() _PREFIX_RE = re.compile( r"(? int: source, pattern, ) continue - if pattern in _PREFIX_PATTERNS or pattern in _PLUGIN_PREFIX_PATTERNS or pattern in accepted: + if pattern in _PREFIX_PATTERNS or pattern in _plugin_patterns() or pattern in accepted: logger.debug("%s: redaction pattern %r already registered", source, pattern) continue accepted.append(pattern) if accepted: with _registry_lock: - _PLUGIN_PREFIX_PATTERNS.extend(accepted) + _PLUGIN_PREFIX_PATTERNS.setdefault(source, []).extend(accepted) _rebuild_prefix_matcher() logger.info( "%s: registered %d redaction pattern(s)", source, len(accepted) diff --git a/tests/test_redaction_registry.py b/tests/test_redaction_registry.py index 04c4d39445..8203cbceec 100644 --- a/tests/test_redaction_registry.py +++ b/tests/test_redaction_registry.py @@ -55,6 +55,28 @@ def test_registered_pattern_masks_token(): assert "nvapi-" in out and "..." in out +def test_prescreen_tuple_rebuilt_not_bypassed(): + # The cheap pre-screen gate (_has_known_prefix_substring) consults + # _PREFIX_SUBSTRINGS. Registration after module load must REBUILD that + # tuple so plugin patterns flow through the same fast path as built-ins + # — never around it. + assert "nvapi-" not in redact_mod._PREFIX_SUBSTRINGS + assert not redact_mod._has_known_prefix_substring(f"x {NVAPI_KEY}") + register_redaction_patterns([NVAPI_PATTERN], source="test") + assert "nvapi-" in redact_mod._PREFIX_SUBSTRINGS + assert redact_mod._has_known_prefix_substring(f"x {NVAPI_KEY}") + + +def test_patterns_attributed_per_source(): + # Patterns are stored keyed by registration source — the seam the + # #64229 lifecycle/ledger path needs to drop one plugin's patterns on + # unload. No public removal API exists; additive-only stands. + register_redaction_patterns([NVAPI_PATTERN], source="plugin:alpha") + register_redaction_patterns([r"zk-[A-Za-z0-9]{24,}"], source="plugin:beta") + assert redact_mod._PLUGIN_PREFIX_PATTERNS["plugin:alpha"] == [NVAPI_PATTERN] + assert redact_mod._PLUGIN_PREFIX_PATTERNS["plugin:beta"] == [r"zk-[A-Za-z0-9]{24,}"] + + def test_builtins_unaffected_by_registration(): register_redaction_patterns([NVAPI_PATTERN], source="test") sk = "sk-proj-AbCdEf1234567890GhIjKl"