diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 71dcb137133..2b968c5f117 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -44,7 +44,7 @@ export function isMattermostSenderAllowed(params: { allowFrom: string[]; allowNameMatching?: boolean; }): boolean { - const allowFrom = params.allowFrom; + const allowFrom = normalizeMattermostAllowList(params.allowFrom); if (allowFrom.length === 0) { return false; } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 1ed2ee74e35..606885811a4 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -37,11 +37,7 @@ import { type MattermostPost, type MattermostUser, } from "./client.js"; -import { - isMattermostSenderAllowed, - normalizeMattermostAllowList, - resolveMattermostEffectiveAllowFromLists, -} from "./monitor-auth.js"; +import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js"; import { createDedupeCache, formatInboundFromLabel, @@ -360,18 +356,32 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId; const rawText = post.message?.trim() || ""; const dmPolicy = account.config.dmPolicy ?? "pairing"; + const normalizedAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); + const normalizedGroupAllowFrom = normalizeMattermostAllowList( + account.config.groupAllowFrom ?? [], + ); const storeAllowFrom = normalizeMattermostAllowList( dmPolicy === "allowlist" ? [] : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), ); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = - resolveMattermostEffectiveAllowFromLists({ - dmPolicy, - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, - }); + const accessDecision = resolveDmGroupAccessWithLists({ + isGroup: kind !== "direct", + dmPolicy, + groupPolicy, + allowFrom: normalizedAllowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom, + allowNameMatching, + }), + }); + const effectiveAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "mattermost", @@ -404,17 +414,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} hasControlCommand, }); const commandAuthorized = - kind === "direct" - ? dmPolicy === "open" || senderAllowedForCommands - : commandGate.commandAuthorized; + kind === "direct" ? accessDecision.decision === "allow" : commandGate.commandAuthorized; - if (kind === "direct") { - if (dmPolicy === "disabled") { - logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); - return; - } - if (dmPolicy !== "open" && !senderAllowedForCommands) { - if (dmPolicy === "pairing") { + if (accessDecision.decision !== "allow") { + if (kind === "direct") { + if (accessDecision.reason === "dmPolicy=disabled") { + logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); + return; + } + if (accessDecision.decision === "pairing") { const { code, created } = await core.channel.pairing.upsertPairingRequest({ channel: "mattermost", id: senderId, @@ -437,26 +445,27 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`); } } - } else { - logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); + return; } + logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); return; } - } else { - if (groupPolicy === "disabled") { + if (accessDecision.reason === "groupPolicy=disabled") { logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)"); return; } - if (groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { - logVerboseMessage("mattermost: drop group message (no group allowlist)"); - return; - } - if (!groupAllowedForCommands) { - logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); - return; - } + if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") { + logVerboseMessage("mattermost: drop group message (no group allowlist)"); + return; } + if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") { + logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); + return; + } + logVerboseMessage( + `mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.reason})`, + ); + return; } if (kind !== "direct" && commandGate.shouldBlock) { @@ -852,14 +861,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} isGroup: kind !== "direct", dmPolicy, groupPolicy, - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, + allowFrom: normalizeMattermostAllowList(account.config.allowFrom ?? []), + groupAllowFrom: normalizeMattermostAllowList(account.config.groupAllowFrom ?? []), storeAllowFrom, isSenderAllowed: (allowFrom) => isMattermostSenderAllowed({ senderId: userId, senderName, - allowFrom: normalizeMattermostAllowList(allowFrom), + allowFrom, allowNameMatching, }), }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index e208d138c3f..36fc0382203 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -146,18 +146,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); const effectiveDmAllowFrom = resolvedAllowFromLists.effectiveAllowFrom; if (isDirectMessage && msteamsCfg) { - const allowFrom = dmAllowFrom; - if (dmPolicy === "disabled") { log.debug?.("dropping dm (dms disabled)"); return; } if (dmPolicy !== "open") { - const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const allowMatch = resolveMSTeamsAllowlistMatch({ - allowFrom: effectiveAllowFrom, + allowFrom: effectiveDmAllowFrom, senderId, senderName, allowNameMatching, diff --git a/package.json b/package.json index 9c029a9a754..dbb56c4fbd9 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:auth:no-pairing-store-group", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", @@ -89,6 +89,7 @@ "ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", "lint": "oxlint --type-aware", "lint:all": "pnpm lint && pnpm lint:swift", + "lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs", "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", diff --git a/scripts/check-no-pairing-store-group-auth.mjs b/scripts/check-no-pairing-store-group-auth.mjs new file mode 100644 index 00000000000..e4fcc0e4fad --- /dev/null +++ b/scripts/check-no-pairing-store-group-auth.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; + +const allowedFiles = new Set([ + path.join(repoRoot, "src", "security", "dm-policy-shared.ts"), + path.join(repoRoot, "src", "channels", "allow-from.ts"), + // Config migration/audit logic may intentionally reference store + group fields. + path.join(repoRoot, "src", "security", "fix.ts"), + path.join(repoRoot, "src", "security", "audit-channel.ts"), +]); + +const storeIdentifierRe = /^(?:storeAllowFrom|storedAllowFrom|storeAllowList)$/i; +const groupNameRe = + /(?:groupAllowFrom|effectiveGroupAllowFrom|groupAllowed|groupAllow|groupAuth|groupSender)/i; +const allowedResolverCallNames = new Set([ + "resolveEffectiveAllowFromLists", + "resolveDmGroupAccessWithLists", + "resolveMattermostEffectiveAllowFromLists", + "resolveIrcEffectiveAllowlists", +]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile() || !entryPath.endsWith(".ts") || isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function toLine(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function getPropertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +function getDeclarationNameText(name) { + if (ts.isIdentifier(name)) { + return name.text; + } + if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + return name.getText(); + } + return null; +} + +function containsStoreIdentifier(node) { + let found = false; + const visit = (current) => { + if (found) { + return; + } + if (ts.isIdentifier(current) && storeIdentifierRe.test(current.text)) { + found = true; + return; + } + ts.forEachChild(current, visit); + }; + visit(node); + return found; +} + +function getCallName(node) { + if (!ts.isCallExpression(node)) { + return null; + } + if (ts.isIdentifier(node.expression)) { + return node.expression.text; + } + if (ts.isPropertyAccessExpression(node.expression)) { + return node.expression.name.text; + } + return null; +} + +function isSuspiciousNormalizeWithStoreCall(node) { + if (!ts.isCallExpression(node)) { + return false; + } + if (!ts.isIdentifier(node.expression) || node.expression.text !== "normalizeAllowFromWithStore") { + return false; + } + const firstArg = node.arguments[0]; + if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) { + return false; + } + let hasStoreProp = false; + let hasGroupAllowProp = false; + for (const property of firstArg.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + const name = getPropertyNameText(property.name); + if (!name) { + continue; + } + if (name === "storeAllowFrom" && containsStoreIdentifier(property.initializer)) { + hasStoreProp = true; + } + if (name === "allowFrom" && groupNameRe.test(property.initializer.getText())) { + hasGroupAllowProp = true; + } + } + return hasStoreProp && hasGroupAllowProp; +} + +function findViolations(content, filePath) { + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + if (ts.isVariableDeclaration(node) && node.initializer) { + const name = getDeclarationNameText(node.name); + if (name && groupNameRe.test(name) && containsStoreIdentifier(node.initializer)) { + const callName = getCallName(node.initializer); + if (callName && allowedResolverCallNames.has(callName)) { + ts.forEachChild(node, visit); + return; + } + violations.push({ + line: toLine(sourceFile, node), + reason: `group-scoped variable "${name}" references pairing-store identifiers`, + }); + } + } + + if (ts.isPropertyAssignment(node)) { + const propName = getPropertyNameText(node.name); + if (propName && groupNameRe.test(propName) && containsStoreIdentifier(node.initializer)) { + violations.push({ + line: toLine(sourceFile, node), + reason: `group-scoped property "${propName}" references pairing-store identifiers`, + }); + } + } + + if (isSuspiciousNormalizeWithStoreCall(node)) { + violations.push({ + line: toLine(sourceFile, node), + reason: "group allowlist uses normalizeAllowFromWithStore(...) with pairing-store entries", + }); + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) + ).flat(); + + const violations = []; + for (const filePath of files) { + if (allowedFiles.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + const fileViolations = findViolations(content, filePath); + for (const violation of fileViolations) { + violations.push({ + path: path.relative(repoRoot, filePath), + ...violation, + }); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found pairing-store identifiers referenced in group auth composition:"); + for (const violation of violations) { + console.error(`- ${violation.path}:${violation.line} (${violation.reason})`); + } + console.error( + "Group auth must be composed via shared resolvers (resolveDmGroupAccessWithLists / resolveEffectiveAllowFromLists).", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/imessage/monitor/inbound-processing.ts b/src/imessage/monitor/inbound-processing.ts index cbfaf051f18..4cfd1a4c99c 100644 --- a/src/imessage/monitor/inbound-processing.ts +++ b/src/imessage/monitor/inbound-processing.ts @@ -20,7 +20,7 @@ import { resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { resolveEffectiveAllowFromLists } from "../../security/dm-policy-shared.js"; +import { resolveDmGroupAccessWithLists } from "../../security/dm-policy-shared.js"; import { truncateUtf16Safe } from "../../utils.js"; import { formatIMessageChatTarget, @@ -139,72 +139,61 @@ export function resolveIMessageInboundDecision(params: { } const groupId = isGroup ? groupIdCandidate : undefined; - const { effectiveAllowFrom: effectiveDmAllowFrom, effectiveGroupAllowFrom } = - resolveEffectiveAllowFromLists({ - allowFrom: params.allowFrom, - groupAllowFrom: params.groupAllowFrom, - storeAllowFrom: params.storeAllowFrom, - dmPolicy: params.dmPolicy, - groupAllowFromFallbackToAllowFrom: false, - }); + const accessDecision = resolveDmGroupAccessWithLists({ + isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom: params.storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + isAllowedIMessageSender({ + allowFrom, + sender, + chatId, + chatGuid, + chatIdentifier, + }), + }); + const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; + const dmAuthorized = !isGroup && accessDecision.decision === "allow"; - if (isGroup) { - if (params.groupPolicy === "disabled") { - params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)"); - return { kind: "drop", reason: "groupPolicy disabled" }; - } - if (params.groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { + if (accessDecision.decision !== "allow") { + if (isGroup) { + if (accessDecision.reason === "groupPolicy=disabled") { + params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)"); + return { kind: "drop", reason: "groupPolicy disabled" }; + } + if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") { params.logVerbose?.( "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", ); return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" }; } - const allowed = isAllowedIMessageSender({ - allowFrom: effectiveGroupAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }); - if (!allowed) { + if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") { params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`); return { kind: "drop", reason: "not in groupAllowFrom" }; } + params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`); + return { kind: "drop", reason: accessDecision.reason }; } - if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { - params.logVerbose?.( - `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, - ); - return { kind: "drop", reason: "group id not in allowlist" }; - } - } - - const dmHasWildcard = effectiveDmAllowFrom.includes("*"); - const dmAuthorized = - params.dmPolicy === "open" - ? true - : dmHasWildcard || - (effectiveDmAllowFrom.length > 0 && - isAllowedIMessageSender({ - allowFrom: effectiveDmAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - })); - - if (!isGroup) { - if (params.dmPolicy === "disabled") { + if (accessDecision.reason === "dmPolicy=disabled") { return { kind: "drop", reason: "dmPolicy disabled" }; } - if (!dmAuthorized) { - if (params.dmPolicy === "pairing") { - return { kind: "pairing", senderId: senderNormalized }; - } - params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`); - return { kind: "drop", reason: "dmPolicy blocked" }; + if (accessDecision.decision === "pairing") { + return { kind: "pairing", senderId: senderNormalized }; } + params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`); + return { kind: "drop", reason: "dmPolicy blocked" }; + } + + if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { + params.logVerbose?.( + `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, + ); + return { kind: "drop", reason: "group id not in allowlist" }; } const route = resolveAgentRoute({ diff --git a/src/security/dm-policy-channel-smoke.test.ts b/src/security/dm-policy-channel-smoke.test.ts new file mode 100644 index 00000000000..05cd67c47de --- /dev/null +++ b/src/security/dm-policy-channel-smoke.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedBlueBubblesSender } from "../../extensions/bluebubbles/src/targets.js"; +import { isMattermostSenderAllowed } from "../../extensions/mattermost/src/mattermost/monitor-auth.js"; +import { isSignalSenderAllowed, type SignalSender } from "../signal/identity.js"; +import { resolveDmGroupAccessWithLists } from "./dm-policy-shared.js"; + +type ChannelSmokeCase = { + name: string; + storeAllowFrom: string[]; + isSenderAllowed: (allowFrom: string[]) => boolean; +}; + +const signalSender: SignalSender = { + kind: "phone", + raw: "+15550001111", + e164: "+15550001111", +}; + +const cases: ChannelSmokeCase[] = [ + { + name: "bluebubbles", + storeAllowFrom: ["attacker-user"], + isSenderAllowed: (allowFrom) => + isAllowedBlueBubblesSender({ + allowFrom, + sender: "attacker-user", + chatId: 101, + }), + }, + { + name: "signal", + storeAllowFrom: [signalSender.e164], + isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), + }, + { + name: "mattermost", + storeAllowFrom: ["user:attacker-user"], + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ + senderId: "attacker-user", + senderName: "Attacker", + allowFrom, + }), + }, +]; + +describe("security/dm-policy-shared channel smoke", () => { + for (const testCase of cases) { + for (const ingress of ["message", "reaction"] as const) { + it(`[${testCase.name}] blocks group ${ingress} when sender is only in pairing store`, () => { + const access = resolveDmGroupAccessWithLists({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner-user"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: testCase.storeAllowFrom, + isSenderAllowed: testCase.isSenderAllowed, + }); + expect(access.decision).toBe("block"); + expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); + }); + } + } +}); diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts index f421fe3b9f3..4e5f0dbf832 100644 --- a/src/security/dm-policy-shared.test.ts +++ b/src/security/dm-policy-shared.test.ts @@ -133,56 +133,88 @@ describe("security/dm-policy-shared", () => { const cases = [ { name: "dmPolicy=open", + isGroup: false, dmPolicy: "open" as const, + groupPolicy: "allowlist" as const, allowFrom: [] as string[], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "allow" as const, expectedReactionAllowed: true, }, { name: "dmPolicy=disabled", + isGroup: false, dmPolicy: "disabled" as const, + groupPolicy: "allowlist" as const, allowFrom: [] as string[], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "block" as const, expectedReactionAllowed: false, }, { name: "dmPolicy=allowlist unauthorized", + isGroup: false, dmPolicy: "allowlist" as const, + groupPolicy: "allowlist" as const, allowFrom: ["owner"], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "block" as const, expectedReactionAllowed: false, }, { name: "dmPolicy=allowlist authorized", + isGroup: false, dmPolicy: "allowlist" as const, + groupPolicy: "allowlist" as const, allowFrom: ["owner"], - senderAllowed: true, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => true, expectedDecision: "allow" as const, expectedReactionAllowed: true, }, { name: "dmPolicy=pairing unauthorized", + isGroup: false, dmPolicy: "pairing" as const, + groupPolicy: "allowlist" as const, allowFrom: [] as string[], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "pairing" as const, expectedReactionAllowed: false, }, + { + name: "groupPolicy=allowlist rejects DM-paired sender not in explicit group list", + isGroup: true, + dmPolicy: "pairing" as const, + groupPolicy: "allowlist" as const, + allowFrom: ["owner"] as string[], + groupAllowFrom: ["group-owner"] as string[], + storeAllowFrom: ["paired-user"] as string[], + isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("paired-user"), + expectedDecision: "block" as const, + expectedReactionAllowed: false, + }, ]; for (const channel of channels) { for (const testCase of cases) { const access = resolveDmGroupAccessWithLists({ - isGroup: false, + isGroup: testCase.isGroup, dmPolicy: testCase.dmPolicy, - groupPolicy: "allowlist", + groupPolicy: testCase.groupPolicy, allowFrom: testCase.allowFrom, - groupAllowFrom: [], - storeAllowFrom: [], - isSenderAllowed: () => testCase.senderAllowed, + groupAllowFrom: testCase.groupAllowFrom, + storeAllowFrom: testCase.storeAllowFrom, + isSenderAllowed: testCase.isSenderAllowed, }); const reactionAllowed = access.decision === "allow"; expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision);