feat(slack): finalize secure Events API integration with no-skip R124/R125 gates and docs/roadmap sync

This commit is contained in:
rookiestar28
2026-02-19 20:45:18 +08:00
parent a88a3320f8
commit f3fadd6d8a
12 changed files with 1542 additions and 48 deletions
+13 -1
View File
@@ -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:
<details>
<summary><strong>Slack app support closeout: secure Events API ingress, connector parity, and no-skip verification lanes</strong></summary>
- 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
</details>
<details>
<summary><strong>Post-Wave E closeout: Hardening chain completed</strong></summary>
- Completed on 2026-02 with full SOP validation:
+20 -1
View File
@@ -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()
+36
View File
@@ -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()]
+476
View File
@@ -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://<host>/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}")
+7
View File
@@ -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
+69 -27
View File
@@ -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://<public-host>/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://<public-host>/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://<public-host>/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://<public-host>/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.
+13 -9
View File
@@ -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"
+15 -9
View File
@@ -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"
+3 -1
View File
@@ -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"
]
}
+569
View File
@@ -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()
+317
View File
@@ -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()
+4
View File
@@ -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",