diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index 0babbc43851..2136b9672dc 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -1,6 +1,7 @@ import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + evaluateGroupRouteAccessForPolicy, issuePairingChallenge, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, @@ -195,24 +196,23 @@ export async function applyGoogleChatInboundAccessPolicy(params: { let effectiveWasMentioned: boolean | undefined; if (isGroup) { - if (groupPolicy === "disabled") { - logVerbose(`drop group message (groupPolicy=disabled, space=${spaceId})`); - return { ok: false }; - } const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured; - const groupAllowed = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]); - if (groupPolicy === "allowlist") { - if (!groupAllowlistConfigured) { + const routeAccess = evaluateGroupRouteAccessForPolicy({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + routeMatched: Boolean(groupEntry), + routeEnabled: groupEntry?.enabled !== false && groupEntry?.allow !== false, + }); + if (!routeAccess.allowed) { + if (routeAccess.reason === "disabled") { + logVerbose(`drop group message (groupPolicy=disabled, space=${spaceId})`); + } else if (routeAccess.reason === "empty_allowlist") { logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`); - return { ok: false }; - } - if (!groupAllowed) { + } else if (routeAccess.reason === "route_not_allowlisted") { logVerbose(`drop group message (not allowlisted, space=${spaceId})`); - return { ok: false }; + } else if (routeAccess.reason === "route_disabled") { + logVerbose(`drop group message (space disabled, space=${spaceId})`); } - } - if (groupEntry?.enabled === false || groupEntry?.allow === false) { - logVerbose(`drop group message (space disabled, space=${spaceId})`); return { ok: false }; } diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index bacd6890ab9..295d61f2de0 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -5,6 +5,7 @@ import { createReplyPrefixOptions, createTypingCallbacks, dispatchReplyFromConfigWithSettledDispatcher, + evaluateGroupRouteAccessForPolicy, formatAllowlistMatchMeta, logInboundDrop, logTypingFailure, @@ -194,10 +195,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); const isRoom = !isDirectMessage; - if (isRoom && groupPolicy === "disabled") { - return; - } - const roomConfigInfo = isRoom ? resolveMatrixRoomConfig({ rooms: roomsConfig, @@ -213,17 +210,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }` : "matchKey=none matchSource=none"; - if (isRoom && roomConfig && !roomConfigInfo?.allowed) { - logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); - return; - } - if (isRoom && groupPolicy === "allowlist") { - if (!roomConfigInfo?.allowlistConfigured) { - logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); - return; - } - if (!roomConfig) { - logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + if (isRoom) { + const routeAccess = evaluateGroupRouteAccessForPolicy({ + groupPolicy, + routeAllowlistConfigured: Boolean(roomConfigInfo?.allowlistConfigured), + routeMatched: Boolean(roomConfig), + routeEnabled: roomConfigInfo?.allowed ?? true, + }); + if (!routeAccess.allowed) { + if (routeAccess.reason === "route_disabled") { + logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + } else if (routeAccess.reason === "empty_allowlist") { + logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + } else if (routeAccess.reason === "route_not_allowlisted") { + logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + } return; } } diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 670e0992c0e..b7d7963956d 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -8,6 +8,7 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + evaluateGroupRouteAccessForPolicy, issuePairingChallenge, resolveOutboundMediaUrls, mergeAllowlist, @@ -94,28 +95,6 @@ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boo }); } -function isGroupAllowed(params: { - groupId: string; - groupName?: string | null; - groups: Record; -}): boolean { - const groups = params.groups ?? {}; - const keys = Object.keys(groups); - if (keys.length === 0) { - return false; - } - const entry = findZalouserGroupEntry( - groups, - buildZalouserGroupCandidates({ - groupId: params.groupId, - groupName: params.groupName, - includeGroupIdAlias: true, - includeWildcard: true, - }), - ); - return isZalouserGroupEntryAllowed(entry); -} - function resolveGroupRequireMention(params: { groupId: string; groupName?: string | null; @@ -223,16 +202,36 @@ async function processMessage( const groups = account.config.groups ?? {}; if (isGroup) { - if (groupPolicy === "disabled") { - logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`); - return; - } - if (groupPolicy === "allowlist") { - const allowed = isGroupAllowed({ groupId: chatId, groupName, groups }); - if (!allowed) { + const groupEntry = findZalouserGroupEntry( + groups, + buildZalouserGroupCandidates({ + groupId: chatId, + groupName, + includeGroupIdAlias: true, + includeWildcard: true, + }), + ); + const routeAccess = evaluateGroupRouteAccessForPolicy({ + groupPolicy, + routeAllowlistConfigured: Object.keys(groups).length > 0, + routeMatched: Boolean(groupEntry), + routeEnabled: isZalouserGroupEntryAllowed(groupEntry), + }); + if (!routeAccess.allowed) { + if (routeAccess.reason === "disabled") { + logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`); + } else if (routeAccess.reason === "empty_allowlist") { + logVerbose( + core, + runtime, + `zalouser: drop group ${chatId} (groupPolicy=allowlist, no allowlist)`, + ); + } else if (routeAccess.reason === "route_not_allowlisted") { logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`); - return; + } else if (routeAccess.reason === "route_disabled") { + logVerbose(core, runtime, `zalouser: drop group ${chatId} (group disabled)`); } + return; } } diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 745fe3bde80..fe2b6106299 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -73,7 +73,10 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; -export { resolveSenderScopedGroupPolicy } from "./group-access.js"; +export { + evaluateGroupRouteAccessForPolicy, + resolveSenderScopedGroupPolicy, +} from "./group-access.js"; export { extractToolSend } from "./tool-send.js"; export { resolveWebhookPath } from "./webhook-path.js"; export type { WebhookInFlightLimiter } from "./webhook-request-guards.js"; diff --git a/src/plugin-sdk/group-access.test.ts b/src/plugin-sdk/group-access.test.ts index 81c58b60962..9a834464ba0 100644 --- a/src/plugin-sdk/group-access.test.ts +++ b/src/plugin-sdk/group-access.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + evaluateGroupRouteAccessForPolicy, evaluateSenderGroupAccess, evaluateSenderGroupAccessForPolicy, resolveSenderScopedGroupPolicy, @@ -59,6 +60,66 @@ describe("evaluateSenderGroupAccessForPolicy", () => { }); }); +describe("evaluateGroupRouteAccessForPolicy", () => { + it("blocks disabled policy", () => { + expect( + evaluateGroupRouteAccessForPolicy({ + groupPolicy: "disabled", + routeAllowlistConfigured: true, + routeMatched: true, + routeEnabled: true, + }), + ).toEqual({ + allowed: false, + groupPolicy: "disabled", + reason: "disabled", + }); + }); + + it("blocks allowlist without configured routes", () => { + expect( + evaluateGroupRouteAccessForPolicy({ + groupPolicy: "allowlist", + routeAllowlistConfigured: false, + routeMatched: false, + }), + ).toEqual({ + allowed: false, + groupPolicy: "allowlist", + reason: "empty_allowlist", + }); + }); + + it("blocks unmatched allowlist route", () => { + expect( + evaluateGroupRouteAccessForPolicy({ + groupPolicy: "allowlist", + routeAllowlistConfigured: true, + routeMatched: false, + }), + ).toEqual({ + allowed: false, + groupPolicy: "allowlist", + reason: "route_not_allowlisted", + }); + }); + + it("blocks disabled matched route even when group policy is open", () => { + expect( + evaluateGroupRouteAccessForPolicy({ + groupPolicy: "open", + routeAllowlistConfigured: true, + routeMatched: true, + routeEnabled: false, + }), + ).toEqual({ + allowed: false, + groupPolicy: "open", + reason: "route_disabled", + }); + }); +}); + describe("evaluateSenderGroupAccess", () => { it("defaults missing provider config to allowlist", () => { const decision = evaluateSenderGroupAccess({ diff --git a/src/plugin-sdk/group-access.ts b/src/plugin-sdk/group-access.ts index 596549212e3..e8bc817fadb 100644 --- a/src/plugin-sdk/group-access.ts +++ b/src/plugin-sdk/group-access.ts @@ -14,6 +14,19 @@ export type SenderGroupAccessDecision = { reason: SenderGroupAccessReason; }; +export type GroupRouteAccessReason = + | "allowed" + | "disabled" + | "empty_allowlist" + | "route_not_allowlisted" + | "route_disabled"; + +export type GroupRouteAccessDecision = { + allowed: boolean; + groupPolicy: GroupPolicy; + reason: GroupRouteAccessReason; +}; + export function resolveSenderScopedGroupPolicy(params: { groupPolicy: GroupPolicy; groupAllowFrom: string[]; @@ -24,6 +37,52 @@ export function resolveSenderScopedGroupPolicy(params: { return params.groupAllowFrom.length > 0 ? "allowlist" : "open"; } +export function evaluateGroupRouteAccessForPolicy(params: { + groupPolicy: GroupPolicy; + routeAllowlistConfigured: boolean; + routeMatched: boolean; + routeEnabled?: boolean; +}): GroupRouteAccessDecision { + if (params.groupPolicy === "disabled") { + return { + allowed: false, + groupPolicy: params.groupPolicy, + reason: "disabled", + }; + } + + if (params.routeMatched && params.routeEnabled === false) { + return { + allowed: false, + groupPolicy: params.groupPolicy, + reason: "route_disabled", + }; + } + + if (params.groupPolicy === "allowlist") { + if (!params.routeAllowlistConfigured) { + return { + allowed: false, + groupPolicy: params.groupPolicy, + reason: "empty_allowlist", + }; + } + if (!params.routeMatched) { + return { + allowed: false, + groupPolicy: params.groupPolicy, + reason: "route_not_allowlisted", + }; + } + } + + return { + allowed: true, + groupPolicy: params.groupPolicy, + reason: "allowed", + }; +} + export function evaluateSenderGroupAccessForPolicy(params: { groupPolicy: GroupPolicy; providerMissingFallbackApplied?: boolean; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 68b71a86eb1..8f25e5c8aed 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -278,9 +278,12 @@ export { isNormalizedSenderAllowed, } from "./allow-from.js"; export { + evaluateGroupRouteAccessForPolicy, evaluateSenderGroupAccess, evaluateSenderGroupAccessForPolicy, resolveSenderScopedGroupPolicy, + type GroupRouteAccessDecision, + type GroupRouteAccessReason, type SenderGroupAccessDecision, type SenderGroupAccessReason, } from "./group-access.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index fa0551ca56e..60ee4c47b79 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -93,7 +93,10 @@ export { } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { resolveSenderScopedGroupPolicy } from "./group-access.js"; +export { + evaluateGroupRouteAccessForPolicy, + resolveSenderScopedGroupPolicy, +} from "./group-access.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { runPluginCommandWithTimeout } from "./run-command.js"; diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 48b4f941e15..fc1c6aebfc0 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -59,6 +59,7 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; +export { evaluateGroupRouteAccessForPolicy } from "./group-access.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js";