fix(approval): carry allow_permanent to TUI + desktop approval prompts

When a tirith content-security warning is present the approval backend
forces allow_permanent=False and silently downgrades an "always" choice to
session scope (the persistence loop in check_all_command_guards only honors
"always" → permanent when no tirith finding exists). But the gateway notify
payload that drives the TUI and the Electron desktop app never carried that
flag, so both surfaces always rendered "Always allow" — offering a permanent
allow the backend would quietly refuse to persist.

Plumb allow_permanent end-to-end:
- tools/approval.py: include `allow_permanent: not has_tirith` in the gateway
  approval_data the notify callback emits as `approval.request`.
- ui-tui: thread `allowPermanent` through the event handler, gateway types,
  and ApprovalReq; ApprovalPrompt drops the "always" option (and renumbers the
  quick-pick keys) when it's false.
- apps/desktop: thread `allow_permanent` through the gateway payload type, the
  per-session approval store, and the inline ApprovalBar, which now hides the
  "Always allow…" dropdown item when permanent allow is disallowed — reusing
  the existing DropdownMenu / confirm-Dialog UI.

The desktop/TUI render path for approvals already landed in #38578 (the root
cause of approvals not surfacing in the GUI); this completes the salvage of
#37856 by carrying allow_permanent across both surfaces. #37856's original
thread-local _block() approach is dropped: desktop/TUI approvals resolve via
approval.respond → resolve_gateway_approval (the per-session queue), not the
_block()/request_id correlation, so a worker-thread callback waiting on _block
would never be released by the real UI.

Tests: gateway notify payload carries allow_permanent (True without tirith,
False with a tirith warning); ui-tui approvalAction reduced option set +
event-handler allowPermanent propagation; desktop store round-trip + the
ApprovalBar showing/hiding "Always allow".

Supersedes #37856
Closes #37812

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
This commit is contained in:
Brooklyn Nicholson
2026-06-11 18:23:59 -05:00
co-authored by LeonSGP43
parent d221e369b8
commit 81436e143e
14 changed files with 214 additions and 28 deletions
+62
View File
@@ -285,3 +285,65 @@ class TestProgrammingErrorsPropagateFromWrapper:
os.environ["HERMES_INTERACTIVE"] = "1"
with pytest.raises(AttributeError, match="bug in wrapper"):
check_all_command_guards("echo hello", "local")
# ---------------------------------------------------------------------------
# Gateway (TUI / desktop) approval notify payload carries allow_permanent
# ---------------------------------------------------------------------------
class TestGatewayApprovalAllowPermanent:
"""The gateway emits the approval prompt to the renderer via the notify
payload (TUI/desktop both consume it). It must carry ``allow_permanent``
so the UI doesn't offer a permanent allow the backend would silently
downgrade to session scope for tirith content-security findings.
"""
def _capture_gateway_payload(self, command, session_key):
"""Run the gateway approval path, denying inline, and return the
single notify payload the renderer would have received."""
from tools.approval import (
register_gateway_notify,
resolve_gateway_approval,
unregister_gateway_notify,
)
captured = []
def notify(data):
captured.append(dict(data))
# The notify fires synchronously before _await_gateway_decision
# blocks, so resolving here releases the wait without a thread.
resolve_gateway_approval(session_key, "deny")
register_gateway_notify(session_key, notify)
token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
check_all_command_guards(command, "local")
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
unregister_gateway_notify(session_key)
assert len(captured) == 1
return captured[0]
def test_dangerous_only_allows_permanent(self):
"""No tirith warning → permanent allow is offered."""
payload = self._capture_gateway_payload("rm -rf /important", "gw-allow-perm")
assert payload["command"] == "rm -rf /important"
assert payload["allow_permanent"] is True
@patch(_TIRITH_PATCH,
return_value=_tirith_result("warn",
[{"rule_id": "shortened_url"}],
"shortened URL detected"))
def test_tirith_warning_disallows_permanent(self, mock_tirith):
"""tirith content-security warning → permanent allow is withheld so the
renderer hides "Always allow"."""
payload = self._capture_gateway_payload("curl https://bit.ly/abc", "gw-no-perm")
assert payload["allow_permanent"] is False