refactor(bluebubbles): share dm/group access policy checks

This commit is contained in:
Peter Steinberger
2026-02-21 20:08:17 +01:00
parent c3af00bddb
commit 4540790cb6
4 changed files with 265 additions and 126 deletions

View File

@@ -310,6 +310,11 @@ export {
readStringParam,
} from "../agents/tools/common.js";
export { formatDocsLink } from "../terminal/links.js";
export {
resolveDmAllowState,
resolveDmGroupAccessDecision,
resolveEffectiveAllowFromLists,
} from "../security/dm-policy-shared.js";
export type { HookEntry } from "../hooks/types.js";
export { clamp, escapeRegExp, normalizeE164, safeParseJson, sleep } from "../utils.js";
export { stripAnsi } from "../terminal/ansi.js";

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveDmAllowState } from "./dm-policy-shared.js";
import {
resolveDmAllowState,
resolveDmGroupAccessDecision,
resolveEffectiveAllowFromLists,
} from "./dm-policy-shared.js";
describe("security/dm-policy-shared", () => {
it("normalizes config + store allow entries and counts distinct senders", async () => {
@@ -28,4 +32,94 @@ describe("security/dm-policy-shared", () => {
expect(state.allowCount).toBe(0);
expect(state.isMultiUserDm).toBe(false);
});
it("builds effective DM/group allowlists from config + pairing store", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: [" owner ", "", "owner2"],
groupAllowFrom: ["group:abc"],
storeAllowFrom: [" owner3 ", ""],
});
expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]);
expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc", "owner3"]);
});
it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: [" owner "],
groupAllowFrom: [],
storeAllowFrom: [" owner2 "],
});
expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]);
expect(lists.effectiveGroupAllowFrom).toEqual(["owner", "owner2"]);
});
const channels = [
"bluebubbles",
"imessage",
"signal",
"telegram",
"whatsapp",
"msteams",
"matrix",
"zalo",
] as const;
for (const channel of channels) {
it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => {
const decision = resolveDmGroupAccessDecision({
isGroup: false,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
decision: "block",
reason: "dmPolicy=allowlist (not allowlisted)",
});
});
it(`[${channel}] uses pairing flow when DM sender is not allowlisted`, () => {
const decision = resolveDmGroupAccessDecision({
isGroup: false,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
decision: "pairing",
reason: "dmPolicy=pairing (not allowlisted)",
});
});
it(`[${channel}] allows DM sender when allowlisted`, () => {
const decision = resolveDmGroupAccessDecision({
isGroup: false,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => true,
});
expect(decision.decision).toBe("allow");
});
it(`[${channel}] blocks group allowlist mode when sender/group is not allowlisted`, () => {
const decision = resolveDmGroupAccessDecision({
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: ["group:abc"],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
decision: "block",
reason: "groupPolicy=allowlist (not allowlisted)",
});
});
}
});

View File

@@ -2,6 +2,77 @@ import type { ChannelId } from "../channels/plugins/types.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
export function resolveEffectiveAllowFromLists(params: {
allowFrom?: Array<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | null;
}): {
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
} {
const configAllowFrom = normalizeStringEntries(
Array.isArray(params.allowFrom) ? params.allowFrom : undefined,
);
const configGroupAllowFrom = normalizeStringEntries(
Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined,
);
const storeAllowFrom = normalizeStringEntries(
Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined,
);
const effectiveAllowFrom = normalizeStringEntries([...configAllowFrom, ...storeAllowFrom]);
const groupBase = configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
const effectiveGroupAllowFrom = normalizeStringEntries([...groupBase, ...storeAllowFrom]);
return { effectiveAllowFrom, effectiveGroupAllowFrom };
}
export type DmGroupAccessDecision = "allow" | "block" | "pairing";
export function resolveDmGroupAccessDecision(params: {
isGroup: boolean;
dmPolicy?: string | null;
groupPolicy?: string | null;
effectiveAllowFrom: Array<string | number>;
effectiveGroupAllowFrom: Array<string | number>;
isSenderAllowed: (allowFrom: string[]) => boolean;
}): {
decision: DmGroupAccessDecision;
reason: string;
} {
const dmPolicy = params.dmPolicy ?? "pairing";
const groupPolicy = params.groupPolicy ?? "allowlist";
const effectiveAllowFrom = normalizeStringEntries(params.effectiveAllowFrom);
const effectiveGroupAllowFrom = normalizeStringEntries(params.effectiveGroupAllowFrom);
if (params.isGroup) {
if (groupPolicy === "disabled") {
return { decision: "block", reason: "groupPolicy=disabled" };
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
return { decision: "block", reason: "groupPolicy=allowlist (empty allowlist)" };
}
if (!params.isSenderAllowed(effectiveGroupAllowFrom)) {
return { decision: "block", reason: "groupPolicy=allowlist (not allowlisted)" };
}
}
return { decision: "allow", reason: `groupPolicy=${groupPolicy}` };
}
if (dmPolicy === "disabled") {
return { decision: "block", reason: "dmPolicy=disabled" };
}
if (dmPolicy === "open") {
return { decision: "allow", reason: "dmPolicy=open" };
}
if (params.isSenderAllowed(effectiveAllowFrom)) {
return { decision: "allow", reason: `dmPolicy=${dmPolicy} (allowlisted)` };
}
if (dmPolicy === "pairing") {
return { decision: "pairing", reason: "dmPolicy=pairing (not allowlisted)" };
}
return { decision: "block", reason: `dmPolicy=${dmPolicy} (not allowlisted)` };
}
export async function resolveDmAllowState(params: {
provider: ChannelId;
allowFrom?: Array<string | number> | null;