Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion docs/integrations/SLACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,30 @@ The Slack settings page includes a workspace-wide mentions policy for direct use
Broadcast mention tokens such as `<!channel>`, `<!here>`, `<!everyone>`, and `<!subteam^...>` are
always stripped from agent notification messages.

### Private channels and DMs

The Slack settings page includes a workspace-wide **Allow use in private channels & DMs** toggle.

| State | Result |
| ------------ | -------------------------------------------------------------------------------------------- |
| On (default) | The bot responds anywhere it is reachable: public channels, private channels, group DMs, DMs |
| Off | The bot only responds in public channels; private channels, group DMs, and DMs are declined |

When off, an invocation from a private context gets a short reply explaining the bot is
public-channels-only, and no session is created. The control is workspace-wide and cannot be
overridden per repository. It defaults to on so existing installs are unaffected.

The setting is cached by the bot for up to ~60 seconds, so flipping it can take up to a minute to
take effect across already-running workers. A deny that was previously read stays in effect through
a control-plane outage; a deny set _during_ an outage takes effect once the bot can reach the
control plane again.

> **Required Slack scopes when off:** for **named** channels the bot calls `conversations.info` to
> distinguish public from private, which needs `channels:read` (public) and `groups:read` (private).
> DMs and group DMs are recognized from the event's `channel_type` and declined **without** an API
> call, so the gate needs no `im:read`/`mpim:read`. If a `conversations.info` lookup fails, the bot
> fails closed (declines). When the toggle is on, no lookup is performed.

---

## Admin and Safety Notes
Expand Down Expand Up @@ -224,7 +248,8 @@ If setup was just changed, confirm the Slack app event subscriptions and interac
### DMs do not start sessions

The Slack app needs the direct message event subscription configured. Once that is set up, send the
bot a plain DM with your request. No `@mention` is required.
bot a plain DM with your request. No `@mention` is required. If the bot replies that it only works
in public channels, the **Allow use in private channels & DMs** toggle is off (see above).

### Open-Inspect asks which repository to use

Expand Down
32 changes: 32 additions & 0 deletions packages/control-plane/src/db/integration-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,28 @@ describe("IntegrationSettingsStore", () => {
).rejects.toThrow(IntegrationSettingsValidationError);
});

it("round-trips allowPrivateChannels at global level", async () => {
await store.setGlobal("slack", { defaults: { allowPrivateChannels: false } });
const result = await store.getGlobal("slack");
expect(result?.defaults?.allowPrivateChannels).toBe(false);
});

it("rejects non-boolean allowPrivateChannels at global level", async () => {
await expect(
store.setGlobal("slack", {
defaults: { allowPrivateChannels: "no" as unknown as boolean },
})
).rejects.toThrow(IntegrationSettingsValidationError);
});

it("rejects allowPrivateChannels at per-repo level (global-only field)", async () => {
await expect(
store.setRepoSettings("slack", "acme/widgets", {
allowPrivateChannels: false,
} as unknown as { agentNotificationsEnabled?: boolean })
).rejects.toThrow(IntegrationSettingsValidationError);
});

it("round-trips per-repo slack settings", async () => {
await store.setRepoSettings("slack", "acme/widgets", {
agentNotificationsEnabled: false,
Expand Down Expand Up @@ -951,16 +973,26 @@ describe("IntegrationSettingsStore", () => {
expect(resolveSlackSettings(undefined)).toEqual({
agentNotificationsEnabled: false,
mentionsPolicy: "allow",
allowPrivateChannels: true,
});
});

it("treats empty object as disabled with default mention policy", () => {
expect(resolveSlackSettings({})).toEqual({
agentNotificationsEnabled: false,
mentionsPolicy: "allow",
allowPrivateChannels: true,
});
});

it("returns allowPrivateChannels false only when explicitly false", () => {
expect(resolveSlackSettings({ allowPrivateChannels: false }).allowPrivateChannels).toBe(
false
);
expect(resolveSlackSettings({ allowPrivateChannels: true }).allowPrivateChannels).toBe(true);
expect(resolveSlackSettings({}).allowPrivateChannels).toBe(true);
});

it("returns enabled true only when the flag is exactly true", () => {
expect(
resolveSlackSettings({ agentNotificationsEnabled: true }).agentNotificationsEnabled
Expand Down
12 changes: 11 additions & 1 deletion packages/control-plane/src/db/integration-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isValidReasoningEffort,
INTEGRATION_DEFINITIONS,
DEFAULT_MENTIONS_POLICY,
resolveAllowPrivateChannels,
type IntegrationId,
type IntegrationSettingsMap,
type GitHubBotSettings,
Expand Down Expand Up @@ -365,7 +366,7 @@ export class IntegrationSettingsStore {
): SlackGlobalSettings {
const allowedKeys =
level === "global"
? new Set(["agentNotificationsEnabled", "mentionsPolicy"])
? new Set(["agentNotificationsEnabled", "mentionsPolicy", "allowPrivateChannels"])
: new Set(["agentNotificationsEnabled"]);

for (const key of Object.keys(settings)) {
Expand All @@ -390,6 +391,13 @@ export class IntegrationSettingsStore {
);
}

if (
settings.allowPrivateChannels !== undefined &&
typeof settings.allowPrivateChannels !== "boolean"
) {
throw new IntegrationSettingsValidationError("allowPrivateChannels must be a boolean");
}

return settings;
}
}
Expand All @@ -411,9 +419,11 @@ export interface ResolvedIntegrationConfig<TRepo extends object = Record<string,
export function resolveSlackSettings(raw: Partial<SlackGlobalSettings> | undefined): {
agentNotificationsEnabled: boolean;
mentionsPolicy: SlackMentionsPolicy;
allowPrivateChannels: boolean;
} {
return {
agentNotificationsEnabled: raw?.agentNotificationsEnabled === true,
mentionsPolicy: raw?.mentionsPolicy ?? DEFAULT_MENTIONS_POLICY,
allowPrivateChannels: resolveAllowPrivateChannels(raw),
};
}
2 changes: 2 additions & 0 deletions packages/shared/src/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ export interface SlackChannelInfo {
name: string;
topic?: { value: string };
purpose?: { value: string };
/** True for private channels and group DMs (mpim). Returned by conversations.info. */
is_private?: boolean;
}

export function getChannelInfo(
Expand Down
25 changes: 25 additions & 0 deletions packages/shared/src/types/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ export interface SlackRepoSettings {
/** Global Slack defaults: per-repo fields plus workspace-wide policy controls. */
export interface SlackGlobalSettings extends SlackRepoSettings {
mentionsPolicy?: SlackMentionsPolicy;
/**
* When false, the Slack bot declines to act in any private context (named private
* channels, group DMs, and 1:1 DMs) and only responds in public channels.
* Workspace-wide; cannot be overridden per repo. Defaults to allow.
*/
allowPrivateChannels?: boolean;
}

/**
* Default for {@link SlackGlobalSettings.allowPrivateChannels}: private contexts are
* allowed unless an operator explicitly opts out. Preserves existing behavior.
*/
export const DEFAULT_ALLOW_PRIVATE_CHANNELS = true;

/**
* Resolve the effective `allowPrivateChannels` value from raw global Slack settings.
*
* Defaults to allow via `!== false` (deliberately unlike `agentNotificationsEnabled`'s
* `=== true`): the rollout must be non-breaking, so only an explicit `false` denies.
* Shared by the control plane and the slack-bot so the default lives in one place.
*/
export function resolveAllowPrivateChannels(
raw: Partial<SlackGlobalSettings> | undefined
): boolean {
return raw?.allowPrivateChannels !== false;
}

/** Maps each integration ID to its global and per-repo settings types. */
Expand Down
26 changes: 15 additions & 11 deletions packages/slack-bot/src/dm-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { stripMentions, isDmDispatchable } from "./dm-utils";
import { stripMentions, isPrivateMessageDispatchable } from "./dm-utils";

describe("stripMentions", () => {
it("removes a single mention", () => {
Expand Down Expand Up @@ -29,7 +29,7 @@ describe("stripMentions", () => {
});
});

describe("isDmDispatchable", () => {
describe("isPrivateMessageDispatchable", () => {
const baseEvent = {
type: "message",
channel_type: "im",
Expand All @@ -39,31 +39,35 @@ describe("isDmDispatchable", () => {
user: "U12345",
};

it("returns true for a valid DM event", () => {
expect(isDmDispatchable(baseEvent)).toBe(true);
it("returns true for a valid DM (im) event", () => {
expect(isPrivateMessageDispatchable(baseEvent)).toBe(true);
});

it("returns true for a valid group DM (mpim) event", () => {
expect(isPrivateMessageDispatchable({ ...baseEvent, channel_type: "mpim" })).toBe(true);
});

it("returns false when subtype is present (e.g. bot_message)", () => {
expect(isDmDispatchable({ ...baseEvent, subtype: "bot_message" })).toBe(false);
expect(isPrivateMessageDispatchable({ ...baseEvent, subtype: "bot_message" })).toBe(false);
});

it("returns false when subtype is message_changed", () => {
expect(isDmDispatchable({ ...baseEvent, subtype: "message_changed" })).toBe(false);
expect(isPrivateMessageDispatchable({ ...baseEvent, subtype: "message_changed" })).toBe(false);
});

it("returns false for non-im channel type", () => {
expect(isDmDispatchable({ ...baseEvent, channel_type: "channel" })).toBe(false);
it("returns false for a public channel event", () => {
expect(isPrivateMessageDispatchable({ ...baseEvent, channel_type: "channel" })).toBe(false);
});

it("returns false when text is missing", () => {
expect(isDmDispatchable({ ...baseEvent, text: undefined })).toBe(false);
expect(isPrivateMessageDispatchable({ ...baseEvent, text: undefined })).toBe(false);
});

it("returns false when user is missing", () => {
expect(isDmDispatchable({ ...baseEvent, user: undefined })).toBe(false);
expect(isPrivateMessageDispatchable({ ...baseEvent, user: undefined })).toBe(false);
});

it("returns false for non-message event type", () => {
expect(isDmDispatchable({ ...baseEvent, type: "app_mention" })).toBe(false);
expect(isPrivateMessageDispatchable({ ...baseEvent, type: "app_mention" })).toBe(false);
});
});
10 changes: 5 additions & 5 deletions packages/slack-bot/src/dm-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ export function stripMentions(text: string): string {
}

/**
* Returns true if a Slack message event should be dispatched as a DM.
* Filters out subtypes (bot_message, message_changed, message_deleted, etc.)
* to prevent processing bot replies and edit/delete notifications.
* Returns true if a Slack message event is a dispatchable private message — a 1:1 DM
* (`im`) or a group DM (`mpim`). Filters out subtypes (bot_message, message_changed,
* message_deleted, etc.) to prevent processing bot replies and edit/delete notifications.
*/
export function isDmDispatchable(event: {
export function isPrivateMessageDispatchable(event: {
type: string;
subtype?: string;
channel_type?: string;
Expand All @@ -26,7 +26,7 @@ export function isDmDispatchable(event: {
return (
event.type === "message" &&
!event.subtype &&
event.channel_type === "im" &&
(event.channel_type === "im" || event.channel_type === "mpim") &&
!!event.text &&
!!event.channel &&
!!event.ts &&
Expand Down
Loading
Loading