diff --git a/agent/redact.py b/agent/redact.py index 06bd242a24a86..4892cfde8be36 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -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 " diff --git a/plugins/nvapi-redaction/README.md b/plugins/nvapi-redaction/README.md deleted file mode 100644 index 744cbb044fdd1..0000000000000 --- a/plugins/nvapi-redaction/README.md +++ /dev/null @@ -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. diff --git a/plugins/nvapi-redaction/__init__.py b/plugins/nvapi-redaction/__init__.py deleted file mode 100644 index c413f6fe950ee..0000000000000 --- a/plugins/nvapi-redaction/__init__.py +++ /dev/null @@ -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]) diff --git a/plugins/nvapi-redaction/plugin.yaml b/plugins/nvapi-redaction/plugin.yaml deleted file mode 100644 index 6ddc67c9e22aa..0000000000000 --- a/plugins/nvapi-redaction/plugin.yaml +++ /dev/null @@ -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)" diff --git a/tests/test_redaction_registry.py b/tests/test_redaction_registry.py index b9460d8596f99..ddb50eda11bdb 100644 --- a/tests/test_redaction_registry.py +++ b/tests/test_redaction_registry.py @@ -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