Skip to content
Closed
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
13 changes: 13 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ class SendResult:

# Type for message handlers
MessageHandler = Callable[[MessageEvent], Awaitable[Optional[str]]]
ApprovalActionHandler = Callable[[str, str], Awaitable[Optional[str]]]


class BasePlatformAdapter(ABC):
Expand All @@ -419,6 +420,7 @@ def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
self._message_handler: Optional[MessageHandler] = None
self._approval_action_handler: Optional[ApprovalActionHandler] = None
self._running = False
self._fatal_error_code: Optional[str] = None
self._fatal_error_message: Optional[str] = None
Expand Down Expand Up @@ -518,6 +520,17 @@ def set_message_handler(self, handler: MessageHandler) -> None:
an optional response string.
"""
self._message_handler = handler

def set_approval_action_handler(self, handler: ApprovalActionHandler) -> None:
"""
Set the handler for platform-native approval actions.

The handler receives ``(approval_id, action)`` where ``approval_id`` is
the opaque platform-provided approval token and ``action`` is one of
the platform's canonical approval actions such as ``approve``,
``approve session``, or ``deny``.
"""
self._approval_action_handler = handler

@abstractmethod
async def connect(self) -> bool:
Expand Down
134 changes: 134 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,21 @@ async def handle_hermes_command(ack, command):
await ack()
await self._handle_slash_command(command)

@self._app.action("hermes_approve_once")
async def handle_approve_once(ack, body, action):
await ack()
await self._handle_approval_action(body, "approve")

@self._app.action("hermes_approve_session")
async def handle_approve_session(ack, body, action):
await ack()
await self._handle_approval_action(body, "approve session")

@self._app.action("hermes_deny")
async def handle_deny(ack, body, action):
await ack()
await self._handle_approval_action(body, "deny")

# Start Socket Mode handler in background
self._handler = AsyncSocketModeHandler(self._app, app_token)
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
Expand Down Expand Up @@ -933,6 +948,125 @@ async def _handle_slash_command(self, command: dict) -> None:

await self.handle_message(event)

async def _handle_approval_action(self, body: dict, command_text: str) -> None:
"""Handle Slack Block Kit approval buttons inside a thread or DM.

Slack slash commands do not work inside threads, so approvals use
buttons that resolve the pending approval directly by approval ID.
"""
if not self._approval_action_handler:
return

channel = body.get("channel") or {}
user = body.get("user") or {}
message = body.get("message") or {}
container = body.get("container") or {}
actions = body.get("actions") or []

channel_id = channel.get("id", "")
user_id = user.get("id", "")
approval_id = actions[0].get("value", "") if actions else ""

actual_thread_ts = (
container.get("thread_ts")
or message.get("thread_ts")
or message.get("ts")
)

try:
response = await self._approval_action_handler(approval_id, command_text)
except Exception as e: # pragma: no cover - defensive logging
logger.error("[Slack] Approval action failed: %s", e, exc_info=True)
response = f"❌ Approval action failed: {e}"

try:
action_label = {
"approve": "Approved once",
"approve session": "Approved for session",
"deny": "Denied",
}.get(command_text, command_text)
await self._app.client.chat_update(
channel=channel_id,
ts=container.get("message_ts") or message.get("ts"),
text=f"βœ… {action_label} by <@{user_id}>",
blocks=[],
)
except Exception:
pass

if response:
metadata = {"thread_id": actual_thread_ts} if actual_thread_ts else None
await self.send(channel_id, response, metadata=metadata)

async def send_exec_approval(
self,
chat_id: str,
command: str,
approval_id: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a Slack Block Kit approval prompt for a dangerous command."""
if not self._app:
return SendResult(success=False, error="Not connected")

try:
max_text = 2700
cmd_display = command if len(command) <= max_text else command[: max_text - 3] + "..."
thread_ts = self._resolve_thread_ts(reply_to, metadata)

blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
"*Dangerous command requires approval*\n"
f"```{cmd_display}```"
),
},
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve Once"},
"style": "primary",
"action_id": "hermes_approve_once",
"value": approval_id,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve Session"},
"action_id": "hermes_approve_session",
"value": approval_id,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Deny"},
"style": "danger",
"action_id": "hermes_deny",
"value": approval_id,
},
],
},
]

kwargs = {
"channel": chat_id,
"text": "Dangerous command requires approval",
"blocks": blocks,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts

result = await self._app.client.chat_postMessage(**kwargs)
return SendResult(success=True, message_id=result.get("ts"), raw_response=result)
except Exception as e: # pragma: no cover - defensive logging
logger.error("[Slack] Failed to send approval prompt: %s", e, exc_info=True)
return SendResult(success=False, error=str(e))

async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
"""Download a Slack file using the bot token for auth, with retry."""
import asyncio
Expand Down
45 changes: 45 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,7 @@ async def start(self) -> bool:

# Set up message + fatal error handlers
adapter.set_message_handler(self._handle_message)
adapter.set_approval_action_handler(self._handle_platform_approval_action)
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)

# Try to connect
Expand Down Expand Up @@ -1362,6 +1363,7 @@ async def _platform_reconnect_watcher(self) -> None:
continue

adapter.set_message_handler(self._handle_message)
adapter.set_approval_action_handler(self._handle_platform_approval_action)
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)

success = await adapter.connect()
Expand Down Expand Up @@ -5094,6 +5096,49 @@ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str:

_APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes

async def _handle_platform_approval_action(
self,
approval_id: str,
action: str,
) -> str:
"""Resolve a pending dangerous-command approval by approval ID.

Messaging platforms with native UI controls, such as Slack buttons,
should call this directly instead of synthesizing a new slash-command
message. The approval ID is the stored session key for the pending
command. Signals the blocked agent thread via the blocking gateway
approval mechanism in tools/approval.py.
"""
session_key = (approval_id or "").strip()
if not session_key:
return "Invalid approval request."

canonical = action.strip().lower()
if canonical not in {"approve", "approve session", "deny"}:
return f"Unsupported approval action: {action}"

from tools.approval import resolve_gateway_approval, has_blocking_approval

if not has_blocking_approval(session_key):
return "No pending command to approve."

choice = "deny" if canonical == "deny" else ("session" if canonical == "approve session" else "once")
scope_msg = " (pattern approved for this session)" if choice == "session" else ""

count = resolve_gateway_approval(session_key, choice)
if not count:
return "No pending command to approve."

if choice == "deny":
logger.info("User denied dangerous command via platform approval UI")
return "❌ Command denied."

logger.info(
"User approved dangerous command via platform approval UI%s",
scope_msg,
)
return f"βœ… Command approved{scope_msg}. The agent is resuming..."

async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]:
"""Handle /approve command β€” unblock waiting agent thread(s).

Expand Down
Loading