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:
parent
fdd45323bf
commit
cfeae1497b
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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])
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue