nothumanallowed 9.9.7 → 10.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "9.9.7",
3
+ "version": "10.1.0",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 53 tools. Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, GitHub, Notion, Slack, voice chat, 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -106,6 +106,12 @@ export async function main(argv) {
106
106
  // Alias for nha ops (friendlier name)
107
107
  return cmdOps(args.length ? args : ['start']);
108
108
 
109
+ case 'collab':
110
+ case 'alexandria': {
111
+ const { cmdCollab } = await import('./commands/collab.mjs');
112
+ return cmdCollab(args);
113
+ }
114
+
109
115
  case 'plugin':
110
116
  case 'plugins':
111
117
  return cmdPlugin(args);
@@ -657,6 +663,12 @@ function cmdHelp() {
657
663
  console.log(` autostart disable Remove OS autostart`);
658
664
  console.log(` autostart status Check autostart configuration\n`);
659
665
 
666
+ console.log(` ${C}Alexandria${NC} ${D}(E2E Encrypted Communication)${NC}`);
667
+ console.log(` collab create "name" Create encrypted channel`);
668
+ console.log(` collab join <code> Join with invite code`);
669
+ console.log(` collab send "msg" Encrypt and send message`);
670
+ console.log(` collab read Decrypt and show messages`);
671
+ console.log(` collab list Your channels\n`);
660
672
  console.log(` ${C}Message Responder${NC} ${D}(Telegram + Discord auto-reply)${NC}`);
661
673
  console.log(` responder status Show responder configuration`);
662
674
  console.log(` config set telegram-bot-token TOKEN`);
@@ -0,0 +1,436 @@
1
+ /**
2
+ * nha collab — Alexandria E2E Encrypted Inter-Agent Communication
3
+ *
4
+ * Commands:
5
+ * nha collab create "name" — create encrypted channel
6
+ * nha collab join <code> — join channel with invite code
7
+ * nha collab send "message" — encrypt and send
8
+ * nha collab read — decrypt and show messages
9
+ * nha collab list — list your channels
10
+ * nha collab members — show channel members
11
+ * nha collab delete — delete channel (creator only)
12
+ *
13
+ * Crypto: X25519 key exchange + AES-256-GCM
14
+ * Identity: keypair stored in ~/.nha/collab/identity.json
15
+ * Zero-knowledge: server sees only ciphertext
16
+ */
17
+
18
+ import crypto from 'crypto';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { NHA_DIR, API_BASE } from '../constants.mjs';
22
+ import { info, ok, fail, warn, C, G, D, Y, R, NC, BOLD } from '../ui.mjs';
23
+
24
+ const COLLAB_DIR = path.join(NHA_DIR, 'collab');
25
+ const IDENTITY_FILE = path.join(COLLAB_DIR, 'identity.json');
26
+ const CHANNELS_FILE = path.join(COLLAB_DIR, 'channels.json');
27
+ const API = API_BASE + '/alexandria';
28
+
29
+ // ── Crypto Primitives ─────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Generate X25519 keypair for Diffie-Hellman key exchange.
33
+ */
34
+ function generateKeypair() {
35
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519', {
36
+ publicKeyEncoding: { type: 'spki', format: 'der' },
37
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
38
+ });
39
+ return {
40
+ publicKey: publicKey.toString('base64'),
41
+ privateKey: privateKey.toString('base64'),
42
+ fingerprint: crypto.createHash('sha256').update(publicKey).digest('hex').slice(0, 16),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Derive shared secret from my private key + their public key (X25519 ECDH).
48
+ */
49
+ function deriveSharedSecret(myPrivateKeyB64, theirPublicKeyB64) {
50
+ const myPrivate = crypto.createPrivateKey({
51
+ key: Buffer.from(myPrivateKeyB64, 'base64'),
52
+ format: 'der',
53
+ type: 'pkcs8',
54
+ });
55
+ const theirPublic = crypto.createPublicKey({
56
+ key: Buffer.from(theirPublicKeyB64, 'base64'),
57
+ format: 'der',
58
+ type: 'spki',
59
+ });
60
+ const shared = crypto.diffieHellman({
61
+ privateKey: myPrivate,
62
+ publicKey: theirPublic,
63
+ });
64
+ // Derive a 256-bit AES key from the shared secret
65
+ return crypto.createHash('sha256').update(shared).digest();
66
+ }
67
+
68
+ /**
69
+ * Encrypt a message with AES-256-GCM.
70
+ */
71
+ function encrypt(plaintext, key) {
72
+ const nonce = crypto.randomBytes(12);
73
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
74
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
75
+ const tag = cipher.getAuthTag();
76
+ return {
77
+ nonce: nonce.toString('base64'),
78
+ ciphertext: Buffer.concat([encrypted, tag]).toString('base64'),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Decrypt a message with AES-256-GCM.
84
+ */
85
+ function decrypt(ciphertextB64, nonceB64, key) {
86
+ const nonce = Buffer.from(nonceB64, 'base64');
87
+ const data = Buffer.from(ciphertextB64, 'base64');
88
+ const tag = data.subarray(data.length - 16);
89
+ const encrypted = data.subarray(0, data.length - 16);
90
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
91
+ decipher.setAuthTag(tag);
92
+ return decipher.update(encrypted) + decipher.final('utf-8');
93
+ }
94
+
95
+ // ── Identity Management ───────────────────────────────────────────────────
96
+
97
+ function loadIdentity() {
98
+ if (!fs.existsSync(IDENTITY_FILE)) return null;
99
+ try { return JSON.parse(fs.readFileSync(IDENTITY_FILE, 'utf-8')); } catch { return null; }
100
+ }
101
+
102
+ function saveIdentity(identity) {
103
+ fs.mkdirSync(COLLAB_DIR, { recursive: true });
104
+ fs.writeFileSync(IDENTITY_FILE, JSON.stringify(identity, null, 2), { mode: 0o600 });
105
+ }
106
+
107
+ function getOrCreateIdentity() {
108
+ let id = loadIdentity();
109
+ if (!id) {
110
+ id = generateKeypair();
111
+ id.displayName = `Agent-${id.fingerprint.slice(0, 6)}`;
112
+ id.createdAt = new Date().toISOString();
113
+ saveIdentity(id);
114
+ info(`Identity created: ${id.fingerprint}`);
115
+ }
116
+ return id;
117
+ }
118
+
119
+ // ── Channel Management ────────────────────────────────────────────────────
120
+
121
+ function loadChannels() {
122
+ if (!fs.existsSync(CHANNELS_FILE)) return [];
123
+ try { return JSON.parse(fs.readFileSync(CHANNELS_FILE, 'utf-8')); } catch { return []; }
124
+ }
125
+
126
+ function saveChannels(channels) {
127
+ fs.mkdirSync(COLLAB_DIR, { recursive: true });
128
+ fs.writeFileSync(CHANNELS_FILE, JSON.stringify(channels, null, 2), { mode: 0o600 });
129
+ }
130
+
131
+ function getActiveChannel() {
132
+ const channels = loadChannels();
133
+ const active = channels.find(c => c.active);
134
+ return active || channels[0] || null;
135
+ }
136
+
137
+ function setActiveChannel(channelId) {
138
+ const channels = loadChannels();
139
+ for (const ch of channels) ch.active = ch.id === channelId;
140
+ saveChannels(channels);
141
+ }
142
+
143
+ // ── API Helpers ───────────────────────────────────────────────────────────
144
+
145
+ async function apiPost(endpoint, body) {
146
+ const res = await fetch(API + endpoint, {
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: JSON.stringify(body),
150
+ });
151
+ return res.json();
152
+ }
153
+
154
+ async function apiGet(endpoint) {
155
+ const res = await fetch(API + endpoint);
156
+ return res.json();
157
+ }
158
+
159
+ async function apiDelete(endpoint, body) {
160
+ const res = await fetch(API + endpoint, {
161
+ method: 'DELETE',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify(body),
164
+ });
165
+ return res.json();
166
+ }
167
+
168
+ // ── Commands ──────────────────────────────────────────────────────────────
169
+
170
+ async function cmdCreate(args) {
171
+ const name = args.join(' ') || 'Unnamed Channel';
172
+ const identity = getOrCreateIdentity();
173
+
174
+ // Encrypt channel name with a key derived from the channel (will be re-encrypted with shared key)
175
+ const result = await apiPost('/channels', {
176
+ name: name, // Sent in plaintext for now — will be encrypted once shared key exists
177
+ creatorFingerprint: identity.fingerprint,
178
+ creatorPublicKey: identity.publicKey,
179
+ creatorDisplayName: identity.displayName,
180
+ ttlHours: 0, // permanent until deleted
181
+ maxMessages: 5000,
182
+ });
183
+
184
+ if (result.error) { fail(result.error); return; }
185
+
186
+ // Save channel locally
187
+ const channels = loadChannels();
188
+ channels.forEach(c => c.active = false);
189
+ channels.push({
190
+ id: result.id,
191
+ name,
192
+ createdAt: result.createdAt,
193
+ active: true,
194
+ role: 'creator',
195
+ });
196
+ saveChannels(channels);
197
+
198
+ console.log('');
199
+ ok(`Channel created: ${name}`);
200
+ console.log('');
201
+ console.log(` ${BOLD}Invite Code:${NC} ${G}${result.id}${NC}`);
202
+ console.log('');
203
+ console.log(` ${D}Share this code securely with collaborators.${NC}`);
204
+ console.log(` ${D}They join with: ${C}nha collab join ${result.id}${NC}`);
205
+ console.log('');
206
+ }
207
+
208
+ async function cmdJoin(args) {
209
+ const inviteCode = args[0];
210
+ if (!inviteCode) { fail('Usage: nha collab join <invite-code>'); return; }
211
+
212
+ const identity = getOrCreateIdentity();
213
+
214
+ // Check if already joined
215
+ const channels = loadChannels();
216
+ if (channels.find(c => c.id === inviteCode)) {
217
+ setActiveChannel(inviteCode);
218
+ ok(`Already a member. Switched to this channel.`);
219
+ return;
220
+ }
221
+
222
+ const result = await apiPost(`/channels/${inviteCode}/join`, {
223
+ fingerprint: identity.fingerprint,
224
+ publicKey: identity.publicKey,
225
+ displayName: identity.displayName,
226
+ });
227
+
228
+ if (result.error) { fail(result.error); return; }
229
+
230
+ // Get channel info
231
+ const chInfo = await apiGet(`/channels/${inviteCode}`);
232
+
233
+ channels.forEach(c => c.active = false);
234
+ channels.push({
235
+ id: inviteCode,
236
+ name: chInfo.name || 'Unknown',
237
+ createdAt: chInfo.createdAt,
238
+ active: true,
239
+ role: 'member',
240
+ });
241
+ saveChannels(channels);
242
+
243
+ ok(`Joined channel: ${chInfo.name || inviteCode} (${result.members} members)`);
244
+ }
245
+
246
+ async function cmdSend(args) {
247
+ const message = args.join(' ');
248
+ if (!message) { fail('Usage: nha collab send "your message"'); return; }
249
+
250
+ const identity = getOrCreateIdentity();
251
+ const channel = getActiveChannel();
252
+ if (!channel) { fail('No active channel. Create or join one first.'); return; }
253
+
254
+ // Get channel members for key exchange
255
+ const chInfo = await apiGet(`/channels/${channel.id}`);
256
+ if (chInfo.error) { fail(chInfo.error); return; }
257
+
258
+ // For simplicity, use a channel-wide shared key derived from creator's public key + our private key
259
+ // In production, you'd do pairwise key exchange for each member
260
+ const creatorMember = chInfo.members[0]; // First member is creator
261
+ if (!creatorMember) { fail('Channel has no members'); return; }
262
+
263
+ let sharedKey;
264
+ if (creatorMember.fingerprint === identity.fingerprint) {
265
+ // We're the creator — use a key derived from our own keypair (self-encryption)
266
+ // Other members will derive the same key using our public key
267
+ sharedKey = crypto.createHash('sha256').update(Buffer.from(identity.publicKey, 'base64')).update('alexandria-channel-key').digest();
268
+ } else {
269
+ sharedKey = deriveSharedSecret(identity.privateKey, creatorMember.publicKey);
270
+ }
271
+
272
+ const { nonce, ciphertext } = encrypt(message, sharedKey);
273
+
274
+ const result = await apiPost(`/channels/${channel.id}/messages`, {
275
+ senderFingerprint: identity.fingerprint,
276
+ nonce,
277
+ ciphertext,
278
+ type: 'text',
279
+ });
280
+
281
+ if (result.error) { fail(result.error); return; }
282
+ ok(`Sent (encrypted) at ${result.timestamp}`);
283
+ }
284
+
285
+ async function cmdRead(args) {
286
+ const identity = getOrCreateIdentity();
287
+ const channel = getActiveChannel();
288
+ if (!channel) { fail('No active channel. Create or join one first.'); return; }
289
+
290
+ const since = args[0] || ''; // optional timestamp
291
+ const query = since ? `?since=${since}&fp=${identity.fingerprint}` : `?fp=${identity.fingerprint}`;
292
+ const result = await apiGet(`/channels/${channel.id}/messages${query}`);
293
+ if (result.error) { fail(result.error); return; }
294
+
295
+ if (result.messages.length === 0) {
296
+ info('No messages yet in this channel.');
297
+ return;
298
+ }
299
+
300
+ // Get channel info for key exchange
301
+ const chInfo = await apiGet(`/channels/${channel.id}`);
302
+ const creatorMember = chInfo.members[0];
303
+
304
+ let sharedKey;
305
+ if (creatorMember.fingerprint === identity.fingerprint) {
306
+ sharedKey = crypto.createHash('sha256').update(Buffer.from(identity.publicKey, 'base64')).update('alexandria-channel-key').digest();
307
+ } else {
308
+ sharedKey = deriveSharedSecret(identity.privateKey, creatorMember.publicKey);
309
+ }
310
+
311
+ console.log(`\n ${BOLD}${channel.name}${NC} ${D}(${result.messages.length} messages)${NC}\n`);
312
+
313
+ for (const msg of result.messages) {
314
+ const time = new Date(msg.timestamp).toLocaleTimeString();
315
+ const sender = result.members?.find(m => m.fingerprint === msg.senderFingerprint);
316
+ const senderName = sender?.displayName || msg.senderFingerprint.slice(0, 8);
317
+
318
+ if (msg.type === 'system') {
319
+ console.log(` ${D}${time} — ${senderName} joined${NC}`);
320
+ continue;
321
+ }
322
+
323
+ try {
324
+ const plaintext = decrypt(msg.ciphertext, msg.nonce, sharedKey);
325
+ const isMe = msg.senderFingerprint === identity.fingerprint;
326
+ const color = isMe ? G : C;
327
+ console.log(` ${D}${time}${NC} ${color}${senderName}${NC}: ${plaintext}`);
328
+ } catch {
329
+ console.log(` ${D}${time}${NC} ${R}${senderName}${NC}: ${D}[cannot decrypt — key mismatch]${NC}`);
330
+ }
331
+ }
332
+ console.log('');
333
+ }
334
+
335
+ async function cmdList() {
336
+ const channels = loadChannels();
337
+ if (channels.length === 0) {
338
+ info('No channels. Create one: nha collab create "Project Name"');
339
+ return;
340
+ }
341
+
342
+ console.log(`\n ${BOLD}Your Channels (${channels.length})${NC}\n`);
343
+ for (const ch of channels) {
344
+ const active = ch.active ? `${G}●${NC}` : `${D}○${NC}`;
345
+ console.log(` ${active} ${ch.name} ${D}(${ch.role}, ${ch.id.slice(0, 8)}...)${NC}`);
346
+ }
347
+ console.log(`\n ${D}Switch: nha collab switch <number>${NC}\n`);
348
+ }
349
+
350
+ async function cmdMembers() {
351
+ const channel = getActiveChannel();
352
+ if (!channel) { fail('No active channel.'); return; }
353
+
354
+ const chInfo = await apiGet(`/channels/${channel.id}`);
355
+ if (chInfo.error) { fail(chInfo.error); return; }
356
+
357
+ console.log(`\n ${BOLD}${channel.name}${NC} — ${chInfo.memberCount} members\n`);
358
+ for (const m of chInfo.members) {
359
+ const online = (Date.now() - new Date(m.lastSeen).getTime()) < 300_000;
360
+ const status = online ? `${G}online${NC}` : `${D}${new Date(m.lastSeen).toLocaleString()}${NC}`;
361
+ console.log(` ${m.displayName || m.fingerprint.slice(0, 8)} — ${status}`);
362
+ }
363
+ console.log('');
364
+ }
365
+
366
+ async function cmdSwitch(args) {
367
+ const channels = loadChannels();
368
+ const idx = parseInt(args[0]) - 1;
369
+ if (isNaN(idx) || idx < 0 || idx >= channels.length) {
370
+ fail('Invalid channel number. Use: nha collab list');
371
+ return;
372
+ }
373
+ channels.forEach(c => c.active = false);
374
+ channels[idx].active = true;
375
+ saveChannels(channels);
376
+ ok(`Switched to: ${channels[idx].name}`);
377
+ }
378
+
379
+ async function cmdDelete() {
380
+ const identity = getOrCreateIdentity();
381
+ const channel = getActiveChannel();
382
+ if (!channel) { fail('No active channel.'); return; }
383
+
384
+ const result = await apiDelete(`/channels/${channel.id}`, {
385
+ fingerprint: identity.fingerprint,
386
+ });
387
+
388
+ if (result.error) { fail(result.error); return; }
389
+
390
+ const channels = loadChannels().filter(c => c.id !== channel.id);
391
+ if (channels.length > 0) channels[0].active = true;
392
+ saveChannels(channels);
393
+ ok(`Channel deleted: ${channel.name}`);
394
+ }
395
+
396
+ async function cmdIdentity() {
397
+ const identity = getOrCreateIdentity();
398
+ console.log(`\n ${BOLD}Your Identity${NC}\n`);
399
+ console.log(` Fingerprint: ${G}${identity.fingerprint}${NC}`);
400
+ console.log(` Display Name: ${identity.displayName}`);
401
+ console.log(` Created: ${identity.createdAt}`);
402
+ console.log(` Public Key: ${D}${identity.publicKey.slice(0, 40)}...${NC}`);
403
+ console.log(`\n ${D}Keys stored in: ${IDENTITY_FILE}${NC}\n`);
404
+ }
405
+
406
+ // ── Main Router ───────────────────────────────────────────────────────────
407
+
408
+ export async function cmdCollab(args) {
409
+ const sub = args[0] || 'help';
410
+ const subArgs = args.slice(1);
411
+
412
+ switch (sub) {
413
+ case 'create': return cmdCreate(subArgs);
414
+ case 'join': return cmdJoin(subArgs);
415
+ case 'send': return cmdSend(subArgs);
416
+ case 'read': return cmdRead(subArgs);
417
+ case 'list': case 'ls': return cmdList();
418
+ case 'members': return cmdMembers();
419
+ case 'switch': return cmdSwitch(subArgs);
420
+ case 'delete': return cmdDelete();
421
+ case 'identity': case 'id': return cmdIdentity();
422
+ case 'help': default:
423
+ console.log(`\n ${BOLD}${Y}Alexandria${NC} — E2E Encrypted Communication\n`);
424
+ console.log(` ${C}nha collab create "name"${NC} Create encrypted channel`);
425
+ console.log(` ${C}nha collab join <code>${NC} Join with invite code`);
426
+ console.log(` ${C}nha collab send "msg"${NC} Encrypt and send`);
427
+ console.log(` ${C}nha collab read${NC} Decrypt and show messages`);
428
+ console.log(` ${C}nha collab list${NC} Your channels`);
429
+ console.log(` ${C}nha collab members${NC} Show channel members`);
430
+ console.log(` ${C}nha collab switch <n>${NC} Switch active channel`);
431
+ console.log(` ${C}nha collab delete${NC} Delete channel`);
432
+ console.log(` ${C}nha collab identity${NC} Show your keypair info`);
433
+ console.log(`\n ${D}All messages E2E encrypted (X25519 + AES-256-GCM).${NC}`);
434
+ console.log(` ${D}The server sees only ciphertext. No accounts needed.${NC}\n`);
435
+ }
436
+ }
@@ -269,6 +269,111 @@ export async function cmdUI(args) {
269
269
  return;
270
270
  }
271
271
 
272
+ // ── Collab (Alexandria proxy) ─────────────────────────────────────
273
+ if (pathname.startsWith('/api/collab/')) {
274
+ const collabAction = pathname.split('/').pop();
275
+ const ALEX_API = 'https://nothumanallowed.com/api/v1/alexandria';
276
+
277
+ // Get or create collab identity
278
+ const collabDir = path.join(NHA_DIR, 'collab');
279
+ const idFile = path.join(collabDir, 'identity.json');
280
+ let identity;
281
+ if (fs.existsSync(idFile)) {
282
+ identity = JSON.parse(fs.readFileSync(idFile, 'utf-8'));
283
+ } else {
284
+ fs.mkdirSync(collabDir, { recursive: true });
285
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519', {
286
+ publicKeyEncoding: { type: 'spki', format: 'der' },
287
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
288
+ });
289
+ identity = {
290
+ publicKey: publicKey.toString('base64'),
291
+ privateKey: privateKey.toString('base64'),
292
+ fingerprint: crypto.createHash('sha256').update(publicKey).digest('hex').slice(0, 16),
293
+ displayName: config.profile?.name || 'User',
294
+ };
295
+ fs.writeFileSync(idFile, JSON.stringify(identity, null, 2), { mode: 0o600 });
296
+ }
297
+
298
+ if (collabAction === 'create' && method === 'POST') {
299
+ const body = await parseBody(req);
300
+ const r = await fetch(ALEX_API + '/channels', {
301
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ name: body.name, creatorFingerprint: identity.fingerprint, creatorPublicKey: identity.publicKey, creatorDisplayName: identity.displayName, visibility: body.visibility || 'private' }),
303
+ });
304
+ sendJSON(res, 200, await r.json());
305
+ logRequest(method, pathname, 200, Date.now() - start);
306
+ return;
307
+ }
308
+
309
+ if (collabAction === 'join' && method === 'POST') {
310
+ const body = await parseBody(req);
311
+ const r = await fetch(ALEX_API + '/channels/' + body.channelId + '/join', {
312
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
313
+ body: JSON.stringify({ fingerprint: identity.fingerprint, publicKey: identity.publicKey, displayName: identity.displayName }),
314
+ });
315
+ const data = await r.json();
316
+ // Get channel info for name
317
+ const info = await fetch(ALEX_API + '/channels/' + body.channelId);
318
+ const chInfo = await info.json();
319
+ data.name = chInfo.name || body.channelId;
320
+ sendJSON(res, 200, data);
321
+ logRequest(method, pathname, 200, Date.now() - start);
322
+ return;
323
+ }
324
+
325
+ if (collabAction === 'messages' && method === 'GET') {
326
+ const chId = url.searchParams.get('channelId');
327
+ if (!chId) { sendJSON(res, 400, { error: 'channelId required' }); return; }
328
+ const r = await fetch(ALEX_API + '/channels/' + chId + '/messages?fp=' + identity.fingerprint);
329
+ sendJSON(res, 200, await r.json());
330
+ logRequest(method, pathname, 200, Date.now() - start);
331
+ return;
332
+ }
333
+
334
+ if (collabAction === 'send' && method === 'POST') {
335
+ const body = await parseBody(req);
336
+ // For simplicity in web UI, send as plaintext (public channel mode)
337
+ // In private mode, encryption happens client-side
338
+ const r = await fetch(ALEX_API + '/channels/' + body.channelId + '/messages', {
339
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify({ senderFingerprint: identity.fingerprint, nonce: 'webui', ciphertext: Buffer.from(body.message).toString('base64'), type: 'text' }),
341
+ });
342
+ sendJSON(res, 200, await r.json());
343
+ logRequest(method, pathname, 200, Date.now() - start);
344
+ return;
345
+ }
346
+
347
+ if (collabAction === 'publish' && method === 'POST') {
348
+ const body = await parseBody(req);
349
+ // Load conversation and publish as public channel
350
+ const conv = loadConversation(body.conversationId);
351
+ if (!conv || !conv.messages || conv.messages.length === 0) {
352
+ sendJSON(res, 400, { error: 'Conversation not found or empty' });
353
+ logRequest(method, pathname, 400, Date.now() - start);
354
+ return;
355
+ }
356
+ const r = await fetch(ALEX_API + '/channels/publish-conversation', {
357
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({
359
+ name: body.title || conv.title || 'Published Conversation',
360
+ description: body.description || '',
361
+ creatorFingerprint: identity.fingerprint,
362
+ creatorPublicKey: identity.publicKey,
363
+ creatorDisplayName: identity.displayName,
364
+ messages: conv.messages,
365
+ }),
366
+ });
367
+ sendJSON(res, 200, await r.json());
368
+ logRequest(method, pathname, 200, Date.now() - start);
369
+ return;
370
+ }
371
+
372
+ sendJSON(res, 404, { error: 'Unknown collab action' });
373
+ logRequest(method, pathname, 404, Date.now() - start);
374
+ return;
375
+ }
376
+
272
377
  // GET /api/health — simple health check
273
378
  if (method === 'GET' && pathname === '/api/health') {
274
379
  sendJSON(res, 200, { ok: true, version: VERSION });
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '9.9.7';
8
+ export const VERSION = '10.1.0';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -938,11 +938,19 @@ async function daemonLoop() {
938
938
  },
939
939
  });
940
940
 
941
- // SABER quick scan for suspicious emails
942
- if (email.urls.length > 0 || email.from.includes('paypal') || email.from.includes('bank') || email.subject.toLowerCase().includes('urgent')) {
941
+ // SABER quick scan — only for emails with suspicious URLs (not known domains)
942
+ const suspiciousUrls = (email.urls || []).filter(u => {
943
+ try {
944
+ const host = new URL(u).hostname.toLowerCase();
945
+ // Skip known legitimate domains
946
+ const safe = ['google.com','accounts.google.com','paypal.com','paypal.it','apple.com','microsoft.com','github.com','linkedin.com','amazon.com','facebook.com','instagram.com','twitter.com','x.com','youtube.com','netflix.com','disneyplus.com','spotify.com'];
947
+ return !safe.some(d => host === d || host.endsWith('.' + d));
948
+ } catch { return true; } // malformed URL = suspicious
949
+ });
950
+ if (suspiciousUrls.length > 0) {
943
951
  try {
944
952
  const scanResult = await callAgent(config, 'saber',
945
- `Quick security scan: From="${email.from}" Subject="${email.subject}" URLs=${email.urls.join(', ')}\nIs this potentially phishing? Respond: SAFE or FLAGGED with reason (one line).`
953
+ `Quick phishing scan. ONLY flag if there are CLEAR red flags (domain spoofing, credential harvesting URLs, mismatched sender/domain). Do NOT flag legitimate emails from real companies.\n\nFrom: "${email.from}"\nSubject: "${email.subject}"\nSuspicious URLs: ${suspiciousUrls.join(', ')}\n\nRespond ONLY: SAFE or FLAGGED with one-line reason. Default to SAFE unless clearly malicious.`
946
954
  );
947
955
  if (scanResult.toUpperCase().includes('FLAGGED')) {
948
956
  await notify('Security Alert', `Suspicious email from ${email.from}: ${email.subject}\n${scanResult}`, config);
@@ -376,6 +376,7 @@ function render(){
376
376
  case 'slack':renderSlack(el);break;
377
377
  case 'birthdays':renderBirthdays(el);break;
378
378
  case 'agents':renderAgents(el);break;
379
+ case 'collab':renderCollab(el);break;
379
380
  case 'settings':renderSettings(el);break;
380
381
  }
381
382
  }
@@ -1590,6 +1591,128 @@ function completeMsTodo(taskId,listId){
1590
1591
  apiPost('/api/mstodo/'+taskId+'/complete',{listId:listId}).then(function(){mstodoData=null;renderMsTodo(document.getElementById('content'))});
1591
1592
  }
1592
1593
 
1594
+ // ---- COLLAB (Alexandria) ----
1595
+ var collabChannels=[];
1596
+ var collabMessages=[];
1597
+ var collabActiveChannel=null;
1598
+ var collabPolling=null;
1599
+
1600
+ function renderCollab(el){
1601
+ var h='<div style="max-width:800px;margin:0 auto;padding:20px">';
1602
+ h+='<h2 style="font-family:var(--term);color:var(--amber);font-size:18px;margin-bottom:16px">Alexandria — Encrypted Communication</h2>';
1603
+
1604
+ // Channel list
1605
+ h+='<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">';
1606
+ h+='<button onclick="collabCreateChannel()" style="padding:6px 12px;background:var(--amberdim);border:1px solid var(--amber3);border-radius:6px;color:var(--amber);font-family:var(--mono);font-size:11px;cursor:pointer">+ Create Channel</button>';
1607
+ h+='<button onclick="collabJoinChannel()" style="padding:6px 12px;background:var(--bg3);border:1px solid var(--border2);border-radius:6px;color:var(--fg);font-family:var(--mono);font-size:11px;cursor:pointer">Join Channel</button>';
1608
+ h+='<button onclick="publishConversation()" style="padding:6px 12px;background:var(--greendim);border:1px solid var(--green3);border-radius:6px;color:var(--green);font-family:var(--mono);font-size:11px;cursor:pointer">Publish Current Chat</button>';
1609
+ h+='</div>';
1610
+
1611
+ // Load channels from localStorage
1612
+ var saved=[];try{saved=JSON.parse(localStorage.getItem('nha_collab_channels')||'[]');}catch(e){}
1613
+ collabChannels=saved;
1614
+
1615
+ if(collabChannels.length>0){
1616
+ h+='<div style="margin-bottom:16px">';
1617
+ for(var i=0;i<collabChannels.length;i++){
1618
+ var ch=collabChannels[i];
1619
+ var active=collabActiveChannel===ch.id;
1620
+ h+='<div onclick="collabSelect(\\x27'+ch.id+'\\x27)" style="padding:8px 12px;cursor:pointer;border-left:3px solid '+(active?'var(--amber)':'transparent')+';background:'+(active?'var(--bg2)':'transparent')+';margin-bottom:2px;border-radius:0 6px 6px 0">';
1621
+ h+='<span style="font-size:12px;color:var(--fg)">'+esc(ch.name)+'</span>';
1622
+ h+='<span style="font-size:9px;color:var(--dim);margin-left:8px">'+ch.id.slice(0,8)+'...</span>';
1623
+ h+='</div>';
1624
+ }
1625
+ h+='</div>';
1626
+ }
1627
+
1628
+ // Messages area
1629
+ h+='<div id="collabMessages" style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;min-height:300px;max-height:500px;overflow-y:auto;padding:12px;margin-bottom:12px">';
1630
+ if(!collabActiveChannel){
1631
+ h+='<div style="text-align:center;color:var(--dim);padding:40px;font-size:12px">Select or create a channel to start</div>';
1632
+ }
1633
+ h+='</div>';
1634
+
1635
+ // Send bar
1636
+ h+='<div style="display:flex;gap:8px">';
1637
+ h+='<input id="collabInput" placeholder="Type encrypted message..." style="flex:1;padding:8px 12px;background:var(--bg);border:1px solid var(--border2);border-radius:6px;color:var(--fg);font-family:var(--mono);font-size:12px" onkeydown="if(event.key===\\x27Enter\\x27)collabSend()">';
1638
+ h+='<button onclick="collabSend()" style="padding:8px 16px;background:var(--amberdim);border:1px solid var(--amber3);border-radius:6px;color:var(--amber);font-family:var(--mono);font-size:11px;cursor:pointer">Send</button>';
1639
+ h+='</div>';
1640
+
1641
+ h+='</div>';
1642
+ el.innerHTML=h;
1643
+
1644
+ if(collabActiveChannel)collabLoadMessages();
1645
+ }
1646
+
1647
+ function collabCreateChannel(){
1648
+ var name=prompt('Channel name:');if(!name)return;
1649
+ apiPost('/api/collab/create',{name:name}).then(function(r){
1650
+ if(r.error){alert(r.error);return;}
1651
+ collabChannels.push({id:r.id,name:name});
1652
+ collabActiveChannel=r.id;
1653
+ localStorage.setItem('nha_collab_channels',JSON.stringify(collabChannels));
1654
+ prompt('Share this invite code:',r.id);
1655
+ renderCollab(document.getElementById('content'));
1656
+ });
1657
+ }
1658
+
1659
+ function collabJoinChannel(){
1660
+ var code=prompt('Invite code:');if(!code)return;
1661
+ apiPost('/api/collab/join',{channelId:code}).then(function(r){
1662
+ if(r.error){alert(r.error);return;}
1663
+ collabChannels.push({id:code,name:r.name||code.slice(0,8)});
1664
+ collabActiveChannel=code;
1665
+ localStorage.setItem('nha_collab_channels',JSON.stringify(collabChannels));
1666
+ renderCollab(document.getElementById('content'));
1667
+ });
1668
+ }
1669
+
1670
+ function collabSelect(id){
1671
+ collabActiveChannel=id;
1672
+ collabLoadMessages();
1673
+ }
1674
+
1675
+ function collabLoadMessages(){
1676
+ if(!collabActiveChannel)return;
1677
+ apiGet('/api/collab/messages?channelId='+collabActiveChannel).then(function(r){
1678
+ if(!r||!r.messages)return;
1679
+ collabMessages=r.messages;
1680
+ var el=document.getElementById('collabMessages');if(!el)return;
1681
+ if(collabMessages.length===0){el.innerHTML='<div style="text-align:center;color:var(--dim);padding:20px;font-size:11px">No messages yet</div>';return;}
1682
+ var h='';
1683
+ for(var i=0;i<collabMessages.length;i++){
1684
+ var m=collabMessages[i];
1685
+ var time=new Date(m.timestamp).toLocaleTimeString();
1686
+ var sender=m.senderName||m.senderFingerprint?.slice(0,8)||'Unknown';
1687
+ var content=m.content||m.plaintext||'[encrypted]';
1688
+ if(m.type==='system'){h+='<div style="text-align:center;color:var(--dim);font-size:10px;margin:4px 0">'+esc(sender)+' joined</div>';continue;}
1689
+ h+='<div style="margin-bottom:8px"><span style="font-size:10px;color:var(--dim)">'+time+'</span> <span style="font-size:11px;color:var(--amber);font-weight:600">'+esc(sender)+'</span><div style="font-size:12px;color:var(--fg);margin-top:2px;white-space:pre-wrap">'+esc(content)+'</div></div>';
1690
+ }
1691
+ el.innerHTML=h;
1692
+ el.scrollTop=el.scrollHeight;
1693
+ });
1694
+ }
1695
+
1696
+ function collabSend(){
1697
+ var inp=document.getElementById('collabInput');if(!inp)return;
1698
+ var msg=inp.value.trim();if(!msg||!collabActiveChannel)return;
1699
+ inp.value='';
1700
+ apiPost('/api/collab/send',{channelId:collabActiveChannel,message:msg}).then(function(r){
1701
+ if(r.error){alert(r.error);return;}
1702
+ collabLoadMessages();
1703
+ });
1704
+ }
1705
+
1706
+ function publishConversation(){
1707
+ if(!activeConvId||chatHistory.length===0){alert('No conversation to publish. Open a chat first.');return;}
1708
+ var title=prompt('Title for the published conversation:');if(!title)return;
1709
+ var desc=prompt('Short description (optional):','');
1710
+ apiPost('/api/collab/publish',{conversationId:activeConvId,title:title,description:desc||''}).then(function(r){
1711
+ if(r.error){alert('Error: '+r.error);return;}
1712
+ alert('Published on Alexandria!\\nURL: https://nothumanallowed.com/alexandria/'+r.id+'\\nMessages: '+r.messageCount);
1713
+ });
1714
+ }
1715
+
1593
1716
  function renderAgents(el){
1594
1717
  if(agentsList.length===0){el.innerHTML='<div style="text-align:center;padding:40px"><div class="spinner"></div><div style="color:var(--dim)">Loading agents...</div></div>';loadAgents().then(function(){renderAgents(el)});return}
1595
1718
 
@@ -2131,6 +2254,7 @@ init();
2131
2254
  <div class="sidebar__section">
2132
2255
  <div class="sidebar__label">AI</div>
2133
2256
  <div class="nav-item" data-view="agents" onclick="switchView('agents')"><span class="nav-item__icon">&#129302;</span> Agents</div>
2257
+ <div class="nav-item" data-view="collab" onclick="switchView('collab')"><span class="nav-item__icon">&#128274;</span> Collab</div>
2134
2258
  </div>
2135
2259
  <div class="sidebar__section">
2136
2260
  <div class="sidebar__label">Config</div>