← Back to PRs

#14318: feat(discord): enforce outbound allowlist on send functions

by builtbyrobben open 2026-02-11 23:28 View on GitHub →
channel: discord scripts stale size: XL
## Summary - Codeword confirmation: lobster-biscuit. - **Code-level outbound allowlist guard** — `enforceOutboundAllowlistAsync()` blocks `sendMessageDiscord`, `sendStickerDiscord`, and `sendPollDiscord` from writing to channels not in the bot's configured allowlist. Reuses the same policy resolution chain and helper functions (`isDiscordGroupAllowedByPolicy`, `resolveDiscordChannelConfigWithFallback`) as the inbound path, ensuring parity. - **Defense-in-depth lockdown script** — `scripts/discord-channel-lockdown.ts` applies Discord permission overwrites (deny SendMessages) on channels outside each bot's allowlist. Supports dry-run, `--apply`, and `--rollback <file>` modes with rollback snapshots. - **DiscordSendError extended** — New `kind` values `"outbound-blocked"` and `"channel-metadata-unavailable"` for clear error categorization. ## Details **Problem**: Outbound sends (`send.outbound.ts`) had zero guards — any agent could write to any channel their bot token has Discord API access to, even when the inbound path correctly gates by `groupPolicy`/guild/channel allowlists. **Fix**: 1. Created `src/discord/send.outbound-allowlist.ts` with `enforceOutboundAllowlist()` (sync) + `enforceOutboundAllowlistAsync()` (async, with lazy guild name resolution for slug-keyed configs). Mirrors the inbound policy resolution: `account.groupPolicy → cfg.channels.defaults.groupPolicy → "open"`. 2. Wired the async guard into all three send functions. Channel metadata fetch uses the `request()` retry wrapper and throws a distinct `"channel-metadata-unavailable"` error on failure. 3. Added `scripts/discord-channel-lockdown.ts` for defense-in-depth containment via Discord API permission overwrites. ## Greptile Review Responses | # | Comment | Verdict | Action | |---|---------|---------|--------| | 1 | Slug-keyed guilds won't match | **Inaccurate** — `enforceOutboundAllowlistAsync` handles this via lazy guild name fetch | No change needed | | 2 | Lockdown script diverges | **Partially accurate** — script does pass guildName; thread edge case is non-critical | May split to follow-up | | 3 | No rate-limit handling in lockdown | **Accurate** — send functions use `request()` wrapper; script needs basic 429 handling | Low priority | | 4 | Code duplication across send funcs | **Accurate** — ~25 lines duplicated; shared helper would reduce drift | Low priority | ## Test plan - [x] 22 unit tests for `enforceOutboundAllowlist` covering all policy/guild/channel/thread combinations - [x] 9 integration tests verifying the guard is wired into all 3 send functions - [x] Full Discord test suite passes (45 files, 381 tests, 0 failures) - [ ] Dry-run lockdown script on staging to verify permission matrix - [ ] Production smoke test: verify agents send to allowed channels, `outbound-blocked` errors appear only for blocked attempts ## Sign-Off - Models used: Claude Opus 4.6 (implementation + rebase) + ChatGPT 5.3 Codex (verification) - Testing: Fully tested — 381/381 Discord tests pass after rebase, no regressions - Verification: ChatGPT 5.3 Codex independently reviewed the PR diff, confirmed `enforceOutboundAllowlistAsync` correctly handles slug-keyed guilds (disproving Greptile comment #1), and recommended splitting the lockdown script into a follow-up PR - Rebased onto current `main` (resolved conflicts in `send.outbound.ts` from upstream `resolveDiscordSendTarget` refactor) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-Verified-By: ChatGPT 5.3 Codex <noreply@openai.com>

Most Similar PRs