Skip to content

feat(plugins): let pre_tool_call hooks block tool execution#9377

Merged
teknium1 merged 1 commit into
mainfrom
hermes/hermes-1a1ffb4e
Apr 14, 2026
Merged

feat(plugins): let pre_tool_call hooks block tool execution#9377
teknium1 merged 1 commit into
mainfrom
hermes/hermes-1a1ffb4e

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

What this PR does

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 its approach.

This is a minimal, backward-compatible change — existing observer-only hooks are unaffected. Invalid or malformed return values are silently ignored.

Use Cases

Policy enforcement for multi-user gateway deployments. A plugin can check the session/user context and restrict tools per user — some Discord users get terminal access, others don't. Unlike the existing approval system (which is interactive and asks the user), plugin blocking is automatic and instant.

Cost guardrails. A plugin tracking cumulative tool calls or tokens can block expensive operations (browser sessions, web searches, delegate_task) after hitting a budget. The model gets back "Budget exceeded for browser tools" and adapts — uses cheaper tools or tells the user.

Environment-specific restrictions. A "demo mode" plugin that blocks write_file, terminal, and patch so the agent can only read and search. Or a "read-only audit" mode for compliance workflows where the agent analyzes code but cannot modify anything.

Tool-level rate limiting. Cap terminal calls to N per minute to prevent runaway loops. The model sees "Rate limit: max 10 terminal calls per minute" and batches its work differently.

Content filtering on arguments. Block web_search calls containing certain query patterns, or terminal commands matching dangerous patterns beyond what the existing approval heuristics catch. The approval system is hardcoded in tools/approval.py; this lets users define their own policy rules as plugins.

Example Plugin

# ~/.hermes/plugins/tool_policy/plugin.yaml
# name: tool_policy
# version: 0.1.0
# description: Block dangerous tools for non-admin users

# ~/.hermes/plugins/tool_policy/__init__.py
ADMIN_SESSIONS = {"admin-session-1", "admin-session-2"}

def register(ctx):
    def enforce_policy(**kwargs):
        tool = kwargs.get("tool_name", "")
        session = kwargs.get("session_id", "")
        if tool == "terminal" and session not in ADMIN_SESSIONS:
            return {"action": "block", "message": "terminal access restricted to admin sessions"}
        return None
    ctx.register_hook("pre_tool_call", enforce_policy)

Related Issues / PRs

Salvaged from PR #5385 (@gianfrancopiana) and PR #4610 (@oredsecurity) — both independently requested this feature. Closes the core ask from both PRs.

Also addresses the blocking subset of #9070 (@bugmaker2), #8642, #8643, and #4169 without the hook renaming or speculative infrastructure.

Changes Made

hermes_cli/plugins.py (+39) — New get_pre_tool_call_block_message() helper. Fires the existing pre_tool_call hook and scans results for a valid block directive. Strict validation: must be a dict, action must be "block", message must be a non-empty string.

model_tools.py (+53 / -20) — handle_function_call() gains skip_pre_tool_call_hook parameter. When False (default), checks for blocking before dispatch. When True (called from _invoke_tool which already checked), still fires the hook for observers but skips the block check. Blocked tools also skip the read-loop tracker notification.

run_agent.py (+72 / -17) — Three insertion points:

  • _invoke_tool(): Block check at the top, before the agent-level tool dispatch chain. Covers the concurrent execution path.
  • _execute_tool_calls_sequential(): Block check after arg parsing. When blocked, skips counter resets, activity tracking, progress/start callbacks, checkpoints, and the entire execution chain. Still appends a tool result message so the model sees the rejection.
  • Both sequential handle_function_call calls pass skip_pre_tool_call_hook=True.

Tests (+230):

  • test_plugins.py: 4 tests — valid block, invalid returns ignored, no hooks, first valid block wins
  • test_model_tools.py: 4 tests — blocked skips dispatch, blocked skips read-loop, invalid returns pass through, skip flag works
  • test_run_agent.py: 5 tests — blocked agent-level tool, blocked registry tool, sequential skips checkpoints/callbacks, blocked memory tool preserves counter, existing test updated

How to Test

python3 -m pytest tests/hermes_cli/test_plugins.py tests/test_model_tools.py tests/run_agent/test_run_agent.py -o "addopts=" -q
# 295 passed

Attribution

Cherry-picked and adapted from PR #5385 by @gianfrancopiana (comprehensive implementation covering both execution paths) and PR #4610 by @oredsecurity (original feature request with clean convention design).

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).
@teknium1 teknium1 merged commit eabc0a2 into main Apr 14, 2026
5 of 7 checks passed
@teknium1 teknium1 deleted the hermes/hermes-1a1ffb4e branch April 14, 2026 05:01
@oredsecurity

Copy link
Copy Markdown

Hey @teknium1, glad to see this land. Thank you for the attribution in the description and for calling out our original PR#4610. Looking forward to contributing more in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants