diff --git a/CHANGELOG.md b/CHANGELOG.md index 339093dbc8b..0df4711abaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. diff --git a/src/slack/modal-metadata.test.ts b/src/slack/modal-metadata.test.ts index d209c70587c..a7a7ce8224b 100644 --- a/src/slack/modal-metadata.test.ts +++ b/src/slack/modal-metadata.test.ts @@ -18,6 +18,7 @@ describe("parseSlackModalPrivateMetadata", () => { sessionKey: "agent:main:slack:channel:C1", channelId: "D123", channelType: "im", + userId: "U123", ignored: "x", }), ), @@ -25,6 +26,7 @@ describe("parseSlackModalPrivateMetadata", () => { sessionKey: "agent:main:slack:channel:C1", channelId: "D123", channelType: "im", + userId: "U123", }); }); }); @@ -37,11 +39,13 @@ describe("encodeSlackModalPrivateMetadata", () => { sessionKey: "agent:main:slack:channel:C1", channelId: "", channelType: "im", + userId: "U123", }), ), ).toEqual({ sessionKey: "agent:main:slack:channel:C1", channelType: "im", + userId: "U123", }); }); diff --git a/src/slack/modal-metadata.ts b/src/slack/modal-metadata.ts index 491fb5d38f3..963024487a9 100644 --- a/src/slack/modal-metadata.ts +++ b/src/slack/modal-metadata.ts @@ -2,6 +2,7 @@ export type SlackModalPrivateMetadata = { sessionKey?: string; channelId?: string; channelType?: string; + userId?: string; }; const SLACK_PRIVATE_METADATA_MAX = 3000; @@ -20,6 +21,7 @@ export function parseSlackModalPrivateMetadata(raw: unknown): SlackModalPrivateM sessionKey: normalizeString(parsed.sessionKey), channelId: normalizeString(parsed.channelId), channelType: normalizeString(parsed.channelType), + userId: normalizeString(parsed.userId), }; } catch { return {}; @@ -31,6 +33,7 @@ export function encodeSlackModalPrivateMetadata(input: SlackModalPrivateMetadata ...(input.sessionKey ? { sessionKey: input.sessionKey } : {}), ...(input.channelId ? { channelId: input.channelId } : {}), ...(input.channelType ? { channelType: input.channelType } : {}), + ...(input.userId ? { userId: input.userId } : {}), }; const encoded = JSON.stringify(payload); if (encoded.length > SLACK_PRIVATE_METADATA_MAX) { diff --git a/src/slack/monitor/auth.ts b/src/slack/monitor/auth.ts index d8fa5e5b4e5..cb43241f899 100644 --- a/src/slack/monitor/auth.ts +++ b/src/slack/monitor/auth.ts @@ -1,6 +1,12 @@ import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; -import { allowListMatches, normalizeAllowList, normalizeAllowListLower } from "./allow-list.js"; -import type { SlackMonitorContext } from "./context.js"; +import { + allowListMatches, + normalizeAllowList, + normalizeAllowListLower, + resolveSlackUserAllowed, +} from "./allow-list.js"; +import { resolveSlackChannelConfig } from "./channel-config.js"; +import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js"; export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) { const storeAllowFrom = @@ -27,3 +33,137 @@ export function isSlackSenderAllowListed(params: { }) ); } + +export type SlackSystemEventAuthResult = { + allowed: boolean; + reason?: + | "missing-sender" + | "sender-mismatch" + | "channel-not-allowed" + | "dm-disabled" + | "sender-not-allowlisted" + | "sender-not-channel-allowed"; + channelType?: "im" | "mpim" | "channel" | "group"; + channelName?: string; +}; + +export async function authorizeSlackSystemEventSender(params: { + ctx: SlackMonitorContext; + senderId?: string; + channelId?: string; + channelType?: string | null; + expectedSenderId?: string; +}): Promise { + const senderId = params.senderId?.trim(); + if (!senderId) { + return { allowed: false, reason: "missing-sender" }; + } + + const expectedSenderId = params.expectedSenderId?.trim(); + if (expectedSenderId && expectedSenderId !== senderId) { + return { allowed: false, reason: "sender-mismatch" }; + } + + const channelId = params.channelId?.trim(); + let channelType = normalizeSlackChannelType(params.channelType, channelId); + let channelName: string | undefined; + if (channelId) { + const info: { + name?: string; + type?: "im" | "mpim" | "channel" | "group"; + } = await params.ctx.resolveChannelName(channelId).catch(() => ({})); + channelName = info.name; + channelType = normalizeSlackChannelType(params.channelType ?? info.type, channelId); + if ( + !params.ctx.isChannelAllowed({ + channelId, + channelName, + channelType, + }) + ) { + return { + allowed: false, + reason: "channel-not-allowed", + channelType, + channelName, + }; + } + } + + const senderInfo: { name?: string } = await params.ctx + .resolveUserName(senderId) + .catch(() => ({})); + const senderName = senderInfo.name; + + const resolveAllowFromLower = async () => + (await resolveSlackEffectiveAllowFrom(params.ctx)).allowFromLower; + + if (channelType === "im") { + if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") { + return { allowed: false, reason: "dm-disabled", channelType, channelName }; + } + if (params.ctx.dmPolicy !== "open") { + const allowFromLower = await resolveAllowFromLower(); + const senderAllowListed = isSlackSenderAllowListed({ + allowListLower: allowFromLower, + senderId, + senderName, + allowNameMatching: params.ctx.allowNameMatching, + }); + if (!senderAllowListed) { + return { + allowed: false, + reason: "sender-not-allowlisted", + channelType, + channelName, + }; + } + } + } else if (!channelId) { + // No channel context. Apply allowFrom if configured so we fail closed + // for privileged interactive events when owner allowlist is present. + const allowFromLower = await resolveAllowFromLower(); + if (allowFromLower.length > 0) { + const senderAllowListed = isSlackSenderAllowListed({ + allowListLower: allowFromLower, + senderId, + senderName, + allowNameMatching: params.ctx.allowNameMatching, + }); + if (!senderAllowListed) { + return { allowed: false, reason: "sender-not-allowlisted" }; + } + } + } else { + const channelConfig = resolveSlackChannelConfig({ + channelId, + channelName, + channels: params.ctx.channelsConfig, + defaultRequireMention: params.ctx.defaultRequireMention, + }); + const channelUsersAllowlistConfigured = + Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; + if (channelUsersAllowlistConfigured) { + const channelUserAllowed = resolveSlackUserAllowed({ + allowList: channelConfig?.users, + userId: senderId, + userName: senderName, + allowNameMatching: params.ctx.allowNameMatching, + }); + if (!channelUserAllowed) { + return { + allowed: false, + reason: "sender-not-channel-allowed", + channelType, + channelName, + }; + } + } + } + + return { + allowed: true, + channelType, + channelName, + }; +} diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index 7710239cc71..cfd53506358 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -30,6 +30,7 @@ type RegisteredViewHandler = (args: { view?: { id?: string; callback_id?: string; + private_metadata?: string; root_view_id?: string; previous_view_id?: string; external_id?: string; @@ -58,7 +59,23 @@ type RegisteredViewClosedHandler = (args: { }; }) => Promise; -function createContext() { +function createContext(overrides?: { + dmEnabled?: boolean; + dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; + allowFrom?: string[]; + allowNameMatching?: boolean; + channelsConfig?: Record; + isChannelAllowed?: (params: { + channelId?: string; + channelName?: string; + channelType?: "im" | "mpim" | "channel" | "group"; + }) => boolean; + resolveUserName?: (userId: string) => Promise<{ name?: string }>; + resolveChannelName?: (channelId: string) => Promise<{ + name?: string; + type?: "im" | "mpim" | "channel" | "group"; + }>; +}) { let handler: RegisteredHandler | null = null; let viewHandler: RegisteredViewHandler | null = null; let viewClosedHandler: RegisteredViewClosedHandler | null = null; @@ -80,9 +97,40 @@ function createContext() { }; const runtimeLog = vi.fn(); const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:slack:channel:C1"); + const isChannelAllowed = vi + .fn< + (params: { + channelId?: string; + channelName?: string; + channelType?: "im" | "mpim" | "channel" | "group"; + }) => boolean + >() + .mockImplementation((params) => overrides?.isChannelAllowed?.(params) ?? true); + const resolveUserName = vi + .fn<(userId: string) => Promise<{ name?: string }>>() + .mockImplementation((userId) => overrides?.resolveUserName?.(userId) ?? Promise.resolve({})); + const resolveChannelName = vi + .fn< + (channelId: string) => Promise<{ + name?: string; + type?: "im" | "mpim" | "channel" | "group"; + }> + >() + .mockImplementation( + (channelId) => overrides?.resolveChannelName?.(channelId) ?? Promise.resolve({}), + ); const ctx = { app, runtime: { log: runtimeLog }, + dmEnabled: overrides?.dmEnabled ?? true, + dmPolicy: overrides?.dmPolicy ?? ("open" as const), + allowFrom: overrides?.allowFrom ?? [], + allowNameMatching: overrides?.allowNameMatching ?? false, + channelsConfig: overrides?.channelsConfig ?? {}, + defaultRequireMention: true, + isChannelAllowed, + resolveUserName, + resolveChannelName, resolveSlackSystemEventSessionKey: resolveSessionKey, }; return { @@ -90,6 +138,9 @@ function createContext() { app, runtimeLog, resolveSessionKey, + isChannelAllowed, + resolveUserName, + resolveChannelName, getHandler: () => handler, getViewHandler: () => viewHandler, getViewClosedHandler: () => viewClosedHandler, @@ -168,7 +219,7 @@ describe("registerSlackInteractionEvents", () => { }); expect(resolveSessionKey).toHaveBeenCalledWith({ channelId: "C1", - channelType: undefined, + channelType: "channel", }); expect(app.client.chat.update).toHaveBeenCalledTimes(1); }); @@ -228,6 +279,85 @@ describe("registerSlackInteractionEvents", () => { ); }); + it("blocks block actions from users outside configured channel users allowlist", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, app, getHandler } = createContext({ + channelsConfig: { + C1: { users: ["U_ALLOWED"] }, + }, + }); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + const respond = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + respond, + body: { + user: { id: "U_DENIED" }, + channel: { id: "C1" }, + message: { + ts: "201.202", + blocks: [{ type: "actions", block_id: "verify_block", elements: [] }], + }, + }, + action: { + type: "button", + action_id: "openclaw:verify", + block_id: "verify_block", + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(app.client.chat.update).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith({ + text: "You are not authorized to use this control.", + response_type: "ephemeral", + }); + }); + + it("blocks DM block actions when sender is not in allowFrom", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, app, getHandler } = createContext({ + dmPolicy: "allowlist", + allowFrom: ["U_OWNER"], + }); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + const respond = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + respond, + body: { + user: { id: "U_ATTACKER" }, + channel: { id: "D222" }, + message: { + ts: "301.302", + blocks: [{ type: "actions", block_id: "verify_block", elements: [] }], + }, + }, + action: { + type: "button", + action_id: "openclaw:verify", + block_id: "verify_block", + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(app.client.chat.update).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith({ + text: "You are not authorized to use this control.", + response_type: "ephemeral", + }); + }); + it("ignores malformed action payloads after ack and logs warning", async () => { enqueueSystemEventMock.mockClear(); const { ctx, app, getHandler, runtimeLog } = createContext(); @@ -338,7 +468,7 @@ describe("registerSlackInteractionEvents", () => { expect(ack).toHaveBeenCalled(); expect(resolveSessionKey).toHaveBeenCalledWith({ channelId: "C222", - channelType: undefined, + channelType: "channel", }); expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; @@ -697,7 +827,11 @@ describe("registerSlackInteractionEvents", () => { previous_view_id: "VPREV", external_id: "deploy-ext-1", hash: "view-hash-1", - private_metadata: JSON.stringify({ channelId: "D123", channelType: "im" }), + private_metadata: JSON.stringify({ + channelId: "D123", + channelType: "im", + userId: "U777", + }), state: { values: { env_block: { @@ -771,6 +905,59 @@ describe("registerSlackInteractionEvents", () => { ); }); + it("blocks modal events when private metadata userId does not match submitter", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + expect(viewHandler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler!({ + ack, + body: { + user: { id: "U222" }, + view: { + callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ + channelId: "D123", + channelType: "im", + userId: "U111", + }), + }, + }, + } as never); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("blocks modal events when private metadata is missing userId", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + expect(viewHandler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler!({ + ack, + body: { + user: { id: "U222" }, + view: { + callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ + channelId: "D123", + channelType: "im", + }), + }, + }, + } as never); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + it("captures modal input labels and picker values across block types", async () => { enqueueSystemEventMock.mockClear(); const { ctx, getViewHandler } = createContext(); @@ -786,6 +973,7 @@ describe("registerSlackInteractionEvents", () => { view: { id: "V400", callback_id: "openclaw:routing_form", + private_metadata: JSON.stringify({ userId: "U444" }), state: { values: { env_block: { @@ -1001,6 +1189,7 @@ describe("registerSlackInteractionEvents", () => { view: { id: "V555", callback_id: "openclaw:long_richtext", + private_metadata: JSON.stringify({ userId: "U555" }), state: { values: { richtext_block: { @@ -1054,7 +1243,10 @@ describe("registerSlackInteractionEvents", () => { previous_view_id: "VPREV900", external_id: "deploy-ext-900", hash: "view-hash-900", - private_metadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }), + private_metadata: JSON.stringify({ + sessionKey: "agent:main:slack:channel:C99", + userId: "U900", + }), state: { values: { env_block: { @@ -1101,7 +1293,10 @@ describe("registerSlackInteractionEvents", () => { viewId: "V900", userId: "U900", isCleared: true, - privateMetadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }), + privateMetadata: JSON.stringify({ + sessionKey: "agent:main:slack:channel:C99", + userId: "U900", + }), rootViewId: "VROOT900", previousViewId: "VPREV900", externalId: "deploy-ext-900", @@ -1131,6 +1326,7 @@ describe("registerSlackInteractionEvents", () => { view: { id: "V901", callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ userId: "U901" }), }, }, }); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index cbc4fc9f36e..40a06ad9f2e 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -2,6 +2,7 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; import { escapeSlackMrkdwn } from "../mrkdwn.js"; @@ -78,6 +79,7 @@ type SlackModalBody = { type SlackModalEventBase = { callbackId: string; userId: string; + expectedUserId?: string; viewId?: string; sessionRouting: ReturnType; payload: { @@ -366,11 +368,15 @@ function summarizeViewState(values: unknown): ModalInputSummary[] { function resolveModalSessionRouting(params: { ctx: SlackMonitorContext; - privateMetadata: unknown; + metadata: ReturnType; }): { sessionKey: string; channelId?: string; channelType?: string } { - const metadata = parseSlackModalPrivateMetadata(params.privateMetadata); + const metadata = params.metadata; if (metadata.sessionKey) { - return { sessionKey: metadata.sessionKey }; + return { + sessionKey: metadata.sessionKey, + channelId: metadata.channelId, + channelType: metadata.channelType, + }; } if (metadata.channelId) { return { @@ -416,17 +422,19 @@ function resolveSlackModalEventBase(params: { ctx: SlackMonitorContext; body: SlackModalBody; }): SlackModalEventBase { + const metadata = parseSlackModalPrivateMetadata(params.body.view?.private_metadata); const callbackId = params.body.view?.callback_id ?? "unknown"; const userId = params.body.user?.id ?? "unknown"; const viewId = params.body.view?.id; const inputs = summarizeViewState(params.body.view?.state?.values); const sessionRouting = resolveModalSessionRouting({ ctx: params.ctx, - privateMetadata: params.body.view?.private_metadata, + metadata, }); return { callbackId, userId, + expectedUserId: metadata.userId, viewId, sessionRouting, payload: { @@ -449,16 +457,17 @@ function resolveSlackModalEventBase(params: { }; } -function emitSlackModalLifecycleEvent(params: { +async function emitSlackModalLifecycleEvent(params: { ctx: SlackMonitorContext; body: SlackModalBody; interactionType: SlackModalInteractionKind; contextPrefix: "slack:interaction:view" | "slack:interaction:view-closed"; -}): void { - const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({ - ctx: params.ctx, - body: params.body, - }); +}): Promise { + const { callbackId, userId, expectedUserId, viewId, sessionRouting, payload } = + resolveSlackModalEventBase({ + ctx: params.ctx, + body: params.body, + }); const isViewClosed = params.interactionType === "view_closed"; const isCleared = params.body.is_cleared === true; const eventPayload = isViewClosed @@ -482,6 +491,27 @@ function emitSlackModalLifecycleEvent(params: { ); } + if (!expectedUserId) { + params.ctx.runtime.log?.( + `slack:interaction drop modal callback=${callbackId} user=${userId} reason=missing-expected-user`, + ); + return; + } + + const auth = await authorizeSlackSystemEventSender({ + ctx: params.ctx, + senderId: userId, + channelId: sessionRouting.channelId, + channelType: sessionRouting.channelType, + expectedSenderId: expectedUserId, + }); + if (!auth.allowed) { + params.ctx.runtime.log?.( + `slack:interaction drop modal callback=${callbackId} user=${userId} reason=${auth.reason ?? "unauthorized"}`, + ); + return; + } + enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { sessionKey: sessionRouting.sessionKey, contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"), @@ -497,7 +527,7 @@ function registerModalLifecycleHandler(params: { }) { params.register(params.matcher, async ({ ack, body }: SlackModalEventHandlerArgs) => { await ack(); - emitSlackModalLifecycleEvent({ + await emitSlackModalLifecycleEvent({ ctx: params.ctx, body: body as SlackModalBody, interactionType: params.interactionType, @@ -557,6 +587,27 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id; const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts; const threadTs = typedBody.container?.thread_ts; + const auth = await authorizeSlackSystemEventSender({ + ctx, + senderId: userId, + channelId, + }); + if (!auth.allowed) { + ctx.runtime.log?.( + `slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); + if (respond) { + try { + await respond({ + text: "You are not authorized to use this control.", + response_type: "ephemeral", + }); + } catch { + // Best-effort feedback only. + } + } + return; + } const actionSummary = summarizeAction(typedAction); const eventPayload: InteractionSummary = { interactionType: "block_action", @@ -581,7 +632,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex // Pass undefined (not "unknown") to allow proper main session fallback const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId: channelId, - channelType: undefined, + channelType: auth.channelType, }); // Build context key - only include defined values to avoid "unknown" noise