nothumanallowed 12.7.0 → 13.2.13

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/src/config.mjs CHANGED
@@ -109,16 +109,6 @@ const DEFAULT_CONFIG = {
109
109
  slack: {
110
110
  token: '',
111
111
  },
112
- emailAccounts: [
113
- // Example:
114
- // {
115
- // label: 'Work',
116
- // address: 'you@company.com',
117
- // imap: { host: 'imap.company.com', port: 993, user: 'you@company.com', pass: '', tls: true },
118
- // smtp: { host: 'smtp.company.com', port: 587, user: 'you@company.com', pass: '', tls: false },
119
- // isDefault: false,
120
- // }
121
- ],
122
112
  profile: {
123
113
  name: '',
124
114
  email: '',
@@ -298,42 +288,6 @@ export function setConfigValue(key, value) {
298
288
  'language': 'language',
299
289
  };
300
290
 
301
- // Special handler for email account management
302
- if (key.startsWith('email-add')) {
303
- // nha config set email-add "label|address|imap-host|imap-port|imap-user|imap-pass|smtp-host|smtp-port|smtp-user|smtp-pass"
304
- const parts = value.split('|').map(p => p.trim());
305
- if (parts.length < 6) return false;
306
- const [label, address, imapHost, imapPort, imapUser, imapPass, smtpHost, smtpPort, smtpUser, smtpPass] = parts;
307
- if (!config.emailAccounts) config.emailAccounts = [];
308
- config.emailAccounts.push({
309
- label: label || 'Email',
310
- address: address || imapUser,
311
- imap: { host: imapHost, port: parseInt(imapPort) || 993, user: imapUser, pass: imapPass, tls: true },
312
- smtp: smtpHost ? { host: smtpHost, port: parseInt(smtpPort) || 587, user: smtpUser || imapUser, pass: smtpPass || imapPass, tls: false } : null,
313
- isDefault: config.emailAccounts.length === 0,
314
- });
315
- saveConfig(config);
316
- return true;
317
- }
318
-
319
- if (key === 'email-remove') {
320
- if (!config.emailAccounts) return false;
321
- const idx = parseInt(value);
322
- if (isNaN(idx) || idx < 0 || idx >= config.emailAccounts.length) return false;
323
- config.emailAccounts.splice(idx, 1);
324
- saveConfig(config);
325
- return true;
326
- }
327
-
328
- if (key === 'email-default') {
329
- if (!config.emailAccounts) return false;
330
- const idx = parseInt(value);
331
- if (isNaN(idx) || idx < 0 || idx >= config.emailAccounts.length) return false;
332
- config.emailAccounts.forEach((a, i) => a.isDefault = i === idx);
333
- saveConfig(config);
334
- return true;
335
- }
336
-
337
291
  const resolved = aliases[key] || key;
338
292
  const resolvedParts = resolved.split('.');
339
293
 
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 = '12.7.0';
8
+ export const VERSION = '13.2.13';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -44,14 +44,9 @@ function generatePKCE() {
44
44
  function openBrowser(url) {
45
45
  const platform = os.platform();
46
46
  try {
47
- if (platform === 'darwin') {
48
- execSync(`open "${url}"`);
49
- } else if (platform === 'win32') {
50
- // Use rundll32 — more reliable than 'start' in batch/npm contexts
51
- execSync(`rundll32 url.dll,FileProtocolHandler "${url}"`);
52
- } else {
53
- execSync(`xdg-open "${url}"`);
54
- }
47
+ if (platform === 'darwin') execSync(`open "${url}"`);
48
+ else if (platform === 'win32') execSync(`start "" "${url}"`);
49
+ else execSync(`xdg-open "${url}"`);
55
50
  } catch {
56
51
  warn('Could not open browser automatically.');
57
52
  info(`Open this URL manually:\n\n ${url}\n`);
@@ -252,12 +252,24 @@ export async function callNHA(apiKey, model, systemPrompt, userMessage, stream =
252
252
  }
253
253
  } catch {}
254
254
 
255
+ // Sanitize content before sending through SENTINEL — strip patterns that trigger WAF
256
+ // (backticks, template literals, SSTI patterns) without affecting semantics
257
+ const sanitizeForSentinel = (s) => String(s || '')
258
+ .replace(/`/g, "'") // backtick → single quote
259
+ .replace(/\$\{([^}]*)\}/g, '[$1]') // ${expr} → [expr]
260
+ .replace(/\{\{([^}]*)\}\}/g, '{$1}') // {{expr}} → {expr}
261
+ .replace(/\{%([^%]*)%\}/g, '{$1}') // {% expr %} → { expr }
262
+ .replace(/<!ENTITY/gi, '&lt;!ENTITY') // XXE
263
+ .replace(/SYSTEM\s+["']/gi, 'SYSTEM ') // XXE SYSTEM
264
+ .replace(/\|\|\(/g, '||(') // LDAP (cosmetic, non-breaking)
265
+ .replace(/\)\|\|/g, ')||'); // LDAP
266
+
255
267
  const body = {
256
268
  model: model || '/opt/models/qwen3-32b',
257
269
  max_tokens: thinkingEnabled ? 8192 : 4096,
258
270
  messages: [
259
- { role: 'system', content: systemPrompt },
260
- { role: 'user', content: userMessage },
271
+ { role: 'system', content: sanitizeForSentinel(systemPrompt) },
272
+ { role: 'user', content: sanitizeForSentinel(userMessage) },
261
273
  ],
262
274
  stream,
263
275
  chat_template_kwargs: { enable_thinking: thinkingEnabled },
@@ -318,7 +330,7 @@ export function getApiKey(config, provider) {
318
330
  * @returns {Promise<string>} The LLM response text.
319
331
  */
320
332
  export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
321
- const provider = opts.provider || config.llm.provider || 'anthropic';
333
+ const provider = opts.provider || config.llm.provider || (config.llm.apiKey ? 'anthropic' : 'nha');
322
334
  const model = opts.model || config.llm.model || null;
323
335
  const apiKey = getApiKey(config, provider);
324
336
  if (!apiKey) throw new Error(`No API key for ${provider}`);
@@ -443,7 +455,7 @@ export async function callLLMVision(config, systemPrompt, userMessage, media) {
443
455
  * @returns {Promise<string>} The full LLM response text.
444
456
  */
445
457
  export async function callLLMStream(config, systemPrompt, userMessage, onToken, opts = {}) {
446
- const provider = opts.provider || config.llm.provider || 'anthropic';
458
+ const provider = opts.provider || config.llm.provider || (config.llm.apiKey ? 'anthropic' : 'nha');
447
459
  const model = opts.model || config.llm.model || null;
448
460
  const apiKey = getApiKey(config, provider);
449
461
  if (!apiKey) throw new Error(`No API key for ${provider}`);
@@ -474,6 +486,59 @@ export async function callLLMStream(config, systemPrompt, userMessage, onToken,
474
486
  return text;
475
487
  }
476
488
 
489
+ // NHA Free tier: delegate entirely to callNHA which handles sanitization,
490
+ // thinking config, and the proxy correctly — then wrap with callback
491
+ if (provider === 'nha') {
492
+ // callNHA with stream=true returns the streamSSE result (async iterable/text)
493
+ // We need callback-based streaming, so use streamSSEWithCallback directly
494
+ // after building the sanitized body ourselves
495
+ const sanitize = (s) => String(s || '')
496
+ .replace(/`/g, "'")
497
+ .replace(/\$\{([^}]*)\}/g, '[$1]')
498
+ .replace(/\{\{([^}]*)\}\}/g, '{$1}')
499
+ .replace(/\{%([^%]*)%\}/g, '{$1}')
500
+ .replace(/<!ENTITY/gi, '&lt;!ENTITY')
501
+ .replace(/SYSTEM\s+["']/gi, 'SYSTEM ')
502
+ .replace(/\|\|\(/g, '||(')
503
+ .replace(/\)\|\|/g, ')||');
504
+
505
+ let thinkingEnabled = false;
506
+ try {
507
+ const fs2 = await import('fs');
508
+ const path2 = await import('path');
509
+ const os2 = await import('os');
510
+ const cfgFile2 = path2.default.join(os2.default.homedir(), '.nha', 'config.json');
511
+ if (fs2.default.existsSync(cfgFile2)) {
512
+ const cfg2 = JSON.parse(fs2.default.readFileSync(cfgFile2, 'utf-8'));
513
+ thinkingEnabled = cfg2.thinking === true || cfg2.thinking === 'on' || cfg2.thinking === 'true';
514
+ }
515
+ } catch {}
516
+
517
+ const nhaBody = {
518
+ model: model || '/opt/models/qwen3-32b',
519
+ max_tokens: thinkingEnabled ? 8192 : 4096,
520
+ messages: [
521
+ { role: 'system', content: sanitize(systemPrompt) },
522
+ { role: 'user', content: sanitize(userMessage) },
523
+ ],
524
+ stream: true,
525
+ chat_template_kwargs: { enable_thinking: thinkingEnabled },
526
+ };
527
+ const nhaRes = await fetch('https://nothumanallowed.com/api/v1/liara/chat', {
528
+ method: 'POST',
529
+ headers: { 'Content-Type': 'application/json' },
530
+ body: JSON.stringify(nhaBody),
531
+ });
532
+ if (!nhaRes.ok) {
533
+ const err = await nhaRes.text();
534
+ throw new Error(`NHA Free ${nhaRes.status}: ${err}`);
535
+ }
536
+ // Node.js native fetch ReadableStream closes after first TCP buffer for SSE.
537
+ // Use res.text() to get the full response, then parse SSE lines synchronously.
538
+ const rawText = await nhaRes.text();
539
+ return parseSSEText(rawText, 'openai', onToken);
540
+ }
541
+
477
542
  const format = provider === 'anthropic' ? 'anthropic' : 'openai';
478
543
  const body = buildRequestBody(provider, model, systemPrompt, userMessage, true);
479
544
  const url = getProviderUrl(provider, model, apiKey);
@@ -558,12 +623,62 @@ function getProviderHeaders(provider, apiKey) {
558
623
  };
559
624
  }
560
625
 
626
+ /** Parse a complete SSE text body (already read via res.text()) and call onToken per token. */
627
+ function parseSSEText(text, format, onToken) {
628
+ let fullText = '';
629
+ let thinkBuf = '';
630
+ let inThink = false;
631
+
632
+ for (const line of text.split('\n')) {
633
+ if (!line.startsWith('data: ')) continue;
634
+ const data = line.slice(6).trim();
635
+ if (data === '[DONE]') continue;
636
+
637
+ try {
638
+ const json = JSON.parse(data);
639
+ let chunk = '';
640
+ if (format === 'anthropic') {
641
+ if (json.type === 'content_block_delta') chunk = json.delta?.text || '';
642
+ } else {
643
+ chunk = json.choices?.[0]?.delta?.content || '';
644
+ }
645
+
646
+ if (chunk) {
647
+ thinkBuf += chunk;
648
+ let out = '';
649
+ while (thinkBuf.length > 0) {
650
+ if (inThink) {
651
+ const end = thinkBuf.indexOf('</think>');
652
+ if (end === -1) { thinkBuf = ''; break; }
653
+ inThink = false;
654
+ thinkBuf = thinkBuf.slice(end + 8);
655
+ } else {
656
+ const start = thinkBuf.indexOf('<think>');
657
+ if (start === -1) { out += thinkBuf; thinkBuf = ''; break; }
658
+ out += thinkBuf.slice(0, start);
659
+ inThink = true;
660
+ thinkBuf = thinkBuf.slice(start + 7);
661
+ }
662
+ }
663
+ if (out) {
664
+ fullText += out;
665
+ if (onToken) onToken(out);
666
+ }
667
+ }
668
+ } catch {}
669
+ }
670
+
671
+ return fullText;
672
+ }
673
+
561
674
  /** SSE stream parser with onToken callback (does NOT write to stdout directly) */
562
675
  async function streamSSEWithCallback(res, format, onToken) {
563
676
  const reader = res.body.getReader();
564
677
  const decoder = new TextDecoder();
565
678
  let buffer = '';
566
679
  let fullText = '';
680
+ let thinkBuf = ''; // accumulates <think>...</think> content to suppress
681
+ let inThink = false;
567
682
 
568
683
  while (true) {
569
684
  const { done, value } = await reader.read();
@@ -591,8 +706,27 @@ async function streamSSEWithCallback(res, format, onToken) {
591
706
  }
592
707
 
593
708
  if (chunk) {
594
- fullText += chunk;
595
- if (onToken) onToken(chunk);
709
+ // Filter out <think>...</think> blocks from Qwen3 thinking mode
710
+ thinkBuf += chunk;
711
+ let out = '';
712
+ while (thinkBuf.length > 0) {
713
+ if (inThink) {
714
+ const end = thinkBuf.indexOf('</think>');
715
+ if (end === -1) { thinkBuf = ''; break; } // still inside think block
716
+ inThink = false;
717
+ thinkBuf = thinkBuf.slice(end + 8);
718
+ } else {
719
+ const start = thinkBuf.indexOf('<think>');
720
+ if (start === -1) { out += thinkBuf; thinkBuf = ''; break; }
721
+ out += thinkBuf.slice(0, start);
722
+ inThink = true;
723
+ thinkBuf = thinkBuf.slice(start + 7);
724
+ }
725
+ }
726
+ if (out) {
727
+ fullText += out;
728
+ if (onToken) onToken(out);
729
+ }
596
730
  }
597
731
  } catch {}
598
732
  }
@@ -55,8 +55,6 @@ export const DESTRUCTIVE_ACTIONS = new Set([
55
55
  'gmail_send_attach',
56
56
  'gmail_reply',
57
57
  'gmail_delete',
58
- 'email_send',
59
- 'outreach_campaign',
60
58
  'calendar_create',
61
59
  'calendar_move',
62
60
  'calendar_update',
@@ -94,42 +92,11 @@ CRITICAL: Never output a JSON block as a "suggestion" or "let me try" — every
94
92
 
95
93
  TOOLS:
96
94
 
97
- --- EMAIL (Gmail) ---
95
+ --- EMAIL ---
98
96
 
99
97
  1. gmail_list(query: string, maxResults?: number)
100
- Search Gmail. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
98
+ Search emails. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
101
99
  Default maxResults = 10.
102
- NOTE: For non-Gmail accounts use email_list instead.
103
-
104
- --- EMAIL (Multi-Provider IMAP/SMTP) ---
105
-
106
- email_accounts()
107
- List all configured email accounts (Gmail + IMAP/SMTP).
108
-
109
- email_list(account?: string, limit?: number)
110
- List recent messages from an IMAP email account. If account not specified, lists all accounts.
111
- account can be the label ("Work") or address ("you@company.com") or index (1, 2, 3).
112
- Read-only: NEVER deletes or modifies messages on the server.
113
-
114
- email_read(messageId: string, account?: string)
115
- Read a single message by ID from an IMAP account.
116
-
117
- email_send(to: string, subject: string, body: string, from?: string, cc?: string, bcc?: string)
118
- Send email via SMTP. If 'from' specified, uses that account's SMTP. Otherwise uses default account.
119
- ALWAYS confirm with the user before sending.
120
-
121
- find_contact_email(url: string)
122
- Scrape a website to find contact email addresses. Checks /contact, /about, /contatti, home page.
123
- Returns list of email addresses found.
124
-
125
- outreach_campaign(query: string, subject: string, template: string, maxSites?: number)
126
- Full market research pipeline: search → find contact emails → prepare campaign.
127
- 1. Searches the web for businesses matching the query
128
- 2. Visits each site's contact page to extract emails
129
- 3. Returns a report with all found emails + template preview
130
- Does NOT send automatically — reports findings and waits for user confirmation.
131
-
132
- --- EMAIL (Gmail) continued ---
133
100
 
134
101
  2. gmail_read(messageId: string)
135
102
  Read the full body of an email by its ID (returned by gmail_list).
@@ -853,149 +820,6 @@ export async function executeTool(action, params, config) {
853
820
  return `Email sent to ${params.to} with attachment "${downloaded.name}" (${formatFileSize(downloaded.size)}).`;
854
821
  }
855
822
 
856
- // ── IMAP/SMTP Multi-Provider Email ────────────────────────────────────
857
- case 'email_accounts': {
858
- const { getEmailAccounts } = await import('./imap-email.mjs');
859
- const accounts = getEmailAccounts(config);
860
- if (accounts.length === 0) return 'No email accounts configured. Use "nha config set email-add" to add one, or configure in Settings.';
861
- return accounts.map((a, i) => `${i + 1}. ${a.label || 'Email'} <${a.address}>${a.isDefault ? ' (default)' : ''}`).join('\n');
862
- }
863
-
864
- case 'email_list': {
865
- const { listAllInboxes, listImapMessages, getEmailAccounts } = await import('./imap-email.mjs');
866
- const accounts = getEmailAccounts(config);
867
- if (accounts.length === 0) return 'No email accounts configured.';
868
-
869
- // If account specified, use it; otherwise list all
870
- if (params.account) {
871
- const acct = accounts.find(a => a.label === params.account || a.address === params.account) || accounts[parseInt(params.account) - 1];
872
- if (!acct) return `Account "${params.account}" not found.`;
873
- const msgs = await listImapMessages(acct, params.limit || 10);
874
- return msgs.length === 0 ? `No messages in ${acct.label || acct.address}.`
875
- : `${acct.label || acct.address} (${msgs.length} messages):\n\n` + msgs.map((m, i) =>
876
- `${i + 1}. ${m.seen ? '' : '[NEW] '}${m.from}\n ${m.subject}\n ${m.date}`).join('\n\n');
877
- }
878
-
879
- const all = await listAllInboxes(config, params.limit || 10);
880
- return all.map(a => {
881
- if (a.error) return `${a.account}: Error — ${a.error}`;
882
- if (a.messages.length === 0) return `${a.account}: No messages.`;
883
- return `${a.account} (${a.messages.length}):\n` + a.messages.map((m, i) =>
884
- ` ${i + 1}. ${m.seen ? '' : '[NEW] '}${m.from} — ${m.subject}`).join('\n');
885
- }).join('\n\n');
886
- }
887
-
888
- case 'email_read': {
889
- const { readImapMessage, getEmailAccounts } = await import('./imap-email.mjs');
890
- const accounts = getEmailAccounts(config);
891
- if (!params.messageId) return 'messageId required.';
892
- const acct = params.account
893
- ? accounts.find(a => a.label === params.account || a.address === params.account) || accounts[0]
894
- : accounts[0];
895
- if (!acct) return 'No email accounts configured.';
896
- const body = await readImapMessage(acct, params.messageId);
897
- return body || 'Empty message.';
898
- }
899
-
900
- case 'email_send': {
901
- const { sendSmtpEmail, getEmailAccounts } = await import('./imap-email.mjs');
902
- const accounts = getEmailAccounts(config);
903
- if (!params.to || !params.subject) return 'to and subject required.';
904
-
905
- // Find the account to send from
906
- let acct;
907
- if (params.from) {
908
- acct = accounts.find(a => a.address === params.from || a.label === params.from);
909
- }
910
- if (!acct) acct = accounts.find(a => a.isDefault) || accounts[0];
911
- if (!acct || !acct.smtp) return 'No SMTP account configured for sending.';
912
-
913
- const result = await sendSmtpEmail(acct, params.to, params.subject, params.body || '', params.cc || '', params.bcc || '');
914
- return result.success ? `Email sent from ${acct.address} to ${params.to}.` : 'Send failed.';
915
- }
916
-
917
- case 'find_contact_email': {
918
- // Scrape a website's contact/about page to find email addresses
919
- const url = params.url || params.website;
920
- if (!url) return 'url required.';
921
-
922
- const fetchModule = await import('./web-tools.mjs');
923
- const contactPaths = ['/contact', '/contacts', '/contatti', '/about', '/about-us', '/chi-siamo', '/impressum', '/kontakt', ''];
924
- const emails = new Set();
925
-
926
- for (const contactPath of contactPaths) {
927
- try {
928
- const fullUrl = url.replace(/\/+$/, '') + contactPath;
929
- const result = await fetchModule.fetchUrl(config, fullUrl);
930
- if (result.error) continue;
931
- const text = result.text || result.content || '';
932
- // Extract emails with regex
933
- const found = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
934
- found.forEach(e => {
935
- const lower = e.toLowerCase();
936
- // Filter out image/asset emails and common fake ones
937
- if (!lower.endsWith('.png') && !lower.endsWith('.jpg') && !lower.endsWith('.gif')
938
- && !lower.includes('example.com') && !lower.includes('sentry.io')
939
- && !lower.includes('webpack') && !lower.includes('wixpress')) {
940
- emails.add(lower);
941
- }
942
- });
943
- if (emails.size > 0) break; // Found emails, stop searching
944
- } catch { /* skip failed pages */ }
945
- }
946
-
947
- if (emails.size === 0) return `No email addresses found on ${url} (checked contact, about, home pages).`;
948
- return `Found ${emails.size} email(s) on ${url}:\n${[...emails].join('\n')}`;
949
- }
950
-
951
- case 'outreach_campaign': {
952
- // Full pipeline: search → find emails → send template
953
- const query = params.query || params.search;
954
- const template = params.template || params.body;
955
- const subject = params.subject || 'Introduction';
956
- if (!query) return 'query required (e.g., "Italian valve manufacturers").';
957
- if (!template) return 'template required (email body text).';
958
-
959
- // Step 1: Web search
960
- const fetchModule = await import('./web-tools.mjs');
961
- const searchResult = await fetchModule.webSearch(config, query, 10);
962
- const urls = (searchResult.results || []).map(r => r.url).filter(Boolean).slice(0, params.maxSites || 5);
963
-
964
- if (urls.length === 0) return 'No results found for the search query.';
965
-
966
- // Step 2: Find emails from each site
967
- const found = [];
968
- for (const siteUrl of urls) {
969
- try {
970
- const contactPaths = ['/contact', '/contacts', '/contatti', '/about', ''];
971
- for (const cp of contactPaths) {
972
- const fullUrl = siteUrl.replace(/\/+$/, '') + cp;
973
- const result = await fetchModule.fetchUrl(config, fullUrl);
974
- const text = result.text || result.content || '';
975
- const emails = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
976
- const valid = emails.filter(e => !e.endsWith('.png') && !e.includes('example.com')).map(e => e.toLowerCase());
977
- if (valid.length > 0) {
978
- found.push({ site: siteUrl, emails: [...new Set(valid)] });
979
- break;
980
- }
981
- }
982
- } catch { /* skip */ }
983
- }
984
-
985
- if (found.length === 0) return `Searched ${urls.length} sites but found no contact emails. Try a different search query.`;
986
-
987
- // Step 3: Report (don't send automatically — user must confirm)
988
- let report = `Found ${found.length} sites with contact emails:\n\n`;
989
- found.forEach((f, i) => {
990
- report += `${i + 1}. ${f.site}\n ${f.emails.join(', ')}\n`;
991
- });
992
- report += `\nSubject: "${subject}"\nTemplate preview: ${template.slice(0, 200)}...\n`;
993
- report += `\nTo send to all, reply: "send the campaign"`;
994
- report += `\nTo send to specific ones, reply: "send to #1 and #3"`;
995
-
996
- return report;
997
- }
998
-
999
823
  // ── Calendar ──────────────────────────────────────────────────────────
1000
824
  case 'calendar_today': {
1001
825
  const events = await getTodayEvents(config);