mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:34:49 +00:00
Restrict chat sender allowlist matching [AI] (#80898)
* fix: restrict chat sender allowlist matching * fix: restrict chat sender allowlist matching * addressing codex review * fix: complete sender allowlist root cause * addressing codex review * addressing codex review * fix: complete root-cause handling * addressing review-skill * addressing codex review * addressing review-skill * addressing codex review * addressing codex review * fix: complete chat sender allowlist handling * addressing codex review * fix: complete root-cause handling * addressing codex review * fix: complete root-cause handling * addressing codex review * fix: cover sender matcher conversation target opt-in * addressing review-skill * addressing codex review * fix: require explicit chat target sender matching * addressing review-skill * addressing codex review * addressing codex review * fix: require explicit chat target sender matching * addressing codex review * fix: require explicit chat target sender matching * addressing codex review * addressing codex review * fix: require explicit chat target sender matching * docs: add changelog entry for PR merge
This commit is contained in:
committed by
GitHub
parent
e3a4280788
commit
b7e0decf0c
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.
|
||||
- Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.
|
||||
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
|
||||
- fix: harden safe-bin argument validation [AI]. (#80999) Thanks @pgondhi987.
|
||||
|
||||
@@ -217,7 +217,7 @@ If SIP-disabled isn't acceptable for your threat model:
|
||||
|
||||
Allowlist field: `channels.imessage.allowFrom`.
|
||||
|
||||
Allowlist entries can be handles, static sender access groups (`accessGroup:<name>`), or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
|
||||
Allowlist entries must identify senders: handles or static sender access groups (`accessGroup:<name>`). Use `channels.imessage.groupAllowFrom` for chat targets such as `chat_id:*`, `chat_guid:*`, or `chat_identifier:*`; use `channels.imessage.groups` for numeric `chat_id` registry keys.
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -232,7 +232,7 @@ If SIP-disabled isn't acceptable for your threat model:
|
||||
|
||||
`groupAllowFrom` entries can also reference static sender access groups (`accessGroup:<name>`).
|
||||
|
||||
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available.
|
||||
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks use `allowFrom`; set `groupAllowFrom` when DM and group admission should differ.
|
||||
Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -59,6 +59,7 @@ async function resolveDispatchDecision(params: {
|
||||
groupHistories?: Parameters<typeof resolveIMessageInboundDecision>[0]["groupHistories"];
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
allowLegacyConversationAllowFromForGroup?: boolean;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||
}) {
|
||||
@@ -72,6 +73,7 @@ async function resolveDispatchDecision(params: {
|
||||
bodyText: params.message.text ?? "",
|
||||
allowFrom: params.allowFrom ?? ["*"],
|
||||
groupAllowFrom: params.groupAllowFrom ?? [],
|
||||
allowLegacyConversationAllowFromForGroup: params.allowLegacyConversationAllowFromForGroup,
|
||||
groupPolicy: params.groupPolicy ?? "open",
|
||||
dmPolicy: params.dmPolicy ?? "open",
|
||||
storeAllowFrom: [],
|
||||
@@ -253,6 +255,90 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(ctxPayload.Body ?? "").not.toContain("[Replying to");
|
||||
});
|
||||
|
||||
it("keeps group reply context when the group allowlist matches the chat target", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
cfg.channels.imessage.contextVisibility = "allowlist";
|
||||
|
||||
const message: IMessagePayload = {
|
||||
id: 8,
|
||||
chat_id: 55,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "@openclaw replying now",
|
||||
is_group: true,
|
||||
reply_to_id: 9001,
|
||||
reply_to_text: "quoted context",
|
||||
reply_to_sender: "+15559998888",
|
||||
};
|
||||
const { decision, groupHistories } = await resolveDispatchDecision({
|
||||
cfg,
|
||||
message,
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["chat_id:55"],
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
const { ctxPayload } = buildIMessageInboundContext({
|
||||
cfg,
|
||||
decision,
|
||||
message,
|
||||
historyLimit: 0,
|
||||
groupHistories,
|
||||
});
|
||||
|
||||
expect(ctxPayload.ReplyToId).toBe("9001");
|
||||
expect(ctxPayload.ReplyToBody).toBe("quoted context");
|
||||
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
|
||||
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
|
||||
});
|
||||
|
||||
it("keeps group reply context when the group allowlist matches an access group", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
cfg.channels.imessage.contextVisibility = "allowlist";
|
||||
cfg.accessGroups = {
|
||||
oncall: {
|
||||
type: "message.senders",
|
||||
members: { imessage: ["+15559998888"] },
|
||||
},
|
||||
};
|
||||
|
||||
const message: IMessagePayload = {
|
||||
id: 9,
|
||||
chat_id: 56,
|
||||
sender: "+15559998888",
|
||||
is_from_me: false,
|
||||
text: "@openclaw replying now",
|
||||
is_group: true,
|
||||
reply_to_id: 9002,
|
||||
reply_to_text: "own quoted context",
|
||||
reply_to_sender: "+15559998888",
|
||||
};
|
||||
const { decision, groupHistories } = await resolveDispatchDecision({
|
||||
cfg,
|
||||
message,
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["accessGroup:oncall"],
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
const { ctxPayload } = buildIMessageInboundContext({
|
||||
cfg,
|
||||
decision,
|
||||
message,
|
||||
historyLimit: 0,
|
||||
groupHistories,
|
||||
});
|
||||
|
||||
expect(ctxPayload.ReplyToId).toBe("9002");
|
||||
expect(ctxPayload.ReplyToBody).toBe("own quoted context");
|
||||
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
|
||||
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9002]");
|
||||
});
|
||||
|
||||
it("keeps group reply context in allowlist_quote mode", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
@@ -432,6 +518,163 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(allowed.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("uses legacy conversation allowFrom entries for group admission", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
|
||||
const { decision } = await resolveDispatchDecision({
|
||||
cfg,
|
||||
message: {
|
||||
id: 35,
|
||||
chat_id: 101,
|
||||
sender: "+15550003333",
|
||||
is_from_me: false,
|
||||
text: "@openclaw ok",
|
||||
is_group: true,
|
||||
},
|
||||
allowFrom: ["chat_id:101"],
|
||||
groupAllowFrom: [],
|
||||
allowLegacyConversationAllowFromForGroup: true,
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("does not use legacy conversation allowFrom entries when groupAllowFrom is explicitly empty", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 38,
|
||||
chat_id: 101,
|
||||
sender: "+15550003333",
|
||||
is_from_me: false,
|
||||
text: "@openclaw ok",
|
||||
is_group: true,
|
||||
},
|
||||
opts: {},
|
||||
messageText: "@openclaw ok",
|
||||
bodyText: "@openclaw ok",
|
||||
allowFrom: ["chat_id:101"],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
dmPolicy: "pairing",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "drop",
|
||||
reason: "groupPolicy allowlist (empty groupAllowFrom)",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge legacy conversation allowFrom entries when groupAllowFrom is configured", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 37,
|
||||
chat_id: 101,
|
||||
sender: "+15550003333",
|
||||
is_from_me: false,
|
||||
text: "@openclaw ok",
|
||||
is_group: true,
|
||||
},
|
||||
opts: {},
|
||||
messageText: "@openclaw ok",
|
||||
bodyText: "@openclaw ok",
|
||||
allowFrom: ["chat_id:101"],
|
||||
groupAllowFrom: ["+15550004444"],
|
||||
groupPolicy: "allowlist",
|
||||
dmPolicy: "pairing",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "not in groupAllowFrom" });
|
||||
});
|
||||
|
||||
it("does not authorize group control commands from conversation allowlist entries", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 34,
|
||||
chat_id: 101,
|
||||
sender: "+15550003333",
|
||||
is_from_me: false,
|
||||
text: "/status",
|
||||
is_group: true,
|
||||
},
|
||||
opts: {},
|
||||
messageText: "/status",
|
||||
bodyText: "/status",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: ["chat_id:101"],
|
||||
groupPolicy: "allowlist",
|
||||
dmPolicy: "pairing",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "control command (unauthorized)" });
|
||||
});
|
||||
|
||||
it("does not authorize group control commands from legacy conversation allowFrom entries", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 36,
|
||||
chat_id: 101,
|
||||
sender: "+15550003333",
|
||||
is_from_me: false,
|
||||
text: "/status",
|
||||
is_group: true,
|
||||
},
|
||||
opts: {},
|
||||
messageText: "/status",
|
||||
bodyText: "/status",
|
||||
allowFrom: ["chat_id:101"],
|
||||
groupAllowFrom: [],
|
||||
allowLegacyConversationAllowFromForGroup: true,
|
||||
groupPolicy: "allowlist",
|
||||
dmPolicy: "pairing",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "control command (unauthorized)" });
|
||||
});
|
||||
|
||||
it("blocks group messages when groupPolicy is disabled", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
type ChannelIngressIdentityDescriptor,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
} from "../monitor-reply-cache.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
isAllowedIMessageSender,
|
||||
isAllowedIMessageReplyContextSender,
|
||||
normalizeIMessageHandle,
|
||||
parseIMessageAllowTarget,
|
||||
} from "../targets.js";
|
||||
@@ -158,11 +159,52 @@ export function resolveIMessageReactionContext(
|
||||
|
||||
const normalizeNonEmpty = (value: string) => value.trim() || null;
|
||||
|
||||
const imessageConversationIdentityKinds = new Set([
|
||||
"plugin:imessage-chat-id",
|
||||
"plugin:imessage-chat-guid",
|
||||
"plugin:imessage-chat-identifier",
|
||||
]);
|
||||
|
||||
const matchIMessageIngressEntry: NonNullable<ChannelIngressIdentityDescriptor["matchEntry"]> = ({
|
||||
entry,
|
||||
context,
|
||||
}) => {
|
||||
if (imessageConversationIdentityKinds.has(entry.kind) && context !== "group") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function isIMessageConversationAllowTarget(entry: string): boolean {
|
||||
const parsed = parseIMessageAllowTarget(entry);
|
||||
return (
|
||||
parsed.kind === "chat_id" || parsed.kind === "chat_guid" || parsed.kind === "chat_identifier"
|
||||
);
|
||||
}
|
||||
|
||||
function mergeIMessageGroupAllowFromWithLegacyChatTargets(params: {
|
||||
groupAllowFrom: string[];
|
||||
allowFrom: string[];
|
||||
allowLegacyConversationTargets?: boolean;
|
||||
}): string[] {
|
||||
if (params.groupAllowFrom.length > 0 || !params.allowLegacyConversationTargets) {
|
||||
return params.groupAllowFrom;
|
||||
}
|
||||
const legacyChatTargets = params.allowFrom.filter((entry) =>
|
||||
isIMessageConversationAllowTarget(entry),
|
||||
);
|
||||
if (legacyChatTargets.length === 0) {
|
||||
return params.groupAllowFrom;
|
||||
}
|
||||
return Array.from(new Set([...params.groupAllowFrom, ...legacyChatTargets]));
|
||||
}
|
||||
|
||||
const imessageIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "imessage-sender",
|
||||
normalizeEntry: normalizeIMessageHandleEntry,
|
||||
normalizeSubject: normalizeIMessageHandle,
|
||||
sensitivity: "pii",
|
||||
matchEntry: matchIMessageIngressEntry,
|
||||
aliases: (
|
||||
[
|
||||
["imessage-chat-id", "plugin:imessage-chat-id", normalizeIMessageChatIdEntry],
|
||||
@@ -392,6 +434,7 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
bodyText: string;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
allowLegacyConversationAllowFromForGroup?: boolean;
|
||||
groupPolicy: string;
|
||||
dmPolicy: string;
|
||||
storeAllowFrom: string[];
|
||||
@@ -425,12 +468,18 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
const reactionContext = resolveIMessageReactionContext(params.message, bodyText || messageText);
|
||||
|
||||
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
|
||||
const groupAllowFromWithLegacyChatTargets = mergeIMessageGroupAllowFromWithLegacyChatTargets({
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
allowFrom: params.allowFrom,
|
||||
allowLegacyConversationTargets: params.allowLegacyConversationAllowFromForGroup,
|
||||
});
|
||||
const groupListPolicy = groupIdCandidate
|
||||
? resolveChannelGroupPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
groupId: groupIdCandidate,
|
||||
hasGroupAllowFrom: groupAllowFromWithLegacyChatTargets.length > 0,
|
||||
})
|
||||
: {
|
||||
allowlistEnabled: false,
|
||||
@@ -514,6 +563,9 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
|
||||
const groupId = isGroup ? groupIdCandidate : undefined;
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg);
|
||||
const groupAllowFromForAccess = isGroup
|
||||
? groupAllowFromWithLegacyChatTargets
|
||||
: params.groupAllowFrom;
|
||||
const accessDecision = await createChannelIngressResolver({
|
||||
channelId: "imessage",
|
||||
accountId: params.accountId,
|
||||
@@ -539,7 +591,7 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
groupPolicy: normalizeGroupPolicy(params.groupPolicy),
|
||||
policy: { groupAllowFromFallbackToAllowFrom: false },
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
groupAllowFrom: groupAllowFromForAccess,
|
||||
command: {
|
||||
allowTextCommands: isGroup,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
@@ -718,12 +770,15 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const replyContextAllowFrom = Array.from(
|
||||
new Set([...groupAllowFromForAccess, ...effectiveGroupAllowFrom]),
|
||||
);
|
||||
const replySenderAllowed =
|
||||
!isGroup || effectiveGroupAllowFrom.length === 0
|
||||
!isGroup || replyContextAllowFrom.length === 0
|
||||
? true
|
||||
: replyContext?.sender
|
||||
? isAllowedIMessageSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
? isAllowedIMessageReplyContextSender({
|
||||
allowFrom: replyContextAllowFrom,
|
||||
sender: replyContext.sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
|
||||
@@ -172,11 +172,12 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
const loopRateLimiter = createLoopRateLimiter();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
||||
const configuredGroupAllowFrom = opts.groupAllowFrom ?? imessageCfg.groupAllowFrom;
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
imessageCfg.groupAllowFrom ??
|
||||
configuredGroupAllowFrom ??
|
||||
(imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []),
|
||||
);
|
||||
const allowLegacyConversationAllowFromForGroup = configuredGroupAllowFrom == null;
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.imessage !== undefined,
|
||||
@@ -374,6 +375,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
bodyText,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
allowLegacyConversationAllowFromForGroup,
|
||||
groupPolicy,
|
||||
dmPolicy,
|
||||
storeAllowFrom,
|
||||
|
||||
@@ -24,27 +24,39 @@ import { normalizeIMessageHandle } from "./targets.js";
|
||||
|
||||
const channel = "imessage" as const;
|
||||
|
||||
const CHAT_TARGET_ALLOWFROM_PREFIXES = [
|
||||
"chat_id:",
|
||||
"chatid:",
|
||||
"chat:",
|
||||
"chat_guid:",
|
||||
"chatguid:",
|
||||
"guid:",
|
||||
"chat_identifier:",
|
||||
"chatidentifier:",
|
||||
"chatident:",
|
||||
];
|
||||
const SERVICE_ALLOWFROM_PREFIXES = ["imessage:", "sms:", "auto:"];
|
||||
|
||||
function normalizeAllowFromEntryForPrefixCheck(entry: string): string {
|
||||
let lower = normalizeLowercaseStringOrEmpty(entry);
|
||||
let stripped = true;
|
||||
while (stripped) {
|
||||
stripped = false;
|
||||
for (const prefix of SERVICE_ALLOWFROM_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
lower = lower.slice(prefix.length).trim();
|
||||
stripped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } {
|
||||
return parseSetupEntriesAllowingWildcard(raw, (entry) => {
|
||||
const lower = normalizeLowercaseStringOrEmpty(entry);
|
||||
if (lower.startsWith("chat_id:")) {
|
||||
const id = entry.slice("chat_id:".length).trim();
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return { error: `Invalid chat_id: ${entry}` };
|
||||
}
|
||||
return { value: entry };
|
||||
}
|
||||
if (lower.startsWith("chat_guid:")) {
|
||||
if (!entry.slice("chat_guid:".length).trim()) {
|
||||
return { error: "Invalid chat_guid entry" };
|
||||
}
|
||||
return { value: entry };
|
||||
}
|
||||
if (lower.startsWith("chat_identifier:")) {
|
||||
if (!entry.slice("chat_identifier:".length).trim()) {
|
||||
return { error: "Invalid chat_identifier entry" };
|
||||
}
|
||||
return { value: entry };
|
||||
const lower = normalizeAllowFromEntryForPrefixCheck(entry);
|
||||
if (CHAT_TARGET_ALLOWFROM_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
||||
return { error: `iMessage allowFrom entries must be sender handles: ${entry}` };
|
||||
}
|
||||
if (!normalizeIMessageHandle(entry)) {
|
||||
return { error: `Invalid handle: ${entry}` };
|
||||
@@ -79,17 +91,15 @@ async function promptIMessageAllowFrom(params: {
|
||||
prompter: params.prompter,
|
||||
noteTitle: "iMessage allowlist",
|
||||
noteLines: [
|
||||
"Allowlist iMessage DMs by handle or chat target.",
|
||||
"Allowlist iMessage DMs by sender handle.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
"- user@example.com",
|
||||
"- chat_id:123",
|
||||
"- chat_guid:... or chat_identifier:...",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
|
||||
],
|
||||
message: "iMessage allowFrom (handle or chat_id)",
|
||||
placeholder: "+15555550123, user@example.com, chat_id:123",
|
||||
message: "iMessage allowFrom (sender handle)",
|
||||
placeholder: "+15555550123, user@example.com",
|
||||
parseEntries: parseIMessageAllowFromEntries,
|
||||
getExistingAllowFrom: ({ cfg, accountId }) =>
|
||||
resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { parseIMessageAllowFromEntries } from "./setup-surface.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
inferIMessageTargetChatType,
|
||||
isAllowedIMessageReplyContextSender,
|
||||
isAllowedIMessageSender,
|
||||
looksLikeIMessageExplicitTargetId,
|
||||
normalizeIMessageHandle,
|
||||
@@ -56,13 +57,46 @@ describe("imessage targets", () => {
|
||||
expect(normalizeIMessageHandle("CHATIDENT:foo")).toBe("chat_identifier:foo");
|
||||
});
|
||||
|
||||
it("checks allowFrom against chat_id", () => {
|
||||
it("does not check allowFrom against conversation targets", () => {
|
||||
const ok = isAllowedIMessageSender({
|
||||
allowFrom: ["chat_id:9"],
|
||||
sender: "+1555",
|
||||
chatId: 9,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(ok).toBe(false);
|
||||
|
||||
expect(
|
||||
isAllowedIMessageSender({
|
||||
allowFrom: ["imessage:chat_id:9"],
|
||||
sender: "+1555",
|
||||
chatId: 9,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isAllowedIMessageSender({
|
||||
allowFrom: ["chat_guid:team-thread"],
|
||||
sender: "+1555",
|
||||
chatGuid: "team-thread",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isAllowedIMessageSender({
|
||||
allowFrom: ["chat_identifier:team"],
|
||||
sender: "+1555",
|
||||
chatIdentifier: "team",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isAllowedIMessageSender({
|
||||
allowFrom: ["chat_id:9"],
|
||||
sender: "+1555",
|
||||
chatId: 9,
|
||||
allowConversationTargets: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("checks allowFrom against handle", () => {
|
||||
@@ -73,6 +107,32 @@ describe("imessage targets", () => {
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("checks reply context allowFrom against conversation targets", () => {
|
||||
expect(
|
||||
isAllowedIMessageReplyContextSender({
|
||||
allowFrom: ["chat_id:9"],
|
||||
sender: "+1555",
|
||||
chatId: 9,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isAllowedIMessageReplyContextSender({
|
||||
allowFrom: ["imessage:chat_guid:team-thread"],
|
||||
sender: "+1555",
|
||||
chatGuid: "team-thread",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isAllowedIMessageReplyContextSender({
|
||||
allowFrom: ["chat_identifier:team"],
|
||||
sender: "+1555",
|
||||
chatIdentifier: "team",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when allowFrom is empty", () => {
|
||||
const ok = isAllowedIMessageSender({
|
||||
allowFrom: [],
|
||||
@@ -130,23 +190,28 @@ describe("imessage group policy", () => {
|
||||
});
|
||||
|
||||
describe("parseIMessageAllowFromEntries", () => {
|
||||
it("parses handles and chat targets", () => {
|
||||
expect(parseIMessageAllowFromEntries("+15555550123, chat_id:123, chat_guid:abc")).toEqual({
|
||||
entries: ["+15555550123", "chat_id:123", "chat_guid:abc"],
|
||||
it("parses handles", () => {
|
||||
expect(parseIMessageAllowFromEntries("+15555550123, user@example.com")).toEqual({
|
||||
entries: ["+15555550123", "user@example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid chat_id", () => {
|
||||
expect(parseIMessageAllowFromEntries("chat_id:abc")).toEqual({
|
||||
it("returns validation errors for chat target entries", () => {
|
||||
expect(parseIMessageAllowFromEntries("chat_id:123")).toEqual({
|
||||
entries: [],
|
||||
error: "Invalid chat_id: chat_id:abc",
|
||||
error: "iMessage allowFrom entries must be sender handles: chat_id:123",
|
||||
});
|
||||
|
||||
expect(parseIMessageAllowFromEntries("imessage:chat_id:123")).toEqual({
|
||||
entries: [],
|
||||
error: "iMessage allowFrom entries must be sender handles: imessage:chat_id:123",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid chat_identifier entries", () => {
|
||||
it("returns validation errors for chat_identifier entries", () => {
|
||||
expect(parseIMessageAllowFromEntries("chat_identifier:")).toEqual({
|
||||
entries: [],
|
||||
error: "Invalid chat_identifier entry",
|
||||
error: "iMessage allowFrom entries must be sender handles: chat_identifier:",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
createAllowedChatSenderMatcher,
|
||||
type ChatSenderAllowParams,
|
||||
createAllowedChatSenderMatcher,
|
||||
type ParsedChatTarget,
|
||||
parseChatTargetPrefixesOrThrow,
|
||||
resolveServicePrefixedChatTarget,
|
||||
@@ -162,10 +162,21 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
||||
const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({
|
||||
normalizeSender: normalizeIMessageHandle,
|
||||
parseAllowTarget: parseIMessageAllowTarget,
|
||||
allowConversationTargets: false,
|
||||
});
|
||||
|
||||
export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean {
|
||||
return isAllowedIMessageSenderMatcher(params);
|
||||
return isAllowedIMessageSenderMatcher({ ...params, allowConversationTargets: false });
|
||||
}
|
||||
|
||||
const isAllowedIMessageReplyContextSenderMatcher = createAllowedChatSenderMatcher({
|
||||
normalizeSender: normalizeIMessageHandle,
|
||||
parseAllowTarget: parseIMessageAllowTarget,
|
||||
allowConversationTargets: true,
|
||||
});
|
||||
|
||||
export function isAllowedIMessageReplyContextSender(params: ChatSenderAllowParams): boolean {
|
||||
return isAllowedIMessageReplyContextSenderMatcher(params);
|
||||
}
|
||||
|
||||
export function formatIMessageChatTarget(chatId?: number | null): string {
|
||||
|
||||
120
src/channels/plugins/chat-target-prefixes.test.ts
Normal file
120
src/channels/plugins/chat-target-prefixes.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createAllowedChatSenderMatcher,
|
||||
isAllowedParsedChatSender,
|
||||
type ParsedChatAllowTarget,
|
||||
} from "./chat-target-prefixes.js";
|
||||
|
||||
function normalizeSender(sender: string): string {
|
||||
return sender.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseAllowTarget(entry: string): ParsedChatAllowTarget {
|
||||
const trimmed = entry.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("chat_id:")) {
|
||||
return { kind: "chat_id", chatId: Number.parseInt(trimmed.slice("chat_id:".length), 10) };
|
||||
}
|
||||
if (lower.startsWith("chat_guid:")) {
|
||||
return { kind: "chat_guid", chatGuid: trimmed.slice("chat_guid:".length).trim() };
|
||||
}
|
||||
if (lower.startsWith("chat_identifier:")) {
|
||||
return {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: trimmed.slice("chat_identifier:".length).trim(),
|
||||
};
|
||||
}
|
||||
return { kind: "handle", handle: normalizeSender(trimmed) };
|
||||
}
|
||||
|
||||
describe("isAllowedParsedChatSender", () => {
|
||||
it("matches wildcard and normalized sender handles", () => {
|
||||
expect(
|
||||
isAllowedParsedChatSender({
|
||||
allowFrom: ["owner@example.com"],
|
||||
sender: "Owner@Example.com",
|
||||
normalizeSender,
|
||||
parseAllowTarget,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isAllowedParsedChatSender({
|
||||
allowFrom: ["*"],
|
||||
sender: "other@example.com",
|
||||
normalizeSender,
|
||||
parseAllowTarget,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match conversation targets unless explicitly enabled", () => {
|
||||
for (const entry of ["chat_id:123", "chat_guid:thread-123", "chat_identifier:team"]) {
|
||||
expect(
|
||||
isAllowedParsedChatSender({
|
||||
allowFrom: [entry],
|
||||
sender: "other@example.com",
|
||||
chatId: 123,
|
||||
chatGuid: "thread-123",
|
||||
chatIdentifier: "team",
|
||||
normalizeSender,
|
||||
parseAllowTarget,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isAllowedParsedChatSender({
|
||||
allowFrom: [entry],
|
||||
sender: "other@example.com",
|
||||
chatId: 123,
|
||||
chatGuid: "thread-123",
|
||||
chatIdentifier: "team",
|
||||
allowConversationTargets: true,
|
||||
normalizeSender,
|
||||
parseAllowTarget,
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAllowedChatSenderMatcher", () => {
|
||||
it("keeps conversation targets disabled unless the matcher opts in", () => {
|
||||
const matcher = createAllowedChatSenderMatcher({
|
||||
normalizeSender,
|
||||
parseAllowTarget,
|
||||
});
|
||||
|
||||
for (const entry of ["chat_id:123", "chat_guid:thread-123", "chat_identifier:team"]) {
|
||||
expect(
|
||||
matcher({
|
||||
allowFrom: [entry],
|
||||
sender: "other@example.com",
|
||||
chatId: 123,
|
||||
chatGuid: "thread-123",
|
||||
chatIdentifier: "team",
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches conversation targets when the matcher explicitly opts in", () => {
|
||||
const matcher = createAllowedChatSenderMatcher({
|
||||
normalizeSender,
|
||||
parseAllowTarget,
|
||||
allowConversationTargets: true,
|
||||
});
|
||||
|
||||
for (const entry of ["chat_id:123", "chat_guid:thread-123", "chat_identifier:team"]) {
|
||||
expect(
|
||||
matcher({
|
||||
allowFrom: [entry],
|
||||
sender: "other@example.com",
|
||||
chatId: 123,
|
||||
chatGuid: "thread-123",
|
||||
chatIdentifier: "team",
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export type ChatSenderAllowParams = {
|
||||
chatId?: number | null;
|
||||
chatGuid?: string | null;
|
||||
chatIdentifier?: string | null;
|
||||
allowConversationTargets?: boolean | null;
|
||||
};
|
||||
|
||||
export function isAllowedParsedChatSender(params: {
|
||||
@@ -35,6 +36,7 @@ export function isAllowedParsedChatSender(params: {
|
||||
chatId?: number | null;
|
||||
chatGuid?: string | null;
|
||||
chatIdentifier?: string | null;
|
||||
allowConversationTargets?: boolean | null;
|
||||
normalizeSender: (sender: string) => string;
|
||||
parseAllowTarget: (entry: string) => ParsedChatAllowTarget;
|
||||
}): boolean {
|
||||
@@ -47,9 +49,12 @@ export function isAllowedParsedChatSender(params: {
|
||||
}
|
||||
|
||||
const senderNormalized = params.normalizeSender(params.sender);
|
||||
const chatId = params.chatId ?? undefined;
|
||||
const chatGuid = normalizeOptionalString(params.chatGuid);
|
||||
const chatIdentifier = normalizeOptionalString(params.chatIdentifier);
|
||||
const allowConversationTargets = params.allowConversationTargets === true;
|
||||
const chatId = allowConversationTargets ? (params.chatId ?? undefined) : undefined;
|
||||
const chatGuid = allowConversationTargets ? normalizeOptionalString(params.chatGuid) : undefined;
|
||||
const chatIdentifier = allowConversationTargets
|
||||
? normalizeOptionalString(params.chatIdentifier)
|
||||
: undefined;
|
||||
|
||||
for (const entry of allowFrom) {
|
||||
if (!entry) {
|
||||
@@ -227,6 +232,7 @@ export function resolveServicePrefixedOrChatAllowTarget<
|
||||
export function createAllowedChatSenderMatcher(params: {
|
||||
normalizeSender: (sender: string) => string;
|
||||
parseAllowTarget: (entry: string) => ParsedChatAllowTarget;
|
||||
allowConversationTargets?: boolean;
|
||||
}): (input: ChatSenderAllowParams) => boolean {
|
||||
return (input) =>
|
||||
isAllowedParsedChatSender({
|
||||
@@ -235,6 +241,8 @@ export function createAllowedChatSenderMatcher(params: {
|
||||
chatId: input.chatId,
|
||||
chatGuid: input.chatGuid,
|
||||
chatIdentifier: input.chatIdentifier,
|
||||
allowConversationTargets:
|
||||
input.allowConversationTargets ?? params.allowConversationTargets ?? false,
|
||||
normalizeSender: params.normalizeSender,
|
||||
parseAllowTarget: params.parseAllowTarget,
|
||||
});
|
||||
|
||||
@@ -64,17 +64,67 @@ describe("isAllowedParsedChatSender", () => {
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "matches chat IDs when provided",
|
||||
input: {
|
||||
allowFrom: ["chat_id:42"],
|
||||
sender: "+15551234567",
|
||||
chatId: 42,
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
expected: true,
|
||||
name: "does not match conversation targets by default",
|
||||
input: [
|
||||
{
|
||||
allowFrom: ["chat_id:42"],
|
||||
sender: "+15551234567",
|
||||
chatId: 42,
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
{
|
||||
allowFrom: ["chat_guid:thread-42"],
|
||||
sender: "+15551234567",
|
||||
chatGuid: "thread-42",
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
{
|
||||
allowFrom: ["chat_identifier:team"],
|
||||
sender: "+15551234567",
|
||||
chatIdentifier: "team",
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
],
|
||||
expected: [false, false, false],
|
||||
},
|
||||
{
|
||||
name: "matches conversation targets when they are enabled",
|
||||
input: [
|
||||
{
|
||||
allowFrom: ["chat_id:42"],
|
||||
sender: "+15551234567",
|
||||
chatId: 42,
|
||||
allowConversationTargets: true,
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
{
|
||||
allowFrom: ["chat_guid:thread-42"],
|
||||
sender: "+15551234567",
|
||||
chatGuid: "thread-42",
|
||||
allowConversationTargets: true,
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
{
|
||||
allowFrom: ["chat_identifier:team"],
|
||||
sender: "+15551234567",
|
||||
chatIdentifier: "team",
|
||||
allowConversationTargets: true,
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
],
|
||||
expected: [true, true, true],
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
if (Array.isArray(input)) {
|
||||
expect(input.map((entry) => isAllowedParsedChatSender(entry))).toEqual(expected);
|
||||
return;
|
||||
}
|
||||
expect(isAllowedParsedChatSender(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,13 +81,14 @@ type ParsedChatAllowTarget =
|
||||
| { kind: "chat_identifier"; chatIdentifier: string }
|
||||
| { kind: "handle"; handle: string };
|
||||
|
||||
/** Match chat-aware allowlist entries against sender, chat id, guid, or identifier fields. */
|
||||
/** Match allowlist entries against senders, with conversation targets requiring explicit opt-in. */
|
||||
export function isAllowedParsedChatSender(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
sender: string;
|
||||
chatId?: number | null;
|
||||
chatGuid?: string | null;
|
||||
chatIdentifier?: string | null;
|
||||
allowConversationTargets?: boolean | null;
|
||||
normalizeSender: (sender: string) => string;
|
||||
parseAllowTarget: (entry: string) => ParsedChatAllowTarget;
|
||||
}): boolean {
|
||||
|
||||
Reference in New Issue
Block a user