feat(approvals): render exec approvals as interactive picker buttons
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s

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:
ARIA committed 2026-08-23 01:16:13 +02:00
1 parent 487fd1c83c
commit 9c50f2dbc1
1 file changed
+109
+109
View File
@@ -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,