"""Hermes-native tool-approval mirror (TUI → web elicitation). The native ``hermes`` TUI gates commands it flags as dangerous with an in-terminal approval prompt (its own ``tools/approval.py`` gate). That prompt lives only in the TUI; to also surface it in the Omnigent web UI (so a user can approve from the chat view, not only the embedded terminal), the runner watches the Hermes pane: 1. poll ``capture-pane`` and detect the approval PANEL — the interactive TUI renders a prompt_toolkit panel titled ``⚠️ Dangerous Command`` with NUMBERED choices (``❯ 1. Allow once`` … ``4. Deny``), NOT the legacy ``Choice [o/s/a/D]:`` ``input()`` prompt (that path is fail-closed while prompt_toolkit owns the terminal). Verified against hermes-agent ``cli.py`` ``_get_approval_display_fragments`` + the number-key bindings, 2. POST it to the server's generic ``native-permission-request`` hook, which publishes ``response.elicitation_request`` and parks for the web verdict, 3. on the verdict, send the choice's DIGIT key (e.g. ``1`` = Allow once, ``4`` = Deny) into the pane — Hermes' number-key binding selects AND confirms in one press, 4. if the panel instead disappears on its own (answered in the embedded terminal), POST ``external_elicitation_resolved`` so the parked web card clears. This does NOT suppress Hermes' own gate — its panel stays the source of truth and the fallback if pane detection ever fails (the user can still pick in the terminal). Mirrors :mod:`omnigent.cursor_native_permissions`. NB: Hermes only prompts for commands it flags *dangerous* (and may auto-approve low-risk ones via smart-approval), so non-dangerous tools won't raise a card. """ from __future__ import annotations import asyncio import hashlib import logging import re from dataclasses import dataclass from pathlib import Path import httpx from omnigent.hermes_native_bridge import capture_hermes_pane, send_hermes_pane_keys _logger = logging.getLogger(__name__) _POLL_INTERVAL_S = 0.3 # The hook parks server-side until a human answers; allow a day so the runner's # POST never abandons a live prompt. _POST_TIMEOUT_S = 86400.0 # Hermes' interactive TUI renders the dangerous-command gate as a prompt_toolkit # PANEL (cli.py ``_get_approval_display_fragments``), NOT the legacy ``input()`` # ``Choice [o/s/a/D]:`` prompt — that path is fail-closed while prompt_toolkit # owns the terminal. The panel is titled ``⚠️ Dangerous Command`` and lists # NUMBERED choices (``❯ 1. Allow once`` … ``4. Deny``); pressing the number both # selects and confirms (cli.py number-key bindings call _handle_approval_selection). # So we detect the panel by title + numbered choices and answer with the digit. _TITLE_RE = re.compile(r"Dangerous Command", re.IGNORECASE) # A numbered choice row, e.g. "❯ 1. Allow once" / " 4. Deny" (box borders ignored). _CHOICE_RE = re.compile( r"(?P\d)\.\s*(?P