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",