fix(discord): gateway channel-scoped access (opt-in) + fail closed on unparseable allowlists - #127
Conversation
… unparseable allowlists Two independent authorization defects found in a Discord control-surface audit. R2 — the gateway had no Discord entry in its chat-scoped allowlist map (_is_user_authorized), while the Discord adapter DOES grant channel-scoped access at intake. The adapter admitted a guild message and the gateway then denied it: the bot went silent with no operator-visible error. The obvious operator response to that is DISCORD_ALLOWED_USERS=*, the worst available state. The fix is OPT-IN per agent via DISCORD_CHANNEL_SCOPED_ACCESS, deliberately. Simply adding Platform.DISCORD to the shared map would have silently widened every existing Discord agent: those agents use DISCORD_ALLOWED_CHANNELS to scope *where the bot speaks* while restricting *who may command it* to a short DISCORD_ALLOWED_USERS list, and a chat-scoped grant authorizes every sender in the channel. Five live agents would have gone from 1-3 authorized users to "anyone in the listed channels" on deploy. `*` is also NOT honored here, unlike the Telegram/Signal branches. For channels it states where the bot may speak, not who may command it; treating it as an authorization grant turns one permissive scope value into a fully open bot. An explicit channel list is required. Known gap, left explicit rather than half-fixed: Discord threads arrive as chat_type "thread", which is not in the guard set, so a thread under an allowlisted channel is not covered — matching one needs its parent channel id, which this layer does not carry. R1 — an allowlist var that was SET but parsed to zero usable entries read downstream as "no allowlist configured". That branch grants channel-scoped access to every sender and honors DISCORD_ALLOW_ALL_USERS / GATEWAY_ALLOW_ALL_USERS, so a misconfiguration WIDENED access instead of denying. The likely trigger is DISCORD_ALLOWED_ROLES=moderators — a role name where a numeric snowflake is required. HSM can write this var since hsm#186 and its own fixtures use a bare name, so an operator typing what the UI implies got a role gate that matched nobody and a bot that quietly fell open. Now tracked via _user/_role_allowlist_configured, with a loud error naming the cause. The flag is ADDITIVE to the parsed truthiness, never a replacement: __init__ leaves it False and several callers (including the slash-auth fixture) assign _allowed_user_ids directly afterwards, so deriving `configured` from the flag alone left it stale-False and denied legitimately allowlisted users. Caught by test_discord_slash_auth during review; covered by a regression test here. _warn_if_fail_closed_default no longer fires for the set-but-unparseable case, which has its own error — telling an operator to "set DISCORD_ALLOWED_ROLES" when they already did sends them in circles. Tests: 14 new in tests/gateway/test_discord_authz_failclosed.py. Full tests/gateway suite is 9765 passed / 16 failed, against a pristine origin/main baseline of 9751 passed / 16 failed — same pre-existing failures (telegram markdown escaping, feishu, channel directory, shutdown forensics), +14 new. No runtime config was changed and nothing was deployed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent audit — do not merge as-is. One fail-open, and the headline fix is inert by default.No regressions: full BLOCKINGB1 — the gateway channel grant is unconditional and overrides an explicit user/role allowlist. The adapter's equivalent grant is gated on no user/role allowlist being configured ( This is the widening the PR's own rationale says must not happen, and it contradicts
Fix is one condition: apply the adapter's This is not hypothetical for our fleet. All six live Discord agents have both SHOULD-FIXS2 — the fix is inert in the default configuration. The comment justifying this ( S3 — "changes no live behavior" is false, but the fleet is safe. R1 is not flag-gated, so S4 — silent total denial with the diagnostic suppressed. S5 — S6 — zero documentation. Test qualityThe fail-closed audit itself is clean: empty/missing
Minimum to merge
S4/S5/S6 and T1/T3/T5 are cheap enough to fold into the same pass. |
Two independent Discord authorization defects from a control-surface audit. Nothing here is deployed — no runtime config was touched.
R2 — the gateway denied what the adapter admitted
_is_user_authorizedhad no Discord entry in its chat-scoped allowlist map, while the Discord adapter does grant channel-scoped access at intake (_is_allowed_userconsultsDISCORD_ALLOWED_CHANNELS). So the adapter admitted a guild message, the gateway then denied it, and the bot went silent with no operator-visible error. The predictable operator response isDISCORD_ALLOWED_USERS=*— the worst available state.Why this is opt-in rather than a one-line map entry
Adding
Platform.DISCORD: "DISCORD_ALLOWED_CHANNELS"to the shared map would have silently widened every existing Discord agent. Those agents useDISCORD_ALLOWED_CHANNELSto scope where the bot speaks, while restricting who may command it to a shortDISCORD_ALLOWED_USERSlist. A chat-scoped grant authorizes every sender in the channel — so five live agents would have gone from 1–3 authorized users to "anyone posting in the listed channels" the moment this shipped.It is therefore gated on
DISCORD_CHANNEL_SCOPED_ACCESS, per agent. Existing agents see zero behavior change until the flag is set deliberately.*is also not honored here, unlike the Telegram/Signal branches above it. For channels,*states where the bot may speak, not who may command it; treating it as an authorization grant turns one permissive scope value into a fully open bot. An explicit channel list is required.Known gap, left explicit rather than half-fixed: Discord threads arrive as
chat_type "thread", which is not in the guard set, so a thread under an allowlisted channel isn't covered. Matching one needs its parent channel id, which this layer doesn't carry.R1 — a misconfigured allowlist widened access instead of denying
An allowlist var that was set but parsed to zero usable entries read downstream as "no allowlist configured". That branch grants channel-scoped access to every sender and honors
DISCORD_ALLOW_ALL_USERS/GATEWAY_ALLOW_ALL_USERS— so a typo opened the bot rather than closing it.The realistic trigger:
{int(rid) for rid in ... if rid.isdigit()}yields an empty set,has_rolesis False, and the role gate vanishes. HSM has been able to write this var since hsm#186 and its own test fixtures use a bare role name, so an operator typing what the console implies got a role gate matching nobody.Now tracked via
_user_allowlist_configured/_role_allowlist_configured, with an error naming the actual cause (numeric snowflake, not role name).One design note worth reviewing
The flag is additive to the parsed truthiness, never a replacement:
__init__leaves the flagFalse, and several callers — includingtests/gateway/test_discord_slash_auth.py's fixture — assign_allowed_user_idsdirectly afterwards. Derivingconfiguredfrom the flag alone left it stale-False, sent control into the widening branch, and denied legitimately allowlisted users. That was caught by the existing slash-auth suite during review and is now covered by its own regression test here._warn_if_fail_closed_defaultno longer fires for the set-but-unparseable case, which has its own error — telling an operator to "setDISCORD_ALLOWED_ROLES" when they already did sends them in circles.Testing
14 new tests in
tests/gateway/test_discord_authz_failclosed.py, covering both defects plus the no-widening and no-regression guards.origin/mainbaselineSame 16 pre-existing failures in both (telegram markdown escaping, feishu websocket, channel directory, shutdown forensics — all unrelated);
+14is exactly the new tests. Verified by running the fulltests/gatewaysuite against a detachedorigin/mainworktree.Deploying this
DISCORD_CHANNEL_SCOPED_ACCESSis off everywhere, so merging changes no live behavior. Before enabling it on any agent, note that cyborg currently runsDISCORD_ALLOWED_CHANNELS=*— the wildcard is rejected as an auth grant, so it would get no channel-scoped access until it has an explicit commons list.