Skip to content

fix(discord): gateway channel-scoped access (opt-in) + fail closed on unparseable allowlists - #127

Open
juniperbevensee wants to merge 1 commit into
mainfrom
dev/juniperbevensee/discord-authz-failclosed
Open

fix(discord): gateway channel-scoped access (opt-in) + fail closed on unparseable allowlists#127
juniperbevensee wants to merge 1 commit into
mainfrom
dev/juniperbevensee/discord-authz-failclosed

Conversation

@juniperbevensee

Copy link
Copy Markdown
Collaborator

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_authorized had no Discord entry in its chat-scoped allowlist map, while the Discord adapter does grant channel-scoped access at intake (_is_allowed_user consults DISCORD_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 is DISCORD_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 use DISCORD_ALLOWED_CHANNELS to scope where the bot speaks, while restricting who may command it to a short DISCORD_ALLOWED_USERS list. 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:

DISCORD_ALLOWED_ROLES=moderators     # a role NAME; snowflake required

{int(rid) for rid in ... if rid.isdigit()} yields an empty set, has_roles is 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:

users_configured = has_users or bool(getattr(self, "_user_allowlist_configured", False))

__init__ leaves the flag False, and several callers — including tests/gateway/test_discord_slash_auth.py's fixture — assign _allowed_user_ids directly afterwards. Deriving configured from 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_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.

Testing

14 new tests in tests/gateway/test_discord_authz_failclosed.py, covering both defects plus the no-widening and no-regression guards.

passed failed
pristine origin/main baseline 9751 16
this branch 9765 16

Same 16 pre-existing failures in both (telegram markdown escaping, feishu websocket, channel directory, shutdown forensics — all unrelated); +14 is exactly the new tests. Verified by running the full tests/gateway suite against a detached origin/main worktree.

Deploying this

DISCORD_CHANNEL_SCOPED_ACCESS is off everywhere, so merging changes no live behavior. Before enabling it on any agent, note that cyborg currently runs DISCORD_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.

… 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>
@juniperbevensee

Copy link
Copy Markdown
Collaborator Author

Independent audit — do not merge as-is. One fail-open, and the headline fix is inert by default.

No regressions: full tests/gateway on PR head = 9765 passed / 16 failed, on origin/main = 9755 / 16, failure sets byte-identical. Net +10 reconciles exactly (+14 new Discord tests − 4 signal tests that exist on main but not this pre-#125 branch). No rebase needed — git merge-tree reports 0 conflicts and none of main's 6 newer commits touch authz_mixin.py or the Discord adapter.

BLOCKING

B1 — the gateway channel grant is unconditional and overrides an explicit user/role allowlist.
gateway/authz_mixin.py:433-445

The adapter's equivalent grant is gated on no user/role allowlist being configured (adapter.py:3353-3367). The gateway branch has no such precondition, so it returns True for any sender in a listed channel regardless of what else is set:

DISCORD_ALLOWED_USERS=alice, DISCORD_ALLOWED_CHANNELS=555, flag on
stranger "999" in ch 555  ->  authorized=True

This is the widening the PR's own rationale says must not happen, and it contradicts website/docs/user-guide/messaging/discord.md:274. Two paths reach _is_user_authorized with no adapter _is_allowed_user gate in front, so the adapter does not save you:

  1. Voice — privilege escalation. gateway/run.py:13913-13947 builds a SessionSource for the voice-bound text channel and _is_user_authorized is the only authz gate on the transcript path. adapter.py:3292-3294 states the intent explicitly: "Calls without channel context (e.g. voice utterances) do not get this bypass." This branch reinstates it. Anyone who can join the voice channel drives the agent as an authorized user.
  2. Prompt-injection mitigation defeated. adapter.py:5220run.py:9513-9527: with the flag on, non-allowlisted posters' history loses its [unverified] tag and enters the LLM context as authoritative.

Fix is one condition: apply the adapter's not users_configured and not roles_configured precondition to the gateway branch.

This is not hypothetical for our fleet. All six live Discord agents have both DISCORD_ALLOWED_USERS and DISCORD_ALLOWED_CHANNELS set — exactly B1's shape. The flag defaults off so nothing is broken today; the risk is on merge-and-enable.

SHOULD-FIX

S2 — the fix is inert in the default configuration. DISCORD_AUTO_THREAD defaults "true" (adapter.py:6405), so an @mention in a guild text channel auto-creates a thread and the source becomes chat_type="thread". The guard at authz_mixin.py:363 is {"group","forum","channel"} → branch skipped. Forum posts are also chat_type="thread". Verified: auto-threaded message in an allowlisted channel → authorized=False.

The comment justifying this (authz_mixin.py:430-432, "matching a thread needs its parent channel id, which this layer does not carry") is factually wrongsource.parent_chat_id exists (gateway/session.py:176) and is populated by this exact path. Fleet check: DISCORD_AUTO_THREAD is unset on all six agents, so it is true everywhere — the fix would be inert on every live agent.

S3 — "changes no live behavior" is false, but the fleet is safe. R1 is not flag-gated, so DISCORD_ALLOWED_ROLES=<bare name> + channels + no users would flip from working to total denial. The PR speculates this misconfig is in the fleet — it is not: DISCORD_ALLOWED_ROLES is unset on all six agents. So the R1 blast radius here is nil. Worth correcting the deploy note rather than acting on it.

S4 — silent total denial with the diagnostic suppressed. _warn_if_fail_closed_default early-returns when either flag is set (adapter.py:3432-3435), but the compensating logger.errors live in connect() while _resolve_allowed_usernames runs later from on_ready and can wipe _allowed_user_ids to empty (adapter.py:4060-4061) when a username won't resolve. Result: bot denies everything, no operator-visible diagnostic.

S5 — DISCORD_ALLOWED_CHANNELS=* + flag on is a silent no-op. Adapter honors * (adapter.py:3263); gateway strips it (authz_mixin.py:440). This is cyborg's live config — enabling the flag there changes nothing and logs nothing. Rejecting * is defensible; it needs a startup warning.

S6 — zero documentation. DISCORD_CHANNEL_SCOPED_ACCESS appears only at authz_mixin.py:434 — absent from discord.md, environment-variables.md, and tools/environments/local.py. Every other Discord authz var is documented.

Test quality

The fail-closed audit itself is clean: empty/missing DISCORD_ALLOWED_CHANNELS → empty set → denies; malformed flag values deny; DMs excluded. Empty means deny-all, correctly.

  • T1 — two wildcard tests are tautological. Deleting and cid.strip() != "*" (authz_mixin.py:440) leaves all three passing, because _chat_id_in_allowlist never matches * anyway (its own docstring says the caller handles it). The headline "* is NOT honored" decision is pinned by nothing.
  • T2 — the two real load-bearing assertions ARE non-vacuous. Defaulting the flag to "true" fails test_channel_scoped_access_is_off_by_default; reverting users_configured/roles_configured to has_users/has_roles fails 3 R1 tests.
  • T3 — R1 tests hand-set _role_allowlist_configured=True rather than exercising the parse at adapter.py:1042, so "env set → flag True" is never tested end-to-end.
  • T4test_no_allowlist_at_all_still_grants_channel_scoped_access stubs _discord_channel_ids_allowed to True, mocking away the matching it purports to test.
  • T5 — fixture uses chat_type="channel", but Discord message sources are only dm|thread|group; "channel" comes only from get_chat_info metadata. No test exercises the real shape, and none pins the thread/forum gap.

Minimum to merge

  1. B1 — add the adapter's precondition to the gateway branch.
  2. S2 — match chat_type=="thread" against source.parent_chat_id, or the flag is decoration on every default deployment.
  3. Correct the S3 deploy note (and drop the fleet-misconfig premise — it does not hold).

S4/S5/S6 and T1/T3/T5 are cheap enough to fold into the same pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant