fix(plugins): reject top-level alternation in redaction patterns, unbundle demo plugin

'ab|.*' compiled and carried the accepted 'ab' literal prefix while its
'.*' branch stayed unprefixed, escaping the no-redact-everything
guarantee (_extract_literal_prefix stops at '|'). Registration now
rejects top-level alternation with a regression test for exactly that
shape; grouped alternation after the prefix, escaped pipes, and
character-class pipes remain accepted.

The bundled nvapi-redaction reference plugin is removed per repo policy
(vendor integrations ship as standalone plugin repos); the end-to-end
register() coverage now uses a synthetic plugin written at test time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnMCvi2vXqfs996AjVeF2F
This commit is contained in:
webdevtodayjason 2026-07-15 12:59:40 -05:00 committed by Teknium
parent fdd45323bf
commit cfeae1497b
5 changed files with 87 additions and 66 deletions

View File

@ -1155,6 +1155,40 @@ def _extract_literal_prefix(pattern: str) -> str:
return pattern
def _has_top_level_alternation(pattern: str) -> bool:
"""True if ``pattern`` contains a ``|`` outside any group or class.
A top-level alternation defeats the literal-prefix guarantee:
``_extract_literal_prefix`` stops at ``|``, so for ``ab|.*`` it
returns ``ab`` even though the ``.*`` branch is not bound by that
prefix and matches anything. Grouped alternation after the prefix
(``ab(?:x|y)``) keeps the guarantee and stays allowed.
"""
depth = 0
i = 0
while i < len(pattern):
ch = pattern[i]
if ch == "\\":
i += 2
continue
if ch == "[":
i += 1
if i < len(pattern) and pattern[i] == "]":
i += 1
while i < len(pattern) and pattern[i] != "]":
if pattern[i] == "\\":
i += 1
i += 1
elif ch == "(":
depth += 1
elif ch == ")":
depth = max(0, depth - 1)
elif ch == "|" and depth == 0:
return True
i += 1
return False
_PREFIX_SUBSTRINGS = tuple(
_extract_literal_prefix(p) for p in _PREFIX_PATTERNS
)
@ -1212,6 +1246,9 @@ def register_redaction_patterns(patterns, source: str = "plugin") -> int:
raised a broken plugin must not break startup):
* must be a non-empty string that compiles as a regex;
* must not contain a top-level alternation (``ab|.*`` would escape
the literal-prefix guarantee below through its unprefixed branch;
grouped alternation after the prefix, ``ab(?:x|y)``, is allowed);
* must start with at least 2 literal characters (the pre-screen
substring gate in ``_has_known_prefix_substring`` needs a literal
anchor; it also structurally rules out redact-everything patterns
@ -1239,6 +1276,15 @@ def register_redaction_patterns(patterns, source: str = "plugin") -> int:
source, pattern, exc,
)
continue
if _has_top_level_alternation(pattern):
logger.warning(
"%s: skipping redaction pattern %r — top-level alternation "
"escapes the literal-prefix guarantee (in 'ab|.*' the "
"prefix binds only the first branch); wrap alternation in "
"a group after the prefix, e.g. 'ab(?:x|y)'",
source, pattern,
)
continue
if len(_extract_literal_prefix(pattern)) < 2:
logger.warning(
"%s: skipping redaction pattern %r — must start with at "

View File

@ -1,20 +0,0 @@
# nvapi-redaction
Masks NVIDIA API keys (`nvapi-...`, used by NIM endpoints and
build.nvidia.com) in logs, terminal output, transport errors, and
transcripts — everywhere the built-in vendor prefixes are masked.
Also the reference implementation for the
`ctx.register_redaction_patterns()` plugin interface: vendor token
formats as plugins instead of one-line core PRs to
`agent/redact.py::_PREFIX_PATTERNS`.
## Enable
```bash
hermes plugins enable nvapi-redaction
```
Respects the global `security.redact_secrets` setting like every
built-in pattern. Additive-only: this plugin (and any redaction plugin)
can extend masking but cannot weaken it.

View File

@ -1,29 +0,0 @@
"""nvapi-redaction — a vendor token format as a plugin, not a core PR.
NVIDIA API keys (``nvapi-...``) authenticate NIM endpoints and
build.nvidia.com common in self-hosted Hermes stacks running local
NIM backends. The format is not in the core ``_PREFIX_PATTERNS`` list
in ``agent/redact.py``, so today an ``nvapi-`` key that lands in a
transport error or an ``env``-dump would be masked only if a generic
pattern happens to catch it.
Historically the fix was a one-line core PR appending to
``_PREFIX_PATTERNS`` (that's how fw_, retaindb_, hsk-, mem0_, and brv_
got there). This plugin is the same one-liner shipped through
``ctx.register_redaction_patterns()`` instead the reference
implementation for the redaction-registry plugin interface.
Registered patterns are additive-only: they extend what gets masked and
cannot weaken built-in redaction.
"""
from __future__ import annotations
# NVIDIA API keys: "nvapi-" followed by the token body. Real keys are
# 60+ chars; the 20-char floor mirrors the conservative floors used for
# other vendors in agent/redact.py while avoiding prose false-positives.
NVAPI_PATTERN = r"nvapi-[A-Za-z0-9_-]{20,}"
def register(ctx) -> None:
ctx.register_redaction_patterns([NVAPI_PATTERN])

View File

@ -1,4 +0,0 @@
name: nvapi-redaction
version: 1.0.0
description: "Redact NVIDIA API keys (nvapi-...) from logs, terminal output, and transport errors. Reference implementation for the register_redaction_patterns plugin interface."
author: "NousResearch (interface demo)"

View File

@ -12,7 +12,6 @@ never leaks between tests.
"""
import importlib.util
from pathlib import Path
import pytest
@ -143,26 +142,55 @@ def test_plugin_context_method_never_raises(monkeypatch):
assert ctx.register_redaction_patterns([NVAPI_PATTERN]) == 0
# ── Bundled reference plugin ────────────────────────────────────────────
# ── Top-level alternation guard ─────────────────────────────────────────
def _load_demo_plugin():
plugin_init = (
Path(__file__).resolve().parent.parent
/ "plugins" / "nvapi-redaction" / "__init__.py"
)
spec = importlib.util.spec_from_file_location("nvapi_redaction_demo", plugin_init)
def test_top_level_alternation_rejected():
# 'ab|.*' compiles and has the accepted 'ab' literal prefix, but the
# '.*' branch is unprefixed — accepting it would redact everything.
assert register_redaction_patterns([r"ab|.*"], source="test") == 0
assert register_redaction_patterns([r"ab|cd"], source="test") == 0
clean = "nothing here resembles a credential"
assert redact_sensitive_text(clean, force=True) == clean
def test_grouped_alternation_and_literal_pipe_accepted():
# Alternation inside a group after the prefix keeps the guarantee.
assert register_redaction_patterns(
[r"zq(?:tok|key)-[A-Za-z0-9]{20,}"], source="test"
) == 1
# Escaped pipes and character-class pipes are literals, not branches.
assert register_redaction_patterns([r"xy\|[A-Za-z0-9]{20,}"], source="test") == 1
assert register_redaction_patterns([r"wv[|][A-Za-z0-9]{20,}"], source="test") == 1
# ── Plugin register() end-to-end (synthetic, written at test time) ──────
_SYNTHETIC_PLUGIN = f'''
NVAPI_PATTERN = r"{NVAPI_PATTERN}"
def register(ctx):
ctx.register_redaction_patterns([NVAPI_PATTERN])
'''
def _load_synthetic_plugin(tmp_path):
plugin_init = tmp_path / "synthetic_redactor.py"
plugin_init.write_text(_SYNTHETIC_PLUGIN, encoding="utf-8")
spec = importlib.util.spec_from_file_location("synthetic_redactor", plugin_init)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_demo_plugin_end_to_end():
def test_plugin_register_end_to_end(tmp_path):
import hermes_cli.plugins as plugins_mod
demo = _load_demo_plugin()
demo = _load_synthetic_plugin(tmp_path)
manager = plugins_mod.PluginManager()
manifest = plugins_mod.PluginManifest(name="nvapi-redaction")
manifest = plugins_mod.PluginManifest(name="synthetic-redactor")
demo.register(plugins_mod.PluginContext(manifest, manager))
out = redact_sensitive_text(
@ -172,8 +200,8 @@ def test_demo_plugin_end_to_end():
assert "nvapi-" in out # label survives for debuggability
def test_demo_plugin_no_prose_false_positive():
demo = _load_demo_plugin()
def test_registered_pattern_no_prose_false_positive(tmp_path):
demo = _load_synthetic_plugin(tmp_path)
register_redaction_patterns([demo.NVAPI_PATTERN], source="test")
prose = "the nvapi-endpoint docs describe rate limits"
assert redact_sensitive_text(prose, force=True) == prose