Skip to content
Merged
9 changes: 9 additions & 0 deletions agent/shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,15 @@ def _parse_pre_tool_call(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
for verb, _, _, payload in _PRE_TOOL_DIALECTS:
if data.get(verb) == "modify" and isinstance(data.get(payload), dict):
return {"action": "modify", "args": data[payload]}
# Hermes-only escalation to the human-approval gate (#92553). Claude-Code's ``decision:
# approve`` means auto-ALLOW, so it is deliberately not mapped onto this.
if data.get("action") == "approve":
directive: Dict[str, Any] = {"action": "approve"}
for key in ("message", "rule_key"):
value = data.get(key)
if isinstance(value, str) and value.strip():
directive[key] = value.strip()
return directive
return None


Expand Down
8 changes: 4 additions & 4 deletions gateway/run_inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ def strip_discord_triggering_note(event: Any, message_text: Any) -> Any:
class GatewayInboundMixin:
"""Inbound message pipeline (_handle_message, text/media preparation, durable-turn markers, plugin injection) for GatewayRunner."""

def _hm_pre_gateway_dispatch_hook(
async def _hm_pre_gateway_dispatch_hook(
self, event: "MessageEvent", source: SessionSource
) -> Optional["MessageEvent"]:
"""Run the ``pre_gateway_dispatch`` plugin hook; None = drop, else the (maybe rewritten) event.
Results: ``{"action": "skip"}`` → drop; ``{"action": "rewrite", "text"}`` → replace ``event.text``;
``allow``/None → normal dispatch. Runs BEFORE auth so plugins can handle unauthorized senders."""
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_hook_results = _invoke_hook(
from hermes_cli.lifecycle import ainvoke_hook as _ainvoke_hook
_hook_results = await _ainvoke_hook(
"pre_gateway_dispatch", event=event, gateway=self,
# getattr: bare-runner tests build GatewayRunner via object.__new__ without __init__.
session_store=getattr(self, "session_store", None),
Expand Down Expand Up @@ -222,7 +222,7 @@ async def _hm_admit_event(
# scale-to-zero: only real user-originated inbound stamps the last-inbound clock;
# counting internal/system events would keep a genuinely idle gateway awake.
self._scale_to_zero_note_real_inbound()
event = self._hm_pre_gateway_dispatch_hook(event, source)
event = await self._hm_pre_gateway_dispatch_hook(event, source)
if event is None:
return None
source = event.source
Expand Down
9 changes: 9 additions & 0 deletions hermes_cli/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
return _plugin_hooks(hook_name, **kwargs)


async def ainvoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
""":func:`invoke_hook` for callers on an event loop: same observers-then-plugins
composition, with ``async def`` plugin callbacks awaited on that loop."""
_observe(hook_name, **kwargs)
from hermes_cli import plugins

return await plugins.ainvoke_hook(hook_name, **kwargs)


def has_hook(hook_name: str) -> bool:
"""Return whether a first-party observer or plugin consumes a hook."""
try:
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,10 @@ def __init__(self, original: BaseException) -> None:


def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwargs: Any) -> Any:
from hermes_cli.plugins import get_plugin_manager
from hermes_cli.plugins import _delivery_manager

payload_key = "request" if "request" in kwargs else "args"
manager = get_plugin_manager()
manager = _delivery_manager()
callbacks = list(manager._middleware.get(kind, []))
if not callbacks:
return terminal_call(kwargs[payload_key])
Expand Down
25 changes: 20 additions & 5 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,12 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
return _delivery_manager().invoke_hook(hook_name, **kwargs)


async def ainvoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
""":func:`invoke_hook` for callers on an event loop: ``async def`` callbacks are awaited
there instead of bridged through a helper thread (see ``PluginManager.ainvoke_hook``)."""
return await _delivery_manager().ainvoke_hook(hook_name, **kwargs)


def render_system_prompt_sections(session_info: Mapping[str, Any]) -> List[RenderedPluginSystemPromptSection]:
"""Render plugin prompt sections after idempotent plugin discovery."""
return _ensure_plugins_discovered().render_system_prompt_sections(session_info)
Expand Down Expand Up @@ -1810,8 +1816,10 @@ def _get_pre_tool_call_directive_details(
) -> _PreToolCallDirective:
"""Check ``pre_tool_call`` hooks for ``{"action": "block", "message"}`` (veto; message becomes
the tool result) or ``{"action": "approve", "message", "rule_key"?}`` (escalate ANY tool to the
human-approval gate; ``rule_key`` picks the ``[a]lways`` allowlist grain). First valid directive
wins; irrelevant returns are ignored."""
human-approval gate; ``rule_key`` picks the ``[a]lways`` allowlist grain). Precedence is
``block`` > ``approve`` > none, not registration order: any plugin's valid veto wins over an
earlier plugin's request for human confirmation (#87420); among approves the first valid one
wins. Irrelevant returns are ignored."""
allowed = getattr(_thread_tool_whitelist, "allowed", None)
if allowed is not None and tool_name not in allowed:
fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied")
Expand All @@ -1823,6 +1831,7 @@ def _get_pre_tool_call_directive_details(
api_request_id=api_request_id, middleware_trace=list(middleware_trace or []),
)
modified_args: Optional[Dict[str, Any]] = None
first_approve: Optional[Tuple[Optional[str], Optional[str]]] = None # (message, rule_key)
for result in hook_results:
if not isinstance(result, dict):
continue
Expand All @@ -1843,9 +1852,15 @@ def _get_pre_tool_call_directive_details(
# A block directive requires a message (it becomes the tool result); approve's is optional.
if action == "block" and not message:
continue
rule_key = result.get("rule_key") if action == "approve" else None
rule_key = (rule_key.strip() or None) if isinstance(rule_key, str) else None
return _PreToolCallDirective(action=action, message=message, rule_key=rule_key, modified_args=modified_args)
if action == "block":
return _PreToolCallDirective(action="block", message=message, modified_args=modified_args)
# approve is held back until the whole list has been scanned for a veto.
if first_approve is None:
rule_key = result.get("rule_key")
first_approve = (message, (rule_key.strip() or None) if isinstance(rule_key, str) else None)
if first_approve is not None:
return _PreToolCallDirective(action="approve", message=first_approve[0], rule_key=first_approve[1],
modified_args=modified_args)
return _PreToolCallDirective(modified_args=modified_args)


Expand Down
107 changes: 88 additions & 19 deletions hermes_cli/plugins_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import asyncio
import contextvars
import copy
import inspect
Expand Down Expand Up @@ -50,8 +51,19 @@
_HOOK_CALLER_THREAD_HOOKS: Set[str] = {"subagent_stop"}
# After a timeout, suppress the same callback this long so a hung hook cannot pile up threads.
_HOOK_TIMEOUT_SUPPRESSION_SECONDS = 60.0
# Live workers a hung callback may accumulate before it is skipped outright (#105223 / #98382).
_HOOK_MAX_ABANDONED_WORKERS = 3
_PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE = "pre_tool_call plugin callback timed out or is still running"


def _policy_error_block_directive(hook_name: str, cb: Callable, exc: BaseException) -> Dict[str, str]:
"""Block directive for a fail-closed hook whose callback raised: names the callback and the
error (truncated — a hook that embeds tool args in its exception must not grow the tool
result) so the operator can tell a crashing guard from a slow one."""
callback_name = getattr(cb, "__name__", repr(cb))
return {"action": "block",
"message": f"{hook_name} plugin callback {callback_name} raised {type(exc).__name__}: {str(exc)[:200]}"}

# System-prompt sections are tightly bounded: they become high-trust prompt bytes charged every turn.
SYSTEM_PROMPT_SECTION_POSITIONS = frozenset({"after_memory"})
DEFAULT_SYSTEM_PROMPT_SECTION_MAX_CHARS = 4_000
Expand Down Expand Up @@ -168,25 +180,31 @@ def _hook_uses_callback_timeout(hook_name: str, timeout: float) -> bool:

class PluginDispatchMixin:
@staticmethod
def _invoke_hook_callback(callback: Callable, payload: Dict[str, Any]) -> Any:
"""Invoke a hook while withholding additive fields from narrow legacy callbacks.

An ``async def`` callback returns a coroutine; resolve it the way plugin slash commands
are (loop-safe), otherwise the bare coroutine object is appended to the results and the
plugin's body never runs (#12449).
"""
from hermes_cli.plugins import resolve_plugin_command_result
def _hook_callback_kwargs(callback: Callable, payload: Dict[str, Any]) -> Dict[str, Any]:
"""The slice of *payload* a callback accepts: everything for ``**kwargs`` (or
un-introspectable) callbacks, only declared names for narrow legacy signatures."""
try:
parameters = inspect.signature(callback).parameters
except (TypeError, ValueError):
return resolve_plugin_command_result(callback(**payload)) # no introspectable signature
return dict(payload) # no introspectable signature
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
return resolve_plugin_command_result(callback(**payload))
return dict(payload)
keyword_kinds = {inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY}
return resolve_plugin_command_result(callback(**{
return {
name: value for name, value in payload.items()
if name in parameters and parameters[name].kind in keyword_kinds
}))
}

@classmethod
def _invoke_hook_callback(cls, callback: Callable, payload: Dict[str, Any]) -> Any:
"""Invoke a hook while withholding additive fields from narrow legacy callbacks.

An ``async def`` callback returns a coroutine; resolve it the way plugin slash commands
are (loop-safe), otherwise the bare coroutine object is appended to the results and the
plugin's body never runs (#12449).
"""
from hermes_cli.plugins import resolve_plugin_command_result
return resolve_plugin_command_result(callback(**cls._hook_callback_kwargs(callback, payload)))

def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]:
"""Call all callbacks for *hook_name*; return their non-``None`` results.
Expand Down Expand Up @@ -220,6 +238,8 @@ def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]:
results.append(ret)
except (Exception, SystemExit) as exc:
self._report_hook_failure(hook_name, cb, kwargs, exc)
if fail_closed: # a guard that raised made no decision: same veto as a timeout
results.append(_policy_error_block_directive(hook_name, cb, exc))
return results

def _report_hook_failure(
Expand Down Expand Up @@ -250,8 +270,9 @@ def _run_hook_callback_bounded(
self, hook_name: str, cb: Callable, kwargs: Dict[str, Any], timeout: float
) -> Any:
"""Run one callback on a daemon worker with a wall-clock cap; ``_HOOK_SKIPPED`` when
suppressed, still running, timed out (worker abandoned, never joined), or the worker
could not be started. Exceptions propagate."""
suppressed, still running for this call id, over the abandoned-worker cap, timed out
(worker abandoned, never joined), or the worker could not be started. Exceptions
propagate."""
callback_name = getattr(cb, "__name__", repr(cb))
# Suppression is a fact about the CALLBACK — a hung one must keep its back-off —
# so that key stays coarse. The gate must instead tell CONCURRENT CALLS apart.
Expand All @@ -260,15 +281,25 @@ def _run_hook_callback_bounded(
token = object()
with self._hook_timeout_lock:
suppressed_until = self._hook_timeout_suppressed_until.get(suppression_key)
# A worker abandoned on timeout still holds a thread; a fresh call id must not
# start a second one for the same callback, or a hung plugin leaks a thread per call.
running = (gate_key in self._hook_running_callbacks
or bool(self._hook_abandoned.get(suppression_key)))
if (suppressed_until is not None and suppressed_until > time.monotonic()) or running:
if (gate_key in self._hook_running_callbacks
or (suppressed_until is not None and suppressed_until > time.monotonic())):
logger.warning(
"Hook '%s' callback %s skipped after previous "
"timeout or while still running", hook_name, callback_name)
return _HOOK_SKIPPED
# Workers abandoned on timeout still hold threads. Once the suppression window has
# passed, a fresh call id may start a new worker (a hung guard must not fail every
# later tool call closed until restart, #105223), but only up to a small cap per
# callback — expiring the bookkeeping while the hung worker lives must not leak a
# thread per call (#98382). At the cap the callback keeps being skipped (fail-closed
# for pre_tool_call) until one of its workers finishes and releases its slot.
abandoned = self._hook_abandoned.get(suppression_key)
if abandoned and len(abandoned) >= _HOOK_MAX_ABANDONED_WORKERS:
logger.warning(
"Hook '%s' callback %s (%s) skipped: %d abandoned worker(s) still running — "
"the plugin is hung; fix or disable it (retried when a worker finishes)",
hook_name, callback_name, getattr(cb, "__module__", "unknown plugin"), len(abandoned))
return _HOOK_SKIPPED
if suppressed_until is not None:
self._hook_timeout_suppressed_until.pop(suppression_key, None)
self._hook_running_callbacks[gate_key] = token
Expand Down Expand Up @@ -449,6 +480,44 @@ def has_hook(self, hook_name: str) -> bool:
"""Return True when at least one callback is registered for a hook."""
return bool(self._hooks.get(hook_name))

async def ainvoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]:
""":meth:`invoke_hook` for callers that are already on an event loop.

Same payload narrowing, per-callback isolation and result contract. The difference is
where an ``async def`` callback runs: here it is awaited on the caller's own loop, so a
callback that awaits anything scheduled on that loop can make progress. Through the
sync path it runs on a helper thread while the caller blocks in ``done.wait()`` — on the
gateway that stalls the whole event loop for the callback's duration. Sync callbacks
run inline. Bounded hooks keep ``plugins.hook_callback_timeout`` via ``asyncio.wait_for``
(the coroutine is cancelled, not abandoned); a timed-out ``pre_tool_call`` fails closed.
"""
from hermes_cli.plugins import _resolve_hook_callback_timeout
if hook_name != "gateway_platform_event":
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
results: List[Any] = []
timeout = _resolve_hook_callback_timeout()
use_timeout = _hook_uses_callback_timeout(hook_name, timeout)
fail_closed = hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
for cb in self._hooks.get(hook_name, []):
callback_name = getattr(cb, "__name__", repr(cb))
try:
ret = cb(**self._hook_callback_kwargs(cb, kwargs))
if inspect.isawaitable(ret):
ret = await (asyncio.wait_for(ret, timeout) if use_timeout else ret)
if ret is not None:
results.append(ret)
except asyncio.TimeoutError:
logger.warning("Hook '%s' callback %s timed out after %.0fs", hook_name, callback_name, timeout)
if fail_closed: # policy hook: fail closed with a block directive
results.append({"action": "block", "message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE})
except (Exception, SystemExit) as exc:
# Same isolation + failure contract as the sync path (#111922 warn-once, #109624
# a raising policy guard fails closed).
self._report_hook_failure(hook_name, cb, kwargs, exc)
if fail_closed:
results.append(_policy_error_block_directive(hook_name, cb, exc))
return results

def iter_hook_callbacks(self, hook_name: str) -> tuple[Callable, ...]:
"""Return a stable snapshot of callbacks registered for a hook."""
return tuple(self._hooks.get(hook_name, ()))
Expand Down
39 changes: 38 additions & 1 deletion tests/agent/test_shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,18 @@ def test_block_claude_code_style(self):
)
assert r == {"action": "block", "message": "nope"}


@pytest.mark.parametrize("stdout, expected", [
('{"action": "approve", "message": " needs a human ", "rule_key": " terminal:rm "}',
{"action": "approve", "message": "needs a human", "rule_key": "terminal:rm"}),
('{"action": "approve", "message": "", "rule_key": 7}', {"action": "approve"}),
# Claude-Code's ``decision: approve`` means auto-ALLOW, not "ask a human": never mapped.
('{"decision": "approve", "reason": "ok"}', None),
('{"action": "approve", "decision": "block", "reason": "no"}', {"action": "block", "message": "no"}),
])
def test_approve_is_parsed_like_the_plugin_directive(self, stdout, expected):
"""The documented ``approve`` action used to parse to None, so the tool ran with no
approval prompt (#92553). It now yields the same shape Python plugins return."""
assert shell_hooks._parse_response("pre_tool_call", stdout) == expected

def test_empty_stdout_returns_none(self):
assert shell_hooks._parse_response("pre_tool_call", "") is None
Expand Down Expand Up @@ -201,6 +212,32 @@ def test_block_aggregation_through_plugin_manager(self, tmp_path, monkeypatch):
)
assert msg == "blocked-by-shell"

def test_approve_reaches_the_human_gate_through_plugin_manager(self, tmp_path, monkeypatch):
"""End to end: a shell hook's approve directive escalates to request_tool_approval with its
message and rule_key, and the gate's denial blocks the tool (#92553)."""
from hermes_cli import plugins

script = _write_script(
tmp_path, "approve.sh",
"#!/usr/bin/env bash\n"
'printf \'{"action": "approve", "message": "risky", "rule_key": "terminal:rm"}\\n\'\n',
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setenv("HERMES_ACCEPT_HOOKS", "1")
plugins._plugin_manager = plugins.PluginManager()
cfg = {"hooks": {"pre_tool_call": [{"matcher": "terminal", "command": str(script)}]}}
assert len(shell_hooks.register_from_config(cfg, accept_hooks=True)) == 1

seen = []

def _gate(tool_name, reason, **kwargs):
seen.append((tool_name, reason, kwargs.get("rule_key")))
return {"approved": False, "message": "denied by human"}

monkeypatch.setattr("tools.approval.request_tool_approval", _gate)
assert plugins.resolve_pre_tool_block("terminal", {"command": "rm"}) == "denied by human"
assert seen == [("terminal", "risky", "terminal:rm")]

def test_matcher_regex_filters_callback(self, tmp_path, monkeypatch):
"""A matcher set to 'terminal' must not fire for 'web_search'."""
calls = tmp_path / "calls.log"
Expand Down
Loading
Loading