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:
kshitijk4poor 2026-07-05 12:38:26 +05:30
parent 1388cd1c0c
commit f512d6f020
9 changed files with 666 additions and 103 deletions

View file

@ -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