Skip to content
Merged
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
8 changes: 4 additions & 4 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2094,12 +2094,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
except Exception as _mw_err:
logger.debug("tool_request middleware error: %s", _mw_err)

# Check plugin hooks for a block directive before executing anything.
# Check plugin hooks for a block or approval directive before executing.
block_message: Optional[str] = None
if not pre_tool_block_checked:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
Expand All @@ -2110,7 +2110,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
middleware_trace=list(_tool_middleware_trace),
)
except Exception:
pass
block_message = None
if block_message is not None:
result = json.dumps({"error": block_message}, ensure_ascii=False)
try:
Expand Down
8 changes: 4 additions & 4 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
)
else:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
Expand Down Expand Up @@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
_block_error_type = "tool_scope_block"
else:
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
_block_msg = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
_block_msg = resolve_pre_tool_block(
function_name,
function_args,
task_id=effective_task_id or "",
Expand Down
119 changes: 107 additions & 12 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -2046,7 +2046,7 @@ def clear_thread_tool_whitelist() -> None:
_thread_tool_whitelist.allowed = None


def get_pre_tool_call_block_message(
def get_pre_tool_call_directive(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
Expand All @@ -2055,22 +2055,38 @@ def get_pre_tool_call_block_message(
turn_id: str = "",
api_request_id: str = "",
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""Check ``pre_tool_call`` hooks for a blocking directive.
) -> tuple[Optional[str], Optional[str]]:
"""Check ``pre_tool_call`` hooks for a blocking or approval directive.

Plugins that need to enforce policy (rate limiting, security
restrictions, approval workflows) can return::
restrictions, approval workflows) can return one of::

{"action": "block", "message": "Reason the tool was blocked"}
{"action": "approve", "message": "Why this needs human confirmation"}

from their ``pre_tool_call`` callback.

- ``block`` vetoes the tool call outright (the message becomes the tool
result the model sees).
- ``approve`` ESCALATES to the existing human-approval gate
(``prompt_dangerous_approval`` on CLI, the approval callback on the
gateway) — the same mechanism Tier-2 dangerous shell patterns use.
This lets a plugin require a human ``[o]nce/[s]ession/[a]lways/[d]eny``
decision on ANY tool, not just terminal command strings. The caller is
responsible for invoking the gate (see
:func:`tools.approval.request_tool_approval`).

{"action": "block", "message": "Reason the tool was blocked"}
The first valid directive wins. Invalid or irrelevant hook return values
are silently ignored so existing observer-only hooks are unaffected.

from their ``pre_tool_call`` callback. The first valid block
directive wins. Invalid or irrelevant hook return values are
silently ignored so existing observer-only hooks are unaffected.
Returns:
``(directive, message)`` where ``directive`` is ``"block"``,
``"approve"``, or ``None``.
"""
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")
return fmt.format(tool_name=tool_name)
return ("block", fmt.format(tool_name=tool_name))

hook_results = invoke_hook(
"pre_tool_call",
Expand All @@ -2087,12 +2103,91 @@ def get_pre_tool_call_block_message(
for result in hook_results:
if not isinstance(result, dict):
continue
if result.get("action") != "block":
action = result.get("action")
if action not in ("block", "approve"):
continue
message = result.get("message")
if isinstance(message, str) and message:
return message
message = message if isinstance(message, str) and message else None
# A block directive requires a message (it becomes the tool result);
# an approve directive can carry an optional reason.
if action == "block" and not message:
continue
return (action, message)

return (None, None)


def get_pre_tool_call_block_message(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
session_id: str = "",
tool_call_id: str = "",
turn_id: str = "",
api_request_id: str = "",
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""Back-compat shim: return only a ``block`` message (or ``None``).

Deprecated in favor of :func:`get_pre_tool_call_directive`, which also
surfaces the ``approve`` escalation directive. Kept so any external caller
importing the old name keeps working; ``approve`` directives are invisible
to this shim (it only reports blocks).
"""
directive, message = get_pre_tool_call_directive(
tool_name, args, task_id=task_id, session_id=session_id,
tool_call_id=tool_call_id, turn_id=turn_id,
api_request_id=api_request_id, middleware_trace=middleware_trace,
)
return message if directive == "block" else None


def resolve_pre_tool_block(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
session_id: str = "",
tool_call_id: str = "",
turn_id: str = "",
api_request_id: str = "",
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""Resolve the pre_tool_call directive to a final block message (or None).

Single entry point for every tool-dispatch site: fetches the plugin
directive and, for an ``approve`` escalation, invokes the human-approval
gate (:func:`tools.approval.request_tool_approval`). Returns the message
the tool result should carry when the call is blocked, or ``None`` when
the call may proceed.

Centralizing this keeps the security-critical fail-closed logic in ONE
place instead of copy-pasted across the concurrent/sequential/helper
dispatch paths: an ``approve`` directive whose gate errors, denies, or
times out is fail-closed to a block; ``block`` blocks with its message;
anything else proceeds.
"""
directive, message = get_pre_tool_call_directive(
tool_name, args, task_id=task_id, session_id=session_id,
tool_call_id=tool_call_id, turn_id=turn_id,
api_request_id=api_request_id, middleware_trace=middleware_trace,
)
if directive == "block":
return message
if directive == "approve":
try:
from tools.approval import request_tool_approval
result = request_tool_approval(
tool_name, message or "", rule_key=tool_name,
)
except Exception:
# Fail-closed: if the gate itself errors, block rather than
# silently execute an action a plugin flagged for approval.
return f"BLOCKED: plugin approval gate failed for {tool_name}"
if not result.get("approved"):
return str(
result.get("message")
or f"BLOCKED: plugin approval required for {tool_name}"
)
return None


Expand Down
19 changes: 10 additions & 9 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1046,21 +1046,22 @@ def handle_function_call(
if function_name in _AGENT_LOOP_TOOLS:
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})

# Check plugin hooks for a block directive (unless caller already
# checked — e.g. run_agent._invoke_tool passes skip=True to
# Check plugin hooks for a block/approve directive (unless caller
# already checked — e.g. run_agent._invoke_tool passes skip=True to
# avoid double-firing the hook).
#
# Single-fire contract: pre_tool_call fires exactly once per tool
# execution. get_pre_tool_call_block_message() internally calls
# invoke_hook("pre_tool_call", ...) and returns the first block
# directive (if any), so observer plugins see the hook on that same
# pass. When skip=True, the caller already fired it — do nothing
# here.
# execution. resolve_pre_tool_block() internally calls
# invoke_hook("pre_tool_call", ...) once and returns the block message
# for a `block` directive OR for an `approve` directive whose human
# gate denied/timed-out/errored (fail-closed). Observer plugins see
# the hook on that same pass. When skip=True, the caller already
# fired it — do nothing here.
if not skip_pre_tool_call_hook:
block_message: Optional[str] = None
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
function_name,
function_args,
task_id=task_id or "",
Expand Down
109 changes: 109 additions & 0 deletions tests/hermes_cli/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,115 @@ def test_first_valid_block_wins(self, monkeypatch):
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"


class TestPreToolCallDirective:
"""Tests for the extended (block | approve) directive helper."""

def test_approve_directive_returned(self, monkeypatch):
from hermes_cli.plugins import get_pre_tool_call_directive
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
{"action": "approve", "message": "needs human ok"}
],
)
assert get_pre_tool_call_directive("write_file", {}) == (
"approve", "needs human ok")

def test_approve_without_message_is_valid(self, monkeypatch):
"""approve may omit a message (block may not)."""
from hermes_cli.plugins import get_pre_tool_call_directive
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "approve"}],
)
assert get_pre_tool_call_directive("write_file", {}) == ("approve", None)

def test_block_still_requires_message(self, monkeypatch):
from hermes_cli.plugins import get_pre_tool_call_directive
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "block"}],
)
assert get_pre_tool_call_directive("terminal", {}) == (None, None)

def test_first_directive_wins_across_actions(self, monkeypatch):
from hermes_cli.plugins import get_pre_tool_call_directive
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
{"action": "approve", "message": "gate first"},
{"action": "block", "message": "block second"},
],
)
assert get_pre_tool_call_directive("terminal", {}) == (
"approve", "gate first")

def test_shim_ignores_approve(self, monkeypatch):
"""Back-compat shim only reports block, never approve."""
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
{"action": "approve", "message": "gate"}
],
)
assert get_pre_tool_call_block_message("write_file", {}) is None


class TestResolvePreToolBlock:
"""Tests for the single dispatch-site chokepoint that resolves a
directive (incl. the approve→gate escalation) to a block message."""

def test_block_returns_message(self, monkeypatch):
from hermes_cli.plugins import resolve_pre_tool_block
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "block", "message": "no"}],
)
assert resolve_pre_tool_block("terminal", {}) == "no"

def test_no_directive_returns_none(self, monkeypatch):
from hermes_cli.plugins import resolve_pre_tool_block
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: [])
assert resolve_pre_tool_block("terminal", {}) is None

def test_approve_denied_blocks(self, monkeypatch):
from hermes_cli.plugins import resolve_pre_tool_block
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "approve", "message": "why"}],
)
monkeypatch.setattr(
"tools.approval.request_tool_approval",
lambda *a, **k: {"approved": False, "message": "user denied it"},
)
assert resolve_pre_tool_block("write_file", {}) == "user denied it"

def test_approve_granted_allows(self, monkeypatch):
from hermes_cli.plugins import resolve_pre_tool_block
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "approve", "message": "why"}],
)
monkeypatch.setattr(
"tools.approval.request_tool_approval",
lambda *a, **k: {"approved": True, "message": None},
)
assert resolve_pre_tool_block("write_file", {}) is None

def test_approve_gate_exception_fails_closed(self, monkeypatch):
from hermes_cli.plugins import resolve_pre_tool_block
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "approve", "message": "why"}],
)
def _boom(*a, **k):
raise RuntimeError("gate crashed")
monkeypatch.setattr("tools.approval.request_tool_approval", _boom)
msg = resolve_pre_tool_block("terminal", {})
assert msg is not None and "gate failed" in msg # fail-closed


class TestGetPreVerifyContinueMessage:
"""`pre_verify` directive aggregation — mirrors the pre_tool_call block path."""

Expand Down
Loading
Loading