#14318: feat(discord): enforce outbound allowlist on send functions
channel: discord
scripts
stale
size: XL
Cluster:
Signal and Discord Fixes
## 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
#17513: fix(discord): respect groupPolicy in channel config fallback (#4555)
by aronchick · 2026-02-15
79.1%
#23158: discord: harden preflight/reply path against slow lookup latency
by danielstarman · 2026-02-22
79.0%
#16736: fix: stagger multi-account channel startup to avoid Discord rate li...
by rm289 · 2026-02-15
77.3%
#12204: fix(discord): resolve numeric guildId/channelId pairs in channel al...
by mcaxtr · 2026-02-09
76.3%
#19615: fix(discord): include default account when sub-accounts are configured
by prue-starfield · 2026-02-18
76.2%
#17254: fix(discord): intercept text-based slash commands instead of forwar...
by robbyczgw-cla · 2026-02-15
75.6%
#19011: fix(discord): enforce owner checks for privileged message actions
by coygeek · 2026-02-17
74.6%
#22591: feat(cli): expose Discord channel lifecycle management through unif...
by yinghaosang · 2026-02-21
74.4%
#15900: fix(discord): filter bot's own messages early to prevent self-DoS
by Shuai-DaiDai · 2026-02-14
74.0%
#20913: fix: intercept Discord embed images to enforce mediaMaxMb
by MumuTW · 2026-02-19
73.9%