neoagent 2.2.1-beta.7 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const {
5
+ createDefaultAccessPolicy,
6
+ migrateLegacyWhitelist,
7
+ normalizeAccessPolicy,
8
+ parseStoredAccessPolicy,
9
+ evaluateAccessPolicy,
10
+ } = require('./access_policy');
11
+ const { BasePlatform } = require('./base');
12
+
13
+ test('discord defaults require allowlists and shared mentions', () => {
14
+ const policy = createDefaultAccessPolicy('discord');
15
+ assert.equal(policy.directPolicy, 'allowlist');
16
+ assert.equal(policy.sharedPolicy, 'allowlist');
17
+ assert.equal(policy.requireMentionInShared, true);
18
+ });
19
+
20
+ test('discord legacy whitelist migrates into canonical buckets', () => {
21
+ const policy = migrateLegacyWhitelist('discord', [
22
+ 'user:123',
23
+ 'guild:456',
24
+ 'channel:789',
25
+ 'role:999',
26
+ ]);
27
+
28
+ assert.deepEqual(
29
+ policy.directRules.map((rule) => `${rule.scope}:${rule.value}`),
30
+ ['user:123'],
31
+ );
32
+ assert.deepEqual(
33
+ policy.sharedSpaceRules.map((rule) => `${rule.scope}:${rule.value}`),
34
+ ['server:456', 'channel:789'],
35
+ );
36
+ assert.deepEqual(
37
+ policy.sharedActorRules.map((rule) => `${rule.scope}:${rule.value}`),
38
+ ['user:123', 'role:999'],
39
+ );
40
+ });
41
+
42
+ test('telegram legacy raw ids split users and groups', () => {
43
+ const policy = migrateLegacyWhitelist('telegram', ['123', '-100555']);
44
+ assert.deepEqual(
45
+ policy.directRules.map((rule) => `${rule.scope}:${rule.value}`),
46
+ ['user:123'],
47
+ );
48
+ assert.deepEqual(
49
+ policy.sharedSpaceRules.map((rule) => `${rule.scope}:${rule.value}`),
50
+ ['group:-100555'],
51
+ );
52
+ assert.deepEqual(
53
+ policy.sharedActorRules.map((rule) => `${rule.scope}:${rule.value}`),
54
+ ['user:123'],
55
+ );
56
+ });
57
+
58
+ test('telnyx legacy numbers become direct phone rules', () => {
59
+ const policy = migrateLegacyWhitelist('telnyx', ['+1 (555) 1200']);
60
+ assert.equal(policy.sharedPolicy, 'disabled');
61
+ assert.deepEqual(policy.directRules, [
62
+ { scope: 'phone_number', value: '+15551200' },
63
+ ]);
64
+ });
65
+
66
+ test('shared access requires both space allowlist and mention when configured', () => {
67
+ const policy = normalizeAccessPolicy('discord', {
68
+ sharedPolicy: 'allowlist',
69
+ requireMentionInShared: true,
70
+ sharedSpaceRules: [{ scope: 'server', value: 'guild-1' }],
71
+ sharedActorRules: [{ scope: 'role', value: 'role-1' }],
72
+ });
73
+
74
+ const deniedByMention = evaluateAccessPolicy(policy, {
75
+ senderId: 'user-1',
76
+ chatId: 'chan-1',
77
+ isDirect: false,
78
+ isShared: true,
79
+ groupId: 'chan-1',
80
+ channelId: 'chan-1',
81
+ serverId: 'guild-1',
82
+ roleIds: ['role-1'],
83
+ wasMentioned: false,
84
+ }, 'discord');
85
+ assert.equal(deniedByMention.allowed, false);
86
+ assert.equal(deniedByMention.reason, 'mention_required');
87
+
88
+ const allowed = evaluateAccessPolicy(policy, {
89
+ senderId: 'user-1',
90
+ chatId: 'chan-1',
91
+ isDirect: false,
92
+ isShared: true,
93
+ groupId: 'chan-1',
94
+ channelId: 'chan-1',
95
+ serverId: 'guild-1',
96
+ roleIds: ['role-1'],
97
+ wasMentioned: true,
98
+ }, 'discord');
99
+ assert.equal(allowed.allowed, true);
100
+ });
101
+
102
+ test('direct allowlist requires a matching direct rule', () => {
103
+ const policy = normalizeAccessPolicy('telegram', {
104
+ directPolicy: 'allowlist',
105
+ directRules: [{ scope: 'user', value: '42' }],
106
+ });
107
+
108
+ assert.equal(
109
+ evaluateAccessPolicy(policy, {
110
+ senderId: '42',
111
+ chatId: 'dm_42',
112
+ isDirect: true,
113
+ isShared: false,
114
+ wasMentioned: false,
115
+ }, 'telegram').allowed,
116
+ true,
117
+ );
118
+
119
+ assert.equal(
120
+ evaluateAccessPolicy(policy, {
121
+ senderId: '99',
122
+ chatId: 'dm_99',
123
+ isDirect: true,
124
+ isShared: false,
125
+ wasMentioned: false,
126
+ }, 'telegram').allowed,
127
+ false,
128
+ );
129
+ });
130
+
131
+ test('shared actor role rules gate allowed spaces', () => {
132
+ const policy = normalizeAccessPolicy('discord', {
133
+ sharedPolicy: 'open',
134
+ requireMentionInShared: false,
135
+ sharedActorRules: [{ scope: 'role', value: 'ops' }],
136
+ });
137
+
138
+ assert.equal(
139
+ evaluateAccessPolicy(policy, {
140
+ senderId: 'user-1',
141
+ chatId: 'chan',
142
+ isDirect: false,
143
+ isShared: true,
144
+ channelId: 'chan',
145
+ serverId: 'guild',
146
+ roleIds: ['ops'],
147
+ wasMentioned: true,
148
+ }, 'discord').allowed,
149
+ true,
150
+ );
151
+
152
+ const denied = evaluateAccessPolicy(policy, {
153
+ senderId: 'user-2',
154
+ chatId: 'chan',
155
+ isDirect: false,
156
+ isShared: true,
157
+ channelId: 'chan',
158
+ serverId: 'guild',
159
+ roleIds: ['guest'],
160
+ wasMentioned: true,
161
+ }, 'discord');
162
+ assert.equal(denied.allowed, false);
163
+ assert.equal(denied.reason, 'shared_actor_not_allowed');
164
+ });
165
+
166
+ test('parseStoredAccessPolicy prefers canonical JSON and falls back to legacy JSON', () => {
167
+ const canonical = parseStoredAccessPolicy(
168
+ 'discord',
169
+ JSON.stringify({
170
+ directPolicy: 'open',
171
+ sharedPolicy: 'disabled',
172
+ requireMentionInShared: false,
173
+ }),
174
+ JSON.stringify(['user:123']),
175
+ );
176
+ assert.equal(canonical.directPolicy, 'open');
177
+ assert.equal(canonical.sharedPolicy, 'disabled');
178
+
179
+ const legacy = parseStoredAccessPolicy(
180
+ 'discord',
181
+ null,
182
+ JSON.stringify(['user:123']),
183
+ );
184
+ assert.deepEqual(
185
+ legacy.directRules.map((rule) => `${rule.scope}:${rule.value}`),
186
+ ['user:123'],
187
+ );
188
+ });
189
+
190
+ test('live platforms can hot-update policy without reconnecting', () => {
191
+ class FakePlatform extends BasePlatform {
192
+ constructor() {
193
+ super('discord', {});
194
+ }
195
+ }
196
+
197
+ const platform = new FakePlatform();
198
+ platform.setAccessPolicy({
199
+ directPolicy: 'disabled',
200
+ sharedPolicy: 'disabled',
201
+ });
202
+ assert.equal(
203
+ platform.evaluateAccess({
204
+ senderId: 'user-1',
205
+ chatId: 'dm_user-1',
206
+ isDirect: true,
207
+ isShared: false,
208
+ wasMentioned: false,
209
+ }).allowed,
210
+ false,
211
+ );
212
+
213
+ platform.setAccessPolicy({
214
+ directPolicy: 'allowlist',
215
+ directRules: [{ scope: 'user', value: 'user-1' }],
216
+ sharedPolicy: 'disabled',
217
+ });
218
+ assert.equal(
219
+ platform.evaluateAccess({
220
+ senderId: 'user-1',
221
+ chatId: 'dm_user-1',
222
+ isDirect: true,
223
+ isShared: false,
224
+ wasMentioned: false,
225
+ }).allowed,
226
+ true,
227
+ );
228
+ });
@@ -2,10 +2,17 @@
2
2
 
3
3
  const db = require('../../db/database');
4
4
  const { detectPromptInjection } = require('../../utils/security');
5
- const { normalizeWhatsAppId } = require('../../utils/whatsapp');
6
5
  const { randomUUID } = require('crypto');
7
6
  const { isMainAgent } = require('../agents/manager');
8
7
  const { buildPlatformFormattingGuide } = require('./formatting_guides');
8
+ const {
9
+ accessPolicyKey,
10
+ legacyWhitelistKey,
11
+ parseStoredAccessPolicy,
12
+ evaluateAccessPolicy,
13
+ buildBlockedSenderPayload,
14
+ contextFromMessage,
15
+ } = require('./access_policy');
9
16
  const {
10
17
  buildVoiceMessagingPrompt,
11
18
  buildVoiceMessagingRunOptions,
@@ -362,120 +369,50 @@ function buildSenderIdentityBlock(msg) {
362
369
  return `<sender_identity>\n${lines.join('\n')}\n</sender_identity>`;
363
370
  }
364
371
 
365
- function messagingAllowlistCandidates(msg) {
366
- const sender = String(msg.sender || '').trim();
367
- const chatId = String(msg.chatId || '').trim();
368
- const values = new Set([sender, chatId].filter(Boolean));
369
- const normalizedChatId = chatId.startsWith('dm_') ? chatId.slice(3) : chatId;
370
- const guildId = String(msg.guildId || '').trim();
371
-
372
- if (normalizedChatId) {
373
- values.add(normalizedChatId);
374
- }
375
- if (sender) values.add(`user:${sender}`);
376
- if (guildId) values.add(`guild:${guildId}`);
377
- if (chatId) {
378
- values.add(`chat:${chatId}`);
379
- values.add(`channel:${chatId}`);
380
- values.add(`room:${chatId}`);
381
- values.add(`group:${chatId}`);
382
- }
383
- if (normalizedChatId) {
384
- values.add(`chat:${normalizedChatId}`);
385
- values.add(`channel:${normalizedChatId}`);
386
- values.add(`room:${normalizedChatId}`);
387
- values.add(`group:${normalizedChatId}`);
388
- }
389
- return [...values];
390
- }
391
-
392
372
  async function isAllowedMessagingSender({ io, userId, msg }) {
393
373
  const agentId = msg.agentId || null;
394
- const whitelistRow = db
374
+ const policyRow = db
395
375
  .prepare('SELECT value FROM agent_settings WHERE user_id = ? AND agent_id = ? AND key = ?')
396
- .get(userId, agentId, `platform_whitelist_${msg.platform}`)
376
+ .get(userId, agentId, accessPolicyKey(msg.platform))
397
377
  || (isMainAgent(userId, agentId)
398
378
  ? db
399
379
  .prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
400
- .get(userId, `platform_whitelist_${msg.platform}`)
380
+ .get(userId, accessPolicyKey(msg.platform))
381
+ : null);
382
+ const legacyRow = db
383
+ .prepare('SELECT value FROM agent_settings WHERE user_id = ? AND agent_id = ? AND key = ?')
384
+ .get(userId, agentId, legacyWhitelistKey(msg.platform))
385
+ || (isMainAgent(userId, agentId)
386
+ ? db
387
+ .prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
388
+ .get(userId, legacyWhitelistKey(msg.platform))
401
389
  : null);
402
390
 
403
- const normalize =
404
- msg.platform === 'whatsapp'
405
- ? normalizeWhatsAppId
406
- : msg.platform === 'telnyx'
407
- ? (id) => String(id || '').replace(/[^0-9+]/g, '')
408
- : (id) => String(id || '').trim();
409
-
410
- let whitelist = [];
411
- if (whitelistRow) {
412
- try {
413
- const parsed = JSON.parse(whitelistRow.value);
414
- if (Array.isArray(parsed)) whitelist = parsed;
415
- } catch {
416
- whitelist = [];
417
- }
418
- }
419
-
420
- const shouldCheckWhitelist = whitelist.length > 0;
421
-
422
- if (!shouldCheckWhitelist) {
423
- if (!msg.isGroup) return true;
424
- console.log(
425
- `[Messaging] Blocked ${msg.platform} group message from ${msg.sender} (no group allowlist configured)`
426
- );
427
- emitBlockedSenderSuggestion({ io, userId, msg });
428
- return false;
429
- }
430
-
431
- const candidates = messagingAllowlistCandidates(msg).map(normalize).filter(Boolean);
432
- const allowed = whitelist.some((entry) => {
433
- const normalizedEntry = normalize(entry);
434
- return normalizedEntry === '*' || candidates.includes(normalizedEntry);
435
- });
436
- if (allowed) {
391
+ const policy = parseStoredAccessPolicy(msg.platform, policyRow?.value, legacyRow?.value);
392
+ const decision = evaluateAccessPolicy(policy, contextFromMessage(msg), msg.platform);
393
+ if (decision.allowed) {
437
394
  return true;
438
395
  }
439
396
 
440
397
  console.log(
441
- `[Messaging] Blocked ${msg.platform} message from ${msg.sender} (not in whitelist)`
398
+ `[Messaging] Blocked ${msg.platform} message from ${msg.sender} (${decision.reason})`
442
399
  );
443
400
  emitBlockedSenderSuggestion({ io, userId, msg });
444
401
  return false;
445
402
  }
446
403
 
447
404
  function emitBlockedSenderSuggestion({ io, userId, msg }) {
448
- const suggestions = [];
449
- if (msg.platform === 'whatsapp') {
450
- const normalizedSender = normalizeWhatsAppId(msg.sender || msg.chatId);
451
- if (normalizedSender) {
452
- suggestions.push({
453
- label: `Add sender (${msg.senderName || normalizedSender})`,
454
- prefixedId: normalizedSender
455
- });
456
- }
457
- } else {
458
- const sender = String(msg.sender || '').trim();
459
- const chatId = String(msg.chatId || '').trim();
460
- if (sender) {
461
- suggestions.push({
462
- label: `Add sender (${msg.senderName || sender})`,
463
- prefixedId: `user:${sender}`
464
- });
465
- }
466
- if (chatId && chatId !== sender) {
467
- suggestions.push({
468
- label: `Add chat (${chatId})`,
469
- prefixedId: msg.isGroup ? `group:${chatId}` : chatId
470
- });
471
- }
472
- }
405
+ const payload = buildBlockedSenderPayload(msg.platform, contextFromMessage(msg), {
406
+ senderName: msg.senderName || null,
407
+ meta: msg.guildName ? `Server: ${msg.guildName}` : (msg.groupName ? `Group: ${msg.groupName}` : ''),
408
+ serverLabel: msg.guildName || '',
409
+ groupLabel: msg.groupName || '',
410
+ channelLabel: msg.channelName || '',
411
+ roomLabel: msg.roomName || '',
412
+ });
473
413
  io.to(`user:${userId}`).emit('messaging:blocked_sender', {
474
414
  platform: msg.platform,
475
- sender: msg.sender,
476
- chatId: msg.chatId,
477
- senderName: msg.senderName || null,
478
- suggestions: suggestions.length > 0 ? suggestions : null
415
+ ...payload,
479
416
  });
480
417
  }
481
418
 
@@ -1,4 +1,11 @@
1
1
  const EventEmitter = require('events');
2
+ const {
3
+ createDefaultAccessPolicy,
4
+ normalizeAccessPolicy,
5
+ getPlatformAccessCapabilities,
6
+ evaluateAccessPolicy,
7
+ buildBlockedSenderPayload,
8
+ } = require('./access_policy');
2
9
 
3
10
  class BasePlatform extends EventEmitter {
4
11
  constructor(name, config = {}) {
@@ -10,6 +17,11 @@ class BasePlatform extends EventEmitter {
10
17
  this.supportsMedia = false;
11
18
  this.supportsVoice = false;
12
19
  this.allowedEntries = new Set();
20
+ this.accessCapabilities = getPlatformAccessCapabilities(name);
21
+ this.accessPolicy = createDefaultAccessPolicy(name);
22
+ if (config.accessPolicy) {
23
+ this.setAccessPolicy(config.accessPolicy);
24
+ }
13
25
  }
14
26
 
15
27
  setAllowedEntries(entries) {
@@ -18,16 +30,43 @@ class BasePlatform extends EventEmitter {
18
30
  }
19
31
  }
20
32
 
33
+ setAccessPolicy(policy) {
34
+ this.accessPolicy = normalizeAccessPolicy(this.name, policy);
35
+ return this.accessPolicy;
36
+ }
37
+
38
+ getAccessPolicy() {
39
+ return this.accessPolicy;
40
+ }
41
+
42
+ getAccessCapabilities() {
43
+ return this.accessCapabilities;
44
+ }
45
+
21
46
  _checkAccess(id) {
22
47
  if (this.allowedEntries.size === 0) return true;
23
48
  return this.allowedEntries.has(String(id));
24
49
  }
25
50
 
51
+ evaluateAccess(context) {
52
+ return evaluateAccessPolicy(this.accessPolicy, context, this.name);
53
+ }
54
+
55
+ _checkInboundAccess(context, options = {}) {
56
+ const result = this.evaluateAccess(context);
57
+ if (result.allowed) {
58
+ return result;
59
+ }
60
+ this.emit('blocked_sender', buildBlockedSenderPayload(this.name, context, options));
61
+ return result;
62
+ }
63
+
26
64
  async connect() { throw new Error('connect() not implemented'); }
27
65
  async disconnect() { throw new Error('disconnect() not implemented'); }
28
66
  async sendMessage(to, content, options) { throw new Error('sendMessage() not implemented'); }
29
67
  async getContacts() { return []; }
30
68
  async getChats() { return []; }
69
+ async listAccessTargets() { return []; }
31
70
  getStatus() { return this.status; }
32
71
  getAuthInfo() { return null; }
33
72
  }
@@ -113,33 +113,6 @@ class DiscordPlatform extends BasePlatform {
113
113
  getStatus() { return this.status; }
114
114
  getAuthInfo() { return this._botUser ? { tag: this._botUser.tag, id: this._botUser.id } : null; }
115
115
 
116
- // ── Whitelist ──────────────────────────────────────────────────────────────
117
-
118
- // Inherits setAllowedEntries from BasePlatform
119
-
120
- /** Returns {allowed, requireMention} */
121
- _checkAccess(message) {
122
- const isDM = message.channel.type === ChannelType.DM;
123
- const userId = message.author.id;
124
- const guildId = message.guildId || null;
125
- const channelId = message.channelId;
126
- const userAllowed = super._checkAccess(`user:${userId}`) || super._checkAccess(userId);
127
-
128
- if (isDM) {
129
- return {
130
- allowed: this.allowedEntries.size === 0 || userAllowed,
131
- requireMention: false,
132
- };
133
- }
134
-
135
- return {
136
- allowed: userAllowed
137
- || (guildId && super._checkAccess(`guild:${guildId}`))
138
- || super._checkAccess(`channel:${channelId}`),
139
- requireMention: true,
140
- };
141
- }
142
-
143
116
  _isMentioned(message) {
144
117
  return this._botUser ? message.mentions.has(this._botUser.id) : false;
145
118
  }
@@ -185,28 +158,30 @@ class DiscordPlatform extends BasePlatform {
185
158
  const channelId = message.channelId;
186
159
  const chatId = isDM ? `dm_${userId}` : channelId;
187
160
 
188
- const { allowed, requireMention } = this._checkAccess(message);
189
-
190
- if (requireMention && !this._isMentioned(message)) return;
191
-
192
- if (!allowed) {
193
- const suggestions = [
194
- { label: `Add user (${message.author.username})`, prefixedId: `user:${userId}` },
195
- ];
196
- if (guildId) suggestions.push({ label: `Add server (${message.guild?.name || guildId})`, prefixedId: `guild:${guildId}` });
197
- if (!isDM) suggestions.push({ label: `Add channel (#${message.channel.name || channelId})`, prefixedId: `channel:${channelId}` });
161
+ const access = this._checkInboundAccess({
162
+ platform: 'discord',
163
+ senderId: userId,
164
+ chatId,
165
+ isDirect: isDM,
166
+ isShared: !isDM,
167
+ groupId: !isDM ? channelId : '',
168
+ channelId: !isDM ? channelId : '',
169
+ serverId: guildId || '',
170
+ roleIds: !isDM && message.member ? [...message.member.roles.cache.keys()] : [],
171
+ phoneNumber: '',
172
+ wasMentioned: isDM ? false : this._isMentioned(message),
173
+ }, {
174
+ senderName: message.author.username || null,
175
+ meta: message.guild?.name ? `Server: ${message.guild.name}` : '',
176
+ serverLabel: message.guild?.name || '',
177
+ channelLabel: !isDM ? `#${message.channel.name || channelId}` : '',
178
+ });
198
179
 
199
- this.emit('blocked_sender', {
200
- sender: userId,
201
- chatId,
202
- senderName: message.author.username,
203
- guildName: message.guild?.name || null,
204
- suggestions,
205
- });
180
+ if (!access.allowed) {
206
181
  return;
207
182
  }
208
183
 
209
- let content = requireMention ? this._stripMention(message.content) : (message.content || '');
184
+ let content = (!isDM && this._isMentioned(message)) ? this._stripMention(message.content) : (message.content || '');
210
185
  if (message.attachments.size > 0) {
211
186
  const urls = [...message.attachments.values()].map(a => a.url).join(', ');
212
187
  content += (content ? '\n' : '') + `[Attachment: ${urls}]`;
@@ -223,7 +198,7 @@ class DiscordPlatform extends BasePlatform {
223
198
  : `${senderDisplayName} in #${message.channel.name || channelId}${message.guild ? ` (${message.guild.name})` : ''}`;
224
199
 
225
200
  // Fetch recent channel history for context on guild/channel mentions
226
- const channelContext = (requireMention && !isDM) ? await this._fetchContext(message.channel, 20) : null;
201
+ const channelContext = (!isDM && this._isMentioned(message)) ? await this._fetchContext(message.channel, 20) : null;
227
202
 
228
203
  this.emit('message', {
229
204
  platform: 'discord',
@@ -279,6 +254,46 @@ class DiscordPlatform extends BasePlatform {
279
254
  }
280
255
  } catch { /* non-fatal */ }
281
256
  }
257
+
258
+ async listAccessTargets() {
259
+ if (!this._client || this.status !== 'connected') return [];
260
+ const targets = [];
261
+ for (const guild of this._client.guilds.cache.values()) {
262
+ targets.push({
263
+ source: 'live',
264
+ bucket: 'sharedSpaceRules',
265
+ scope: 'server',
266
+ value: guild.id,
267
+ label: guild.name || guild.id,
268
+ subtitle: 'Discord server',
269
+ });
270
+ const channels = guild.channels?.cache?.values?.() || [];
271
+ for (const channel of channels) {
272
+ if (!channel?.isTextBased?.() || channel.type === ChannelType.DM) continue;
273
+ targets.push({
274
+ source: 'live',
275
+ bucket: 'sharedSpaceRules',
276
+ scope: 'channel',
277
+ value: channel.id,
278
+ label: `#${channel.name || channel.id}`,
279
+ subtitle: guild.name || 'Discord channel',
280
+ });
281
+ }
282
+ const roles = guild.roles?.cache?.values?.() || [];
283
+ for (const role of roles) {
284
+ if (!role || role.managed || role.name === '@everyone') continue;
285
+ targets.push({
286
+ source: 'live',
287
+ bucket: 'sharedActorRules',
288
+ scope: 'role',
289
+ value: role.id,
290
+ label: role.name || role.id,
291
+ subtitle: guild.name || 'Discord role',
292
+ });
293
+ }
294
+ }
295
+ return targets;
296
+ }
282
297
  }
283
298
 
284
299
  module.exports = { DiscordPlatform };