Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/agent-sec-core/hermes-plugin/src/capabilities/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions src/agent-sec-core/hermes-plugin/src/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] transform_llm_output 组合链缺少顺序与幂等性约束说明

当前 _compose_transform_chain(callbacks) 直接依赖 callbacks 列表顺序,且在 register_capabilities 末尾用 "transform-compose" 统一包装。建议这里用注释明确:1)能力注册顺序即链执行顺序,后注册能力只能追加告警;2)transform-compose 自身不应在能力列表中重复注册,避免链上多次包装。


🤖 Generated by QoderFix in Qoder

"""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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept session_id in composed transform hook

When Hermes invokes transform_llm_output with its documented (response_text, session_id, **kwargs) signature, this composed _chain rejects the second positional argument. Because register_capabilities wraps it in safe_hook_wrapper, that TypeError is swallowed and the composed transform returns None, so prompt/PII/skill-ledger final-response warnings are all dropped for that turn; keep the same signature or accept positional args and forward session_id to the capability callbacks.

AGENTS.md reference: src/agent-sec-core/AGENTS.md:L397-L405

Useful? React with 👍 / 👎.

current = response_text
for _capability_id, callback in callbacks:
result = callback(response_text=current, **kwargs)
if isinstance(result, str) and result:
Comment on lines +60 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] transform_llm_output 组合链未显式处理非字符串返回值

_chain 中仅在 isinstance(result, str) 且非空时更新 current,其余类型直接忽略。若未来某 capability transform 返回结构化对象或 None 表示特殊语义,现有实现会静默吞掉这类约定。建议在此明确约定:transform_llm_output 必须返回字符串,非字符串视为错误并记录日志,避免后续扩展产生难以发现的行为偏差。


🤖 Generated by QoderFix in Qoder

current = result
return current

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return None when the chain makes no change

When none of the collected capabilities emits a warning/redaction, current is still the original non-empty response_text, so the composed hook now returns a non-empty string for every normal turn. Under the first-non-empty-wins behavior this code is explicitly handling, that makes agent-sec-core claim the transform even though it made no change, preventing any later Hermes transform_llm_output hook from running; track whether any callback changed the text and return None on no-op.

Useful? React with 👍 / 👎.


return _chain


def load_config(plugin_dir: Path) -> dict[str, Any]:
"""Load config.toml from the plugin directory.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] transform_llm_output 组合链对 _TRANSFORM_HOOKS 全局列表缺少并发安全说明

register_capabilities 通过全局 _TRANSFORM_HOOKS 收集能力回调,并在末尾清空。若 Hermes 插件未来支持并发 register_capabilities 调用或热重载能力,这种共享全局列表可能产生交叉污染。建议在此处增加显式约束:注册流程仅在初始化阶段串行执行,不支持并发/重入;如未来需要并发,需改为局部容器。


🤖 Generated by QoderFix in Qoder

_TRANSFORM_HOOKS = []
if "capabilities" not in config:
logger.error(
f"[agent-sec-core] config missing [capabilities] section, no capabilities registered"
Expand Down Expand Up @@ -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 = []
Loading