Skip to content

feat(slack): gate private channels/DMs behind allowPrivateChannels toggle#684

Open
fredwanteeed wants to merge 2 commits into
ColeMurray:mainfrom
fredwanteeed:feat/slack-allow-private-channels
Open

feat(slack): gate private channels/DMs behind allowPrivateChannels toggle#684
fredwanteeed wants to merge 2 commits into
ColeMurray:mainfrom
fredwanteeed:feat/slack-allow-private-channels

Conversation

@fredwanteeed
Copy link
Copy Markdown

@fredwanteeed fredwanteeed commented May 29, 2026

Summary

Adds a workspace-wide allowPrivateChannels Slack setting (Settings → Integrations → Slack), mirroring the existing mentionsPolicy global-only pattern.
Motivation : https://shopify.engineering/under-the-river

  • Default true — backward-compatible; existing installs are unaffected.
  • When off, the Slack bot only responds in public channels and declines private channels, group DMs (mpim), and 1:1 DMs (im) with a polite "public channels only" reply. No session is created.

How it works

  • A single guard (refuseIfPrivateContext) runs as the first action in all three session-creating ingresses — handleAppMention, handleDirectMessage, and the /interactions repo-select path (handleRepoSelection) — so nothing (status, "Working on…", session) is emitted before the gate. This also covers existing-thread follow-ups.
  • Privacy is determined from conversations.info.is_private (fail-closed: only a confirmed public channel is allowed). im/mpim are private with no API call.
  • The toggle is read from the control plane (GET /integration-settings/slack) via the service binding, cached ~60s in memory with a last-known-good KV fallback, so a previously-observed deny survives a control-plane outage and the bot doesn't storm a downed control plane.
  • When the toggle is on (default), no conversations.info call and no guard work happen — behavior is unchanged with zero added cost.

Why default-allow (note for maintainers)

Chosen for backward compatibility on upgrade. A coding agent reachable from private DMs is a data-exfiltration surface, so operators concerned about that should turn this off. The kill-switch is bounded, not absolute: a deny set during a control-plane outage, or on a cold isolate that never fetched, isn't enforced until a fetch succeeds (in-memory TTL + reconnect). If you'd prefer secure-by-default, it's a one-line default flip in resolveAllowPrivateChannels.

Changes

  • shared: SlackGlobalSettings.allowPrivateChannels, DEFAULT_ALLOW_PRIVATE_CHANNELS, shared resolveAllowPrivateChannels (!== false); SlackChannelInfo.is_private.
  • control-plane: validate allowPrivateChannels (global-only boolean); surface it in resolveSlackSettings.
  • slack-bot: new integration-settings.ts (cached reader + sticky last-known-good); isPrivateMessageDispatchable (im+mpim); refuseIfPrivateContext guard wired into the three entry points.
  • web: toggle Switch + reset-dialog copy in the Slack settings panel.
  • docs/integrations/SLACK.md: toggle behavior, required Slack scopes (groups:read/im:read/mpim:read), and the propagation SLA.

Testing

  • npm run typecheck — clean across all packages
  • npm test — shared 169, control-plane 1170, slack-bot 101, web 264 — all passing
  • New tests cover: setting validation/resolution, isPrivateMessageDispatchable (im/mpim/public), the gate end-to-end (private/public/im/mpim/fail-closed/toggle-on//interactions back-door), and getAllowPrivateChannels cache + fail-closed behavior.

Summary by CodeRabbit

  • New Features

    • Workspace-wide toggle to control whether the Slack bot responds in private channels, group DMs, and direct messages (enabled by default). When turned off, the bot will politely decline private-context interactions.
  • Behavior

    • Private-context refusals are immediate and fail-closed if channel privacy cannot be confirmed; setting changes may take time to propagate.
  • Documentation

    • Slack guide updated with configuration, propagation timing, scopes, and troubleshooting.
  • Tests

    • Expanded test coverage for the new private-channel setting and related flows.

Review Change Stack

…ggle

Add a workspace-wide `allowPrivateChannels` Slack setting (default true,
backward-compatible). When off, the bot only acts in public channels and
declines private channels, group DMs, and 1:1 DMs with a polite reply.

Enforcement is a single guard run first in all three entry points
(handleAppMention, handleDirectMessage, handleRepoSelection), so no status
or session is created before the gate. Privacy is read from
conversations.info.is_private, fail-closed when the operator set deny;
im/mpim are treated as private without an API call. The toggle is read
(cached) from the control plane with a last-known-good KV fallback so a
previously-observed deny survives a control-plane outage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8df6dad-0d59-4e1d-bae1-41c011346e01

📥 Commits

Reviewing files that changed from the base of the PR and between 9609e6b and 46a04a7.

📒 Files selected for processing (2)
  • docs/integrations/SLACK.md
  • packages/slack-bot/src/index.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/integrations/SLACK.md
  • packages/slack-bot/src/index.test.ts

📝 Walkthrough

Walkthrough

This PR adds a workspace-wide allowPrivateChannels Slack integration setting that gates bot responses in private channels, group DMs, and 1:1 DMs. Changes span type definitions, control-plane persistence, runtime fetching with TTL and KV caching, bot-side gating, web UI toggle, and user documentation.

Changes

Slack Private Channels Integration

Layer / File(s) Summary
Type contracts and default constants
packages/shared/src/types/integrations.ts, packages/shared/src/slack/client.ts
SlackGlobalSettings gains allowPrivateChannels?: boolean, plus DEFAULT_ALLOW_PRIVATE_CHANNELS and resolveAllowPrivateChannels. SlackChannelInfo gains optional is_private from conversations.info.
Control plane persistence and validation
packages/control-plane/src/db/integration-settings.ts, packages/control-plane/src/db/integration-settings.test.ts
Integration settings store validates allowPrivateChannels as boolean at global level only (rejecting per-repo overrides), includes it in resolveSlackSettings return shape, with round-trip and type-validation tests.
Slack bot integration settings fetcher with caching
packages/slack-bot/src/integration-settings.ts, packages/slack-bot/src/integration-settings.test.ts
New module fetches allowPrivateChannels from control plane with 60s in-memory TTL cache, persists last-known-good to KV for resilience, and provides fail-closed resolution via getAllowPrivateChannels.
Private message dispatch helper refactoring
packages/slack-bot/src/dm-utils.ts, packages/slack-bot/src/dm-utils.test.ts
Renames isDmDispatchable to isPrivateMessageDispatchable and expands channel-type eligibility to include both im (1:1 DM) and mpim (group DM) with updated docs and tests.
Private context gating in bot handlers
packages/slack-bot/src/index.ts
Implements refuseIfPrivateContext helper that checks allowPrivateChannels, determines channel privacy, and posts refusal message before processing halts. Gate applied to handleDirectMessage, handleAppMention (after conversations.info fetch), and handleRepoSelection interactions.
Slack bot test infrastructure and gating tests
packages/slack-bot/src/index.test.ts
Adds test factories and helpers for allowPrivateChannels mocking, extends mockSlackFetch to simulate channel privacy, adds cache resets in test setup, and introduces comprehensive private-context gate test suite covering toggle OFF/ON behavior, fail-closed handling, and repo-selection decline.
Web UI settings controls
packages/web/src/components/settings/integrations/slack-integration-settings.tsx, packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
UI adds state management, initialization, sync, reset logic, and a new toggle ("Allow use in private channels & DMs") included in save payload and reset behavior. Tests assert saved payload contains allowPrivateChannels.
User documentation
docs/integrations/SLACK.md
New section explains private-channels toggle behavior when on/off, enforcement (no session creation when off), caching/propagation timing, control-plane outage semantics, and required Slack scopes. Troubleshooting section linked to toggle state.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

"I'm a rabbit in a dev den bright,
I hop through settings day and night.
Private channels now have a gate,
Toggle on or toggle late—
Bots whisper where the team says right."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature addition: a toggle that gates private channels and DMs in Slack. It aligns with the core objective of adding workspace-wide access control.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/slack-bot/src/integration-settings.ts (1)

101-117: ⚖️ Poor tradeoff

Cold-start outage path re-hits the control plane on every event.

When the fetch fails and no KV value was ever recorded, you intentionally return DEFAULT_ALLOW_PRIVATE_CHANNELS without caching so a freshly-set deny is picked up on recovery. The tradeoff is that during a sustained outage on a fresh install, every Slack event issues an uncached control-plane request (no in-flight coalescing either), which can amplify load against an already-degraded control plane. The behavior is correct; consider a short negative-cache or single-flight to bound the request rate if event volume is non-trivial.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/slack-bot/src/integration-settings.ts` around lines 101 - 117, When
kv.get(LAST_KNOWN_KV_KEY) fails and you return DEFAULT_ALLOW_PRIVATE_CHANNELS,
add a short negative-cache or single-flight to avoid re-hitting the control
plane on every event: either (A) set localCache = { allowPrivateChannels:
DEFAULT_ALLOW_PRIVATE_CHANNELS, timestamp: Date.now() } and honor a small
NEGATIVE_CACHE_TTL before trying kv.get again (do not persist this to KV), or
(B) implement a single-flight/in-flight promise keyed for this fetch so
concurrent events reuse the same kv.get promise (e.g., an inFlightFetch map used
by the function that calls kv.get). Ensure you reference LAST_KNOWN_KV_KEY,
localCache and DEFAULT_ALLOW_PRIVATE_CHANNELS when making the change and do not
change the long-term KV write behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/integrations/SLACK.md`:
- Around line 211-215: The docs claim `im:read` and `mpim:read` are required for
the "decline private contexts" path, but refuseIfPrivateContext treats
channelType === "im" | "mpim" as private and returns without calling
conversations.info; only non-DM ambiguous channels use conversations.info and
thus require groups:read (and channels:read for public), so update
docs/integrations/SLACK.md to remove `im:read` and `mpim:read` from the
"Required Slack scopes when off" sentence and reword it to state that
conversations.info (and groups:read) are required only for non-DM channel
lookups (public vs private ambiguity), while DM/mpim denial does not call the
API.

In `@packages/slack-bot/src/index.test.ts`:
- Around line 386-389: The ordering assertions using
order.indexOf("channelInfo") can pass vacuously if "channelInfo" is missing, so
add an explicit presence guard for "channelInfo" before the two index
comparisons: assert that the test array (order) contains "channelInfo" (e.g.,
expect(order).toContain("channelInfo") or
expect(order.indexOf("channelInfo")).not.toBe(-1)) and only then perform the
existing expects that compare indexOf("channelInfo"), indexOf("status"), and
indexOf("session"); update the assertions around the existing order variable and
the lines referencing "channelInfo", "status", and "session" accordingly.

---

Nitpick comments:
In `@packages/slack-bot/src/integration-settings.ts`:
- Around line 101-117: When kv.get(LAST_KNOWN_KV_KEY) fails and you return
DEFAULT_ALLOW_PRIVATE_CHANNELS, add a short negative-cache or single-flight to
avoid re-hitting the control plane on every event: either (A) set localCache = {
allowPrivateChannels: DEFAULT_ALLOW_PRIVATE_CHANNELS, timestamp: Date.now() }
and honor a small NEGATIVE_CACHE_TTL before trying kv.get again (do not persist
this to KV), or (B) implement a single-flight/in-flight promise keyed for this
fetch so concurrent events reuse the same kv.get promise (e.g., an inFlightFetch
map used by the function that calls kv.get). Ensure you reference
LAST_KNOWN_KV_KEY, localCache and DEFAULT_ALLOW_PRIVATE_CHANNELS when making the
change and do not change the long-term KV write behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47031212-a533-478f-a746-def33565388b

📥 Commits

Reviewing files that changed from the base of the PR and between e4afdc3 and 9609e6b.

📒 Files selected for processing (13)
  • docs/integrations/SLACK.md
  • packages/control-plane/src/db/integration-settings.test.ts
  • packages/control-plane/src/db/integration-settings.ts
  • packages/shared/src/slack/client.ts
  • packages/shared/src/types/integrations.ts
  • packages/slack-bot/src/dm-utils.test.ts
  • packages/slack-bot/src/dm-utils.ts
  • packages/slack-bot/src/index.test.ts
  • packages/slack-bot/src/index.ts
  • packages/slack-bot/src/integration-settings.test.ts
  • packages/slack-bot/src/integration-settings.ts
  • packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
  • packages/web/src/components/settings/integrations/slack-integration-settings.tsx

Comment thread docs/integrations/SLACK.md Outdated
Comment thread packages/slack-bot/src/index.test.ts
…ering

- SLACK.md: im/mpim are declined from channel_type without a conversations.info
  call, so the gate needs only channels:read (public) + groups:read (private),
  not im:read/mpim:read.
- index.test.ts: assert order contains "channelInfo" before the indexOf ordering
  checks so a regression can't pass vacuously via indexOf === -1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fredwanteeed
Copy link
Copy Markdown
Author

Thanks for the review. Addressed in 46a04a7:

  • Required Slack scopes (valid): corrected the docs — im/mpim are declined from the event's channel_type without a conversations.info call, so the gate needs only channels:read (public) and groups:read (private), not im:read/mpim:read.
  • Vacuous test assertion (valid): added expect(order).toContain("channelInfo") before the indexOf ordering checks so a regression can't pass via indexOf === -1.
  • Cold-start outage re-fetch (nitpick): left as-is, intentionally. Not caching the permissive default is a deliberate tradeoff so a freshly-set deny is picked up the moment the control plane recovers, rather than waiting out a cache TTL. Slack traffic is human-paced and this path only triggers during a full control-plane outage on a never-configured install, so the request rate is bounded in practice. Happy to add a short negative-cache or single-flight if you'd prefer to pre-empt it.

npm run typecheck clean; tests green (shared 169, control-plane 1170, slack-bot 101, web 264).

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