diff --git a/src/agent-sec-core/hermes-plugin/src/capabilities/base.py b/src/agent-sec-core/hermes-plugin/src/capabilities/base.py index 0d842f629e..f1e9975661 100644 --- a/src/agent-sec-core/hermes-plugin/src/capabilities/base.py +++ b/src/agent-sec-core/hermes-plugin/src/capabilities/base.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from typing import Callable, final -from ..registry import safe_hook_wrapper +from ..registry import _collect_transform_hook, safe_hook_wrapper logger = logging.getLogger("agent-sec-core") @@ -45,7 +45,13 @@ def register(self, ctx, config: dict) -> None: self._on_register(config) for hook_name, callback_func in self.get_hooks_define().items(): wrapper_func = safe_hook_wrapper(callback_func, self.id) - ctx.register_hook(hook_name, wrapper_func) + if hook_name == "transform_llm_output": + # Collect for single composed registration (Hermes applies + # transform_llm_output with "first non-empty wins", which would + # let one capability's warning silently drop another's). + _collect_transform_hook(self.id, wrapper_func) + else: + ctx.register_hook(hook_name, wrapper_func) @abstractmethod def _on_register(self, config: dict) -> None: diff --git a/src/agent-sec-core/hermes-plugin/src/registry.py b/src/agent-sec-core/hermes-plugin/src/registry.py index e8551e2ea3..ba87ac8e56 100644 --- a/src/agent-sec-core/hermes-plugin/src/registry.py +++ b/src/agent-sec-core/hermes-plugin/src/registry.py @@ -27,6 +27,46 @@ # If a single hook invocation exceeds this threshold (seconds), emit a warning. _SLOW_HOOK_THRESHOLD = 2.0 +# --------------------------------------------------------------------------- +# transform_llm_output composition +# +# Hermes applies transform_llm_output hooks with "first hook returning a +# non-empty string wins" semantics (see Hermes agent/turn_finalizer.py). +# Multiple agent-sec-core capabilities (pii-scan, prompt-scan, skill-ledger) +# each register a transform_llm_output hook; under "first-wins", a later +# capability's warning is silently dropped whenever an earlier capability also +# fires in the same turn — a security warning (e.g. "status=tampered") becomes +# invisible to the user. To avoid this, collect all capability transform +# callbacks here and register a single composed hook that chains them, so every +# capability's warning is prepended to the response. +# --------------------------------------------------------------------------- +_TRANSFORM_HOOKS: list[tuple[str, Any]] = [] + + +def _collect_transform_hook(capability_id: str, callback: Any) -> None: + """Called by AgentSecCoreCapability.register to collect (not register) + a capability's transform_llm_output callback for composition.""" + _TRANSFORM_HOOKS.append((capability_id, callback)) + + +def _compose_transform_chain(callbacks: list[tuple[str, Any]]): + """Return a single transform_llm_output callback that chains ``callbacks``. + + Each callback receives the accumulated text as ``response_text`` and + prepends its own warnings, so the composed result carries every + capability's warning instead of only the first one to fire. + """ + + def _chain(response_text: str = "", **kwargs: Any) -> str: + current = response_text + for _capability_id, callback in callbacks: + result = callback(response_text=current, **kwargs) + if isinstance(result, str) and result: + current = result + return current + + return _chain + def load_config(plugin_dir: Path) -> dict[str, Any]: """Load config.toml from the plugin directory. @@ -70,6 +110,8 @@ def register_capabilities( ctx, capabilities: list[AgentSecCoreCapability], config: dict ) -> None: """Register all enabled capabilities with the Hermes plugin context.""" + global _TRANSFORM_HOOKS + _TRANSFORM_HOOKS = [] if "capabilities" not in config: logger.error( f"[agent-sec-core] config missing [capabilities] section, no capabilities registered" @@ -103,3 +145,15 @@ def register_capabilities( logger.info(f"[agent-sec-core] {cap.id} registered successfully") except Exception as e: logger.error(f"[agent-sec-core] {cap.id} registration failed: {e}") + + # Register a single composed transform_llm_output hook that chains every + # capability's transform callback, so all security warnings (prompt-scan, + # pii-scan, skill-ledger) are preserved instead of one silently dropping + # another under Hermes' "first non-empty wins" semantics. + if _TRANSFORM_HOOKS: + composed = _compose_transform_chain(_TRANSFORM_HOOKS) + ctx.register_hook( + "transform_llm_output", + safe_hook_wrapper(composed, "transform-compose"), + ) + _TRANSFORM_HOOKS = []