Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion gateway/authz_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,13 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
chat_allowlist_env = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
# WhatsApp group allowlist is chat-scoped (group JIDs), same
# shape as Telegram's GROUP_ALLOWED_CHATS. Without this entry,
# messages that already passed the adapter's group_policy still
# fail the gateway user allowlist for non-DM-allowlisted
# senders — so other members' text/voice in an allowlisted
# group is silently unauthorized.
Platform.WHATSAPP: "WHATSAPP_GROUP_ALLOWED_USERS",
}.get(source.platform, "")
if chat_allowlist_env:
raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip()
Expand All @@ -354,7 +361,23 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
for cid in raw_chat_allowlist.split(",")
if cid.strip()
}
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
# WhatsApp JIDs: accept bare numeric id or full @g.us form.
if source.platform == Platform.WHATSAPP:
bare = source.chat_id.split("@", 1)[0]
expanded = set()
for cid in allowed_group_ids:
expanded.add(cid)
expanded.add(cid.split("@", 1)[0])
if "@" not in cid:
expanded.add(f"{cid}@g.us")
allowed_group_ids = expanded
if (
"*" in allowed_group_ids
or source.chat_id in allowed_group_ids
or bare in allowed_group_ids
):
return True
elif "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
return True

# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).
Expand Down Expand Up @@ -403,6 +426,10 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
platform_group_chat_env_map = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
# Chat-scoped WhatsApp groups (JIDs). Must match the early
# chat_allowlist_env map above so the later group_chat_allowlist
# branch also authorizes allowlisted @g.us chats.
Platform.WHATSAPP: "WHATSAPP_GROUP_ALLOWED_USERS",
}
platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
Expand Down
59 changes: 57 additions & 2 deletions plugins/platforms/whatsapp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,30 @@ def check_whatsapp_requirements() -> bool:
return False


def _resolve_allow_list_source(config_extra: dict, *config_keys: str, env_vars: tuple[str, ...] | list[str]):
"""Resolve an allowlist source with explicit-empty-means-deny-all semantics.

If any of ``config_keys`` is present in ``config_extra`` (even if its
value is an empty list or empty string), that value wins and we never
fall back to the environment. Only when none of the config keys are
present at all do we fall back to the first non-empty env var in
``env_vars``. This prevents an explicit ``allow_from: []`` in config
from silently widening to a stale environment allowlist.
"""
for key in config_keys:
if key in config_extra:
return config_extra[key]
for env_var in env_vars:
value = os.getenv(env_var)
if value is not None and str(value).strip() != "":
return value
# Last env var wins even if empty so callers can still see "" vs None.
for env_var in env_vars:
if os.getenv(env_var) is not None:
return os.getenv(env_var)
return None


class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
"""
WhatsApp adapter.
Expand Down Expand Up @@ -407,9 +431,40 @@ def __init__(self, config: PlatformConfig):
))
self._reply_prefix: Optional[str] = config.extra.get("reply_prefix")
self._dm_policy = str(config.extra.get("dm_policy") or os.getenv("WHATSAPP_DM_POLICY", "pairing")).strip().lower()
self._allow_from = self._coerce_allow_list(config.extra.get("allow_from") or config.extra.get("allowFrom"))
# Prefer PlatformConfig.extra (YAML / plugin bridge), then the documented
# env vars. Without the env fallback, a wizard/.env-only install ends up
# with dm_policy=allowlist and an EMPTY allowlist — every inbound DM and
# (with group_policy=allowlist) every group message is silently dropped
# after the bridge has already queued it. Mirrors WhatsApp Cloud
# (WHATSAPP_CLOUD_ALLOWED_USERS) and the documented WHATSAPP_* vars.
# Key-presence semantics: explicit empty config stays deny-all.
self._allow_from = self._coerce_allow_list(
_resolve_allow_list_source(
config.extra,
"allow_from",
"allowFrom",
env_vars=("WHATSAPP_ALLOWED_USERS", "WHATSAPP_ALLOW_FROM"),
)
)
# WHATSAPP_ALLOW_ALL_USERS=* / true is the documented open-DM opt-in.
# Only apply when no allowlist source was configured at all.
_allow_all = (os.getenv("WHATSAPP_ALLOW_ALL_USERS") or "").strip().lower()
if (
not self._allow_from
and "allow_from" not in config.extra
and "allowFrom" not in config.extra
and _allow_all in {"true", "1", "yes", "on", "*"}
):
self._allow_from = {"*"}
self._group_policy = str(config.extra.get("group_policy") or os.getenv("WHATSAPP_GROUP_POLICY", "pairing")).strip().lower()
self._group_allow_from = self._coerce_allow_list(config.extra.get("group_allow_from") or config.extra.get("groupAllowFrom"))
self._group_allow_from = self._coerce_allow_list(
_resolve_allow_list_source(
config.extra,
"group_allow_from",
"groupAllowFrom",
env_vars=("WHATSAPP_GROUP_ALLOWED_USERS", "WHATSAPP_GROUP_ALLOW_FROM"),
)
)
self._mention_patterns = self._compile_mention_patterns()
self._message_queue: asyncio.Queue = asyncio.Queue()
self._bridge_log_fh = None
Expand Down
54 changes: 48 additions & 6 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import qrcode from 'qrcode-terminal';
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
import { createOutboundIdTracker } from './outbound_ids.js';
import { classifyOwnerMessageGate } from './owner_message_gate.js';
import { classifyFromMeGroupGate } from './from_me_group_gate.js';
import { shouldEnforceDmSenderAllowlist } from './dm_allowlist_scope.js';
import {
buildPollPayload,
buildLocationPayload,
Expand Down Expand Up @@ -546,17 +548,46 @@ async function startSocket() {
// Handle fromMe messages based on mode
let fromOwner = false;
if (msg.key.fromMe) {
if (isGroup || chatId.includes('status')) {
const isStatus = chatId.includes('status');
const groupDecision = classifyFromMeGroupGate({
isGroup,
fromMe: true,
isStatus,
mode: WHATSAPP_MODE,
forwardOwnerMessages: FORWARD_OWNER_MESSAGES,
recentlySent: recentlySentIds.has(msg.key.id),
});
if (groupDecision.action === 'drop_status') {
emitDebugEvent({
stage: 'ignored',
reason: isGroup ? 'from_me_group' : 'from_me_status',
reason: 'from_me_status',
chatId: redactWhatsAppId(chatId),
});
continue;
}

if (WHATSAPP_MODE === 'bot') {
// Bot mode: separate bot number. fromMe inbound is either
if (groupDecision.action === 'drop_echo') {
continue;
}
if (groupDecision.action === 'drop_from_me_group') {
emitDebugEvent({
stage: 'ignored',
reason: 'from_me_group',
chatId: redactWhatsAppId(chatId),
});
try {
console.log(JSON.stringify({
event: 'ignored',
reason: 'from_me_group',
chatId,
senderId,
}));
} catch {}
continue;
}
if (groupDecision.action === 'forward_owner') {
fromOwner = true;
} else if (WHATSAPP_MODE === 'bot') {
// Bot mode DM: fromMe inbound is either
// (a) an echo of our own /send (recentlySentIds will catch it), or
// (b) a message the owner typed from their own phone using the
// linked-device session.
Expand Down Expand Up @@ -634,7 +665,18 @@ async function startSocket() {
} catch {}
continue;
}
if (WHATSAPP_DM_POLICY !== 'pairing' && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
// DM allowlist applies to direct chats only. Group chats are gated by
// WHATSAPP_GROUP_POLICY / WHATSAPP_GROUP_ALLOWED_USERS in the Python
// adapter — applying the DM sender allowlist here blocked every
// non-owner participant (and made allowlisted groups look "dead").
if (
shouldEnforceDmSenderAllowlist({
isGroup,
fromMe: false,
dmPolicy: WHATSAPP_DM_POLICY,
})
&& !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)
) {
try {
console.log(JSON.stringify({
event: 'ignored',
Expand Down
24 changes: 24 additions & 0 deletions scripts/whatsapp-bridge/dm_allowlist_scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Decide whether the DM sender allowlist should run for this inbound.
*
* Group chats are gated by WHATSAPP_GROUP_POLICY / WHATSAPP_GROUP_ALLOWED_USERS
* on the Python adapter. Applying WHATSAPP_ALLOWED_USERS (DM sender allowlist)
* to group participants blocked every non-owner member and made allowlisted
* support groups look "dead".
*
* @param {object} opts
* @param {boolean} opts.isGroup
* @param {boolean} opts.fromMe
* @param {string} opts.dmPolicy WHATSAPP_DM_POLICY
* @returns {boolean} true if the bridge should enforce the DM sender allowlist
*/
export function shouldEnforceDmSenderAllowlist({
isGroup = false,
fromMe = false,
dmPolicy = 'allowlist',
} = {}) {
if (fromMe) return false;
if (isGroup) return false;
if (dmPolicy === 'pairing') return false;
return true;
}
48 changes: 48 additions & 0 deletions scripts/whatsapp-bridge/dm_allowlist_scope.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import { shouldEnforceDmSenderAllowlist } from './dm_allowlist_scope.js';

test('DM stranger under allowlist policy is enforced', () => {
assert.equal(
shouldEnforceDmSenderAllowlist({
isGroup: false,
fromMe: false,
dmPolicy: 'allowlist',
}),
true,
);
});

test('group messages never run the DM sender allowlist', () => {
assert.equal(
shouldEnforceDmSenderAllowlist({
isGroup: true,
fromMe: false,
dmPolicy: 'allowlist',
}),
false,
);
});

test('pairing policy skips bridge allowlist (Python pairing handles it)', () => {
assert.equal(
shouldEnforceDmSenderAllowlist({
isGroup: false,
fromMe: false,
dmPolicy: 'pairing',
}),
false,
);
});

test('fromMe never runs the stranger DM allowlist', () => {
assert.equal(
shouldEnforceDmSenderAllowlist({
isGroup: false,
fromMe: true,
dmPolicy: 'allowlist',
}),
false,
);
});
55 changes: 55 additions & 0 deletions scripts/whatsapp-bridge/from_me_group_gate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Pure classifier for fromMe messages that land in a WhatsApp group.
*
* Stock Hermes dropped every fromMe group message (`from_me_group`), which
* blocked two legitimate paths:
* 1. self-chat mode operators talking to the agent in a group
* 2. bot-mode personal-number deployments where the linked phone is the
* owner's own number and they type in an allowlisted group
*
* Group membership / group_policy is enforced on the Python side
* (WHATSAPP_GROUP_ALLOWED_USERS). This gate only decides whether the
* bridge should forward a fromMe group message at all.
*
* @param {object} opts
* @param {boolean} opts.isGroup
* @param {boolean} opts.fromMe
* @param {boolean} opts.isStatus
* @param {string} opts.mode 'bot' | 'self-chat'
* @param {boolean} opts.forwardOwnerMessages WHATSAPP_FORWARD_OWNER_MESSAGES
* @param {boolean} opts.recentlySent true if this message id was our /send
* @returns {{ action: 'not_applicable'|'drop_status'|'drop_echo'|'drop_from_me_group'|'forward_owner' }}
*/
export function classifyFromMeGroupGate({
isGroup = false,
fromMe = false,
isStatus = false,
mode = 'bot',
forwardOwnerMessages = false,
recentlySent = false,
} = {}) {
if (!fromMe) {
return { action: 'not_applicable' };
}
if (isStatus) {
return { action: 'drop_status' };
}
if (!isGroup) {
return { action: 'not_applicable' };
}
if (recentlySent) {
return { action: 'drop_echo' };
}
// Self-chat: the linked account is the only speaker the agent ever sees.
// Group fromMe must reach Python so group_policy / group_allow_from apply.
if (mode === 'self-chat') {
return { action: 'forward_owner' };
}
// Bot mode: only when the operator opts into owner-typed forwards
// (personal-number bot testing). Default stays drop_from_me_group so
// dedicated bot-number deployments are unchanged.
if (forwardOwnerMessages) {
return { action: 'forward_owner' };
}
return { action: 'drop_from_me_group' };
}
Loading