Add WhatsApp graded phone ACL (4.9.1-ops.4).

Replace allowlist-only gating with phone-scoped grants: global admins, DM members, per-group admins/members, pending approval via quote YES/NO or settings UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 16:45:21 +08:00
parent e5e1e6573a
commit 9f97c44031
15 changed files with 3146 additions and 327 deletions

View file

@ -0,0 +1,293 @@
/**
* WhatsApp ops access gate: phone grants, pending notify, quote approve/deny.
*/
import {
ACCESS_GRANT_COPY,
approverPhonesForPending,
attachPendingNotifyRefs,
emptyAccessGrant,
enqueueAccessPending,
ensureOwnerGlobalAdmin,
evaluateAccessGrant,
findPendingByNotifyMessageId,
formatPendingNotifyBody,
migrateAccessPolicyToGrant,
normalizeAccessPhone,
parseApprovalIntent,
parsePendingIdFromNotifyText,
phoneFromWhatsappJid,
resolveAccessPending,
upsertAccessContact,
} from '../shared/access-grant.mjs';
import { isSharedLocalCommand } from '../shared/command-permission.mjs';
import { isWhatsappLidJid } from './whatsapp-identity.mjs';
/**
* Resolve canonical phone from an enriched inbound message.
* @param {object} message
* @returns {{ phone: string|null, lid: string|null }}
*/
export function resolveInboundPhone(message) {
const candidates = [
message?.senderId,
message?.senderAlternateId,
...(Array.isArray(message?.senderAliasIds) ? message.senderAliasIds : []),
];
let lid = null;
for (const candidate of candidates) {
if (typeof candidate !== 'string' || !candidate) continue;
if (isWhatsappLidJid(candidate)) {
lid = lid ?? candidate;
continue;
}
const phone = phoneFromWhatsappJid(candidate) ?? normalizeAccessPhone(candidate);
if (phone) return { phone, lid };
}
return { phone: null, lid };
}
function quotedNotifyId(raw) {
const context = raw?.message?.extendedTextMessage?.contextInfo
?? raw?.message?.imageMessage?.contextInfo
?? null;
const stanzaId = typeof context?.stanzaId === 'string' ? context.stanzaId.trim() : '';
return stanzaId || null;
}
function quotedNotifyText(raw) {
const quoted = raw?.message?.extendedTextMessage?.contextInfo?.quotedMessage;
if (!quoted) return '';
return quoted.conversation
?? quoted.extendedTextMessage?.text
?? '';
}
/**
* Load or seed the bot access grant document.
* @param {{ workspaces: object, botId: string, accountJid: string, accessPolicy?: object|null }} input
*/
export async function loadWhatsappAccessGrant({
workspaces,
botId,
accountJid,
accessPolicy = null,
}) {
const ownerPhone = phoneFromWhatsappJid(accountJid);
let grant = workspaces.accessGrantFor(botId);
if (!grant) {
grant = migrateAccessPolicyToGrant(
accessPolicy ?? workspaces.accessPolicyFor?.(botId),
null,
ownerPhone,
);
await workspaces.setAccessGrant(botId, grant);
return grant;
}
const seeded = ensureOwnerGlobalAdmin(grant, ownerPhone);
if (seeded !== grant && JSON.stringify(seeded) !== JSON.stringify(grant)) {
await workspaces.setAccessGrant(botId, seeded);
return seeded;
}
return grant;
}
/**
* Persist contact sighting and return updated grant.
*/
export async function rememberWhatsappContact(workspaces, botId, grant, message, { phone, lid }) {
const pushName = message.contextSource?.()?.senderName;
const next = upsertAccessContact(grant, {
phone,
lid,
pushName,
scene: message.kind === 'group' ? 'group' : 'direct',
groupJid: message.kind === 'group' ? message.conversationId : undefined,
});
await workspaces.setAccessGrant(botId, next);
return next;
}
/**
* Try to handle an admin quote-reply approval before normal chat.
* @returns {Promise<boolean>} true if consumed
*/
export async function tryHandleWhatsappApprovalReply({
workspaces,
botId,
grant,
message,
raw,
sendText,
accountJid,
}) {
const { phone } = resolveInboundPhone(message);
if (!phone || message.kind !== 'direct') return false;
const intent = parseApprovalIntent(message.content);
if (!intent) return false;
const notifyId = quotedNotifyId(raw);
let pending = notifyId ? findPendingByNotifyMessageId(grant, notifyId) : null;
if (!pending) {
const pendingId = parsePendingIdFromNotifyText(quotedNotifyText(raw));
if (pendingId) {
pending = (grant.pending ?? []).find((entry) => entry.id === pendingId) ?? null;
}
}
if (!pending || pending.status !== 'pending') return false;
try {
const { grant: next, pending: resolved } = resolveAccessPending(grant, {
pendingId: pending.id,
action: intent,
resolvedByPhone: phone,
});
await workspaces.setAccessGrant(botId, next);
await sendText(
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied,
);
const requesterJid = resolved.phone
? `${resolved.phone}@s.whatsapp.net`
: (resolved.lid ?? null);
if (requesterJid) {
const body = intent === 'approve'
? (resolved.kind === 'group'
? ACCESS_GRANT_COPY.approvedGroup
: ACCESS_GRANT_COPY.approvedDirect)
: ACCESS_GRANT_COPY.denied;
try {
await sendText({ jid: requesterJid }, body);
} catch {
// Requester notify is best-effort.
}
}
return true;
} catch (error) {
if (error?.code === 'pending-forbidden' || error?.code === 'pending-expired'
|| error?.code === 'pending-unresolved' || error?.code === 'pending-not-found') {
await sendText(
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
error.message || '无法处理该审批。',
);
return true;
}
throw error;
}
}
/**
* Evaluate grant; on deny create pending + notify admins.
* @returns {Promise<{ allowed: boolean, reason: string, grant: object, accessDecision?: object }>}
*/
export async function gateWhatsappInbound({
workspaces,
botId,
grant,
message,
sendText,
accountJid,
}) {
const { phone, lid } = resolveInboundPhone(message);
let current = await rememberWhatsappContact(workspaces, botId, grant, message, { phone, lid });
const scene = message.kind === 'group' ? 'group' : 'direct';
const isCommand = isSharedLocalCommand(message.content ?? '', {
hasImages: Array.isArray(message.images) && message.images.length > 0,
hasFiles: Array.isArray(message.files) && message.files.length > 0,
});
const decision = evaluateAccessGrant(current, {
scene,
phone,
groupJid: scene === 'group' ? message.conversationId : undefined,
isCommand,
});
if (decision.allowed) {
return {
allowed: true,
reason: decision.reason,
grant: current,
accessDecision: {
allowed: true,
reason: decision.reason,
},
};
}
if (decision.reason === 'command-not-allowed') {
return {
allowed: false,
reason: decision.reason,
grant: current,
accessDecision: {
allowed: false,
reason: 'command-not-allowed',
},
};
}
// Unaddressed group spam: do not create pending.
if (scene === 'group' && message.addressed !== true) {
return { allowed: false, reason: 'group-unaddressed', grant: current };
}
const { grant: withPending, pending, created } = enqueueAccessPending(current, {
kind: scene,
groupJid: scene === 'group' ? message.conversationId : undefined,
phone: phone ?? '',
lid: lid ?? undefined,
pushName: message.contextSource?.()?.senderName,
requestText: message.content,
});
current = withPending;
await workspaces.setAccessGrant(botId, current);
const ack = !phone
? ACCESS_GRANT_COPY.pendingUnresolved
: (scene === 'group'
? ACCESS_GRANT_COPY.pendingAckGroup
: ACCESS_GRANT_COPY.pendingAckDirect);
try {
await sendText(message.replyTarget, ack);
} catch {
// Ack is best-effort.
}
if (created) {
const admins = approverPhonesForPending(current, pending);
const body = formatPendingNotifyBody(pending);
const refs = [];
for (const adminPhone of admins) {
// Never DM the linked bot account as if it were a peer when it is the only self-chat path.
const jid = `${adminPhone}@s.whatsapp.net`;
try {
const result = await sendText({
jid,
selfChat: adminPhone === phoneFromWhatsappJid(accountJid),
}, body);
const providerMessageId = result?.providerMessageIds?.[0]
?? result?.key?.id
?? result?.providerMessageId
?? null;
if (providerMessageId) {
refs.push({ adminPhone, providerMessageId: String(providerMessageId) });
}
} catch {
// Continue notifying other admins.
}
}
if (refs.length > 0) {
current = attachPendingNotifyRefs(current, pending.id, refs);
await workspaces.setAccessGrant(botId, current);
}
}
return {
allowed: false,
reason: decision.reason,
grant: current,
};
}
export { emptyAccessGrant, loadWhatsappAccessGrant as ensureWhatsappAccessGrant };

View file

@ -13,6 +13,11 @@ import { ImagePromptError } from '../shared/image-prompt.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import { enrichWhatsappInboundIdentities } from './whatsapp-identity.mjs';
import {
gateWhatsappInbound,
loadWhatsappAccessGrant,
tryHandleWhatsappApprovalReply,
} from './whatsapp-access-gate.mjs';
import {
WHATSAPP_ACCESS_MODES,
} from './config-store.mjs';
@ -627,6 +632,8 @@ export class WhatsappRuntime {
#contextEnhancement;
#accessPolicy;
#groupSessionScope;
#workspaces;
#botId;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
@ -648,6 +655,8 @@ export class WhatsappRuntime {
contextEnhancement,
accessPolicy,
groupSessionScope,
workspaces,
botId,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 30_000,
@ -664,6 +673,8 @@ export class WhatsappRuntime {
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#groupSessionScope = groupSessionScope;
this.#workspaces = workspaces;
this.#botId = botId;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -724,6 +735,44 @@ export class WhatsappRuntime {
});
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
if (this.#workspaces && this.#botId) {
const sendText = (target, text) => this.#client.sendText(target, text);
let grant = await loadWhatsappAccessGrant({
workspaces: this.#workspaces,
botId: this.#botId,
accountJid: this.#config.accountJid,
accessPolicy: this.#accessPolicy?.getSettings?.() ?? null,
});
const handled = await tryHandleWhatsappApprovalReply({
workspaces: this.#workspaces,
botId: this.#botId,
grant,
message,
raw,
sendText,
accountJid: this.#config.accountJid,
});
if (handled) return;
grant = this.#workspaces.accessGrantFor(this.#botId) ?? grant;
const gated = await gateWhatsappInbound({
workspaces: this.#workspaces,
botId: this.#botId,
grant,
message,
sendText,
accountJid: this.#config.accountJid,
});
if (!gated.allowed) {
if (gated.accessDecision) {
await this.#bridge.accept(message, { accessDecision: gated.accessDecision });
} else if (!this.#state.hasSeen(message.messageId)) {
await this.#state.markSeen(message.messageId);
}
return;
}
await this.#bridge.accept(message, { accessDecision: gated.accessDecision });
return;
}
await this.#bridge.accept(message);
},
onDisconnect: ({ error }) => {