From 4de981b9b91fa217a313f96f4d17dbcb6e19416d Mon Sep 17 00:00:00 2001 From: Akshan Krithick <97239696+akshan-main@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:05:45 -0800 Subject: [PATCH] add sender allowlist for per-chat access control (#705) * feat: add sender allowlist for per-chat access control * style: fix prettier formatting --- groups/main/CLAUDE.md | 31 +++++ src/config.ts | 6 + src/db.ts | 4 +- src/index.ts | 45 +++++++- src/sender-allowlist.test.ts | 216 +++++++++++++++++++++++++++++++++++ src/sender-allowlist.ts | 128 +++++++++++++++++++++ 6 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 src/sender-allowlist.test.ts create mode 100644 src/sender-allowlist.ts diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index e7aa25a..11e846b 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -188,6 +188,37 @@ Groups can have extra directories mounted. Add `containerConfig` to their entry: The directory will appear at `/workspace/extra/webapp` in that group's container. +#### Sender Allowlist + +After registering a group, explain the sender allowlist feature to the user: + +> This group can be configured with a sender allowlist to control who can interact with me. There are two modes: +> +> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}. +> - **Drop mode**: Messages from non-allowed senders are not stored at all. +> +> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that? + +If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host: + +```json +{ + "default": { "allow": "*", "mode": "trigger" }, + "chats": { + "": { + "allow": ["sender-id-1", "sender-id-2"], + "mode": "trigger" + } + }, + "logDenied": true +} +``` + +Notes: +- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist. +- If the config file doesn't exist or is invalid, all senders are allowed (fail-open) +- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container + ### Removing a Group 1. Read `/workspace/project/data/registered_groups.json` diff --git a/src/config.ts b/src/config.ts index d57205b..c438b70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,12 @@ export const MOUNT_ALLOWLIST_PATH = path.join( 'nanoclaw', 'mount-allowlist.json', ); +export const SENDER_ALLOWLIST_PATH = path.join( + HOME_DIR, + '.config', + 'nanoclaw', + 'sender-allowlist.json', +); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); diff --git a/src/db.ts b/src/db.ts index 09d786f..be1f605 100644 --- a/src/db.ts +++ b/src/db.ts @@ -313,7 +313,7 @@ export function getNewMessages( // Filter bot messages using both the is_bot_message flag AND the content // prefix as a backstop for messages written before the migration ran. const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me FROM messages WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND is_bot_message = 0 AND content NOT LIKE ? @@ -341,7 +341,7 @@ export function getMessagesSince( // Filter bot messages using both the is_bot_message flag AND the content // prefix as a backstop for messages written before the migration ran. const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me FROM messages WHERE chat_jid = ? AND timestamp > ? AND is_bot_message = 0 AND content NOT LIKE ? diff --git a/src/index.ts b/src/index.ts index 234be79..8abf660 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,12 @@ import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { + isSenderAllowed, + isTriggerAllowed, + loadSenderAllowlist, + shouldDropMessage, +} from './sender-allowlist.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -155,8 +161,11 @@ async function processGroupMessages(chatJid: string): Promise { // For non-main groups, check if trigger is required and present if (!isMainGroup && group.requiresTrigger !== false) { - const hasTrigger = missedMessages.some((m) => - TRIGGER_PATTERN.test(m.content.trim()), + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = missedMessages.some( + (m) => + TRIGGER_PATTERN.test(m.content.trim()) && + (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) return true; } @@ -380,8 +389,12 @@ async function startMessageLoop(): Promise { // Non-trigger messages accumulate in DB and get pulled as // context when a trigger eventually arrives. if (needsTrigger) { - const hasTrigger = groupMessages.some((m) => - TRIGGER_PATTERN.test(m.content.trim()), + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = groupMessages.some( + (m) => + TRIGGER_PATTERN.test(m.content.trim()) && + (m.is_from_me || + isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) continue; } @@ -465,7 +478,29 @@ async function main(): Promise { // Channel callbacks (shared by all channels) const channelOpts = { - onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg), + onMessage: (_chatJid: string, msg: NewMessage) => { + // Sender allowlist drop mode: discard messages from denied senders before storing + if ( + !msg.is_from_me && + !msg.is_bot_message && + registeredGroups[_chatJid] + ) { + const cfg = loadSenderAllowlist(); + if ( + shouldDropMessage(_chatJid, cfg) && + !isSenderAllowed(_chatJid, msg.sender, cfg) + ) { + if (cfg.logDenied) { + logger.debug( + { chatJid: _chatJid, sender: msg.sender }, + 'sender-allowlist: dropping message (drop mode)', + ); + } + return; + } + } + storeMessage(msg); + }, onChatMetadata: ( chatJid: string, timestamp: string, diff --git a/src/sender-allowlist.test.ts b/src/sender-allowlist.test.ts new file mode 100644 index 0000000..9e2513f --- /dev/null +++ b/src/sender-allowlist.test.ts @@ -0,0 +1,216 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + isSenderAllowed, + isTriggerAllowed, + loadSenderAllowlist, + SenderAllowlistConfig, + shouldDropMessage, +} from './sender-allowlist.js'; + +let tmpDir: string; + +function cfgPath(name = 'sender-allowlist.json'): string { + return path.join(tmpDir, name); +} + +function writeConfig(config: unknown, name?: string): string { + const p = cfgPath(name); + fs.writeFileSync(p, JSON.stringify(config)); + return p; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('loadSenderAllowlist', () => { + it('returns allow-all defaults when file is missing', () => { + const cfg = loadSenderAllowlist(cfgPath()); + expect(cfg.default.allow).toBe('*'); + expect(cfg.default.mode).toBe('trigger'); + expect(cfg.logDenied).toBe(true); + }); + + it('loads allow=* config', () => { + const p = writeConfig({ + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: false, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); + expect(cfg.logDenied).toBe(false); + }); + + it('loads allow=[] (deny all)', () => { + const p = writeConfig({ + default: { allow: [], mode: 'trigger' }, + chats: {}, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toEqual([]); + }); + + it('loads allow=[list]', () => { + const p = writeConfig({ + default: { allow: ['alice', 'bob'], mode: 'drop' }, + chats: {}, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toEqual(['alice', 'bob']); + expect(cfg.default.mode).toBe('drop'); + }); + + it('per-chat override beats default', () => { + const p = writeConfig({ + default: { allow: '*', mode: 'trigger' }, + chats: { 'group-a': { allow: ['alice'], mode: 'drop' } }, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.chats['group-a'].allow).toEqual(['alice']); + expect(cfg.chats['group-a'].mode).toBe('drop'); + }); + + it('returns allow-all on invalid JSON', () => { + const p = cfgPath(); + fs.writeFileSync(p, '{ not valid json }}}'); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); + }); + + it('returns allow-all on invalid schema', () => { + const p = writeConfig({ default: { oops: true } }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); + }); + + it('rejects non-string allow array items', () => { + const p = writeConfig({ + default: { allow: [123, null, true], mode: 'trigger' }, + chats: {}, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); // falls back to default + }); + + it('skips invalid per-chat entries', () => { + const p = writeConfig({ + default: { allow: '*', mode: 'trigger' }, + chats: { + good: { allow: ['alice'], mode: 'trigger' }, + bad: { allow: 123 }, + }, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.chats['good']).toBeDefined(); + expect(cfg.chats['bad']).toBeUndefined(); + }); +}); + +describe('isSenderAllowed', () => { + it('allow=* allows any sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true); + }); + + it('allow=[] denies any sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: [], mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false); + }); + + it('allow=[list] allows exact match only', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice', 'bob'], mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true); + expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false); + }); + + it('uses per-chat entry over default', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: { g1: { allow: ['alice'], mode: 'trigger' } }, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false); + expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true); + }); +}); + +describe('shouldDropMessage', () => { + it('returns false for trigger mode', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(shouldDropMessage('g1', cfg)).toBe(false); + }); + + it('returns true for drop mode', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'drop' }, + chats: {}, + logDenied: true, + }; + expect(shouldDropMessage('g1', cfg)).toBe(true); + }); + + it('per-chat mode override', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: { g1: { allow: '*', mode: 'drop' } }, + logDenied: true, + }; + expect(shouldDropMessage('g1', cfg)).toBe(true); + expect(shouldDropMessage('g2', cfg)).toBe(false); + }); +}); + +describe('isTriggerAllowed', () => { + it('allows trigger for allowed sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice'], mode: 'trigger' }, + chats: {}, + logDenied: false, + }; + expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true); + }); + + it('denies trigger for disallowed sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice'], mode: 'trigger' }, + chats: {}, + logDenied: false, + }; + expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false); + }); + + it('logs when logDenied is true', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice'], mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + isTriggerAllowed('g1', 'eve', cfg); + // Logger.debug is called — we just verify no crash; logger is a real pino instance + }); +}); diff --git a/src/sender-allowlist.ts b/src/sender-allowlist.ts new file mode 100644 index 0000000..9cc2bde --- /dev/null +++ b/src/sender-allowlist.ts @@ -0,0 +1,128 @@ +import fs from 'fs'; + +import { SENDER_ALLOWLIST_PATH } from './config.js'; +import { logger } from './logger.js'; + +export interface ChatAllowlistEntry { + allow: '*' | string[]; + mode: 'trigger' | 'drop'; +} + +export interface SenderAllowlistConfig { + default: ChatAllowlistEntry; + chats: Record; + logDenied: boolean; +} + +const DEFAULT_CONFIG: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: true, +}; + +function isValidEntry(entry: unknown): entry is ChatAllowlistEntry { + if (!entry || typeof entry !== 'object') return false; + const e = entry as Record; + const validAllow = + e.allow === '*' || + (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string')); + const validMode = e.mode === 'trigger' || e.mode === 'drop'; + return validAllow && validMode; +} + +export function loadSenderAllowlist( + pathOverride?: string, +): SenderAllowlistConfig { + const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH; + + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG; + logger.warn( + { err, path: filePath }, + 'sender-allowlist: cannot read config', + ); + return DEFAULT_CONFIG; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON'); + return DEFAULT_CONFIG; + } + + const obj = parsed as Record; + + if (!isValidEntry(obj.default)) { + logger.warn( + { path: filePath }, + 'sender-allowlist: invalid or missing default entry', + ); + return DEFAULT_CONFIG; + } + + const chats: Record = {}; + if (obj.chats && typeof obj.chats === 'object') { + for (const [jid, entry] of Object.entries( + obj.chats as Record, + )) { + if (isValidEntry(entry)) { + chats[jid] = entry; + } else { + logger.warn( + { jid, path: filePath }, + 'sender-allowlist: skipping invalid chat entry', + ); + } + } + } + + return { + default: obj.default as ChatAllowlistEntry, + chats, + logDenied: obj.logDenied !== false, + }; +} + +function getEntry( + chatJid: string, + cfg: SenderAllowlistConfig, +): ChatAllowlistEntry { + return cfg.chats[chatJid] ?? cfg.default; +} + +export function isSenderAllowed( + chatJid: string, + sender: string, + cfg: SenderAllowlistConfig, +): boolean { + const entry = getEntry(chatJid, cfg); + if (entry.allow === '*') return true; + return entry.allow.includes(sender); +} + +export function shouldDropMessage( + chatJid: string, + cfg: SenderAllowlistConfig, +): boolean { + return getEntry(chatJid, cfg).mode === 'drop'; +} + +export function isTriggerAllowed( + chatJid: string, + sender: string, + cfg: SenderAllowlistConfig, +): boolean { + const allowed = isSenderAllowed(chatJid, sender, cfg); + if (!allowed && cfg.logDenied) { + logger.debug( + { chatJid, sender }, + 'sender-allowlist: trigger denied for sender', + ); + } + return allowed; +}