mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
feat(plugins): pre_tool_call approve action escalates to human gate
Extend the pre_tool_call plugin hook return contract with a new directive:
{"action": "approve", "message": "why this needs human confirmation"}
Previously a pre_tool_call hook could only veto a tool call (action: block)
or allow it silently. It could not escalate to the existing human-approval
flow. This unlocks user-defined runtime approval rules on ANY tool (HTTP
writes, file writes to sensitive paths, email sends), enforced at runtime —
resolving #51221 as a pure plugin, with no core approval.py rule schema.
Mechanism:
- get_pre_tool_call_directive() returns (action, message) for block|approve;
get_pre_tool_call_block_message() kept as a block-only back-compat shim.
- resolve_pre_tool_block() is the single dispatch-site chokepoint: fetches
the directive and, for approve, invokes the human gate; fail-closed to a
block on denial, timeout, or gate exception. ALL FOUR tool-dispatch sites
now call it: tool_executor (concurrent + sequential), agent_runtime_helpers,
and model_tools.handle_function_call.
- request_tool_approval() escalates via the SAME machinery as Tier-2
dangerous commands: session/permanent allowlist, prompt_dangerous_approval
(CLI) / submit_pending (gateway), [o]nce/[s]ession/[a]lways/[d]eny,
timeout fail-closed, approvals.cron_mode for cron contexts.
Architecture: extracted the shared decision core into _run_approval_gate(),
called by BOTH check_dangerous_command() and request_tool_approval() so the
fail-closed / cron / gateway / yolo / persist policy lives in ONE place and
cannot drift. Fixed a latent divergence — the plugin path now honors --yolo.
Approval grain: [a]lways is keyed on tool_name + a hash of the reason (an
explicit plugin rule_key overrides), so distinct reasons on the same tool
persist independently instead of one 'always' blanketing the whole tool.
Non-interactive: cron honors approvals.cron_mode (parity with commands); any
other non-interactive non-gateway context fails CLOSED for the plugin path
(the command path keeps its historical fail-open default, unchanged).
No new config schema, no new env vars, no new hook events.
This commit is contained in:
parent
1388cd1c0c
commit
f512d6f020
9 changed files with 666 additions and 103 deletions
|
|
@ -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 = "",
|
||||
|
|
@ -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": "block", "message": "Reason the tool was blocked"}
|
||||
{"action": "approve", "message": "Why this needs human confirmation"}
|
||||
|
||||
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.
|
||||
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`).
|
||||
|
||||
The first valid 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",
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue