diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f698c11d5ac..8c790e7e856 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3439,6 +3439,13 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "messaging", }, + "SLACK_ALLOWED_USERS": { + "description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.", + "prompt": "Allowed Slack member IDs", + "url": "https://api.slack.com/apps", + "password": False, + "category": "messaging", + }, "MATTERMOST_URL": { "description": "Mattermost server URL (e.g. https://mm.example.com)", "prompt": "Mattermost server URL", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2dbb316d32d..b1320875c53 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2325,6 +2325,23 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str: return " ".join(["hermes", *_gateway_subcommand(profile, verb)]) +def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None: + """Reject platform credentials that are clearly in the wrong field.""" + if platform_id != "slack" or not value: + return + + if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"): + raise HTTPException( + status_code=400, + detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.", + ) + if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"): + raise HTTPException( + status_code=400, + detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.", + ) + + def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]: """Spawn ``hermes gateway restart``, reusing an in-flight restart. @@ -4155,9 +4172,9 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { }, "slack": { "name": "Slack", - "description": "Use Hermes from Slack via Socket Mode.", + "description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.", "docs_url": "https://api.slack.com/apps", - "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), + "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"), "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), }, "mattermost": { @@ -5221,6 +5238,7 @@ async def update_messaging_platform( ) trimmed = value.strip() if trimmed: + _validate_messaging_env_value(platform_id, key, trimmed) save_env_value(key, trimmed) if body.enabled is not None: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e65a28101cd..3f6ed3e0435 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1552,6 +1552,24 @@ class TestWebServerEndpoints: assert telegram["enabled"] is False assert any(field["key"] == "TELEGRAM_BOT_TOKEN" and field["required"] for field in telegram["env_vars"]) + def test_slack_messaging_platform_exposes_user_allowlist(self): + resp = self.client.get("/api/messaging/platforms") + + assert resp.status_code == 200 + platforms = resp.json()["platforms"] + slack = next(platform for platform in platforms if platform["id"] == "slack") + fields = {field["key"]: field for field in slack["env_vars"]} + + assert "allowed Slack member IDs" in slack["description"] + assert set(fields) >= { + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "SLACK_ALLOWED_USERS", + } + assert fields["SLACK_ALLOWED_USERS"]["prompt"] == "Allowed Slack member IDs" + assert fields["SLACK_ALLOWED_USERS"]["is_password"] is False + assert "member IDs" in fields["SLACK_ALLOWED_USERS"]["description"] + def test_weixin_messaging_metadata_describes_personal_ilink_setup(self): resp = self.client.get("/api/messaging/platforms") @@ -1628,6 +1646,35 @@ class TestWebServerEndpoints: telegram = next(platform for platform in status if platform["id"] == "telegram") assert telegram["enabled"] is False + def test_update_messaging_platform_saves_slack_allowed_users(self): + from hermes_cli.config import load_env + + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_ALLOWED_USERS": "U01ABC2DEF3,U04XYZ5LMN6"}}, + ) + + assert resp.status_code == 200 + assert load_env()["SLACK_ALLOWED_USERS"] == "U01ABC2DEF3,U04XYZ5LMN6" + + def test_update_messaging_platform_rejects_swapped_slack_bot_token(self): + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_BOT_TOKEN": "xapp-wrong-token-type"}}, + ) + + assert resp.status_code == 400 + assert "xoxb-" in resp.json()["detail"] + + def test_update_messaging_platform_rejects_swapped_slack_app_token(self): + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_APP_TOKEN": "xoxb-wrong-token-type"}}, + ) + + assert resp.status_code == 400 + assert "xapp-" in resp.json()["detail"] + def test_messaging_platform_test_reports_missing_required_setup(self): resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True}) assert resp.status_code == 200