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
32 changes: 28 additions & 4 deletions agent/shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
# Inject context for pre_llm_call:
{"context": "Today is Friday"}

# Replace output for any transform_* hook (field-shaped objects also work):
"replacement text"

# Silent no-op:
<empty or any non-matching JSON object>

Expand Down Expand Up @@ -138,6 +141,11 @@
MAX_TIMEOUT_SECONDS = 300
ALLOWLIST_FILENAME = "shell-hooks-allowlist.json"
_DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook."
_TRANSFORM_RESPONSE_FIELDS = {
"transform_llm_output": "response_text",
"transform_tool_result": "result",
"transform_terminal_output": "output",
}

# (event, matcher, command) triples that have been wired to the plugin
# manager in the current process. Matcher is part of the key because
Expand Down Expand Up @@ -489,10 +497,12 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
return result


def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any]]]:
def _make_callback(
spec: ShellHookSpec,
) -> Callable[..., Optional[Dict[str, Any] | str]]:
"""Build the closure that ``invoke_hook()`` will call per firing."""

def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]:
def _callback(**kwargs: Any) -> Optional[Dict[str, Any] | str]:
# Matcher gate — only meaningful for tool-scoped events.
if spec.event in {"pre_tool_call", "post_tool_call"}:
if not spec.matches_tool(kwargs.get("tool_name")):
Expand Down Expand Up @@ -563,8 +573,8 @@ def _block_message(primary: Any, secondary: Any) -> str:
return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE


def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
"""Translate stdout JSON into a Hermes wire-shape dict.
def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any] | str]:
"""Translate stdout JSON into the return type expected by a hook.

For ``pre_tool_call`` the Claude-Code-style ``{"decision": "block",
"reason": "..."}`` payload is translated into the canonical Hermes
Expand All @@ -577,6 +587,11 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
For ``pre_llm_call``, ``{"context": "..."}`` is passed through
unchanged to match the existing plugin-hook contract.

Transform hooks return strings rather than dictionaries. Their shell
equivalents may emit either a JSON string directly or an object keyed by
the callback's input/output field (``response_text``, ``result``, or
``output``).

Anything else returns ``None``.
"""
stdout = (stdout or "").strip()
Expand All @@ -592,6 +607,15 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
)
return None

transform_field = _TRANSFORM_RESPONSE_FIELDS.get(event)
if transform_field is not None:
if isinstance(data, str):
return data or None
if isinstance(data, dict):
replacement = data.get(transform_field)
return replacement if isinstance(replacement, str) and replacement else None
return None

if not isinstance(data, dict):
return None

Expand Down
59 changes: 59 additions & 0 deletions tests/agent/test_shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,41 @@ def test_invalid_json_returns_none(self):
def test_non_dict_json_returns_none(self):
assert shell_hooks._parse_response("pre_tool_call", "[1, 2]") is None

@pytest.mark.parametrize(
"event",
[
"transform_llm_output",
"transform_tool_result",
"transform_terminal_output",
],
)
def test_transform_json_string_returns_replacement(self, event):
assert shell_hooks._parse_response(event, '"rewritten"') == "rewritten"

@pytest.mark.parametrize(
("event", "field"),
[
("transform_llm_output", "response_text"),
("transform_tool_result", "result"),
("transform_terminal_output", "output"),
],
)
def test_transform_field_object_returns_replacement(self, event, field):
stdout = json.dumps({field: "rewritten"})
assert shell_hooks._parse_response(event, stdout) == "rewritten"

@pytest.mark.parametrize(
"stdout",
[
'""',
'{"response_text": ""}',
'{"response_text": 42}',
'{"context": "not a transform result"}',
],
)
def test_transform_invalid_replacement_is_ignored(self, stdout):
assert shell_hooks._parse_response("transform_llm_output", stdout) is None

def test_non_block_pre_tool_call_returns_none(self):
r = shell_hooks._parse_response("pre_tool_call", '{"decision": "allow"}')
assert r is None
Expand Down Expand Up @@ -353,6 +388,30 @@ def test_block_aggregation_through_plugin_manager(self, tmp_path, monkeypatch):
)
assert msg == "blocked-by-shell"

def test_transform_results_flow_through_plugin_manager(self, tmp_path, monkeypatch):
"""Real shell callbacks must return strings to transform-hook callers."""
from hermes_cli import plugins

script = _write_script(
tmp_path,
"rewrite.sh",
"#!/usr/bin/env bash\ncat - >/dev/null\nprintf '\"shell-rewritten\"\\n'\n",
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
plugins._plugin_manager = plugins.PluginManager()

events = (
"transform_llm_output",
"transform_tool_result",
"transform_terminal_output",
)
cfg = {"hooks": {event: [{"command": str(script)}] for event in events}}
registered = shell_hooks.register_from_config(cfg, accept_hooks=True)
assert len(registered) == len(events)

for event in events:
assert plugins.invoke_hook(event) == ["shell-rewritten"]

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
8 changes: 8 additions & 0 deletions website/docs/user-guide/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,14 @@ Each time the event fires, Hermes spawns a subprocess for every matching hook (m
// Inject context for pre_llm_call:
{"context": "Today is Friday, 2026-04-17"}

// Replace output for a transform hook (a JSON string is the shortest form):
"Rewritten assistant response"

// Field-shaped objects are also accepted for scripts that edit the input payload:
{"response_text": "Rewritten assistant response"} // transform_llm_output
{"result": "Rewritten tool result"} // transform_tool_result
{"output": "Rewritten terminal output"} // transform_terminal_output

// Keep the agent going at the verify gate (pre_verify); both shapes accepted:
{"action": "continue", "message": "Run the formatter, then finish."}
{"decision": "block", "reason": "Run the formatter, then finish."}
Expand Down
Loading