From 9c50f2dbc1268a6bcd32d9173e287546d444b32e Mon Sep 17 00:00:00 2001 From: ARIA Date: Sun, 23 Aug 2026 01:16:13 +0200 Subject: [PATCH] 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. --- gateway-plugin/adapter.py | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 6fff61a..65cda54 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -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,