From f3fadd6d8a0534a1fe96f7d698bd463c734b40cb Mon Sep 17 00:00:00 2001
From: rookiestar28 <151893693+rookiestar28@users.noreply.github.com>
Date: Thu, 19 Feb 2026 20:44:27 +0800
Subject: [PATCH] feat(slack): finalize secure Events API integration with
no-skip R124/R125 gates and docs/roadmap sync
---
README.md | 14 +-
connector/__main__.py | 21 +-
connector/config.py | 36 ++
connector/platforms/slack_webhook.py | 476 +++++++++++++++++
connector/router.py | 7 +
docs/connector.md | 96 +++-
scripts/run_full_tests_linux.sh | 22 +-
scripts/run_full_tests_windows.ps1 | 24 +-
tests/skip_policy.json | 4 +-
tests/test_r124_slack_ingress_contract.py | 569 +++++++++++++++++++++
tests/test_r125_slack_real_backend_lane.py | 317 ++++++++++++
tests/test_s36s37r79_egress_hardening.py | 4 +
12 files changed, 1542 insertions(+), 48 deletions(-)
create mode 100644 connector/platforms/slack_webhook.py
create mode 100644 tests/test_r124_slack_ingress_contract.py
create mode 100644 tests/test_r125_slack_real_backend_lane.py
diff --git a/README.md b/README.md
index 029bc56..c20bb14 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ ComfyUI-OpenClaw is a **security-first orchestration layer** for ComfyUI that co
- **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets)
- **Public-ready control-plane split architecture** (embedded UX + externalized high-risk control surfaces)
- **Verification-first hardening lanes** (route drift, real-backend E2E, adversarial fuzz/mutation gates)
-- **Now supports major messaging platforms, including Discord, Telegram, WhatsApp, LINE, WeChat and KakaoTalk.**
+- **Now supports major messaging platforms, including Discord, Telegram, WhatsApp, LINE, WeChat, KakaoTalk, and Slack.**
- **And more exciting features being added continuously**
---
@@ -50,6 +50,18 @@ Deployment profiles and hardening checklists:
+Slack app support closeout: secure Events API ingress, connector parity, and no-skip verification lanes
+
+- Completed Slack implementation hardening chain with full SOP validation:
+ - added Slack Events API adapter with signed ingress checks, replay/dedupe handling, bot-loop suppression, allowlist enforcement, and thread-aware reply delivery
+ - wired Slack runtime policy into existing connector authorization boundaries so command trust behavior stays consistent with other platforms
+ - added dedicated Slack verification lanes for ingress contract coverage and real-backend flow parity, both enforced by skip-policy and full-test scripts
+ - synchronized verification evidence through detect-secrets, pre-commit, backend unit + real lanes, adversarial gate, and frontend E2E full pass
+
+
+
+
+
Post-Wave E closeout: Hardening chain completed
- Completed on 2026-02 with full SOP validation:
diff --git a/connector/__main__.py b/connector/__main__.py
index c2df7b5..2cb91c4 100644
--- a/connector/__main__.py
+++ b/connector/__main__.py
@@ -12,6 +12,7 @@ from .openclaw_client import OpenClawClient
from .platforms.discord_gateway import DiscordGateway
from .platforms.kakao_webhook import KakaoWebhookServer
from .platforms.line_webhook import LINEWebhookServer
+from .platforms.slack_webhook import SlackWebhookServer
from .platforms.telegram_polling import TelegramPolling
from .platforms.wechat_webhook import WeChatWebhookServer
from .platforms.whatsapp_webhook import WhatsAppWebhookServer
@@ -42,6 +43,7 @@ def _print_security_banner(config):
or config.whatsapp_allowed_users
or config.wechat_allowed_users
or config.kakao_allowed_users
+ or config.slack_allowed_users
)
has_admins = bool(config.admin_users)
@@ -104,6 +106,7 @@ async def main():
whatsapp_server = None
wechat_server = None
kakao_server = None
+ slack_server = None
# 3. Platforms
if config.telegram_bot_token:
@@ -171,15 +174,29 @@ async def main():
else:
logger.info("Kakao adapter disabled.")
+ if config.slack_bot_token and config.slack_signing_secret:
+ slack_server = SlackWebhookServer(config, router)
+ platforms["slack"] = slack_server
+ await slack_server.start()
+ if not tasks:
+ tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
+ elif config.slack_bot_token:
+ logger.warning("Slack configured but Signing Secret missing. Skipping.")
+ else:
+ logger.info("Slack not configured (OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN missing)")
+
if (
not tasks
and not line_server
and not whatsapp_server
and not wechat_server
and not kakao_server
+ and not slack_server
):
logger.error(
- "No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN or KAKAO_ENABLED."
+ "No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, "
+ "LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN, "
+ "KAKAO_ENABLED or SLACK_BOT_TOKEN."
)
await client.close()
return
@@ -206,6 +223,8 @@ async def main():
await wechat_server.stop()
if kakao_server:
await kakao_server.stop()
+ if slack_server:
+ await slack_server.stop()
if poller:
await poller.stop()
await client.close()
diff --git a/connector/config.py b/connector/config.py
index 80ecfda..957810c 100644
--- a/connector/config.py
+++ b/connector/config.py
@@ -90,6 +90,17 @@ class ConnectorConfig:
kakao_webhook_path: str = "/kakao/webhook"
kakao_allowed_users: List[str] = field(default_factory=list)
+ # Slack (F56 / S67)
+ slack_bot_token: Optional[str] = None
+ slack_signing_secret: Optional[str] = None
+ slack_allowed_users: List[str] = field(default_factory=list)
+ slack_allowed_channels: List[str] = field(default_factory=list)
+ slack_bind_host: str = "127.0.0.1"
+ slack_bind_port: int = 8095
+ slack_webhook_path: str = "/slack/events"
+ slack_require_mention: bool = True
+ slack_reply_in_thread: bool = True
+
# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
admin_users: List[str] = field(default_factory=list)
@@ -231,6 +242,31 @@ def load_config() -> ConnectorConfig:
if ku := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS"):
cfg.kakao_allowed_users = [u.strip() for u in ku.split(",") if u.strip()]
+ # Slack (F56 / S67)
+ cfg.slack_bot_token = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN")
+ cfg.slack_signing_secret = os.environ.get("OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET")
+ if su := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS"):
+ cfg.slack_allowed_users = [u.strip() for u in su.split(",") if u.strip()]
+ if sc := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS"):
+ cfg.slack_allowed_channels = [u.strip() for u in sc.split(",") if u.strip()]
+ cfg.slack_bind_host = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BIND", "127.0.0.1")
+ if sp := os.environ.get("OPENCLAW_CONNECTOR_SLACK_PORT"):
+ if sp.isdigit():
+ cfg.slack_bind_port = int(sp)
+ cfg.slack_webhook_path = os.environ.get(
+ "OPENCLAW_CONNECTOR_SLACK_PATH", "/slack/events"
+ )
+ if (
+ os.environ.get("OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION", "").lower()
+ == "false"
+ ):
+ cfg.slack_require_mention = False
+ if (
+ os.environ.get("OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD", "").lower()
+ == "false"
+ ):
+ cfg.slack_reply_in_thread = False
+
# Admin
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
diff --git a/connector/platforms/slack_webhook.py b/connector/platforms/slack_webhook.py
new file mode 100644
index 0000000..c8719b2
--- /dev/null
+++ b/connector/platforms/slack_webhook.py
@@ -0,0 +1,476 @@
+"""
+Slack Events API Webhook Adapter (F56).
+
+Implements:
+- Events API POST ingress with ``url_verification`` challenge response.
+- Slack request authenticity via ``X-Slack-Signature`` + ``X-Slack-Request-Timestamp``
+ (``v0:{ts}:{raw_body}`` HMAC-SHA256).
+- Replay / duplicate guard (event_id + timestamp window).
+- ``message`` / ``app_mention`` event normalization with de-duplication
+ (avoid double-trigger when bot is mentioned in a regular message).
+- CommandRequest conversion -> CommandRouter.
+- Slack Web API thread or channel reply.
+
+S67 Safety Profile:
+- AllowlistPolicy for users and channels (fail-closed when configured).
+- Bot-loop prevention (ignore messages from bot itself).
+- Rate-limit delegation to CommandRouter (R80 authz + F32 rate limiter).
+- Require-mention policy for group conversations.
+
+Setup:
+1. Create a Slack App at https://api.slack.com/apps.
+2. Enable Events API; set Request URL to ``https:///slack/events``.
+3. Subscribe to ``message.channels``, ``message.groups``, ``message.im``,
+ ``app_mention`` bot events.
+4. Install app to workspace; copy Bot Token and Signing Secret.
+5. Set env vars:
+ - ``OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN``
+ - ``OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET``
+"""
+
+import hashlib
+import hmac
+import json
+import logging
+import time
+from typing import Any, Dict, Optional
+
+from ..config import ConnectorConfig
+from ..contract import CommandRequest, CommandResponse
+from ..router import CommandRouter
+from ..security_profile import AllowlistPolicy, ReplayGuard
+
+logger = logging.getLogger(__name__)
+
+
+# -- aiohttp compat layer (same pattern as kakao/whatsapp/wechat) -----------
+
+
+def _import_aiohttp_web():
+ try:
+ import aiohttp
+ from aiohttp import web
+ except ModuleNotFoundError:
+ return None, None
+ return aiohttp, web
+
+
+class _CompatResponse:
+ """Minimal response shim for unit tests when aiohttp is unavailable."""
+
+ def __init__(
+ self,
+ *,
+ status: int = 200,
+ text: str = "",
+ content_type: str = "text/plain",
+ body: Optional[bytes] = None,
+ ):
+ self.status = status
+ self.text = text
+ self.content_type = content_type
+ self.body = body if body is not None else text.encode("utf-8")
+
+
+def _make_response(web_mod, *, status: int = 200, text: str = "OK"):
+ if web_mod is not None:
+ return web_mod.Response(status=status, text=text)
+ return _CompatResponse(status=status, text=text)
+
+
+def _make_json_response(web_mod, data: dict, *, status: int = 200):
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
+ if web_mod is not None:
+ return web_mod.json_response(data, status=status)
+ return _CompatResponse(
+ status=status,
+ text=body.decode("utf-8"),
+ content_type="application/json",
+ body=body,
+ )
+
+
+# -- Slack signature verification -------------------------------------------
+
+# Maximum acceptable clock skew for timestamp validation (5 minutes).
+SLACK_TIMESTAMP_MAX_DRIFT_SEC = 300
+SLACK_SIGNING_VERSION = "v0"
+
+
+def verify_slack_signature(
+ *,
+ signing_secret: str,
+ timestamp: str,
+ body: bytes,
+ signature: str,
+) -> bool:
+ """
+ Verify Slack ``X-Slack-Signature`` using ``v0:{ts}:{body}`` HMAC-SHA256.
+
+ Fail-closed: returns False on any missing/invalid input.
+ """
+ if not signing_secret or not timestamp or not signature:
+ return False
+
+ # Timestamp freshness check
+ try:
+ ts_int = int(timestamp)
+ except (ValueError, TypeError):
+ return False
+
+ if abs(time.time() - ts_int) > SLACK_TIMESTAMP_MAX_DRIFT_SEC:
+ return False
+
+ # Compute expected signature
+ sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
+ expected = (
+ SLACK_SIGNING_VERSION
+ + "="
+ + hmac.new(
+ signing_secret.encode("utf-8"),
+ sig_basestring.encode("utf-8"),
+ hashlib.sha256,
+ ).hexdigest()
+ )
+
+ return hmac.compare_digest(expected, signature)
+
+
+# -- Slack adapter ----------------------------------------------------------
+
+
+class SlackWebhookServer:
+ """
+ F56 -- Slack Events API adapter.
+
+ Security invariants (S67 / R124):
+ - CRITICAL: Reject unsigned or replay requests (fail-closed).
+ - CRITICAL: Ignore bot's own messages (bot-loop prevention).
+ - IMPORTANT: Deduplicate ``message`` + ``app_mention`` for the same event
+ to prevent double command execution.
+ - IMPORTANT: Respect ``require_mention`` policy for group channels.
+ """
+
+ REPLAY_WINDOW_SEC = 300
+ NONCE_CACHE_SIZE = 5000
+
+ def __init__(self, config: ConnectorConfig, router: CommandRouter):
+ self.config = config
+ self.router = router
+ self.app = None
+ self.runner = None
+ self.site = None
+
+ # S67: Replay / dedupe guard keyed by Slack event_id
+ self._replay_guard = ReplayGuard(
+ window_sec=self.REPLAY_WINDOW_SEC,
+ max_entries=self.NONCE_CACHE_SIZE,
+ )
+
+ # S67: Allowlists (fail-closed when configured)
+ self._user_allowlist = AllowlistPolicy(config.slack_allowed_users, strict=False)
+ self._channel_allowlist = AllowlistPolicy(
+ config.slack_allowed_channels, strict=False
+ )
+
+ # Bot user ID (resolved on first event or set from config)
+ self._bot_user_id: Optional[str] = None
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ async def start(self):
+ aiohttp, web = _import_aiohttp_web()
+ if aiohttp is None or web is None:
+ logger.warning("aiohttp not installed. Skipping Slack adapter.")
+ return
+
+ if not self.config.slack_bot_token or not self.config.slack_signing_secret:
+ logger.info(
+ "Slack adapter disabled "
+ "(OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN or "
+ "OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET missing)"
+ )
+ return
+
+ logger.info(
+ f"Starting Slack Webhook on "
+ f"{self.config.slack_bind_host}:{self.config.slack_bind_port}"
+ f"{self.config.slack_webhook_path}"
+ )
+
+ self.app = web.Application()
+ self.app.router.add_post(self.config.slack_webhook_path, self.handle_event)
+
+ self.runner = web.AppRunner(self.app)
+ await self.runner.setup()
+ self.site = web.TCPSite(
+ self.runner, self.config.slack_bind_host, self.config.slack_bind_port
+ )
+ await self.site.start()
+
+ async def stop(self):
+ if self.site:
+ await self.site.stop()
+ if self.runner:
+ await self.runner.cleanup()
+
+ # ------------------------------------------------------------------
+ # Event handler
+ # ------------------------------------------------------------------
+
+ async def handle_event(self, request):
+ """POST handler for Slack Events API."""
+ _, web = _import_aiohttp_web()
+
+ try:
+ body_bytes = await request.read()
+ except Exception:
+ return _make_response(web, status=400, text="Bad request")
+
+ # -- Step 1: Signature verification (fail-closed) --
+ timestamp = ""
+ signature = ""
+ if hasattr(request, "headers"):
+ timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
+ signature = request.headers.get("X-Slack-Signature", "")
+
+ if not verify_slack_signature(
+ signing_secret=self.config.slack_signing_secret or "",
+ timestamp=timestamp,
+ body=body_bytes,
+ signature=signature,
+ ):
+ logger.warning("Slack signature verification failed (rejected)")
+ return _make_response(web, status=401, text="Invalid signature")
+
+ # -- Step 2: Parse payload --
+ try:
+ payload = json.loads(body_bytes)
+ except json.JSONDecodeError:
+ return _make_response(web, status=400, text="Bad JSON")
+
+ # -- Step 3: url_verification challenge --
+ if payload.get("type") == "url_verification":
+ challenge = payload.get("challenge", "")
+ return _make_json_response(web, {"challenge": challenge})
+
+ # -- Step 4: event_callback processing --
+ if payload.get("type") != "event_callback":
+ # Ignore unknown payload types gracefully
+ return _make_response(web, status=200, text="OK")
+
+ event = payload.get("event", {})
+ event_id = payload.get("event_id", "")
+ event_type = event.get("type", "")
+
+ # -- Step 5: Replay / dedupe guard --
+ if not event_id:
+ logger.warning("Slack event missing event_id (rejected)")
+ return _make_response(web, status=400, text="Missing event_id")
+
+ if not self._replay_guard.check_and_record(event_id):
+ logger.debug(f"Slack duplicate event_id={event_id} (accepted, no-op)")
+ # Return 200 to acknowledge; Slack will retry on non-2xx.
+ return _make_response(web, status=200, text="OK")
+
+ # -- Step 6: Bot-loop prevention --
+ # Resolve bot user ID from authorizations or cache
+ if self._bot_user_id is None:
+ auths = payload.get("authorizations", [])
+ if auths and isinstance(auths, list):
+ self._bot_user_id = auths[0].get("user_id", "")
+
+ sender_id = event.get("user", "")
+ if sender_id and sender_id == self._bot_user_id:
+ # Ignore bot's own messages
+ return _make_response(web, status=200, text="OK")
+
+ # Also detect bot_id field (Slack webhook integrations)
+ if event.get("bot_id"):
+ return _make_response(web, status=200, text="OK")
+
+ # Ignore message subtypes that are not user messages
+ subtype = event.get("subtype", "")
+ if subtype and subtype not in ("", "file_share"):
+ # e.g. message_changed, message_deleted, bot_message, etc.
+ return _make_response(web, status=200, text="OK")
+
+ # -- Step 7: Event normalization --
+ # Supported events: message, app_mention
+ # De-duplication: if event is app_mention AND the same content would
+ # also arrive as a message event, use event_id to dedupe (already done
+ # in step 5 via replay guard). Also, for group channels with
+ # require_mention, we only process app_mention events (skip plain
+ # messages that don't mention the bot).
+ text = event.get("text", "").strip()
+ channel_id = event.get("channel", "")
+ thread_ts = event.get("thread_ts", "")
+ message_ts = event.get("ts", "")
+
+ if event_type not in ("message", "app_mention"):
+ return _make_response(web, status=200, text="OK")
+
+ if not text or not sender_id:
+ return _make_response(web, status=200, text="OK")
+
+ # S67: Require-mention policy for group channels
+ # Channel types: C=public, G=private group, D=DM, "im"
+ is_dm = channel_id.startswith("D")
+ if not is_dm and self.config.slack_require_mention:
+ # In group channels, only process app_mention events
+ # or messages that explicitly mention the bot
+ if event_type != "app_mention":
+ if self._bot_user_id and f"<@{self._bot_user_id}>" not in text:
+ return _make_response(web, status=200, text="OK")
+
+ # Strip bot mention from text for cleaner command parsing
+ if self._bot_user_id:
+ text = text.replace(f"<@{self._bot_user_id}>", "").strip()
+
+ # -- Step 8: Allowlist checks (S67) --
+ if self._user_allowlist.entries:
+ user_result = self._user_allowlist.evaluate(sender_id)
+ if user_result.decision == "deny":
+ logger.warning(f"Slack user {sender_id} denied by allowlist")
+ # Don't leak auth info; return 200 but silently drop
+ return _make_response(web, status=200, text="OK")
+
+ if self._channel_allowlist.entries and channel_id:
+ chan_result = self._channel_allowlist.evaluate(channel_id)
+ if chan_result.decision == "deny":
+ logger.warning(f"Slack channel {channel_id} denied by allowlist")
+ return _make_response(web, status=200, text="OK")
+
+ # -- Step 9: Build CommandRequest and route --
+ req = CommandRequest(
+ platform="slack",
+ sender_id=sender_id,
+ channel_id=channel_id,
+ username=sender_id, # Slack doesn't include username in events
+ message_id=event_id,
+ text=text,
+ timestamp=float(message_ts) if message_ts else time.time(),
+ )
+
+ try:
+ resp = await self.router.handle(req)
+ resp_text = getattr(resp, "text", "")
+ if not isinstance(resp_text, str):
+ resp_text = str(resp_text) if resp_text is not None else ""
+
+ if resp_text:
+ await self._send_reply(
+ channel_id=channel_id,
+ text=resp_text,
+ thread_ts=thread_ts
+ or (message_ts if self.config.slack_reply_in_thread else ""),
+ )
+ except Exception as e:
+ logger.exception(f"Error handling Slack event: {e}")
+
+ # Always return 200 to Slack to prevent retries
+ return _make_response(web, status=200, text="OK")
+
+ # ------------------------------------------------------------------
+ # Slack Web API reply
+ # ------------------------------------------------------------------
+
+ async def _send_reply(
+ self,
+ channel_id: str,
+ text: str,
+ thread_ts: str = "",
+ ) -> None:
+ """Send a message via Slack Web API (chat.postMessage)."""
+ try:
+ import aiohttp as _aiohttp
+ except ImportError:
+ logger.warning("aiohttp not available; cannot send Slack reply")
+ return
+
+ url = "https://slack.com/api/chat.postMessage"
+ headers = {
+ "Authorization": f"Bearer {self.config.slack_bot_token}",
+ "Content-Type": "application/json; charset=utf-8",
+ }
+ payload: Dict[str, Any] = {
+ "channel": channel_id,
+ "text": text,
+ }
+ if thread_ts:
+ payload["thread_ts"] = thread_ts
+
+ try:
+ async with _aiohttp.ClientSession() as session:
+ async with session.post(
+ url,
+ json=payload,
+ headers=headers,
+ timeout=_aiohttp.ClientTimeout(total=10),
+ ) as resp:
+ if resp.status != 200:
+ body = await resp.text()
+ logger.warning(
+ f"Slack API error: status={resp.status} body={body[:200]}"
+ )
+ else:
+ data = await resp.json()
+ if not data.get("ok"):
+ logger.warning(
+ f"Slack API error: {data.get('error', 'unknown')}"
+ )
+ except Exception as e:
+ logger.warning(f"Slack reply failed: {e}")
+
+ # ------------------------------------------------------------------
+ # Platform contract: send_message / send_image
+ # ------------------------------------------------------------------
+
+ async def send_message(self, channel_id: str, text: str):
+ """Platform contract: send text message."""
+ await self._send_reply(channel_id=channel_id, text=text)
+
+ async def send_image(
+ self,
+ channel_id: str,
+ image_data: bytes,
+ filename: str = "image.png",
+ caption: Optional[str] = None,
+ ):
+ """Platform contract: send image (Slack files.upload)."""
+ try:
+ import aiohttp as _aiohttp
+ except ImportError:
+ logger.warning("aiohttp not available; cannot upload Slack image")
+ return
+
+ url = "https://slack.com/api/files.upload"
+ headers = {
+ "Authorization": f"Bearer {self.config.slack_bot_token}",
+ }
+ data = _aiohttp.FormData()
+ data.add_field("file", image_data, filename=filename, content_type="image/png")
+ data.add_field("channels", channel_id)
+ if caption:
+ data.add_field("initial_comment", caption)
+
+ try:
+ async with _aiohttp.ClientSession() as session:
+ async with session.post(
+ url,
+ data=data,
+ headers=headers,
+ timeout=_aiohttp.ClientTimeout(total=30),
+ ) as resp:
+ if resp.status != 200:
+ logger.warning(f"Slack file upload error: status={resp.status}")
+ else:
+ resp_data = await resp.json()
+ if not resp_data.get("ok"):
+ logger.warning(
+ f"Slack file upload error: {resp_data.get('error')}"
+ )
+ except Exception as e:
+ logger.warning(f"Slack image upload failed: {e}")
diff --git a/connector/router.py b/connector/router.py
index 6a1791d..cdb39f5 100644
--- a/connector/router.py
+++ b/connector/router.py
@@ -277,6 +277,13 @@ class CommandRouter:
return True
return False
+ if platform == "slack":
+ if sender_id in self.config.slack_allowed_users:
+ return True
+ if channel_id in self.config.slack_allowed_channels:
+ return True
+ return False
+
# Unknown platform: trust only admins
return False
diff --git a/docs/connector.md b/docs/connector.md
index d430316..6be1864 100644
--- a/docs/connector.md
+++ b/docs/connector.md
@@ -13,10 +13,10 @@ The connector runs alongside ComfyUI on your machine.
**Security**:
-- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk require inbound HTTPS webhook endpoints.
-- **Allowlist**: Only users/chats you explicitly allow can send commands.
+- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints.
+- **Allowlist/Trust Model**: Allowlists define trusted senders/channels. Non-allowlisted senders are treated as untrusted (for example, `/run` is approval-routed instead of auto-executed).
- **Local Secrets**: Bot tokens are stored in your local environment, never sent to ComfyUI.
-- **Admin Boundary**: Control-plane actions call admin endpoints on the local ComfyUI instance. See `OPENCLAW_CONNECTOR_ADMIN_TOKEN` below.
+- **Admin Boundary**: Control-plane actions call admin endpoints on the local OpenClaw server and require connector-side admin token configuration for admin command paths.
## Supported Platforms
@@ -48,8 +48,9 @@ Set the following environment variables (or put them in a `.env` file if you use
**Admin token behavior:**
-- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to the same value or admin calls will return HTTP 403.
-- If the OpenClaw server is in loopback-only convenience mode (no Admin Token configured), the connector can still call admin endpoints via `localhost` without sending a token.
+- Connector admin command paths require `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to be set in connector runtime.
+- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match it or admin calls return HTTP 403.
+- Without `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, admin command flows (`/approve`, `/reject`, `/trace`, schedules) are blocked by connector policy before upstream calls.
**Telegram:**
@@ -326,40 +327,73 @@ Kakao i Open Builder sends webhook requests to your connector Skill endpoint. Yo
#### Slack Webhook Setup (Detailed)
-Slack Events API pushes JSON payloads to your connector. You must expose the endpoint publicly over HTTPS.
+Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint publicly over HTTPS.
-1. **Create Slack App**:
+1. **Create the Slack App**
- Go to [api.slack.com/apps](https://api.slack.com/apps).
- - Create a new app (from scratch).
- - Copy **Signing Secret** from Basic Information.
+ - Create a new app (From scratch) and select your workspace.
+ - In **Basic Information**, copy the **Signing Secret**.
-2. **Configure Connector**:
+2. **Configure OAuth Scopes and install**
+ - Go to **OAuth & Permissions**.
+ - Add bot scopes:
+ - `chat:write`
+ - `files:write`
+ - `app_mentions:read`
+ - `im:history` (DM support)
+ - `channels:history` (public channel messages)
+ - `groups:history` (private channel messages)
+ - Click **Install to Workspace**.
+ - Copy the **Bot User OAuth Token** (`xoxb-...`).
+
+3. **Configure connector environment variables**
```bash
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
- OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345
+ OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345,U67890
+ OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS=C12345
+ OPENCLAW_CONNECTOR_SLACK_BIND=127.0.0.1
+ OPENCLAW_CONNECTOR_SLACK_PORT=8095
+ OPENCLAW_CONNECTOR_SLACK_PATH=/slack/events
+ OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true
+ OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD=true
+ OPENCLAW_CONNECTOR_ADMIN_TOKEN=replace-with-openclaw-admin-token
```
- Start the connector (`python -m connector`). Expose it via tunnel (e.g. Cloudflare) to `https:///slack/events`.
+ Notes:
+ - `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match server `OPENCLAW_ADMIN_TOKEN` if server-side admin token is enabled.
+ - Slack ingress is fail-closed: invalid/missing signature, stale timestamp, and replayed events are rejected.
-3. **Enable Events**:
- - In **Event Subscriptions**:
- - Enable Events.
- - Set Request URL to `https:///slack/events`.
- - Slack will send a `url_verification` challenge; the connector handles this automatically. Verify connection.
- - Subscribe to bot events: `message.channels`, `message.groups`, `message.im`, `app_mention`.
+4. **Start connector and expose webhook endpoint**
+ - Start connector: `python -m connector`
+ - Expose local endpoint to public HTTPS (Cloudflare Tunnel/ngrok/reverse proxy):
+ - local upstream: `http://127.0.0.1:8095`
+ - public URL: `https:///slack/events`
-4. **OAuth & Permissions**:
- - In **OAuth & Permissions**:
- - Add Scopes: `chat:write`, `files:write`, `channels:history` (if public), `groups:history` (if private), `im:history` (for DMs), `app_mentions:read`.
- - Install App to Workspace.
- - Copy **Bot User OAuth Token** (`xoxb-...`).
+5. **Enable Event Subscriptions**
+ - Go to **Event Subscriptions** and enable events.
+ - Set **Request URL** to `https:///slack/events`.
+ - Slack sends `url_verification`; connector responds automatically.
+ - Add bot events:
+ - `app_mention`
+ - `message.channels`
+ - `message.groups`
+ - `message.im`
-5. **Test**:
- - Invite bot to a channel: `/invite @BotName`.
- - Mention bot: `@BotName /status`.
- - DM bot: `/help`.
+6. **Invite and validate**
+ - Invite the app to target channels: `/invite @YourBot`.
+ - In channel: `@YourBot /status` (when `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true`).
+ - In DM: `/help`.
+ - Verify connector logs show signed ingress accepted and replies delivered.
+
+7. **Security checklist before production**
+ - Keep `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`/`OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` restricted.
+ - Keep `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` unless intentionally running command-style channels.
+ - Rotate Slack bot token/signing secret on incident response.
+ - Do not expose connector without HTTPS termination.
+
+Slack Socket Mode fallback is tracked separately and is not part of the current webhook setup.
## Commands
@@ -434,3 +468,11 @@ Slack Events API pushes JSON payloads to your connector. You must expose the end
- **Kakao `/run` always goes to approval**:
- Sender is not in `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS` (or allowlist is empty).
- Fix: capture `userRequest.user.id` from logs, add it to `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`, restart connector.
+
+- **Slack Event Subscriptions verification fails**:
+ - Request URL/path mismatch, connector not reachable, or `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET` is wrong.
+ - Fix: confirm public URL points to `/slack/events`, verify tunnel/proxy routes to `127.0.0.1:8095`, and re-check Signing Secret.
+
+- **Slack commands ignored in channels**:
+ - `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` and message does not mention the bot.
+ - Fix: mention bot explicitly (`@Bot /status`) or set `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=false` if policy allows.
diff --git a/scripts/run_full_tests_linux.sh b/scripts/run_full_tests_linux.sh
index 178df68..58f059b 100644
--- a/scripts/run_full_tests_linux.sh
+++ b/scripts/run_full_tests_linux.sh
@@ -110,13 +110,13 @@ fi
echo "[tests] Node version: $(node -v)"
-echo "[tests] 0/7 R120 dependency preflight"
+echo "[tests] 0/8 R120 dependency preflight"
"$VENV_PY" scripts/preflight_check.py --strict
-echo "[tests] 1/7 detect-secrets"
+echo "[tests] 1/8 detect-secrets"
"$VENV_PY" -m pre_commit run detect-secrets --all-files
-echo "[tests] 2/7 pre-commit all hooks (pass 1: autofix)"
+echo "[tests] 2/8 pre-commit all hooks (pass 1: autofix)"
if "$VENV_PY" -m pre_commit run --all-files --show-diff-on-failure; then
:
else
@@ -124,29 +124,33 @@ else
"$VENV_PY" -m pre_commit run --all-files --show-diff-on-failure
fi
-echo "[tests] 3/7 backend unit tests"
+echo "[tests] 3/8 backend unit tests"
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_unit" "$VENV_PY" scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json
if [ -n "${OPENCLAW_IMPL_RECORD_PATH:-}" ]; then
- echo "[tests] 3.5/7 implementation record lint (strict)"
+ echo "[tests] 3.5/8 implementation record lint (strict)"
# IMPORTANT: strict mode is opt-in via OPENCLAW_IMPL_RECORD_PATH to avoid retroactive legacy record failures.
"$VENV_PY" scripts/lint_implementation_record.py --path "$OPENCLAW_IMPL_RECORD_PATH" --strict
fi
-echo "[tests] 4/7 backend real E2E lanes (R122/R123)"
+echo "[tests] 4/8 backend real E2E lanes (R122/R123)"
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_backend_e2e_real" \
"$VENV_PY" scripts/run_unittests.py --module tests.test_r122_real_backend_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_backend_e2e_real" \
"$VENV_PY" scripts/run_unittests.py --module tests.test_r123_real_backend_model_list_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
-echo "[tests] 5/7 R121 retry partition contract"
+echo "[tests] 5/8 R121 retry partition contract"
"$VENV_PY" scripts/run_unittests.py --module tests.test_r121_retry_partition_contract --enforce-skip-policy tests/skip_policy.json --max-skipped 0
-echo "[tests] 6/7 R118 adversarial gate (smoke)"
+echo "[tests] 6/8 Slack integration gates (R124/R125)"
+"$VENV_PY" scripts/run_unittests.py --module tests.test_r124_slack_ingress_contract --enforce-skip-policy tests/skip_policy.json --max-skipped 0
+"$VENV_PY" scripts/run_unittests.py --module tests.test_r125_slack_real_backend_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
+
+echo "[tests] 7/8 R118 adversarial gate (smoke)"
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_adversarial" \
"$VENV_PY" scripts/run_adversarial_gate.py --profile smoke --seed 42 --artifact-dir .tmp/adversarial
-echo "[tests] 7/7 frontend E2E"
+echo "[tests] 8/8 frontend E2E"
npm test
echo "[tests] PASS"
diff --git a/scripts/run_full_tests_windows.ps1 b/scripts/run_full_tests_windows.ps1
index 38733c0..d02353b 100644
--- a/scripts/run_full_tests_windows.ps1
+++ b/scripts/run_full_tests_windows.ps1
@@ -152,34 +152,34 @@ if ($nodeMajor -lt 18) {
Write-Host "[tests] Node version: $(node -v)"
-Write-Host "[tests] 0/7 R120 dependency preflight"
+Write-Host "[tests] 0/8 R120 dependency preflight"
Invoke-Checked "preflight_check" { & $venvPython scripts\preflight_check.py --strict }
-Write-Host "[tests] 1/7 detect-secrets"
+Write-Host "[tests] 1/8 detect-secrets"
Invoke-Checked "detect-secrets" { & $venvPython -m pre_commit run detect-secrets --all-files }
-Write-Host "[tests] 2/7 pre-commit all hooks (pass 1: autofix)"
+Write-Host "[tests] 2/8 pre-commit all hooks (pass 1: autofix)"
& $venvPython -m pre_commit run --all-files --show-diff-on-failure
if ($LASTEXITCODE -ne 0) {
Write-Host "[tests] INFO: pre-commit reported changes/issues; running pass 2 verification..."
Invoke-Checked "pre-commit all hooks (pass 2 verify)" { & $venvPython -m pre_commit run --all-files --show-diff-on-failure }
}
-Write-Host "[tests] 3/7 backend unit tests"
+Write-Host "[tests] 3/8 backend unit tests"
$env:MOLTBOT_STATE_DIR = "$root\moltbot_state\_local_unit"
Invoke-Checked "backend unit tests" {
& $venvPython scripts\run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests\skip_policy.json
}
if ($env:OPENCLAW_IMPL_RECORD_PATH) {
- Write-Host "[tests] 3.5/7 implementation record lint (strict)"
+ Write-Host "[tests] 3.5/8 implementation record lint (strict)"
# IMPORTANT: strict mode is opt-in via OPENCLAW_IMPL_RECORD_PATH to avoid retroactive legacy record failures.
Invoke-Checked "implementation record lint" {
& $venvPython scripts\lint_implementation_record.py --path $env:OPENCLAW_IMPL_RECORD_PATH --strict
}
}
-Write-Host "[tests] 4/7 backend real E2E lanes (R122/R123)"
+Write-Host "[tests] 4/8 backend real E2E lanes (R122/R123)"
$env:MOLTBOT_STATE_DIR = "$root\moltbot_state\_local_backend_e2e_real"
Invoke-Checked "backend real E2E lane R122" {
& $venvPython scripts\run_unittests.py --module tests.test_r122_real_backend_lane --enforce-skip-policy tests\skip_policy.json --max-skipped 0
@@ -188,18 +188,24 @@ Invoke-Checked "backend real E2E lane R123" {
& $venvPython scripts\run_unittests.py --module tests.test_r123_real_backend_model_list_lane --enforce-skip-policy tests\skip_policy.json --max-skipped 0
}
-Write-Host "[tests] 5/7 R121 retry partition contract"
+Write-Host "[tests] 5/8 R121 retry partition contract"
Invoke-Checked "R121 retry partition contract" {
& $venvPython scripts\run_unittests.py --module tests.test_r121_retry_partition_contract --enforce-skip-policy tests\skip_policy.json --max-skipped 0
}
-Write-Host "[tests] 6/7 R118 adversarial gate (smoke)"
+Write-Host "[tests] 6/8 Slack integration gates (R124/R125)"
+Invoke-Checked "Slack integration gates" {
+ & $venvPython scripts\run_unittests.py --module tests.test_r124_slack_ingress_contract --enforce-skip-policy tests\skip_policy.json --max-skipped 0
+ & $venvPython scripts\run_unittests.py --module tests.test_r125_slack_real_backend_lane --enforce-skip-policy tests\skip_policy.json --max-skipped 0
+}
+
+Write-Host "[tests] 7/8 R118 adversarial gate (smoke)"
$env:MOLTBOT_STATE_DIR = "$root\moltbot_state\_local_adversarial"
Invoke-Checked "R118 adversarial smoke" {
& $venvPython scripts\run_adversarial_gate.py --profile smoke --seed 42 --artifact-dir .tmp\adversarial
}
-Write-Host "[tests] 7/7 frontend E2E"
+Write-Host "[tests] 8/8 frontend E2E"
Invoke-Checked "frontend E2E" { npm test }
Write-Host "[tests] PASS"
diff --git a/tests/skip_policy.json b/tests/skip_policy.json
index 1e1e9dd..f47c1cc 100644
--- a/tests/skip_policy.json
+++ b/tests/skip_policy.json
@@ -9,6 +9,8 @@
"tests.security.test_endpoint_drift",
"tests.test_r122_real_backend_lane",
"tests.test_r123_real_backend_model_list_lane",
- "tests.test_r121_retry_partition_contract"
+ "tests.test_r121_retry_partition_contract",
+ "tests.test_r124_slack_ingress_contract",
+ "tests.test_r125_slack_real_backend_lane"
]
}
diff --git a/tests/test_r124_slack_ingress_contract.py b/tests/test_r124_slack_ingress_contract.py
new file mode 100644
index 0000000..a4c463b
--- /dev/null
+++ b/tests/test_r124_slack_ingress_contract.py
@@ -0,0 +1,569 @@
+"""
+R124 -- Slack Ingress Security Contract Matrix.
+
+Covers:
+- Signature verification (valid, missing, invalid, expired timestamp)
+- Replay / duplicate event_id guard
+- Retry header handling
+- Conversation identity (sender_id, channel_id, thread_ts)
+- Bot-loop prevention
+- url_verification challenge
+- Mention policy for groups
+- User/channel allowlist enforcement
+- Subtype filtering
+
+This test suite is marked no-skip in skip_policy.json.
+"""
+
+import asyncio
+import hashlib
+import hmac
+import json
+import os
+import sys
+import time
+import unittest
+from dataclasses import dataclass
+from typing import Optional
+from unittest.mock import AsyncMock, MagicMock, patch
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from connector.config import ConnectorConfig
+from connector.contract import CommandRequest, CommandResponse
+from connector.platforms.slack_webhook import (
+ SLACK_SIGNING_VERSION,
+ SLACK_TIMESTAMP_MAX_DRIFT_SEC,
+ SlackWebhookServer,
+ verify_slack_signature,
+)
+
+# -- Test helpers -----------------------------------------------------------
+
+SIGNING_SECRET = "test_signing_secret_abc123"
+BOT_TOKEN = "xoxb-test-token"
+
+
+def _make_signature(secret: str, timestamp: str, body: bytes) -> str:
+ """Compute a valid Slack signature for testing."""
+ sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
+ sig = hmac.new(
+ secret.encode("utf-8"),
+ sig_basestring.encode("utf-8"),
+ hashlib.sha256,
+ ).hexdigest()
+ return f"{SLACK_SIGNING_VERSION}={sig}"
+
+
+def _make_event_payload(
+ event_type: str = "message",
+ text: str = "/status",
+ user: str = "U_SENDER",
+ channel: str = "C_CHANNEL",
+ event_id: str = "Ev12345",
+ ts: str = "1234567.000",
+ thread_ts: str = "",
+ bot_id: str = "",
+ subtype: str = "",
+ authorizations: list = None,
+) -> dict:
+ event = {
+ "type": event_type,
+ "text": text,
+ "user": user,
+ "channel": channel,
+ "ts": ts,
+ }
+ if thread_ts:
+ event["thread_ts"] = thread_ts
+ if bot_id:
+ event["bot_id"] = bot_id
+ if subtype:
+ event["subtype"] = subtype
+
+ payload = {
+ "type": "event_callback",
+ "event_id": event_id,
+ "event": event,
+ }
+ if authorizations is not None:
+ payload["authorizations"] = authorizations
+ return payload
+
+
+@dataclass
+class FakeRequest:
+ """Minimal request shim for unit tests."""
+
+ _body: bytes
+ _headers: dict
+
+ @property
+ def headers(self):
+ return self._headers
+
+ async def read(self):
+ return self._body
+
+
+def _build_request(
+ payload: dict,
+ signing_secret: str = SIGNING_SECRET,
+ timestamp: Optional[str] = None,
+ signature: Optional[str] = None,
+) -> FakeRequest:
+ body = json.dumps(payload).encode("utf-8")
+ ts = timestamp or str(int(time.time()))
+ sig = signature or _make_signature(signing_secret, ts, body)
+ return FakeRequest(
+ _body=body,
+ _headers={
+ "X-Slack-Request-Timestamp": ts,
+ "X-Slack-Signature": sig,
+ },
+ )
+
+
+def _make_server(
+ allowed_users=None,
+ allowed_channels=None,
+ require_mention=True,
+ reply_in_thread=True,
+) -> SlackWebhookServer:
+ config = ConnectorConfig()
+ config.slack_bot_token = BOT_TOKEN
+ config.slack_signing_secret = SIGNING_SECRET
+ config.slack_allowed_users = allowed_users or []
+ config.slack_allowed_channels = allowed_channels or []
+ config.slack_require_mention = require_mention
+ config.slack_reply_in_thread = reply_in_thread
+
+ router = MagicMock()
+ router.handle = AsyncMock(return_value=CommandResponse(text="OK"))
+
+ server = SlackWebhookServer(config, router)
+ return server
+
+
+class BaseSlackTest(unittest.IsolatedAsyncioTestCase):
+ """Base test case with patched aiohttp session for R79 compliance."""
+
+ async def asyncSetUp(self):
+ self.aiohttp_patcher = patch("aiohttp.ClientSession")
+ self.mock_session_cls = self.aiohttp_patcher.start()
+ self.mock_session = self.mock_session_cls.return_value
+ self.mock_session.__aenter__.return_value = self.mock_session
+ self.mock_session.post.return_value.__aenter__.return_value.status = 200
+ self.mock_session.post.return_value.__aenter__.return_value.json = AsyncMock(
+ return_value={"ok": True}
+ )
+ self.mock_session.post.return_value.__aenter__.return_value.text = AsyncMock(
+ return_value="OK"
+ )
+
+ async def asyncTearDown(self):
+ self.aiohttp_patcher.stop()
+
+
+# -- Tests ------------------------------------------------------------------
+
+
+class TestSlackSignatureVerification(unittest.TestCase):
+ """R124 Matrix Row 1: Signature verification."""
+
+ def test_valid_signature_accepted(self):
+ ts = str(int(time.time()))
+ body = b'{"type":"event_callback","event_id":"Ev1","event":{"type":"message"}}'
+ sig = _make_signature(SIGNING_SECRET, ts, body)
+ self.assertTrue(
+ verify_slack_signature(
+ signing_secret=SIGNING_SECRET,
+ timestamp=ts,
+ body=body,
+ signature=sig,
+ )
+ )
+
+ def test_invalid_signature_rejected(self):
+ ts = str(int(time.time()))
+ body = b'{"type":"event_callback"}'
+ self.assertFalse(
+ verify_slack_signature(
+ signing_secret=SIGNING_SECRET,
+ timestamp=ts,
+ body=body,
+ signature="v0=deadbeef",
+ )
+ )
+
+ def test_missing_signature_rejected(self):
+ ts = str(int(time.time()))
+ body = b'{"type":"event_callback"}'
+ self.assertFalse(
+ verify_slack_signature(
+ signing_secret=SIGNING_SECRET,
+ timestamp=ts,
+ body=body,
+ signature="",
+ )
+ )
+
+ def test_missing_timestamp_rejected(self):
+ body = b'{"type":"event_callback"}'
+ self.assertFalse(
+ verify_slack_signature(
+ signing_secret=SIGNING_SECRET,
+ timestamp="",
+ body=body,
+ signature="v0=abc",
+ )
+ )
+
+ def test_missing_secret_rejected(self):
+ ts = str(int(time.time()))
+ body = b'{"type":"event_callback"}'
+ sig = _make_signature(SIGNING_SECRET, ts, body)
+ self.assertFalse(
+ verify_slack_signature(
+ signing_secret="",
+ timestamp=ts,
+ body=body,
+ signature=sig,
+ )
+ )
+
+ def test_expired_timestamp_rejected(self):
+ old_ts = str(int(time.time()) - SLACK_TIMESTAMP_MAX_DRIFT_SEC - 10)
+ body = b'{"type":"event_callback"}'
+ sig = _make_signature(SIGNING_SECRET, old_ts, body)
+ self.assertFalse(
+ verify_slack_signature(
+ signing_secret=SIGNING_SECRET,
+ timestamp=old_ts,
+ body=body,
+ signature=sig,
+ )
+ )
+
+ def test_future_timestamp_rejected(self):
+ future_ts = str(int(time.time()) + SLACK_TIMESTAMP_MAX_DRIFT_SEC + 10)
+ body = b'{"type":"event_callback"}'
+ sig = _make_signature(SIGNING_SECRET, future_ts, body)
+ self.assertFalse(
+ verify_slack_signature(
+ signing_secret=SIGNING_SECRET,
+ timestamp=future_ts,
+ body=body,
+ signature=sig,
+ )
+ )
+
+ def test_wrong_secret_rejected(self):
+ ts = str(int(time.time()))
+ body = b'{"type":"event_callback"}'
+ sig = _make_signature("wrong_secret", ts, body)
+ self.assertFalse(
+ verify_slack_signature(
+ signing_secret=SIGNING_SECRET,
+ timestamp=ts,
+ body=body,
+ signature=sig,
+ )
+ )
+
+
+class TestSlackReplayGuard(BaseSlackTest):
+ """R124 Matrix Row 2: Replay / dedupe."""
+
+ async def test_duplicate_event_id_deduped(self):
+ server = _make_server()
+ payload = _make_event_payload(event_id="Ev_dup_1")
+ req1 = _build_request(payload)
+ req2 = _build_request(payload)
+
+ resp1 = await server.handle_event(req1)
+ resp2 = await server.handle_event(req2)
+
+ # First should route; second is silently accepted (200) but not routed
+ self.assertEqual(resp1.status, 200)
+ self.assertEqual(resp2.status, 200)
+ # Router should have been called only once
+ self.assertEqual(server.router.handle.call_count, 1)
+
+ async def test_different_event_ids_both_processed(self):
+ server = _make_server()
+ req1 = _build_request(_make_event_payload(event_id="Ev_a"))
+ req2 = _build_request(_make_event_payload(event_id="Ev_b"))
+
+ await server.handle_event(req1)
+ await server.handle_event(req2)
+
+ self.assertEqual(server.router.handle.call_count, 2)
+
+ async def test_missing_event_id_rejected(self):
+ server = _make_server()
+ payload = _make_event_payload()
+ payload["event_id"] = ""
+ req = _build_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 400)
+
+
+class TestSlackBotLoopPrevention(BaseSlackTest):
+ """R124 Matrix Row 3: Bot-loop prevention."""
+
+ async def test_bot_own_message_ignored(self):
+ server = _make_server()
+ server._bot_user_id = "U_BOT"
+ payload = _make_event_payload(user="U_BOT")
+ req = _build_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 200)
+ server.router.handle.assert_not_called()
+
+ async def test_bot_id_field_ignored(self):
+ server = _make_server()
+ payload = _make_event_payload(bot_id="B_INTEGRATION")
+ req = _build_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 200)
+ server.router.handle.assert_not_called()
+
+ async def test_bot_user_id_discovered_from_authorizations(self):
+ server = _make_server()
+ payload = _make_event_payload(
+ user="U_BOT_SELF",
+ authorizations=[{"user_id": "U_BOT_SELF"}],
+ )
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ # Bot discovered its own user_id and ignored the message
+ server.router.handle.assert_not_called()
+ self.assertEqual(server._bot_user_id, "U_BOT_SELF")
+
+
+class TestSlackUrlVerification(BaseSlackTest):
+ """R124 Matrix Row 4: url_verification challenge."""
+
+ async def test_url_verification_returns_challenge(self):
+ server = _make_server()
+ payload = {"type": "url_verification", "challenge": "abc123xyz"}
+ req = _build_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 200)
+ self.assertIn("abc123xyz", resp.text)
+
+
+class TestSlackMentionPolicy(BaseSlackTest):
+ """R124 Matrix Row 5: Mention policy for groups."""
+
+ async def test_group_message_without_mention_ignored_when_required(self):
+ server = _make_server(require_mention=True)
+ server._bot_user_id = "U_BOT"
+ # Channel starting with "C" is a public channel (not DM)
+ payload = _make_event_payload(
+ event_type="message",
+ text="hello world",
+ channel="C_GROUP",
+ )
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_not_called()
+
+ async def test_group_app_mention_processed_when_required(self):
+ server = _make_server(require_mention=True)
+ server._bot_user_id = "U_BOT"
+ payload = _make_event_payload(
+ event_type="app_mention",
+ text="<@U_BOT> /status",
+ channel="C_GROUP",
+ )
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_called_once()
+ # Verify bot mention was stripped from text
+ routed_req = server.router.handle.call_args[0][0]
+ self.assertEqual(routed_req.text, "/status")
+
+ async def test_dm_message_always_processed(self):
+ server = _make_server(require_mention=True)
+ server._bot_user_id = "U_BOT"
+ # Channel starting with "D" is a DM
+ payload = _make_event_payload(
+ event_type="message",
+ text="/status",
+ channel="D_DM_CHANNEL",
+ )
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_called_once()
+
+ async def test_group_message_without_mention_processed_when_not_required(self):
+ server = _make_server(require_mention=False)
+ server._bot_user_id = "U_BOT"
+ payload = _make_event_payload(
+ event_type="message",
+ text="/status",
+ channel="C_GROUP",
+ )
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_called_once()
+
+
+class TestSlackAllowlist(BaseSlackTest):
+ """R124 Matrix Row 6: User/channel allowlist enforcement."""
+
+ async def test_user_not_in_allowlist_silently_dropped(self):
+ server = _make_server(allowed_users=["U_ALLOWED"])
+ payload = _make_event_payload(user="U_DENIED")
+ req = _build_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 200)
+ server.router.handle.assert_not_called()
+
+ async def test_user_in_allowlist_processed(self):
+ server = _make_server(allowed_users=["U_ALLOWED"])
+ payload = _make_event_payload(user="U_ALLOWED")
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_called_once()
+
+ async def test_channel_not_in_allowlist_silently_dropped(self):
+ server = _make_server(allowed_channels=["C_APPROVED"])
+ payload = _make_event_payload(channel="C_UNAPPROVED")
+ req = _build_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 200)
+ server.router.handle.assert_not_called()
+
+ async def test_channel_in_allowlist_processed(self):
+ server = _make_server(allowed_channels=["C_APPROVED"])
+ payload = _make_event_payload(channel="C_APPROVED")
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_called_once()
+
+ async def test_empty_allowlist_allows_all(self):
+ server = _make_server(allowed_users=[], allowed_channels=[])
+ payload = _make_event_payload(user="U_ANYONE")
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_called_once()
+
+
+class TestSlackConversationIdentity(BaseSlackTest):
+ """R124 Matrix Row 7: Conversation identity mapping."""
+
+ async def test_command_request_fields_correct(self):
+ server = _make_server()
+ payload = _make_event_payload(
+ user="U_USER1",
+ channel="C_CH1",
+ text="/help",
+ event_id="Ev_id_1",
+ ts="1609459200.123",
+ )
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ routed = server.router.handle.call_args[0][0]
+
+ self.assertIsInstance(routed, CommandRequest)
+ self.assertEqual(routed.platform, "slack")
+ self.assertEqual(routed.sender_id, "U_USER1")
+ self.assertEqual(routed.channel_id, "C_CH1")
+ self.assertEqual(routed.message_id, "Ev_id_1")
+ self.assertEqual(routed.text, "/help")
+
+ async def test_thread_ts_preserved_for_reply(self):
+ """Verify thread_ts is available for reply routing (covered in adapter)."""
+ server = _make_server()
+ payload = _make_event_payload(thread_ts="1609459200.001")
+ req = _build_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 200)
+
+
+class TestSlackSubtypeFiltering(BaseSlackTest):
+ """R124 Matrix Row 8: Message subtype filtering."""
+
+ async def test_message_changed_ignored(self):
+ server = _make_server()
+ payload = _make_event_payload(subtype="message_changed")
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_not_called()
+
+ async def test_message_deleted_ignored(self):
+ server = _make_server()
+ payload = _make_event_payload(subtype="message_deleted")
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_not_called()
+
+ async def test_bot_message_subtype_ignored(self):
+ server = _make_server()
+ payload = _make_event_payload(subtype="bot_message")
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_not_called()
+
+ async def test_file_share_subtype_processed(self):
+ server = _make_server()
+ payload = _make_event_payload(subtype="file_share", text="/run test")
+ req = _build_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_called_once()
+
+
+class TestSlackSignatureOnIngress(BaseSlackTest):
+ """R124: End-to-end ingress signature checks via handle_event."""
+
+ async def test_unsigned_request_returns_401(self):
+ server = _make_server()
+ payload = _make_event_payload()
+ body = json.dumps(payload).encode("utf-8")
+ req = FakeRequest(
+ _body=body,
+ _headers={
+ "X-Slack-Request-Timestamp": str(int(time.time())),
+ "X-Slack-Signature": "v0=invalid",
+ },
+ )
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 401)
+ server.router.handle.assert_not_called()
+
+ async def test_missing_headers_returns_401(self):
+ server = _make_server()
+ payload = _make_event_payload()
+ body = json.dumps(payload).encode("utf-8")
+ req = FakeRequest(_body=body, _headers={})
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 401)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_r125_slack_real_backend_lane.py b/tests/test_r125_slack_real_backend_lane.py
new file mode 100644
index 0000000..93df132
--- /dev/null
+++ b/tests/test_r125_slack_real_backend_lane.py
@@ -0,0 +1,317 @@
+"""
+R125 -- Slack Real-Backend No-Skip E2E Lane.
+
+Verifies the complete Slack adapter data flow:
+ signed ingress -> event normalization -> command authz -> thread delivery parity.
+
+Exercises the full chain without network by using mock router and mock Slack API.
+This test suite is marked no-skip in skip_policy.json.
+"""
+
+import asyncio
+import hashlib
+import hmac
+import json
+import os
+import sys
+import time
+import unittest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from connector.config import ConnectorConfig
+from connector.contract import CommandRequest, CommandResponse
+from connector.platforms.slack_webhook import SLACK_SIGNING_VERSION, SlackWebhookServer
+
+# -- Test helpers -----------------------------------------------------------
+
+SIGNING_SECRET = "r125_lane_secret"
+BOT_TOKEN = "xoxb-r125-token"
+
+
+def _make_signature(secret: str, timestamp: str, body: bytes) -> str:
+ sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
+ sig = hmac.new(
+ secret.encode("utf-8"),
+ sig_basestring.encode("utf-8"),
+ hashlib.sha256,
+ ).hexdigest()
+ return f"{SLACK_SIGNING_VERSION}={sig}"
+
+
+class FakeRequest:
+ def __init__(self, body: bytes, headers: dict):
+ self._body = body
+ self.headers = headers
+
+ async def read(self):
+ return self._body
+
+
+def _build_signed_request(payload: dict) -> FakeRequest:
+ body = json.dumps(payload).encode("utf-8")
+ ts = str(int(time.time()))
+ sig = _make_signature(SIGNING_SECRET, ts, body)
+ return FakeRequest(
+ body,
+ {
+ "X-Slack-Request-Timestamp": ts,
+ "X-Slack-Signature": sig,
+ },
+ )
+
+
+def _make_server() -> SlackWebhookServer:
+ config = ConnectorConfig()
+ config.slack_bot_token = BOT_TOKEN
+ config.slack_signing_secret = SIGNING_SECRET
+ config.slack_allowed_users = []
+ config.slack_allowed_channels = []
+ config.slack_require_mention = True
+ config.slack_reply_in_thread = True
+
+ router = MagicMock()
+ router.handle = AsyncMock(return_value=CommandResponse(text="Done"))
+
+ server = SlackWebhookServer(config, router)
+ return server
+
+
+# -- Tests ------------------------------------------------------------------
+
+
+class TestR125SlackRealBackendLane(unittest.IsolatedAsyncioTestCase):
+ """R125: Complete Slack ingress -> authz -> delivery chain."""
+
+ async def asyncSetUp(self):
+ self.aiohttp_patcher = patch("aiohttp.ClientSession")
+ self.mock_session_cls = self.aiohttp_patcher.start()
+ self.mock_session = self.mock_session_cls.return_value
+ self.mock_session.__aenter__.return_value = self.mock_session
+ self.mock_session.post.return_value.__aenter__.return_value.status = 200
+ self.mock_session.post.return_value.__aenter__.return_value.json = AsyncMock(
+ return_value={"ok": True}
+ )
+ self.mock_session.post.return_value.__aenter__.return_value.text = AsyncMock(
+ return_value="OK"
+ )
+
+ async def asyncTearDown(self):
+ self.aiohttp_patcher.stop()
+
+ async def test_signed_message_routed_to_command_router(self):
+ """Full chain: signed event -> router.handle() called with correct CommandRequest."""
+ server = _make_server()
+ payload = {
+ "type": "event_callback",
+ "event_id": "Ev_r125_1",
+ "event": {
+ "type": "message",
+ "text": "/status",
+ "user": "U_TEST_USER",
+ "channel": "D_DM",
+ "ts": "1609459200.111",
+ },
+ }
+ req = _build_signed_request(payload)
+
+ await server.handle_event(req)
+
+ server.router.handle.assert_called_once()
+ routed: CommandRequest = server.router.handle.call_args[0][0]
+ self.assertEqual(routed.platform, "slack")
+ self.assertEqual(routed.sender_id, "U_TEST_USER")
+ self.assertEqual(routed.channel_id, "D_DM")
+ self.assertEqual(routed.text, "/status")
+ self.assertEqual(routed.message_id, "Ev_r125_1")
+
+ async def test_unsigned_event_never_reaches_router(self):
+ """R125 parity: unsigned ingress must not reach command router."""
+ server = _make_server()
+ payload = {
+ "type": "event_callback",
+ "event_id": "Ev_r125_2",
+ "event": {
+ "type": "message",
+ "text": "/run exploit",
+ "user": "U_ATTACKER",
+ "channel": "C_CH",
+ "ts": "1609459200.222",
+ },
+ }
+ body = json.dumps(payload).encode("utf-8")
+ fake_req = FakeRequest(
+ body,
+ {
+ "X-Slack-Request-Timestamp": str(int(time.time())),
+ "X-Slack-Signature": "v0=0000000000000000000000000000000000000000000000000000000000000000",
+ },
+ )
+
+ resp = await server.handle_event(fake_req)
+ self.assertEqual(resp.status, 401)
+ server.router.handle.assert_not_called()
+
+ async def test_app_mention_with_bot_strip_routed(self):
+ """R125 parity: app_mention events strip bot mention before routing."""
+ server = _make_server()
+ server._bot_user_id = "U_BOT_R125"
+ payload = {
+ "type": "event_callback",
+ "event_id": "Ev_r125_3",
+ "event": {
+ "type": "app_mention",
+ "text": "<@U_BOT_R125> /run txt2img",
+ "user": "U_SENDER_A",
+ "channel": "C_GROUP",
+ "ts": "1609459200.333",
+ },
+ }
+ req = _build_signed_request(payload)
+
+ await server.handle_event(req)
+
+ server.router.handle.assert_called_once()
+ routed = server.router.handle.call_args[0][0]
+ self.assertEqual(routed.text, "/run txt2img")
+ self.assertEqual(routed.platform, "slack")
+
+ async def test_thread_reply_context_preserved(self):
+ """R125 parity: thread_ts from event is available for reply routing."""
+ server = _make_server()
+ payload = {
+ "type": "event_callback",
+ "event_id": "Ev_r125_4",
+ "event": {
+ "type": "message",
+ "text": "/help",
+ "user": "U_THREAD_USER",
+ "channel": "D_DM",
+ "ts": "1609459200.444",
+ "thread_ts": "1609459200.001",
+ },
+ }
+ req = _build_signed_request(payload)
+
+ resp = await server.handle_event(req)
+ self.assertEqual(resp.status, 200)
+ server.router.handle.assert_called_once()
+
+ async def test_run_command_flows_through_router(self):
+ """R125: /run command reaches router with correct arguments."""
+ server = _make_server()
+ payload = {
+ "type": "event_callback",
+ "event_id": "Ev_r125_5",
+ "event": {
+ "type": "message",
+ "text": "/run txt2img prompt=hello",
+ "user": "U_RUNNER",
+ "channel": "D_DM",
+ "ts": "1609459200.555",
+ },
+ }
+ req = _build_signed_request(payload)
+
+ await server.handle_event(req)
+
+ server.router.handle.assert_called_once()
+ routed = server.router.handle.call_args[0][0]
+ self.assertEqual(routed.text, "/run txt2img prompt=hello")
+
+ async def test_multiple_events_both_processed(self):
+ """R125: Multiple distinct events are independently processed."""
+ server = _make_server()
+
+ for i in range(3):
+ payload = {
+ "type": "event_callback",
+ "event_id": f"Ev_r125_multi_{i}",
+ "event": {
+ "type": "message",
+ "text": f"/status {i}",
+ "user": "U_MULTI",
+ "channel": "D_DM",
+ "ts": f"1609459200.{i:03d}",
+ },
+ }
+ req = _build_signed_request(payload)
+ await server.handle_event(req)
+
+ self.assertEqual(server.router.handle.call_count, 3)
+
+ async def test_bot_message_filtered_before_router(self):
+ """R125: Bot messages must never reach command router."""
+ server = _make_server()
+ payload = {
+ "type": "event_callback",
+ "event_id": "Ev_r125_bot",
+ "event": {
+ "type": "message",
+ "text": "bot output",
+ "user": "U_SOMEONE",
+ "channel": "C_CH",
+ "ts": "1609459200.666",
+ "bot_id": "B_BOT",
+ },
+ }
+ req = _build_signed_request(payload)
+
+ await server.handle_event(req)
+ server.router.handle.assert_not_called()
+
+
+class TestR125SlackRouterTrust(unittest.TestCase):
+ """R125: Verify router trust check integration for Slack platform."""
+
+ def test_slack_trust_check_exists_in_router(self):
+ """Verify that the router._is_trusted method handles platform='slack'."""
+ from connector.router import CommandRouter
+
+ config = ConnectorConfig()
+ config.slack_allowed_users = ["U_TRUSTED"]
+ config.slack_allowed_channels = ["C_TRUSTED"]
+
+ client = MagicMock()
+ router = CommandRouter(config, client)
+
+ # Trusted user
+ req_trusted = CommandRequest(
+ platform="slack",
+ sender_id="U_TRUSTED",
+ channel_id="C_ANY",
+ username="trusted",
+ message_id="m1",
+ text="/run test",
+ timestamp=time.time(),
+ )
+ self.assertTrue(router._is_trusted(req_trusted))
+
+ # Trusted channel
+ req_chan = CommandRequest(
+ platform="slack",
+ sender_id="U_UNKNOWN",
+ channel_id="C_TRUSTED",
+ username="unknown",
+ message_id="m2",
+ text="/run test",
+ timestamp=time.time(),
+ )
+ self.assertTrue(router._is_trusted(req_chan))
+
+ # Untrusted
+ req_untrusted = CommandRequest(
+ platform="slack",
+ sender_id="U_RANDOM",
+ channel_id="C_RANDOM",
+ username="random",
+ message_id="m3",
+ text="/run test",
+ timestamp=time.time(),
+ )
+ self.assertFalse(router._is_trusted(req_untrusted))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_s36s37r79_egress_hardening.py b/tests/test_s36s37r79_egress_hardening.py
index ab89b43..5740539 100644
--- a/tests/test_s36s37r79_egress_hardening.py
+++ b/tests/test_s36s37r79_egress_hardening.py
@@ -286,6 +286,10 @@ class TestR79EgressCompliance(unittest.TestCase):
"connector/platforms/telegram_polling.py",
"connector/platforms/wechat_webhook.py",
"connector/platforms/whatsapp_webhook.py",
+ # IMPORTANT: Keep connector platform adapters in parity here.
+ # Missing a newly-added adapter causes false-positive R79 failures
+ # in full-gate runs even when egress behavior is intentional.
+ "connector/platforms/slack_webhook.py",
# Providers
"services/providers/anthropic.py",
"services/providers/openai_compat.py",