fix(approval): wire gateway notify round-trip into the plugin escalation gate

_run_approval_gate's gateway branch only queued via submit_pending, so
plugin-escalated approvals never sent the interactive embed+buttons on
Discord/Telegram/Slack (#59413) - the user was never notified and the
action stayed silently blocked. Mirror check_dangerous_command's path:
when a session notify callback is registered, run the blocking
_await_gateway_decision round-trip (redacted payload, once/session/
always persistence, deny/timeout produce definitive BLOCKED outcomes);
fall back to submit_pending only when no callback exists.

Fixes #59413.
This commit is contained in:
teknium1 2026-07-07 13:52:24 -07:00 committed by Teknium
parent 36308f0667
commit 117f49b7d4

View file

@ -2106,6 +2106,72 @@ def _run_approval_gate(
return {"approved": True, "message": None}
if is_gateway or env_var_enabled("HERMES_EXEC_ASK"):
# Interactive gateway round-trip when a notify callback is
# registered for this session (Discord/Telegram/Slack embed +
# buttons, same mechanism as check_dangerous_command). Blocks the
# agent thread until the user answers; the agent never sees
# "approval_required" on this path — it gets a definitive
# approved/BLOCKED outcome.
notify_cb = None
with _lock:
notify_cb = _gateway_notify_cbs.get(session_key)
if notify_cb is not None:
from agent.redact import redact_sensitive_text
approval_data = {
"command": redact_sensitive_text(display_target),
"pattern_key": pattern_key,
"pattern_keys": [pattern_key],
"description": redact_sensitive_text(description),
"allow_permanent": True,
}
decision = _await_gateway_decision(
session_key, notify_cb, approval_data, surface="gateway"
)
if decision.get("notify_failed"):
return {
"approved": False,
"message": "BLOCKED: Failed to send approval request to user. Do NOT retry.",
"pattern_key": pattern_key,
"description": description,
}
resolved = decision["resolved"]
choice = decision["choice"]
deny_reason = decision.get("reason")
if not resolved or choice is None or choice == "deny":
if not resolved:
reason = "timed out without user response"
timeout_addendum = " Silence is not consent."
else:
reason = "denied by user"
timeout_addendum = ""
reason_addendum = ""
if resolved and deny_reason:
reason_addendum = f' Reason given by the user: "{deny_reason}".'
return {
"approved": False,
"message": (
f"BLOCKED: Action {reason}.{reason_addendum} The user "
f"has NOT consented to this action. Do NOT retry it, "
f"do NOT rephrase it, and do NOT attempt the same "
f"outcome via a different path.{timeout_addendum}"
),
"pattern_key": pattern_key,
"description": description,
"user_consent": False,
}
if choice == "session":
approve_session(session_key, pattern_key)
elif choice == "always":
approve_session(session_key, pattern_key)
approve_permanent(pattern_key)
save_permanent_allowlist(_permanent_approved)
return {"approved": True, "message": None}
# No notify callback (e.g. API server without an attached chat):
# queue for /approve /deny review, agent sees approval_required.
submit_pending(session_key, {
"command": display_target,
"pattern_key": pattern_key,