feat(plugins): let pre_tool_call hooks block tool execution#9377
Merged
Conversation
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 was referenced Apr 14, 2026
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
Plugins can now return
{"action": "block", "message": "reason"}from theirpre_tool_callhook 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
terminalaccess, 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
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) — Newget_pre_tool_call_block_message()helper. Fires the existingpre_tool_callhook and scans results for a valid block directive. Strict validation: must be a dict,actionmust be"block",messagemust be a non-empty string.model_tools.py(+53 / -20) —handle_function_call()gainsskip_pre_tool_call_hookparameter. When False (default), checks for blocking before dispatch. When True (called from_invoke_toolwhich 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.handle_function_callcalls passskip_pre_tool_call_hook=True.Tests (+230):
test_plugins.py: 4 tests — valid block, invalid returns ignored, no hooks, first valid block winstest_model_tools.py: 4 tests — blocked skips dispatch, blocked skips read-loop, invalid returns pass through, skip flag workstest_run_agent.py: 5 tests — blocked agent-level tool, blocked registry tool, sequential skips checkpoints/callbacks, blocked memory tool preserves counter, existing test updatedHow to Test
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).