From 09746c467b4f641d531ae7694e11f05a65baceca Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 01:53:44 -0400 Subject: [PATCH 1/2] Discord: channel permission math for radio bots (#225 S1) New pure module app/internal/discord_perms.py: - effective_permissions(guild, member, channel) -> (bits, trace), in Discord's order: owner, base (@everyone | member roles), ADMINISTRATOR, @everyone overwrite, role overwrites (all denies then all allows), member overwrite, no View Channel = 0, no Connect drops voice perms (as discord.py does, so labels match the edge), timeout keeps only View + Read History. - trace names what removed each radio permission, e.g. "denied by #dispatch's override for @Muted". - check_voice(bits, channel, trace=None) -> {ok, is_voice, missing, reasons, user_limit, limit_bypass}; only type-2 channels count; labels match node-26#38's _join_refusal ("View Channel", "Connect", "Speak"). - Permission strings parsed with int (values above 2**53 tested); malformed values raise ValueError so callers treat them as unknown. No caller yet; S3 wires it into pick_token. Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/internal/discord_perms.py | 285 ++++++++++++++++++++++ drb-c2-core/tests/test_discord_perms.py | 276 +++++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 drb-c2-core/app/internal/discord_perms.py create mode 100644 drb-c2-core/tests/test_discord_perms.py diff --git a/drb-c2-core/app/internal/discord_perms.py b/drb-c2-core/app/internal/discord_perms.py new file mode 100644 index 0000000..e2ae2da --- /dev/null +++ b/drb-c2-core/app/internal/discord_perms.py @@ -0,0 +1,285 @@ +""" +Discord channel permission math for radio bots (server-26#225, slice S1). + +Pure functions, no I/O. Input is the raw JSON Discord's REST API returns: + + guild GET /guilds/{g} {id, owner_id, roles: [{id, name, permissions}]} + member GET /guilds/{g}/members/{bot_id} {user: {id}, roles: [role_id], communication_disabled_until} + channel GET /guilds/{g}/channels (one) {id, name, type, user_limit, permission_overwrites: + [{id, type, allow, deny}]} + +``effective_permissions`` follows Discord's documented order +(https://discord.com/developers/docs/topics/permissions#permission-overwrites): + + 1. The guild owner has every permission. + 2. Base = @everyone's permissions OR every role the member has. + ADMINISTRATOR there means every permission. + 3. The channel's @everyone overwrite (deny, then allow). + 4. The member's role overwrites: OR every deny, OR every allow, then apply + deny before allow, so a role that allows beats a role that denies. + 5. The member's own overwrite, last. + 6. No VIEW_CHANNEL in the channel means no permission there at all. + No CONNECT in a voice channel also takes the voice permissions (Speak, + etc.) — discord.py applies the same rule, so the edge's ``_join_refusal`` + (node-26#38) reports the same labels. + 7. A timed-out member keeps only View Channel and Read Message History. + +It also returns a ``trace``: for each radio permission the bot ends up +without, a sentence naming what removed it ("denied by #dispatch's override +for @Muted"), so the customer is told exactly which Discord toggle to fix. + +Permission integers are strings in the API and can exceed 2**53; they are +parsed with ``int`` and never pass through a float. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Mapping, Optional + +VIEW_CHANNEL = 1 << 10 +READ_MESSAGE_HISTORY = 1 << 16 +CONNECT = 1 << 20 +SPEAK = 1 << 21 +MOVE_MEMBERS = 1 << 24 +ADMINISTRATOR = 1 << 3 + +# Every bit Discord defines today fits well under 64; owner/admin get them all. +ALL_PERMISSIONS = (1 << 64) - 1 + +# What a timed-out member keeps (Discord: "timed out members ... only View +# Channel and Read Message History"). +TIMEOUT_MASK = VIEW_CHANNEL | READ_MESSAGE_HISTORY + +# Permissions that only mean something to someone connected to voice; lost +# with Connect (discord.py ``Permissions.voice()``: connect, speak, mute, +# deafen, move, VAD, priority speaker, stream, soundboard...). +VOICE_IMPLIED_BY_CONNECT = ( + (1 << 8) # PRIORITY_SPEAKER + | (1 << 9) # STREAM + | SPEAK + | (1 << 22) # MUTE_MEMBERS + | (1 << 23) # DEAFEN_MEMBERS + | MOVE_MEMBERS + | (1 << 25) # USE_VAD + | (1 << 32) # REQUEST_TO_SPEAK + | (1 << 39) # USE_EMBEDDED_ACTIVITIES + | (1 << 42) # USE_SOUNDBOARD + | (1 << 45) # USE_EXTERNAL_SOUNDS +) + +# Same bits as discord_leases.RADIO_BOT_PERMISSIONS, in the order the edge +# lists them. Labels match node-26#38's ``_join_refusal`` strings exactly. +REQUIRED_VOICE = ((VIEW_CHANNEL, "View Channel"), (CONNECT, "Connect"), (SPEAK, "Speak")) +TRACKED = REQUIRED_VOICE + ((MOVE_MEMBERS, "Move Members"),) + +GUILD_VOICE = 2 # Discord channel type for a normal voice channel + + +def parse_permissions(value: Any) -> int: + """A Discord permission integer from its API string (or int / None). + + Raises ValueError on anything else, so a malformed payload is "unknown" + to the caller rather than silently read as "no permissions". + """ + if value is None or value == "": + return 0 + if isinstance(value, bool): + raise ValueError(f"not a permission integer: {value!r}") + if isinstance(value, int): + n = value + elif isinstance(value, str) and value.strip().isdigit(): + n = int(value.strip()) + else: + raise ValueError(f"not a permission integer: {value!r}") + if n < 0: + raise ValueError(f"not a permission integer: {value!r}") + return n + + +def _sid(value: Any) -> str: + return "" if value is None else str(value) + + +def _role_label(role: Optional[Mapping[str, Any]], role_id: str) -> str: + name = (role or {}).get("name") or f"role {role_id}" + return name if name.startswith("@") else f"@{name}" + + +def _channel_label(channel: Mapping[str, Any]) -> str: + name = channel.get("name") + return f"#{name}" if name else "the channel" + + +def _overwrite_kind(ow: Mapping[str, Any]) -> Optional[str]: + t = ow.get("type") + if t in (0, "0", "role"): + return "role" + if t in (1, "1", "member"): + return "member" + return None + + +def _timed_out(member: Mapping[str, Any], now: datetime) -> bool: + raw = member.get("communication_disabled_until") + if not raw: + return False + try: + until = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + except ValueError: + return False + if until.tzinfo is None: + until = until.replace(tzinfo=timezone.utc) + return until > now + + +class _Tracer: + """Remembers, per tracked bit, the last step that removed it.""" + + def __init__(self) -> None: + self.reasons: dict[int, str] = {} + + def step(self, before: int, after: int, reason: str) -> int: + for bit, _ in TRACKED: + if before & bit and not after & bit: + self.reasons[bit] = reason + elif after & bit: + self.reasons.pop(bit, None) + return after + + def result(self, bits: int) -> dict[str, str]: + return {label: self.reasons.get(bit, "not granted") + for bit, label in TRACKED if not bits & bit} + + +def effective_permissions( + guild: Mapping[str, Any], + member: Mapping[str, Any], + channel: Mapping[str, Any], + *, + now: Optional[datetime] = None, +) -> tuple[int, dict[str, str]]: + """The member's permission bits in ``channel``, and why tracked bits are missing. + + ``trace`` maps a label ("View Channel", "Connect", "Speak", + "Move Members") to a sentence naming what removed it; only labels the + member ends up WITHOUT appear. Raises ValueError on a malformed + permission value. + """ + now = now or datetime.now(timezone.utc) + guild_id = _sid(guild.get("id")) + user = member.get("user") or {} + member_id = _sid(user.get("id") or member.get("id")) + member_role_ids = {_sid(r) for r in (member.get("roles") or [])} + roles = {_sid(r.get("id")): r for r in (guild.get("roles") or [])} + chan = _channel_label(channel) + tracer = _Tracer() + + # 1. Owner. + if member_id and member_id == _sid(guild.get("owner_id")): + return ALL_PERMISSIONS, {} + + # 2. Base from @everyone + the member's roles. + everyone_role = roles.get(guild_id) + base = parse_permissions(everyone_role.get("permissions")) if everyone_role else 0 + for rid in member_role_ids: + role = roles.get(rid) + if role is not None and rid != guild_id: + base |= parse_permissions(role.get("permissions")) + if base & ADMINISTRATOR: + return ALL_PERMISSIONS, {} + tracer.step(ALL_PERMISSIONS, base, + "not granted by @everyone or any of the bot's roles (Server Settings → Roles)") + perms = base + + everyone_ow = None + role_ows: list[Mapping[str, Any]] = [] + member_ow = None + for ow in channel.get("permission_overwrites") or []: + oid = _sid(ow.get("id")) + kind = _overwrite_kind(ow) + if oid == guild_id and kind != "member": + everyone_ow = ow + elif kind == "member" or (kind is None and oid == member_id): + if oid == member_id: + member_ow = ow + elif oid in member_role_ids: + role_ows.append(ow) + + # 3. @everyone overwrite. + if everyone_ow is not None: + deny = parse_permissions(everyone_ow.get("deny")) + allow = parse_permissions(everyone_ow.get("allow")) + perms = tracer.step(perms, (perms & ~deny) | allow, + f"denied by {chan}'s override for @everyone") + + # 4. Role overwrites: all denies, then all allows. + if role_ows: + deny = allow = 0 + deniers: dict[int, list[str]] = {} + for ow in sorted(role_ows, key=lambda o: _sid(o.get("id"))): + d = parse_permissions(ow.get("deny")) + deny |= d + allow |= parse_permissions(ow.get("allow")) + oid = _sid(ow.get("id")) + for bit, _ in TRACKED: + if d & bit: + deniers.setdefault(bit, []).append(_role_label(roles.get(oid), oid)) + after = (perms & ~deny) | allow + for bit, _ in TRACKED: + if perms & bit and not after & bit: + tracer.reasons[bit] = ( + f"denied by {chan}'s override for {', '.join(deniers.get(bit, []))}") + elif after & bit: + tracer.reasons.pop(bit, None) + perms = after + + # 5. The member's own overwrite. + if member_ow is not None: + deny = parse_permissions(member_ow.get("deny")) + allow = parse_permissions(member_ow.get("allow")) + perms = tracer.step(perms, (perms & ~deny) | allow, + f"denied by {chan}'s override for the bot itself") + + # 6. Implicit denials. + if not perms & VIEW_CHANNEL: + perms = tracer.step(perms, 0, f"the bot can't see {chan} without View Channel") + elif channel.get("type") == GUILD_VOICE and not perms & CONNECT: + perms = tracer.step(perms, perms & ~VOICE_IMPLIED_BY_CONNECT, + f"Discord removes it in {chan} without Connect") + + # 7. Timeout. + if _timed_out(member, now): + perms = tracer.step(perms, perms & TIMEOUT_MASK, "the bot is timed out in this server") + + return perms, tracer.result(perms) + + +def check_voice( + bits: int, + channel: Mapping[str, Any], + trace: Optional[Mapping[str, str]] = None, +) -> dict[str, Any]: + """Can a bot with ``bits`` in ``channel`` stream into it? + + Only a normal voice channel (type 2) counts; anything else is + ``ok=False, is_voice=False`` with nothing ``missing``. ``user_limit`` is + advisory: REST can't see who is connected, so the edge's ``channel_full`` + stays the enforcement. ``limit_bypass`` is Move Members, which Discord + lets past a full channel. ``reasons`` carries the trace sentence for each + missing label when ``trace`` is given. + """ + is_voice = channel.get("type") == GUILD_VOICE + try: + user_limit = int(channel.get("user_limit") or 0) + except (TypeError, ValueError): + user_limit = 0 + missing = [label for bit, label in REQUIRED_VOICE if not bits & bit] if is_voice else [] + reasons = {label: trace[label] for label in missing if trace and label in trace} + return { + "ok": is_voice and not missing, + "is_voice": is_voice, + "missing": missing, + "reasons": reasons, + "user_limit": user_limit, + "limit_bypass": bool(bits & MOVE_MEMBERS), + } diff --git a/drb-c2-core/tests/test_discord_perms.py b/drb-c2-core/tests/test_discord_perms.py new file mode 100644 index 0000000..3593748 --- /dev/null +++ b/drb-c2-core/tests/test_discord_perms.py @@ -0,0 +1,276 @@ +""" +Discord channel permission math (server-26#225, slice S1). + +Payloads are shaped like Discord's REST JSON: permission integers are +strings, overwrite ``type`` is 0 (role) / 1 (member). +""" +from datetime import datetime, timedelta, timezone + +import pytest + +from app.internal import discord_leases as leases +from app.internal import discord_perms as perms +from app.internal.discord_perms import ( + ADMINISTRATOR, ALL_PERMISSIONS, CONNECT, MOVE_MEMBERS, READ_MESSAGE_HISTORY, + SPEAK, VIEW_CHANNEL, check_voice, effective_permissions, parse_permissions, +) + +GUILD = "100" +BOT = "900" +OWNER = "1" +MUTED = "201" +BOTS = "202" +DJ = "203" +NOW = datetime(2026, 10, 5, 12, 0, tzinfo=timezone.utc) +VOICE = VIEW_CHANNEL | CONNECT | SPEAK + + +def guild(everyone=VOICE, extra_roles=()): + roles = [{"id": GUILD, "name": "@everyone", "permissions": str(everyone)}] + roles += [{"id": rid, "name": name, "permissions": str(p)} for rid, name, p in extra_roles] + return {"id": GUILD, "owner_id": OWNER, "roles": roles} + + +def member(roles=(), uid=BOT, timeout=None): + return {"user": {"id": uid}, "roles": list(roles), "communication_disabled_until": timeout} + + +def ow(oid, kind, allow=0, deny=0): + return {"id": oid, "type": kind, "allow": str(allow), "deny": str(deny)} + + +def channel(overwrites=(), type_=2, user_limit=0, name="dispatch"): + return {"id": "500", "name": name, "type": type_, "user_limit": user_limit, + "permission_overwrites": list(overwrites)} + + +def eff(g, m, c): + return effective_permissions(g, m, c, now=NOW) + + +def test_required_bits_match_radio_bot_invite(): + assert VIEW_CHANNEL | CONNECT | SPEAK == leases.RADIO_BOT_PERMISSIONS + + +def test_plain_everyone_grant_is_ok(): + bits, trace = eff(guild(), member(), channel()) + assert bits == VOICE and trace["Move Members"] + r = check_voice(bits, channel(), trace) + assert r == {"ok": True, "is_voice": True, "missing": [], "reasons": {}, + "user_limit": 0, "limit_bypass": False} + + +def test_owner_gets_everything_despite_denies(): + c = channel([ow(GUILD, 0, deny=VOICE), ow(OWNER, 1, deny=VOICE)]) + bits, trace = eff(guild(everyone=0), member(uid=OWNER), c) + assert bits == ALL_PERMISSIONS and trace == {} + + +def test_administrator_role_bypasses_overwrites(): + g = guild(everyone=0, extra_roles=[(BOTS, "Bots", ADMINISTRATOR)]) + c = channel([ow(GUILD, 0, deny=VOICE), ow(BOT, 1, deny=VOICE)]) + bits, _ = eff(g, member([BOTS]), c) + assert bits == ALL_PERMISSIONS + assert check_voice(bits, c)["ok"] and check_voice(bits, c)["limit_bypass"] + + +def test_admin_on_everyone_counts_too(): + bits, _ = eff(guild(everyone=ADMINISTRATOR), member(), channel()) + assert bits == ALL_PERMISSIONS + + +def test_base_is_everyone_or_member_roles_only(): + # A role the bot doesn't hold grants nothing. + g = guild(everyone=VIEW_CHANNEL, extra_roles=[(DJ, "DJ", CONNECT | SPEAK), (BOTS, "Bots", 0)]) + bits, trace = eff(g, member([BOTS]), channel()) + assert bits == VIEW_CHANNEL + assert check_voice(bits, channel(), trace)["missing"] == ["Connect", "Speak"] + assert "not granted by @everyone or any of the bot's roles" in trace["Connect"] + bits, _ = eff(g, member([DJ]), channel()) + assert bits == VOICE + + +def test_everyone_overwrite_deny_then_allow(): + c = channel([ow(GUILD, 0, deny=SPEAK)]) + bits, trace = eff(guild(), member(), c) + assert check_voice(bits, c, trace)["missing"] == ["Speak"] + assert trace["Speak"] == "denied by #dispatch's override for @everyone" + # An allow in the same overwrite restores a bit the base lacked. + c2 = channel([ow(GUILD, 0, allow=SPEAK)]) + bits, _ = eff(guild(everyone=VIEW_CHANNEL | CONNECT), member(), c2) + assert bits == VOICE + + +def test_role_overwrite_denial_is_named(): + g = guild(extra_roles=[(MUTED, "Muted", 0)]) + c = channel([ow(MUTED, 0, deny=SPEAK)]) + bits, trace = eff(g, member([MUTED]), c) + r = check_voice(bits, c, trace) + assert not r["ok"] and r["missing"] == ["Speak"] + assert r["reasons"]["Speak"] == "denied by #dispatch's override for @Muted" + + +def test_role_overwrite_for_role_bot_lacks_is_ignored(): + g = guild(extra_roles=[(MUTED, "Muted", 0)]) + c = channel([ow(MUTED, 0, deny=SPEAK)]) + bits, _ = eff(g, member([]), c) + assert bits == VOICE + + +def test_role_allow_beats_role_deny_across_roles(): + g = guild(extra_roles=[(MUTED, "Muted", 0), (DJ, "DJ", 0)]) + c = channel([ow(MUTED, 0, deny=SPEAK), ow(DJ, 0, allow=SPEAK)]) + bits, trace = eff(g, member([MUTED, DJ]), c) + assert bits & SPEAK and "Speak" not in trace + + +def test_role_overwrites_override_everyone_overwrite(): + g = guild(extra_roles=[(DJ, "DJ", 0)]) + c = channel([ow(GUILD, 0, deny=CONNECT | SPEAK), ow(DJ, 0, allow=CONNECT | SPEAK)]) + bits, trace = eff(g, member([DJ]), c) + assert bits == VOICE and trace.get("Connect") is None + + +def test_several_denying_roles_are_all_named(): + g = guild(extra_roles=[(MUTED, "Muted", 0), (BOTS, "Bots", 0)]) + c = channel([ow(MUTED, 0, deny=CONNECT), ow(BOTS, 0, deny=CONNECT)]) + _, trace = eff(g, member([MUTED, BOTS]), c) + assert trace["Connect"] == "denied by #dispatch's override for @Muted, @Bots" + + +def test_member_overwrite_applies_last(): + g = guild(extra_roles=[(DJ, "DJ", 0)]) + c = channel([ow(DJ, 0, allow=SPEAK), ow(BOT, 1, deny=SPEAK)]) + bits, trace = eff(g, member([DJ]), c) + assert not bits & SPEAK + assert trace["Speak"] == "denied by #dispatch's override for the bot itself" + # ...and its allow beats a role deny. + c2 = channel([ow(GUILD, 0, deny=SPEAK), ow(BOT, 1, allow=SPEAK)]) + bits, _ = eff(guild(), member(), c2) + assert bits == VOICE + + +def test_member_overwrite_for_another_user_is_ignored(): + c = channel([ow("777", 1, deny=VOICE)]) + bits, _ = eff(guild(), member(), c) + assert bits == VOICE + + +def test_no_view_channel_means_nothing(): + c = channel([ow(GUILD, 0, deny=VIEW_CHANNEL)]) + bits, trace = eff(guild(everyone=VOICE | MOVE_MEMBERS), member(), c) + assert bits == 0 + r = check_voice(bits, c, trace) + assert r["missing"] == ["View Channel", "Connect", "Speak"] and not r["limit_bypass"] + assert trace["View Channel"] == "denied by #dispatch's override for @everyone" + assert trace["Connect"] == "the bot can't see #dispatch without View Channel" + + +def test_no_connect_takes_speak_and_move_in_voice(): + c = channel([ow(GUILD, 0, deny=CONNECT)]) + bits, trace = eff(guild(everyone=VOICE | MOVE_MEMBERS), member(), c) + assert bits == VIEW_CHANNEL + assert check_voice(bits, c, trace)["missing"] == ["Connect", "Speak"] + assert trace["Connect"] == "denied by #dispatch's override for @everyone" + assert trace["Speak"] == "Discord removes it in #dispatch without Connect" + + +def test_earlier_cause_survives_later_implicit_denial(): + # Speak denied by a role AND later wiped by missing View: the trace keeps + # the last step that removed it, which is the View wipe only when Speak + # was still present at that point. + g = guild(extra_roles=[(MUTED, "Muted", 0)]) + c = channel([ow(MUTED, 0, deny=SPEAK), ow(BOT, 1, deny=VIEW_CHANNEL)]) + _, trace = eff(g, member([MUTED]), c) + assert trace["Speak"] == "denied by #dispatch's override for @Muted" + assert trace["Connect"] == "the bot can't see #dispatch without View Channel" + + +def test_timed_out_keeps_only_view_and_history(): + future = (NOW + timedelta(hours=1)).isoformat() + g = guild(everyone=VOICE | READ_MESSAGE_HISTORY | MOVE_MEMBERS) + bits, trace = eff(g, member(timeout=future), channel()) + assert bits == VIEW_CHANNEL | READ_MESSAGE_HISTORY + assert trace["Connect"] == "the bot is timed out in this server" + past = (NOW - timedelta(hours=1)).isoformat().replace("+00:00", "Z") + bits, _ = eff(g, member(timeout=past), channel()) + assert bits & VOICE == VOICE + + +def test_unparseable_timeout_is_ignored(): + bits, _ = eff(guild(), member(timeout="not-a-date"), channel()) + assert bits == VOICE + + +def test_permission_strings_above_2_53(): + big = (1 << 60) | (1 << 53) | 1 + assert parse_permissions(str(big)) == big + assert parse_permissions(str((1 << 53) + 1)) == (1 << 53) + 1 + # Float rounding would turn this into 2**53 and drop the low bit. + assert parse_permissions("9007199254740993") == 9007199254740993 + g = guild(everyone=VOICE | (1 << 60)) + c = channel([ow(GUILD, 0, allow=1 << 55, deny=1 << 60)]) + bits, _ = eff(g, member(), c) + assert bits == VOICE | (1 << 55) + + +@pytest.mark.parametrize("value", ["abc", "-5", "1.5", True, 3.0, -1, [], {}]) +def test_malformed_permission_values_raise(value): + with pytest.raises(ValueError): + parse_permissions(value) + + +def test_missing_permission_values_are_zero(): + assert parse_permissions(None) == 0 + assert parse_permissions("") == 0 + assert parse_permissions(7) == 7 + + +def test_malformed_payload_raises_not_silently_denies(): + g = guild() + g["roles"][0]["permissions"] = "garbage" + with pytest.raises(ValueError): + eff(g, member(), channel()) + + +def test_legacy_string_overwrite_types(): + g = guild(extra_roles=[(MUTED, "Muted", 0)]) + c = channel([ow(MUTED, "role", deny=SPEAK), ow(BOT, "member", deny=CONNECT)]) + _, trace = eff(g, member([MUTED]), c) + assert set(trace) >= {"Connect", "Speak"} + + +def test_unknown_member_role_ids_are_ignored(): + bits, _ = eff(guild(), member(["999999"]), channel()) + assert bits == VOICE + + +def test_missing_everyone_role_means_empty_base(): + g = {"id": GUILD, "owner_id": OWNER, "roles": []} + bits, trace = eff(g, member(), channel()) + assert bits == 0 and "not granted" in trace["View Channel"] + + +def test_check_voice_only_counts_type_2(): + for t in (0, 13, 4): + r = check_voice(ALL_PERMISSIONS, channel(type_=t)) + assert r["ok"] is False and r["is_voice"] is False and r["missing"] == [] + + +def test_check_voice_user_limit_and_bypass(): + c = channel(user_limit=5) + assert check_voice(VOICE, c)["user_limit"] == 5 + assert check_voice(VOICE, c)["limit_bypass"] is False + assert check_voice(VOICE | MOVE_MEMBERS, c)["limit_bypass"] is True + assert check_voice(VOICE, channel(user_limit=None))["user_limit"] == 0 + + +def test_labels_match_edge_join_refusal_strings(): + # node-26#38 discord_radio._join_refusal emits exactly these labels. + assert [label for _, label in perms.REQUIRED_VOICE] == ["View Channel", "Connect", "Speak"] + + +def test_now_defaults_to_current_time(): + future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat() + bits, _ = effective_permissions(guild(), member(timeout=future), channel()) + assert bits == VIEW_CHANNEL -- 2.54.0 From 9e850fedf0cd08a2d5994cebddb2c62dcabe2c09 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 01:57:15 -0400 Subject: [PATCH 2/2] Discord perms: partial guild payload is unknown, not a deny (#225 S1 review) A guild without its @everyone role (or that role's permissions) was read as base 0, a definite deny that would block a start. It now raises ValueError, which S3 treats as unknown and lets the start go ahead. Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/internal/discord_perms.py | 9 +++++++-- drb-c2-core/tests/test_discord_perms.py | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/drb-c2-core/app/internal/discord_perms.py b/drb-c2-core/app/internal/discord_perms.py index e2ae2da..d248114 100644 --- a/drb-c2-core/app/internal/discord_perms.py +++ b/drb-c2-core/app/internal/discord_perms.py @@ -178,9 +178,14 @@ def effective_permissions( if member_id and member_id == _sid(guild.get("owner_id")): return ALL_PERMISSIONS, {} - # 2. Base from @everyone + the member's roles. + # 2. Base from @everyone + the member's roles. Every guild has an + # @everyone role (id == guild id) with a permissions value; without one + # the payload is partial, and reading it as "no permissions" would turn + # it into a definite deny instead of "unknown" (the plan is fail-open). everyone_role = roles.get(guild_id) - base = parse_permissions(everyone_role.get("permissions")) if everyone_role else 0 + if not guild_id or everyone_role is None or everyone_role.get("permissions") in (None, ""): + raise ValueError(f"guild payload has no @everyone role permissions (guild {guild_id or '?'})") + base = parse_permissions(everyone_role.get("permissions")) for rid in member_role_ids: role = roles.get(rid) if role is not None and rid != guild_id: diff --git a/drb-c2-core/tests/test_discord_perms.py b/drb-c2-core/tests/test_discord_perms.py index 3593748..81215ed 100644 --- a/drb-c2-core/tests/test_discord_perms.py +++ b/drb-c2-core/tests/test_discord_perms.py @@ -245,9 +245,21 @@ def test_unknown_member_role_ids_are_ignored(): assert bits == VOICE -def test_missing_everyone_role_means_empty_base(): - g = {"id": GUILD, "owner_id": OWNER, "roles": []} - bits, trace = eff(g, member(), channel()) +@pytest.mark.parametrize("g", [ + {"id": GUILD, "owner_id": OWNER, "roles": []}, + {"id": GUILD, "owner_id": OWNER}, + {"id": GUILD, "owner_id": OWNER, "roles": [{"id": GUILD, "name": "@everyone"}]}, + {"owner_id": OWNER, "roles": [{"id": GUILD, "name": "@everyone", "permissions": "0"}]}, +]) +def test_partial_guild_payload_raises_not_silently_denies(g): + # A guild without its @everyone role (or its permissions) is a partial + # payload: "unknown" (ValueError, start goes ahead), never a definite deny. + with pytest.raises(ValueError): + eff(g, member(), channel()) + + +def test_everyone_role_granting_nothing_is_a_real_deny(): + bits, trace = eff(guild(everyone=0), member(), channel()) assert bits == 0 and "not granted" in trace["View Channel"] -- 2.54.0