feat(plugins): let pre_tool_call hooks block tool execution

Plugins can now return {"action": "block", "message": "reason"} from
their pre_tool_call hook to prevent a tool from executing. The error
message is returned to the model as a tool result so it can adjust.

Covers both execution paths: handle_function_call (model_tools.py) and
agent-level tools (run_agent.py _invoke_tool + sequential/concurrent).
Blocked tools skip all side effects (counter resets, checkpoints,
callbacks, read-loop tracker).

Adds skip_pre_tool_call_hook flag to avoid double-firing the hook when
run_agent.py already checked and then calls handle_function_call.

Salvaged from PR #5385 (gianfrancopiana) and PR #4610 (oredsecurity).
This commit is contained in:
Gianfranco Piana 2026-04-13 21:15:25 -07:00 committed by Teknium
parent ea74f61d98
commit eabc0a2f66
6 changed files with 335 additions and 40 deletions

View file

@ -18,6 +18,7 @@ from hermes_cli.plugins import (
PluginManager,
PluginManifest,
get_plugin_manager,
get_pre_tool_call_block_message,
discover_plugins,
invoke_hook,
)
@ -310,6 +311,50 @@ class TestPluginHooks:
assert any("on_banana" in record.message for record in caplog.records)
class TestPreToolCallBlocking:
"""Tests for the pre_tool_call block directive helper."""
def test_block_message_returned_for_valid_directive(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "block", "message": "blocked by plugin"}],
)
assert get_pre_tool_call_block_message("todo", {}, task_id="t1") == "blocked by plugin"
def test_invalid_returns_are_ignored(self, monkeypatch):
"""Various malformed hook returns should not trigger a block."""
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
"block", # not a dict
123, # not a dict
{"action": "block"}, # missing message
{"action": "deny", "message": "nope"}, # wrong action
{"message": "missing action"}, # no action key
{"action": "block", "message": 123}, # message not str
],
)
assert get_pre_tool_call_block_message("todo", {}, task_id="t1") is None
def test_none_when_no_hooks(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [],
)
assert get_pre_tool_call_block_message("web_search", {"q": "test"}) is None
def test_first_valid_block_wins(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
{"action": "allow"},
{"action": "block", "message": "first blocker"},
{"action": "block", "message": "second blocker"},
],
)
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"
# ── TestPluginContext ──────────────────────────────────────────────────────