fix(gateway): make privileged Discord intents opt-in#651
Conversation
The Discord client now requests Guilds and GuildMessages as its baseline. Callers that need MessageContent or GuildMembers pass them explicitly via options.intents — the existing Set-based merge composes them on top of the smaller default. No behavioral change for callers that supply the privileged intents; existing deployments that relied on the implicit defaults need to opt in via the next unit's config knob.
…tents Operators that need MessageContent or GuildMembers now set DISCORD_PRIVILEGED_INTENTS in the gateway env (or the matching _FILE secret). The parser accepts a comma-separated list with whitespace tolerance, allowlists exactly those two values, and throws a clear startup error on any other token. main.ts threads the parsed list into createDiscordClient as the intents override so the existing Set-based merge composes them on top of the non-privileged baseline. Existing deployments that depended on the implicit privileged defaults must set this env var on next deploy.
- New tests cover the non-privileged baseline, single-intent opt-ins for MessageContent and GuildMembers, and both-opted-in composition. - The obsolete "default intents include MessageContent" assertion is gone, replaced by an exact-baseline check. - A small sibling module exports validateTokenIsFake; a beforeAll in client.test.ts calls it against process.env.DISCORD_TOKEN so the suite refuses to run with what looks like a real bot token. - packages/gateway/AGENTS.md documents the new DISCORD_PRIVILEGED_INTENTS knob in a Configuration knobs section.
- Reject prototype-property tokens (`constructor`, `__proto__`, etc.) in the privileged-intent allowlist by switching to a hasOwnProperty check, with regression tests for the three common bypass vectors. - Loosen `DiscordClientOptions.intents` and `DEFAULT_INTENTS` to `readonly GatewayIntentBits[]` so callers pass `config.privilegedIntents` directly without a defensive spread. - Extract `expectClientIntents()` in the gateway client tests so the bitfield-cast idiom lives in one place. - Use an explicit `=== true` boolean check in `validateTokenIsFake` to match the project's strict-boolean convention. - Configure vitest/expect-expect with `assertFunctionNames` so helpers like `expectClientIntents` count as assertions; drop the now-unused `eslint-disable` comments across the gateway test suite.
Captures the v0.45-track gateway change: drop privileged intents from the default set, expose them as opt-in via DISCORD_PRIVILEGED_INTENTS, add the test-isolation guard, document the knob in packages/gateway/AGENTS.md. Origin issue: #646.
fro-bot
left a comment
There was a problem hiding this comment.
Verdict: CONDITIONAL
The security posture flip is correct and well-motivated. The config parsing, allowlist guard, and duplicate-dedup logic are all sound. The GuildPresences test confirms the allowlist isn't just blocking non-privileged intents accidentally. Two issues need to be addressed before merge.
Blocking issues
1. main.ts passes only privilegedIntents — the merge semantics are asymmetric and the behavior gap is untested.
createDiscordClient({intents: config.privilegedIntents, logger}) passes only the privileged intents array. Inside createDiscordClient, when options.intents is defined (including an empty array [] from an unset env var), the code branches into the Set-merge path: [...new Set([...DEFAULT_INTENTS, ...options.intents])]. This is actually fine — the defaults are always included — but the behavior when privilegedIntents is [] is: options.intents is [], which is not undefined, so the Set branch runs and produces [Guilds, GuildMessages] — the correct baseline. However, the PR description explicitly calls this out as an untracked follow-up todo:
Add a regression test proving
main.tsthreadsconfig.privilegedIntentsintocreateDiscordClient.
Shipping a known-untested wiring path in a security-posture change is a gap. The empty-array case (privilegedIntents === []) hitting the non-undefined branch rather than the options.intents === undefined short-circuit is a subtle correctness dependency — if createDiscordClient is ever refactored to treat [] as "no intents" rather than "use defaults," main.ts silently breaks. A focused integration test (even a unit-level spy on createDiscordClient) should be included in this PR, not deferred.
2. validateTokenIsFake regex is anchored wrong — test prefix allows testing123 but also test-my-production-bot.
const FAKE_TOKEN_PATTERN = /^(?:test-token-fake|fake|test|MOCK)/iThe pattern matches any string starting with test (case-insensitive), which includes strings like testnet-mainnet-token-abc123 or testing.env.value. The PR description says the intent is to match "a string starting with… test" — but this is case-insensitive, so TEST, Test, etc. also pass. The guard's purpose is to prevent real tokens from slipping through, not to reject valid fake values. Given that real Discord bot tokens are base64 blobs starting with a numeric user-ID segment (e.g. MTIzNDU2.), the regex is permissive in a direction that weakens the guard. A real token will not start with test, so this doesn't cause a practical bypass — but the pattern is looser than stated. The test suite doesn't cover testing123 or test-my-real-token. Consider anchoring more tightly or adding a word-boundary/delimiter, e.g. /^(?:test-token-fake|fake-token|mock-token|test)(?:[^a-z]|$)/i.
Non-blocking concerns
-
The
expectClientIntentshelper inclient.test.tsuses a doubleunknowncast to reach the BitField constructor. This is brittle against discord.js internal API changes. A comment noting why this approach was chosen (and that it's test-only) would help the next reader. Not a bug, just maintenance risk. -
runtime-effect.test.ts: removing the// eslint-disable-next-line vitest/expect-expectcomments is fine given the eslint config update, but the ESLint rule change (assertFunctionNames: ['expect', 'expect*', 'assert*']) is a project-wide change bundled into a gateway-scoped PR. This is low risk but worth noting for reviewers of other packages.
Missing tests
- Integration/wiring test:
main.tspassingconfig.privilegedIntentsthrough tocreateDiscordClient. The PR itself acknowledges this is missing. validateTokenIsFakewithtesting123and othertest-prefixed-but-realistic-looking strings, to document the intended acceptance boundary.
Risk assessment: MED
The default-intent narrowing is a breaking change for existing deployments that rely on MessageContent or GuildMembers without setting the new env var — bots will silently stop receiving message content and member events on next deploy. The migration note is clear and correct. The config-parse logic and allowlist guard are solid. The untested main.ts wiring and the slightly-too-permissive token guard regex are the residual risk.
Run Summary
| Field | Value |
|---|---|
| Event | pull_request |
| Repository | fro-bot/agent |
| Run ID | 26144344550 |
| Cache | hit |
| Session | ses_1bc093282ffe8c0a0nqWZ6dkW2 |
- Extract `makeDiscordClientFromConfig` from the main program so the config -> client wiring has a small, directly-testable surface. Add `main.test.ts` with three scenarios covering empty intents, `MessageContent`-only, and both privileged intents. - Tighten `validateTokenIsFake`: bare `fake` and `MOCK` no longer pass. The regex now requires `fake-token`, `mock-token`, `test-token-fake`, or `test` followed by a non-alphanumeric character. Update the guard's tests to match — adds coverage for `testing123` and similar previously-accepted-but-shouldn't-have-been strings. - Document the `expectClientIntents` helper's BitField cast in `client.test.ts` so future readers understand the discord.js internal-API dependency.
fro-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS
Both blocking issues from the previous review pass are resolved in commit a5b9b1b.
Blocking issues
None.
Resolution of prior blocking issues:
-
main.tswiring now tested.makeDiscordClientFromConfigwas extracted as a named, exported helper and covered by three focused tests inmain.test.ts— empty intents, MessageContent-only, and both-privileged. Thevi.mockapproach is appropriate given the Effect-based top-level program. The wiring contract is now explicit and regression-protected. -
Token guard regex tightened correctly. The new pattern
/^(?:test-token-fake|fake-token|mock-token|test)(?:[^a-z0-9]|$)/icloses the over-permissivetest*prefix. BarefakeandMOCKnow correctly throw;testing123throws;test-variants pass. The updated test suite documents both the accept and reject edges including the intentional design choice thattest-my-real-botis accepted (operators using atest-prefix are considered to be explicitly opting in to a fake-token label). That tradeoff is reasonable and clearly documented in the test comment.
Non-blocking concerns
- The
expectClientIntentsdouble-cast inclient.test.tsis still present and still somewhat brittle against discord.js internals, but it's test-only and unchanged from the prior pass — carrying it forward as acknowledged maintenance risk is acceptable.
Missing tests
None. The previously missing tests have been added.
Risk assessment: LOW
The security posture change is correct, well-tested (+141 additions from the original +637 to this pass's +778 net), and the migration path is clearly documented. The operator blast radius is bounded to deployments that don't set DISCORD_PRIVILEGED_INTENTS on next deploy — and those deployments will see connection stay up with the narrowed non-privileged baseline rather than a crash. Risk is LOW for this revision.
Run Summary
| Field | Value |
|---|---|
| Event | pull_request |
| Repository | fro-bot/agent |
| Run ID | 26145938999 |
| Cache | hit |
| Session | ses_1bc093282ffe8c0a0nqWZ6dkW2 |
The gateway already reads the privileged-intents env var from `$DISCORD_PRIVILEGED_INTENTS_FILE` (PR #651), but compose.yaml didn't pass it through. Add the matching `_FILE` env line and a long-syntax bind mount with `create_host_path: false` so missing secret files fail at `docker compose up` time instead of silently becoming directories. README now lists the `touch` step alongside the other optional Discord secrets.
The gateway already reads the privileged-intents env var from `$DISCORD_PRIVILEGED_INTENTS_FILE` (PR #651), but compose.yaml didn't pass it through. Add the matching `_FILE` env line and a long-syntax bind mount with `create_host_path: false` so missing secret files fail at `docker compose up` time instead of silently becoming directories. README now lists the `touch` step alongside the other optional Discord secrets.
Three small cleanups now that Fro Bot gateway is deployed and running in Fronomenal: A. Document DISCORD_PRIVILEGED_INTENTS in the handoff contract. fro-bot/agent#651 flipped DEFAULT_INTENTS to non-privileged by default; #652 plumbed the new env var through the gateway compose pipeline. The admin-agent runbook's handoff contract now lists DISCORD_PRIVILEGED_INTENTS as a gateway-only knob with its default and the DISALLOWED_INTENTS failure mode. B. Acknowledge the redundant #poly @everyone override. Live state has a per-channel @everyone deny ViewChannel on #poly that's byte-for-byte identical to its parent category override. Behaviorally a no-op. Listed in 'Channel-level overrides' with the reason cleanup is deferred (bot lacks per-channel ManageRoles on #poly; Discord doesn't honor category-level grants for permission overwrite mutations). Drift detector treats it as informational. C. Refine Unit 9 plan checkbox. Was 'acknowledged via tracker issue'; now points at the actual shipped PRs (fro-bot/agent#651 and #652) since the work landed overnight. Also dismissed smart note #111 (Unit 8 table-schema concern) — Unit 8 shipped as prose runbook, not a parser, so the schema concern is moot.
* chore(discord): post-deployment runbook cleanup Three small cleanups now that Fro Bot gateway is deployed and running in Fronomenal: A. Document DISCORD_PRIVILEGED_INTENTS in the handoff contract. fro-bot/agent#651 flipped DEFAULT_INTENTS to non-privileged by default; #652 plumbed the new env var through the gateway compose pipeline. The admin-agent runbook's handoff contract now lists DISCORD_PRIVILEGED_INTENTS as a gateway-only knob with its default and the DISALLOWED_INTENTS failure mode. B. Acknowledge the redundant #poly @everyone override. Live state has a per-channel @everyone deny ViewChannel on #poly that's byte-for-byte identical to its parent category override. Behaviorally a no-op. Listed in 'Channel-level overrides' with the reason cleanup is deferred (bot lacks per-channel ManageRoles on #poly; Discord doesn't honor category-level grants for permission overwrite mutations). Drift detector treats it as informational. C. Refine Unit 9 plan checkbox. Was 'acknowledged via tracker issue'; now points at the actual shipped PRs (fro-bot/agent#651 and #652) since the work landed overnight. Also dismissed smart note #111 (Unit 8 table-schema concern) — Unit 8 shipped as prose runbook, not a parser, so the schema concern is moot. * docs(discord): link admin-agent runbook to infra token-lifecycle runbook marcusrbrown/infra PR #284 landed the canonical token-lifecycle runbook at docs/runbooks/discord-token-lifecycle.md. Updates here: - Replace the 'Where to find the canonical doc' two-row placeholder table + TODO marker with a single live link to the infra runbook, plus a pointer to apps/gateway/AGENTS.md for deploy-time mechanics. Brief note that the infra runbook treats this admin-agent path as a co-equal token consumer — its rotation procedure tells the operator to update both the macOS Keychain (Option A here) and the GitHub Environment secret in infra in the same operator sweep. - Plan Unit 11 gets a 'Status (2026-05-20)' note acknowledging the trigger has fired and the link has been replaced as part of this post-deployment cleanup branch. The dual-consumer coupling (this dotfiles admin-agent runbook + the infra gateway runbook) is now bidirectional: infra's runbook references this one, this one references infra's.
Closes #646.
What changes
The Discord gateway's default intent set drops to the non-privileged baseline (
Guilds+GuildMessages). The two privileged intents —MessageContentandGuildMembers— become opt-in via a single env var.# Opt in to either or both privileged intents DISCORD_PRIVILEGED_INTENTS=MessageContent,GuildMembersAllowed values are exactly those two, case-sensitive, comma-separated, whitespace-tolerant. An unknown token (typo, wrong case, or any other intent name) fails the gateway at startup with an operator-friendly error naming the bad value and listing what's allowed. The matching
DISCORD_PRIVILEGED_INTENTS_FILEsecret-file fallback comes for free fromreadOptionalSecret.createDiscordClientkeeps itsSet-based merge —DEFAULT_INTENTSis just smaller now. Callers that opt in via the new env var get their privileged intents composed on top of the baseline; callers that don't run with the minimum needed for slash commands and @-mention events.Why
Discord marks
MessageContentandGuildMembersprivileged because they grant broad-scope reads against guild data. Until now the gateway requested both unconditionally even though the runtime workload (slash commands, @-mention responses in guild channels) doesn't need either. Flipping the default narrows the blast radius for every deployment that doesn't have a content-reading reason to ask for them.Migration
Existing deployments that depend on the privileged intents must set
DISCORD_PRIVILEGED_INTENTS=MessageContent,GuildMembers(or whichever subset they actually use) on next deploy. Without it, the bot stays connected and serves mentions in guild channels but stops receiving message content and full member lists.Notes
validateTokenIsFakeis a small test-only guard: abeforeAllinclient.test.tsrefuses to run the suite whenDISCORD_TOKENlooks like a real token, catching the common case of a.envaccidentally sourced into a dev shell.Object.prototype.hasOwnProperty.callrather thaninso prototype-property tokens (constructor,__proto__, etc.) hit the explicit error path instead of leaking into Discord client construction.DiscordClientOptions.intentsandDEFAULT_INTENTSare nowreadonly GatewayIntentBits[], which letsmain.tspassconfig.privilegedIntentsdirectly without a defensive spread.packages/gateway/AGENTS.mdunder## Configuration knobs.Verification
main.tsthreadsconfig.privilegedIntentsintocreateDiscordClient.DISCORD_PRIVILEGED_INTENTS_FILEthroughdeploy/compose.yamlwithbind.create_host_path: false, matching the other secret-file mounts.