refactor(security): enforce account-scoped pairing APIs

This commit is contained in:
Peter Steinberger
2026-02-26 21:57:10 +01:00
parent a0c5e28f3b
commit bce643a0bd
27 changed files with 331 additions and 94 deletions

View File

@@ -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 && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm check:host-env-policy:swift",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
@@ -93,6 +93,7 @@
"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:auth:pairing-account-scope": "node scripts/check-pairing-account-scope.mjs",
"lint:docs": "pnpm dlx markdownlint-cli2",
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
"lint:fix": "oxlint --type-aware --fix && pnpm format",

View File

@@ -0,0 +1,157 @@
#!/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")];
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 isUndefinedLikeExpression(node) {
if (ts.isIdentifier(node) && node.text === "undefined") {
return true;
}
return node.kind === ts.SyntaxKind.NullKeyword;
}
function hasRequiredAccountIdProperty(node) {
if (!ts.isObjectLiteralExpression(node)) {
return false;
}
for (const property of node.properties) {
if (ts.isShorthandPropertyAssignment(property) && property.name.text === "accountId") {
return true;
}
if (!ts.isPropertyAssignment(property)) {
continue;
}
if (getPropertyNameText(property.name) !== "accountId") {
continue;
}
if (isUndefinedLikeExpression(property.initializer)) {
return false;
}
return true;
}
return false;
}
function findViolations(content, filePath) {
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const callName = node.expression.text;
if (callName === "readChannelAllowFromStore") {
if (node.arguments.length < 3 || isUndefinedLikeExpression(node.arguments[2])) {
violations.push({
line: toLine(sourceFile, node),
reason: "readChannelAllowFromStore call must pass explicit accountId as 3rd arg",
});
}
} else if (
callName === "readLegacyChannelAllowFromStore" ||
callName === "readLegacyChannelAllowFromStoreSync"
) {
violations.push({
line: toLine(sourceFile, node),
reason: `${callName} is legacy-only; use account-scoped readChannelAllowFromStore* APIs`,
});
} else if (callName === "upsertChannelPairingRequest") {
const firstArg = node.arguments[0];
if (!firstArg || !hasRequiredAccountIdProperty(firstArg)) {
violations.push({
line: toLine(sourceFile, node),
reason: "upsertChannelPairingRequest call must include accountId in params",
});
}
}
}
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) {
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 unscoped pairing-store calls:");
for (const violation of violations) {
console.error(`- ${violation.path}:${violation.line} (${violation.reason})`);
}
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);
});
}

View File

@@ -390,7 +390,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
const pairingChannels = listPairingChannels();
const supportsStore = pairingChannels.includes(channelId);
const storeAllowFrom = supportsStore
? await readChannelAllowFromStore(channelId).catch(() => [])
? await readChannelAllowFromStore(channelId, process.env, accountId).catch(() => [])
: [];
let dmAllowFrom: string[] = [];

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { normalizeE164 } from "../../utils.js";
import { normalizeChatChannelId } from "../registry.js";
@@ -56,7 +57,11 @@ export function resolveWhatsAppHeartbeatRecipients(
Array.isArray(cfg.channels?.whatsapp?.allowFrom) && cfg.channels.whatsapp.allowFrom.length > 0
? cfg.channels.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164)
: [];
const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp").map(normalizeE164);
const storeAllowFrom = readChannelAllowFromStoreSync(
"whatsapp",
process.env,
DEFAULT_ACCOUNT_ID,
).map(normalizeE164);
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
const allowFrom = unique([...configuredAllowFrom, ...storeAllowFrom]);

View File

@@ -90,6 +90,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
const warnDmPolicy = async (params: {
label: string;
provider: ChannelId;
accountId: string;
dmPolicy: string;
allowFrom?: Array<string | number> | null;
policyPath?: string;
@@ -101,6 +102,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
const policyPath = params.policyPath ?? `${params.allowFromPath}policy`;
const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowState({
provider: params.provider,
accountId: params.accountId,
allowFrom: params.allowFrom,
normalizeEntry: params.normalizeEntry,
});
@@ -158,6 +160,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
await warnDmPolicy({
label: plugin.meta.label ?? plugin.id,
provider: plugin.id,
accountId: defaultAccountId,
dmPolicy: dmPolicy.policy,
allowFrom: dmPolicy.allowFrom,
policyPath: dmPolicy.policyPath,

View File

@@ -13,7 +13,7 @@ import {
} from "../../infra/outbound/targets.js";
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
import { buildChannelAccountBindings } from "../../routing/bindings.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
@@ -160,13 +160,15 @@ export async function resolveDeliveryTarget(
let allowFromOverride: string[] | undefined;
if (channel === "whatsapp") {
const configuredAllowFromRaw = resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [];
const resolvedAccountId = normalizeAccountId(accountId);
const configuredAllowFromRaw =
resolveWhatsAppAccount({ cfg, accountId: resolvedAccountId }).allowFrom ?? [];
const configuredAllowFrom = configuredAllowFromRaw
.map((entry) => String(entry).trim())
.filter((entry) => entry && entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp", process.env, accountId)
const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp", process.env, resolvedAccountId)
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
allowFromOverride = [...new Set([...configuredAllowFrom, ...storeAllowFrom])];

View File

@@ -35,10 +35,7 @@ import { logVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { logDebug, logError } from "../../logger.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js";
@@ -474,8 +471,8 @@ async function ensureDmComponentAuthorized(params: {
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
@@ -498,6 +495,7 @@ async function ensureDmComponentAuthorized(params: {
const { code, created } = await upsertChannelPairingRequest({
channel: "discord",
id: user.id,
accountId: ctx.accountId,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,

View File

@@ -11,7 +11,6 @@ import { danger, logVerbose } from "../../globals.js";
import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import {
readStoreAllowFromForDmPolicy,
@@ -208,6 +207,7 @@ async function runDiscordReactionHandler(params: {
}
type DiscordReactionIngressAuthorizationParams = {
accountId: string;
user: User;
isDirectMessage: boolean;
isGroupDm: boolean;
@@ -238,8 +238,8 @@ async function authorizeDiscordReactionIngress(
if (params.isDirectMessage) {
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: params.accountId,
dmPolicy: params.dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
});
const access = resolveDmGroupAccessWithLists({
isGroup: false,
@@ -358,6 +358,7 @@ async function handleDiscordReactionEvent(params: {
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const ingressAccess = await authorizeDiscordReactionIngress({
accountId: params.accountId,
user,
isDirectMessage,
isGroupDm,
@@ -486,6 +487,7 @@ async function handleDiscordReactionEvent(params: {
const channelConfig = resolveThreadChannelConfig();
const threadAccess = await authorizeDiscordReactionIngress({
accountId: params.accountId,
user,
isDirectMessage,
isGroupDm,
@@ -528,6 +530,7 @@ async function handleDiscordReactionEvent(params: {
const channelConfig = resolveThreadChannelConfig();
const threadAccess = await authorizeDiscordReactionIngress({
accountId: params.accountId,
user,
isDirectMessage,
isGroupDm,
@@ -571,6 +574,7 @@ async function handleDiscordReactionEvent(params: {
});
if (isGuildMessage) {
const channelAccess = await authorizeDiscordReactionIngress({
accountId: params.accountId,
user,
isDirectMessage,
isGroupDm,

View File

@@ -25,12 +25,9 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { logDebug } from "../../logger.js";
import { getChildLogger } from "../../logging.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js";
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
import { sendMessageDiscord } from "../send.js";
@@ -177,6 +174,7 @@ export async function preflightDiscordMessage(
}
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
let commandAuthorized = true;
if (isDirectMessage) {
if (dmPolicy === "disabled") {
@@ -186,8 +184,8 @@ export async function preflightDiscordMessage(
if (dmPolicy !== "open") {
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: resolvedAccountId,
dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
});
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
@@ -210,6 +208,7 @@ export async function preflightDiscordMessage(
const { code, created } = await upsertChannelPairingRequest({
channel: "discord",
id: author.id,
accountId: resolvedAccountId,
meta: {
tag: formatDiscordUserTag(author),
name: author.username ?? undefined,

View File

@@ -46,10 +46,7 @@ import { logVerbose } from "../../globals.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
@@ -1363,8 +1360,8 @@ async function dispatchDiscordCommandInteraction(params: {
if (dmPolicy !== "open") {
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId,
dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
});
const effectiveAllowFrom = [
...(discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? []),
@@ -1388,6 +1385,7 @@ async function dispatchDiscordCommandInteraction(params: {
const { code, created } = await upsertChannelPairingRequest({
channel: "discord",
id: user.id,
accountId,
meta: {
tag: sender.tag,
name: sender.name,

View File

@@ -230,7 +230,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
: "";
const bodyText = messageText || placeholder;
const storeAllowFrom = await readChannelAllowFromStore("imessage").catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore(
"imessage",
process.env,
accountInfo.accountId,
).catch(() => []);
const decision = resolveIMessageInboundDecision({
cfg,
accountId: accountInfo.accountId,
@@ -262,6 +266,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const { code, created } = await upsertChannelPairingRequest({
channel: "imessage",
id: decision.senderId,
accountId: accountInfo.accountId,
meta: {
sender: decision.senderId,
chatId: chatId ? String(chatId) : undefined,

View File

@@ -74,6 +74,7 @@ async function sendLinePairingReply(params: {
const { code, created } = await upsertChannelPairingRequest({
channel: "line",
id: senderId,
accountId: context.account.accountId,
});
if (!created) {
return;
@@ -121,7 +122,11 @@ async function shouldProcessLineEvent(
const senderId = userId ?? "";
const dmPolicy = account.config.dmPolicy ?? "pairing";
const storeAllowFrom = await readChannelAllowFromStore("line").catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore(
"line",
process.env,
account.accountId,
).catch(() => []);
const effectiveDmAllow = normalizeDmAllowFromWithStore({
allowFrom: account.config.allowFrom,
storeAllowFrom,

View File

@@ -4,12 +4,15 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { resolveOAuthDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
addChannelAllowFromStoreEntry,
approveChannelPairingCode,
listChannelPairingRequests,
readChannelAllowFromStore,
readLegacyChannelAllowFromStore,
readLegacyChannelAllowFromStoreSync,
readChannelAllowFromStoreSync,
removeChannelAllowFromStoreEntry,
upsertChannelPairingRequest,
@@ -69,10 +72,12 @@ describe("pairing store", () => {
const first = await upsertChannelPairingRequest({
channel: "discord",
id: "u1",
accountId: DEFAULT_ACCOUNT_ID,
});
const second = await upsertChannelPairingRequest({
channel: "discord",
id: "u1",
accountId: DEFAULT_ACCOUNT_ID,
});
expect(first.created).toBe(true);
expect(second.created).toBe(false);
@@ -89,6 +94,7 @@ describe("pairing store", () => {
const created = await upsertChannelPairingRequest({
channel: "signal",
id: "+15550001111",
accountId: DEFAULT_ACCOUNT_ID,
});
expect(created.created).toBe(true);
@@ -111,6 +117,7 @@ describe("pairing store", () => {
const next = await upsertChannelPairingRequest({
channel: "signal",
id: "+15550001111",
accountId: DEFAULT_ACCOUNT_ID,
});
expect(next.created).toBe(true);
});
@@ -128,6 +135,7 @@ describe("pairing store", () => {
const first = await upsertChannelPairingRequest({
channel: "telegram",
id: "123",
accountId: DEFAULT_ACCOUNT_ID,
});
expect(first.code).toBe("AAAAAAAA");
@@ -137,6 +145,7 @@ describe("pairing store", () => {
const second = await upsertChannelPairingRequest({
channel: "telegram",
id: "456",
accountId: DEFAULT_ACCOUNT_ID,
});
expect(second.code).toBe("BBBBBBBB");
} finally {
@@ -152,6 +161,7 @@ describe("pairing store", () => {
const created = await upsertChannelPairingRequest({
channel: "whatsapp",
id,
accountId: DEFAULT_ACCOUNT_ID,
});
expect(created.created).toBe(true);
}
@@ -159,6 +169,7 @@ describe("pairing store", () => {
const blocked = await upsertChannelPairingRequest({
channel: "whatsapp",
id: "+15550000004",
accountId: DEFAULT_ACCOUNT_ID,
});
expect(blocked.created).toBe(false);
@@ -181,7 +192,7 @@ describe("pairing store", () => {
});
const accountScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
const channelScoped = await readChannelAllowFromStore("telegram");
const channelScoped = await readLegacyChannelAllowFromStore("telegram");
expect(accountScoped).toContain("12345");
expect(channelScoped).not.toContain("12345");
});
@@ -203,7 +214,7 @@ describe("pairing store", () => {
expect(approved?.id).toBe("12345");
const accountScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
const channelScoped = await readChannelAllowFromStore("telegram");
const channelScoped = await readLegacyChannelAllowFromStore("telegram");
expect(accountScoped).toContain("12345");
expect(channelScoped).not.toContain("12345");
});
@@ -278,7 +289,7 @@ describe("pairing store", () => {
});
const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
const channelScoped = readChannelAllowFromStoreSync("telegram");
const channelScoped = readLegacyChannelAllowFromStoreSync("telegram");
expect(scoped).toEqual(["1002", "1001"]);
expect(channelScoped).toEqual(["1001"]);
});
@@ -380,7 +391,7 @@ describe("pairing store", () => {
allowFrom: ["1002"],
});
const scoped = await readChannelAllowFromStore("telegram", process.env, "default");
const scoped = await readChannelAllowFromStore("telegram", process.env, DEFAULT_ACCOUNT_ID);
expect(scoped).toEqual(["1002", "1001"]);
});
});

View File

@@ -8,6 +8,7 @@ import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import { withFileLock as withPathLock } from "../infra/file-lock.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "../plugin-sdk/json-store.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -221,7 +222,7 @@ function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: str
function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean {
// Keep backward compatibility for legacy channel-scoped allowFrom only on default account.
// Non-default accounts should remain isolated to avoid cross-account implicit approvals.
return !normalizedAccountId || normalizedAccountId === "default";
return !normalizedAccountId || normalizedAccountId === DEFAULT_ACCOUNT_ID;
}
function normalizeId(value: string | number): string {
@@ -383,25 +384,30 @@ async function updateAllowFromStoreEntry(params: {
);
}
export async function readLegacyChannelAllowFromStore(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): Promise<string[]> {
const filePath = resolveAllowFromPath(channel, env);
return await readAllowFromStateForPath(channel, filePath);
}
export async function readChannelAllowFromStore(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
accountId?: string,
accountId: string,
): Promise<string[]> {
const normalizedAccountId = accountId?.trim().toLowerCase() ?? "";
if (!normalizedAccountId) {
const filePath = resolveAllowFromPath(channel, env);
return await readAllowFromStateForPath(channel, filePath);
}
const normalizedAccountId = accountId.trim().toLowerCase();
const resolvedAccountId = normalizedAccountId || DEFAULT_ACCOUNT_ID;
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
if (!shouldIncludeLegacyAllowFromEntries(resolvedAccountId)) {
return await readNonDefaultAccountAllowFrom({
channel,
env,
accountId: normalizedAccountId,
accountId: resolvedAccountId,
});
}
const scopedPath = resolveAllowFromPath(channel, env, accountId);
const scopedPath = resolveAllowFromPath(channel, env, resolvedAccountId);
const scopedEntries = await readAllowFromStateForPath(channel, scopedPath);
// Backward compatibility: legacy channel-level allowFrom store was unscoped.
// Keep honoring it for default account to prevent re-pair prompts after upgrades.
@@ -410,25 +416,30 @@ export async function readChannelAllowFromStore(
return dedupePreserveOrder([...scopedEntries, ...legacyEntries]);
}
export function readLegacyChannelAllowFromStoreSync(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const filePath = resolveAllowFromPath(channel, env);
return readAllowFromStateForPathSync(channel, filePath);
}
export function readChannelAllowFromStoreSync(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
accountId?: string,
accountId: string,
): string[] {
const normalizedAccountId = accountId?.trim().toLowerCase() ?? "";
if (!normalizedAccountId) {
const filePath = resolveAllowFromPath(channel, env);
return readAllowFromStateForPathSync(channel, filePath);
}
const normalizedAccountId = accountId.trim().toLowerCase();
const resolvedAccountId = normalizedAccountId || DEFAULT_ACCOUNT_ID;
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
if (!shouldIncludeLegacyAllowFromEntries(resolvedAccountId)) {
return readNonDefaultAccountAllowFromSync({
channel,
env,
accountId: normalizedAccountId,
accountId: resolvedAccountId,
});
}
const scopedPath = resolveAllowFromPath(channel, env, accountId);
const scopedPath = resolveAllowFromPath(channel, env, resolvedAccountId);
const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath);
const legacyPath = resolveAllowFromPath(channel, env);
const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath);
@@ -537,7 +548,7 @@ export async function listChannelPairingRequests(
export async function upsertChannelPairingRequest(params: {
channel: PairingChannel;
id: string | number;
accountId?: string;
accountId: string;
meta?: Record<string, string | undefined | null>;
env?: NodeJS.ProcessEnv;
/** Extension channels can pass their adapter directly to bypass registry lookup. */
@@ -552,7 +563,7 @@ export async function upsertChannelPairingRequest(params: {
const now = new Date().toISOString();
const nowMs = Date.now();
const id = normalizeId(params.id);
const normalizedAccountId = params.accountId?.trim();
const normalizedAccountId = normalizePairingAccountId(params.accountId) || DEFAULT_ACCOUNT_ID;
const baseMeta =
params.meta && typeof params.meta === "object"
? Object.fromEntries(
@@ -561,7 +572,7 @@ export async function upsertChannelPairingRequest(params: {
.filter(([_, v]) => Boolean(v)),
)
: undefined;
const meta = normalizedAccountId ? { ...baseMeta, accountId: normalizedAccountId } : baseMeta;
const meta = { ...baseMeta, accountId: normalizedAccountId };
let reqs = await readPairingRequests(filePath);
const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests(
@@ -569,7 +580,7 @@ export async function upsertChannelPairingRequest(params: {
nowMs,
);
reqs = prunedExpired;
const normalizedMatchingAccountId = normalizePairingAccountId(normalizedAccountId);
const normalizedMatchingAccountId = normalizedAccountId;
const existingIdx = reqs.findIndex((r) => {
if (r.id !== id) {
return false;

View File

@@ -317,8 +317,17 @@ function createRuntimeChannel(): PluginRuntime["channel"] {
},
pairing: {
buildPairingReply,
readAllowFromStore: readChannelAllowFromStore,
upsertPairingRequest: upsertChannelPairingRequest,
readAllowFromStore: ({ channel, accountId, env }) =>
readChannelAllowFromStore(channel, env, accountId),
upsertPairingRequest: ({ channel, id, accountId, meta, env, pairingAdapter }) =>
upsertChannelPairingRequest({
channel,
id,
accountId,
meta,
env,
pairingAdapter,
}),
},
media: {
fetchRemoteMedia,

View File

@@ -14,6 +14,14 @@ type ReadChannelAllowFromStore =
typeof import("../../pairing/pairing-store.js").readChannelAllowFromStore;
type UpsertChannelPairingRequest =
typeof import("../../pairing/pairing-store.js").upsertChannelPairingRequest;
type ReadChannelAllowFromStoreForAccount = (params: {
channel: Parameters<ReadChannelAllowFromStore>[0];
accountId: string;
env?: Parameters<ReadChannelAllowFromStore>[1];
}) => ReturnType<ReadChannelAllowFromStore>;
type UpsertChannelPairingRequestForAccount = (
params: Omit<Parameters<UpsertChannelPairingRequest>[0], "accountId"> & { accountId: string },
) => ReturnType<UpsertChannelPairingRequest>;
type FetchRemoteMedia = typeof import("../../media/fetch.js").fetchRemoteMedia;
type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer;
type TextToSpeechTelephony = typeof import("../../tts/tts.js").textToSpeechTelephony;
@@ -235,8 +243,8 @@ export type PluginRuntime = {
};
pairing: {
buildPairingReply: BuildPairingReply;
readAllowFromStore: ReadChannelAllowFromStore;
upsertPairingRequest: UpsertChannelPairingRequest;
readAllowFromStore: ReadChannelAllowFromStoreForAccount;
upsertPairingRequest: UpsertChannelPairingRequestForAccount;
};
media: {
fetchRemoteMedia: FetchRemoteMedia;

View File

@@ -115,6 +115,7 @@ export async function collectChannelSecurityFindings(params: {
const warnDmPolicy = async (input: {
label: string;
provider: ChannelId;
accountId: string;
dmPolicy: string;
allowFrom?: Array<string | number> | null;
policyPath?: string;
@@ -124,6 +125,7 @@ export async function collectChannelSecurityFindings(params: {
const policyPath = input.policyPath ?? `${input.allowFromPath}policy`;
const { hasWildcard, isMultiUserDm } = await resolveDmAllowState({
provider: input.provider,
accountId: input.accountId,
allowFrom: input.allowFrom,
normalizeEntry: input.normalizeEntry,
});
@@ -224,7 +226,11 @@ export async function collectChannelSecurityFindings(params: {
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore(
"discord",
process.env,
accountId,
).catch(() => []);
const discordNameBasedAllowEntries = new Set<string>();
const discordPathPrefix =
orderedAccountIds.length > 1 || hasExplicitAccountPath
@@ -427,7 +433,11 @@ export async function collectChannelSecurityFindings(params: {
: Array.isArray(legacyAllowFromRaw)
? legacyAllowFromRaw
: [];
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore(
"slack",
process.env,
accountId,
).catch(() => []);
const ownerAllowFromConfigured =
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
@@ -462,6 +472,7 @@ export async function collectChannelSecurityFindings(params: {
await warnDmPolicy({
label: plugin.meta.label ?? plugin.id,
provider: plugin.id,
accountId,
dmPolicy: dmPolicy.policy,
allowFrom: dmPolicy.allowFrom,
policyPath: dmPolicy.policyPath,
@@ -513,7 +524,11 @@ export async function collectChannelSecurityFindings(params: {
continue;
}
const storeAllowFrom = await readChannelAllowFromStore("telegram").catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore(
"telegram",
process.env,
accountId,
).catch(() => []);
const storeHasWildcard = storeAllowFrom.some((v) => String(v).trim() === "*");
const invalidTelegramAllowFromEntries = new Set<string>();
for (const entry of storeAllowFrom) {

View File

@@ -13,9 +13,10 @@ describe("security/dm-policy-shared", () => {
it("normalizes config + store allow entries and counts distinct senders", async () => {
const state = await resolveDmAllowState({
provider: "telegram",
accountId: "default",
allowFrom: [" * ", " alice ", "ALICE", "bob"],
normalizeEntry: (value) => value.toLowerCase(),
readStore: async () => [" Bob ", "carol", ""],
readStore: async (_provider, _accountId) => [" Bob ", "carol", ""],
});
expect(state.configAllowFrom).toEqual(["*", "alice", "ALICE", "bob"]);
expect(state.hasWildcard).toBe(true);
@@ -26,8 +27,9 @@ describe("security/dm-policy-shared", () => {
it("handles empty allowlists and store failures", async () => {
const state = await resolveDmAllowState({
provider: "slack",
accountId: "default",
allowFrom: undefined,
readStore: async () => {
readStore: async (_provider, _accountId) => {
throw new Error("offline");
},
});
@@ -41,8 +43,9 @@ describe("security/dm-policy-shared", () => {
let called = false;
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "telegram",
accountId: "default",
dmPolicy: "allowlist",
readStore: async () => {
readStore: async (_provider, _accountId) => {
called = true;
return ["should-not-be-read"];
},
@@ -55,8 +58,9 @@ describe("security/dm-policy-shared", () => {
let called = false;
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "slack",
accountId: "default",
shouldRead: false,
readStore: async () => {
readStore: async (_provider, _accountId) => {
called = true;
return ["should-not-be-read"];
},

View File

@@ -52,14 +52,19 @@ export type DmGroupAccessReasonCode =
export async function readStoreAllowFromForDmPolicy(params: {
provider: ChannelId;
accountId: string;
dmPolicy?: string | null;
shouldRead?: boolean | null;
readStore?: (provider: ChannelId) => Promise<string[]>;
readStore?: (provider: ChannelId, accountId: string) => Promise<string[]>;
}): Promise<string[]> {
if (params.shouldRead === false || params.dmPolicy === "allowlist") {
return [];
}
return await (params.readStore ?? readChannelAllowFromStore)(params.provider).catch(() => []);
const readStore =
params.readStore ??
((provider: ChannelId, accountId: string) =>
readChannelAllowFromStore(provider, process.env, accountId));
return await readStore(params.provider, params.accountId).catch(() => []);
}
export function resolveDmGroupAccessDecision(params: {
@@ -258,9 +263,10 @@ export function resolveDmGroupAccessWithCommandGate(params: {
export async function resolveDmAllowState(params: {
provider: ChannelId;
accountId: string;
allowFrom?: Array<string | number> | null;
normalizeEntry?: (raw: string) => string;
readStore?: (provider: ChannelId) => Promise<string[]>;
readStore?: (provider: ChannelId, accountId: string) => Promise<string[]>;
}): Promise<{
configAllowFrom: string[];
hasWildcard: boolean;
@@ -273,6 +279,7 @@ export async function resolveDmAllowState(params: {
const hasWildcard = configAllowFrom.includes("*");
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: params.provider,
accountId: params.accountId,
readStore: params.readStore,
});
const normalizeEntry = params.normalizeEntry ?? ((value: string) => value);

View File

@@ -7,7 +7,7 @@ import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { runExec } from "../process/exec.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js";
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
export type SecurityFixChmodAction = {
@@ -412,7 +412,11 @@ export async function fixSecurityFootguns(opts?: {
const fixed = applyConfigFixes({ cfg: snap.config, env });
changes = fixed.changes;
const whatsappStoreAllowFrom = await readChannelAllowFromStore("whatsapp", env).catch(() => []);
const whatsappStoreAllowFrom = await readChannelAllowFromStore(
"whatsapp",
env,
DEFAULT_ACCOUNT_ID,
).catch(() => []);
if (whatsappStoreAllowFrom.length > 0) {
setWhatsAppGroupAllowFromFromStore({
cfg: fixed.cfg,

View File

@@ -31,10 +31,7 @@ import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import {
DM_GROUP_ACCESS_REASON,
@@ -459,8 +456,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const senderDisplay = formatSignalSenderDisplay(sender);
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "signal",
accountId: deps.accountId,
dmPolicy: deps.dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
});
const resolveAccessDecision = (isGroup: boolean) =>
resolveDmGroupAccessWithLists({
@@ -517,6 +514,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const { code, created } = await upsertChannelPairingRequest({
channel: "signal",
id: senderId,
accountId: deps.accountId,
meta: { name: envelope.sourceName ?? undefined },
});
if (created) {

View File

@@ -1,4 +1,3 @@
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js";
import {
allowListMatches,
@@ -17,8 +16,8 @@ export async function resolveSlackEffectiveAllowFrom(
const storeAllowFrom = includePairingStore
? await readStoreAllowFromForDmPolicy({
provider: "slack",
accountId: ctx.accountId,
dmPolicy: ctx.dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
})
: [];
const allowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);

View File

@@ -155,6 +155,7 @@ export async function prepareSlackMessage(params: {
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
id: directUserId,
accountId: account.accountId,
meta: { name: senderName },
});
if (created) {

View File

@@ -6,10 +6,7 @@ import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js";
import { chunkItems } from "../../utils/chunk-items.js";
import type { ResolvedSlackAccount } from "../accounts.js";
@@ -339,8 +336,8 @@ export async function registerSlackMonitorSlashCommands(params: {
const storeAllowFrom = isDirectMessage
? await readStoreAllowFromForDmPolicy({
provider: "slack",
accountId: ctx.accountId,
dmPolicy: ctx.dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
})
: [];
const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);
@@ -373,6 +370,7 @@ export async function registerSlackMonitorSlashCommands(params: {
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
id: command.user_id,
accountId: ctx.accountId,
meta: { name: senderName },
});
if (created) {

View File

@@ -3,6 +3,7 @@ import { formatLocationText, type NormalizedLocation } from "../../channels/loca
import { resolveTelegramPreviewStreamMode } from "../../config/discord-preview-streaming.js";
import type { TelegramGroupConfig, TelegramTopicConfig } from "../../config/types.js";
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
import type { TelegramStreamMode } from "./types.js";
@@ -32,15 +33,14 @@ export async function resolveTelegramGroupAllowFromContext(params: {
effectiveGroupAllow: NormalizedAllowFrom;
hasGroupAllowOverride: boolean;
}> {
const accountId = normalizeAccountId(params.accountId);
const resolvedThreadId = resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId,
});
const storeAllowFrom = await readChannelAllowFromStore(
"telegram",
process.env,
params.accountId,
).catch(() => []);
const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch(
() => [],
);
const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig(
params.chatId,
resolvedThreadId,

View File

@@ -25,7 +25,6 @@ import {
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import type { getChildLogger } from "../../../logging.js";
import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js";
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
import {
readStoreAllowFromForDmPolicy,
@@ -80,9 +79,8 @@ async function resolveWhatsAppCommandAuthorized(params: {
? []
: await readStoreAllowFromForDmPolicy({
provider: "whatsapp",
accountId: params.msg.accountId,
dmPolicy,
readStore: (provider) =>
readChannelAllowFromStore(provider, process.env, params.msg.accountId),
});
const dmAllowFrom =
configuredAllowFrom.length > 0

View File

@@ -6,10 +6,7 @@ import {
} from "../../config/runtime-group-policy.js";
import { logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
@@ -66,8 +63,8 @@ export async function checkInboundAccessControl(params: {
const configuredAllowFrom = account.allowFrom ?? [];
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "whatsapp",
accountId: account.accountId,
dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider, process.env, account.accountId),
});
// Without user config, default to self-only DM access so the owner can talk to themselves.
const defaultAllowFrom =