feat(approvals): render exec approvals as interactive picker buttons
Issue #4: approvals were only sent as a banner + text /approve prompt, while the app already had the interactive choice-picker card (used by clarify and slash commands). Add send_exec_approval() to the iris adapter. Hermes auto-detects this method and calls it when the agent wants to run a dangerous command. It now emits a high-priority approval notification (wakes a backgrounded device) plus a picker.choice card showing the command + reason with Allow Once / Session / Always / Deny buttons (gated by the same allow_session/allow_permanent/smart_denied flags as the native adapters). A tap resolves via resolve_gateway_approval (same primitive as the text /approve and /deny handlers), unblocking the agent, and posts a short confirmation. No live device -> report failure so hermes falls back to the text prompt. No app changes needed: picker.choice cards are rendered generically.
This commit is contained in:
1 parent
487fd1c83c
commit
9c50f2dbc1
1 file changed
+109
@@ -659,6 +659,39 @@ def _clarify_picker_callback(clarify_id: str, choices: list[str]):
|
||||
return on_choice_selected
|
||||
|
||||
|
||||
# The four exec-approval outcomes hermes understands (tools.approval).
|
||||
_APPROVAL_CHOICES = ("once", "session", "always", "deny")
|
||||
|
||||
|
||||
def _approval_picker_callback(session_key: str):
|
||||
"""Build the ``on_choice_selected`` callback for an exec-approval picker.
|
||||
|
||||
The option values are the raw hermes approval outcomes (``once`` /
|
||||
``session`` / ``always`` / ``deny``); a tap resolves the waiting agent
|
||||
thread via ``resolve_gateway_approval`` (the same primitive the text
|
||||
``/approve`` / ``/deny`` handlers use) and returns a short confirmation
|
||||
label, which the picker handler delivers as a normal message. An unknown
|
||||
value is treated as a deny so a stray tap never approves a command.
|
||||
"""
|
||||
|
||||
async def on_choice_selected(chat_id: str, value: str) -> str | None:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
|
||||
choice = value if value in _APPROVAL_CHOICES else "deny"
|
||||
count = resolve_gateway_approval(session_key, choice)
|
||||
label = {
|
||||
"once": "✅ Approved once",
|
||||
"session": "✅ Approved for this session",
|
||||
"always": "✅ Approved permanently",
|
||||
"deny": "❌ Denied",
|
||||
}[choice]
|
||||
if not count:
|
||||
label = "⌛ Approval expired — no command was waiting."
|
||||
return label
|
||||
|
||||
return on_choice_selected
|
||||
|
||||
|
||||
def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
|
||||
if not metadata:
|
||||
return None
|
||||
@@ -2953,6 +2986,82 @@ class IrisAdapter(BasePlatformAdapter):
|
||||
chat_id, title, message, session_key, confirm_id, metadata=metadata
|
||||
)
|
||||
|
||||
async def send_exec_approval(
|
||||
self,
|
||||
chat_id: str,
|
||||
command: str,
|
||||
session_key: str,
|
||||
description: str = "dangerous command",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
allow_permanent: bool = True,
|
||||
allow_session: bool = True,
|
||||
smart_denied: bool = False,
|
||||
) -> SendResult:
|
||||
"""Interactive exec-approval picker (buttons) for a dangerous command.
|
||||
|
||||
Hermes calls this (detected on the adapter type) when the agent wants
|
||||
to run a command that needs approval; the agent thread blocks until the
|
||||
user decides. With a live device we render the same choice set as the
|
||||
native adapters (Allow Once / Session / Always / Deny, gated by the
|
||||
same flags) as a ``picker.choice`` card, reusing the clarify/slash
|
||||
picker mechanism. A tap resolves via ``resolve_gateway_approval``
|
||||
(the same primitive the text ``/approve`` / ``/deny`` handlers use),
|
||||
unblocking the agent, and a short confirmation is delivered as a
|
||||
normal message. A high-priority ``approval`` notification is also
|
||||
emitted so a backgrounded device is woken (pushed even when live).
|
||||
|
||||
With no live device the picker could never be answered, so report
|
||||
failure and let hermes fall back to the text ``/approve`` prompt.
|
||||
"""
|
||||
if not self._http_server.has_devices():
|
||||
return SendResult(success=False, error="no live devices for approval picker")
|
||||
thread_id = _thread_id_from_metadata(metadata)
|
||||
|
||||
# High-priority banner + push (wakes a backgrounded device).
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.notification(
|
||||
chat_id,
|
||||
protocol.NOTIF_APPROVAL,
|
||||
"Approval needed",
|
||||
_push_preview(description or command),
|
||||
thread_id=thread_id,
|
||||
),
|
||||
)
|
||||
|
||||
# Choice set mirrors the native adapters (telegram/relay).
|
||||
frame_choices = [{"value": "once", "label": "\u2705 Allow Once", "is_current": False}]
|
||||
if not smart_denied and allow_session:
|
||||
frame_choices.append(
|
||||
{"value": "session", "label": "\u2705 Allow Session", "is_current": False}
|
||||
)
|
||||
if allow_permanent:
|
||||
frame_choices.append(
|
||||
{"value": "always", "label": "\u2705 Always Allow", "is_current": False}
|
||||
)
|
||||
frame_choices.append({"value": "deny", "label": "\u274c Deny", "is_current": False})
|
||||
|
||||
cmd_preview = command if len(command) <= 1500 else command[:1500] + "\u2026"
|
||||
title = (
|
||||
"\u26a0\ufe0f **Command approval required**\n\n"
|
||||
f"```\n{cmd_preview}\n```\n\n"
|
||||
f"Reason: {description}"
|
||||
)
|
||||
if smart_denied:
|
||||
title += "\n\n**Smart DENY:** owner override applies to this one operation only."
|
||||
|
||||
picker_id = _mint_picker_id()
|
||||
self._pending_pickers[picker_id] = {
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id,
|
||||
"on_choice_selected": _approval_picker_callback(session_key),
|
||||
}
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.picker_choice(picker_id, title, frame_choices, chat_id, thread_id=thread_id),
|
||||
)
|
||||
return SendResult(success=True, message_id=picker_id)
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
Reference in new issue
Block a user