nothumanallowed 9.9.6 → 10.0.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.6",
3
+ "version": "10.0.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
+ }
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.6';
8
+ export const VERSION = '10.0.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);
@@ -1928,7 +1928,7 @@ var wsMaxRetries = 1;
1928
1928
  function connectWebSocket() {
1929
1929
  if (wsRetryCount >= wsMaxRetries) return; // Stop trying after 3 failures
1930
1930
  try {
1931
- ws = new WebSocket('ws://' + window.location.hostname + ':3848');
1931
+ ws = new WebSocket('ws://' + window.location.host);
1932
1932
  } catch(e) { return; }
1933
1933
 
1934
1934
  ws.onopen = function() {