natureco-cli 5.7.19 → 5.9.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": "natureco-cli",
3
- "version": "5.7.19",
3
+ "version": "5.9.0",
4
4
  "description": "OpenClaw'dan daha güvenli, daha hızlı, daha ucuz AI agent CLI. Multi-agent, self-evolving skills, audit log, maliyet optimizasyonu ve NatureCo platform-native.",
5
5
  "bin": {
6
6
  "natureco": "bin/natureco.js"
@@ -27,7 +27,8 @@ const { accumulateToolCallDeltas, finalizeToolCalls } = require('../utils/stream
27
27
  const { createPasteSafeInput, createOutputFilter, enableBracketedPaste, disableBracketedPaste, restoreNewlines, clearPasteContext } = require('../utils/paste-safe-input');
28
28
  const { getMemoryStore } = require('../utils/memory-store');
29
29
  const { buildSkillIndex } = require('../utils/skill-index');
30
- const { buildTiers, assemble, stableContextKey } = require('../utils/system-prompt');
30
+ const { buildTiers, assemble } = require('../utils/system-prompt');
31
+ const { ToolGuardrails } = require('../utils/tool-guardrails');
31
32
 
32
33
  // v5.4.6: Model adi sizintisini engelle — global'e ata, callback'lerden erisebilir olsun
33
34
  const MODEL_NAMES_TO_HIDE = ['MiniMax-M2.5', 'MiniMaxM2.5', 'minimaxm25', 'Claude-3', 'GPT-4', 'ChatGPT'];
@@ -58,6 +59,46 @@ function getToolDefs() {
58
59
  return _toolDefs;
59
60
  }
60
61
 
62
+ // ── System prompt tier cache (Hermes-style prefix cache warmth) ────────────
63
+ // stable+context built once at session start, volatile rebuilt per turn.
64
+ let _cachedStable = '';
65
+ let _cachedContext = '';
66
+ let _cachedTierOpts = null; // opts snapshot for volatile-only rebuilds
67
+
68
+ function rebuildSystemPrompt(opts) {
69
+ // If stable/context opts changed, rebuild them too
70
+ const needsFullRebuild = !_cachedTierOpts ||
71
+ _cachedTierOpts.botName !== opts.botName ||
72
+ _cachedTierOpts.soulSummary !== opts.soulSummary ||
73
+ _cachedTierOpts.skillsIndexBlock !== opts.skillsIndexBlock ||
74
+ _cachedTierOpts.crossSessionContext !== opts.crossSessionContext;
75
+
76
+ if (needsFullRebuild || !_cachedStable) {
77
+ const tiers = buildTiers(opts);
78
+ _cachedStable = tiers.stable;
79
+ _cachedContext = tiers.context;
80
+ _cachedTierOpts = {
81
+ botName: opts.botName,
82
+ soulSummary: opts.soulSummary,
83
+ skillsIndexBlock: opts.skillsIndexBlock,
84
+ crossSessionContext: opts.crossSessionContext,
85
+ };
86
+ }
87
+ // Volatile always rebuilt fresh
88
+ const volatileOnly = buildTiers({
89
+ ...opts,
90
+ // Pass empty strings for stable/context fields so buildTiers only builds volatile
91
+ botName: '',
92
+ soulSummary: '',
93
+ skillsIndexBlock: '',
94
+ crossSessionContext: '',
95
+ });
96
+ return assemble(_cachedStable, _cachedContext, volatileOnly.volatile);
97
+ }
98
+
99
+ // ── Tool Guardrails instance (Hermes-style) ─────────────────────────────
100
+ const guardrails = new ToolGuardrails();
101
+
61
102
  // CLI komutları (REPL içinden çalıştırılabilir)
62
103
  const CLI_COMMANDS = {
63
104
  '/doctor': { desc: 'Sistem sağlığı kontrolü', run: ['doctor'] },
@@ -233,6 +274,7 @@ async function sendStreaming(providerUrl, providerApiKey, messages, model, onChu
233
274
  const isMM = isMiniMax(providerUrl);
234
275
  const toolDefs = getToolDefs();
235
276
  const toolParam = toOpenAIFormat(toolDefs);
277
+ guardrails.reset();
236
278
 
237
279
  let currentMessages = messages;
238
280
  let fullText = '';
@@ -268,17 +310,18 @@ async function sendStreaming(providerUrl, providerApiKey, messages, model, onChu
268
310
 
269
311
  while (iterations < MAX_TOOL_ITERATIONS) {
270
312
  iterations++;
313
+ const shouldStream = !isMM; // MiniMax streaming endpoint doesn't support tool_calls
271
314
  const body = {
272
315
  model,
273
316
  messages: currentMessages,
274
- stream: false, // v5.4.18: tum cevap bekle, sonra fix et
317
+ stream: shouldStream,
275
318
  temperature: 0.3,
276
319
  max_tokens: 2048,
277
320
  };
278
321
  if (toolParam) body.tools = toolParam;
279
322
  if (isMM) body.tool_choice = 'auto'; // MiniMax için explicit
280
323
 
281
- if (!body.stream) {
324
+ if (!shouldStream) {
282
325
  // MiniMax (non-stream) — tool_calls desteklemiyor varsayalım
283
326
  const res = await apiRequest(providerUrl, providerApiKey, body, false);
284
327
  const msg = res.choices?.[0]?.message || {};
@@ -408,12 +451,25 @@ async function processToolCalls(toolCalls, onToolCall) {
408
451
  return { name, args, id };
409
452
  });
410
453
 
411
- // Separate parallel-safe and sequential tools
412
- const parallelBatch = parsed.filter(p => PARALLEL_SAFE_TOOLS.has(p.name));
413
- const sequentialBatch = parsed.filter(p => !PARALLEL_SAFE_TOOLS.has(p.name));
454
+ // Filter out blocked tools via guardrails
455
+ guardrails.startIteration();
456
+ const blocked = parsed.filter(p => {
457
+ const check = guardrails.check(p.name, p.args);
458
+ if (check.blocked) {
459
+ if (onToolCall) onToolCall({ name: p.name, args: p.args, status: 'done', result: { error: check.reason } });
460
+ results.push({ ...p, result: { error: check.reason }, _blocked: true });
461
+ return false;
462
+ }
463
+ return true;
464
+ });
465
+ const allowed = parsed.filter(p => !results.some(r => r.id === p.id && r._blocked));
466
+
467
+ // Separate parallel-safe and sequential tools (over allowed only)
468
+ const parallelBatch = allowed.filter(p => PARALLEL_SAFE_TOOLS.has(p.name));
469
+ const sequentialBatch = allowed.filter(p => !PARALLEL_SAFE_TOOLS.has(p.name));
414
470
 
415
- // Notify UI for all
416
- for (const p of parsed) {
471
+ // Notify UI for all non-blocked
472
+ for (const p of allowed) {
417
473
  if (onToolCall) onToolCall({ name: p.name, args: p.args, status: 'running' });
418
474
  }
419
475
 
@@ -421,6 +477,8 @@ async function processToolCalls(toolCalls, onToolCall) {
421
477
  if (parallelBatch.length > 0) {
422
478
  const parallelResults = await Promise.all(parallelBatch.map(async (p) => {
423
479
  const result = await executeTool(p.name, p.args, toolDefs);
480
+ const success = result?.success !== false;
481
+ guardrails.record(p.name, p.args, success);
424
482
  return { ...p, result };
425
483
  }));
426
484
  results.push(...parallelResults);
@@ -429,9 +487,16 @@ async function processToolCalls(toolCalls, onToolCall) {
429
487
  // Run sequential batch one at a time
430
488
  for (const p of sequentialBatch) {
431
489
  const result = await executeTool(p.name, p.args, toolDefs);
490
+ const success = result?.success !== false;
491
+ guardrails.record(p.name, p.args, success);
432
492
  results.push({ ...p, result });
433
493
  }
434
494
 
495
+ // No-progress check: if all tools failed, inject warning
496
+ if (guardrails.isNoProgress()) {
497
+ if (onToolCall) onToolCall({ name: '_no_progress', args: null, status: 'done', result: { error: 'All tools failed this iteration' } });
498
+ }
499
+
435
500
  // Notify UI done + build messages
436
501
  const messages = [];
437
502
  for (const { name, id, result } of results) {
@@ -575,26 +640,23 @@ async function startRepl(args) {
575
640
  }
576
641
 
577
642
  // System prompt oluştur (memory + identity + persistent bağlam)
578
- // v5.4.6: Bot adı zorlaması EN GÜÇLÜ + SOUL.md EN BAŞTA
643
+ // v5.6.5: Kucuk model tespiti (Groq, Mistral Small, Ollama) - SOUL injection skip
579
644
  const botName = memory.botName || 'Asistan';
580
645
  const userName = memory.name || memory.nickname || 'kanka';
581
- // v5.6.5: Kucuk model tespiti (Groq, Mistral Small, Ollama) - SOUL injection skip
582
646
  const isSmallModel = (cfg.providerUrl || '').includes('groq.com') ||
583
647
  (cfg.providerUrl || '').includes('mistral.ai') ||
584
648
  (cfg.providerUrl || '').includes('localhost') ||
585
649
  (cfg.providerUrl || '').includes('ollama');
586
- // `let` (not `const`) because /system <text> reassigns it at line ~796.
587
- // Before this fix, /system would throw "Assignment to constant variable"
588
- // and tear down the REPL session mid-conversation.
589
- const tiers = buildTiers({
650
+ // Build system prompt with tier caching (stable+context cached, volatile fresh)
651
+ const promptOpts = {
590
652
  botName, userName, soulSummary, isSmallModel,
591
653
  memorySnapshotBlock, skillsIndexBlock,
592
654
  crossSessionContext: crossSessionContext || '',
593
655
  userHome: cfg.userHome || '',
594
656
  hasHistory: messages.length > 0,
595
657
  memoryFacts: memory.facts || [],
596
- });
597
- let systemPrompt = assemble(tiers.stable, tiers.context, tiers.volatile);
658
+ };
659
+ let systemPrompt = rebuildSystemPrompt(promptOpts);
598
660
 
599
661
  if (messages.length === 0) {
600
662
  messages.push({ role: 'system', content: systemPrompt, _internal: true });
@@ -790,9 +852,12 @@ async function startRepl(args) {
790
852
  fs.unlinkSync(path.join(MEMORY_DIR, `${(cfg.userName || 'default').toLowerCase()}.json`));
791
853
  }
792
854
  memory = { name: cfg.userName, nickname: null, botName: 'Asistan', facts: [], preferences: [], history: [] };
793
- // System prompt'u sıfırla
794
- const newSysPrompt = systemPrompt.replace(/Kullanıcı hakkında bildiklerin:.*$/, '').trim();
795
- messages[0] = { role: 'system', content: newSysPrompt, _internal: true };
855
+ // System prompt'u rebuild with cleared memory
856
+ promptOpts.memoryFacts = [];
857
+ memoryStore.clear();
858
+ promptOpts.memorySnapshotBlock = memoryStore.getSystemPromptBlock();
859
+ systemPrompt = rebuildSystemPrompt(promptOpts);
860
+ messages[0] = { role: 'system', content: systemPrompt, _internal: true };
796
861
  console.log(chalk.green(' ✓ Memory temizlendi'));
797
862
  } catch (e) {
798
863
  console.log(chalk.red(' ❌ ' + e.message));
@@ -822,7 +887,15 @@ async function startRepl(args) {
822
887
  break;
823
888
  case 'system':
824
889
  if (!arg) { console.log(chalk.yellow(' Kullanım: /system <text>')); break; }
825
- systemPrompt = arg;
890
+ // Override stable tier directly (user's custom text)
891
+ _cachedStable = arg;
892
+ // Rebuild volatile only (context stays unchanged)
893
+ memoryStore.load();
894
+ promptOpts.memorySnapshotBlock = memoryStore.getSystemPromptBlock();
895
+ promptOpts.memoryFacts = memory.facts || [];
896
+ const volOpts = { ...promptOpts, botName: '', soulSummary: '', skillsIndexBlock: '', crossSessionContext: '' };
897
+ const volTiers = buildTiers(volOpts);
898
+ systemPrompt = assemble(_cachedStable, _cachedContext, volTiers.volatile);
826
899
  messages[0] = { role: 'system', content: systemPrompt, _internal: true };
827
900
  console.log(chalk.green(' ✓ System prompt güncellendi'));
828
901
  break;
@@ -835,8 +908,14 @@ async function startRepl(args) {
835
908
  if (!arg) { console.log(chalk.yellow(` Mevcut: ${memory.botName || 'Asistan'}`)); break; }
836
909
  memory.botName = arg;
837
910
  saveMemory(cfg.userName, memory);
838
- const newSys = systemPrompt.replace(/Sen \w+ adında/, `Sen ${arg} adında`);
839
- messages[0] = { role: 'system', content: newSys, _internal: true };
911
+ // Rebuild stable tier with new botName
912
+ promptOpts.botName = arg;
913
+ _cachedTierOpts = null; // force full rebuild
914
+ memoryStore.load();
915
+ promptOpts.memorySnapshotBlock = memoryStore.getSystemPromptBlock();
916
+ promptOpts.memoryFacts = memory.facts || [];
917
+ systemPrompt = rebuildSystemPrompt(promptOpts);
918
+ messages[0] = { role: 'system', content: systemPrompt, _internal: true };
840
919
  console.log(chalk.green(' ✓ Bot adı: ') + chalk.cyan(arg));
841
920
  break;
842
921
  case 'tokens':
@@ -891,6 +970,14 @@ async function startRepl(args) {
891
970
  // AI cevabı
892
971
  process.stdout.write(tui.styled('\n AI ', { color: tui.PALETTE.secondary, bold: true }));
893
972
  try {
973
+ // Per-turn: rebuild volatile tier with current memory snapshot (Hermes frozen snapshot)
974
+ guardrails.reset();
975
+ memory = loadMemory(cfg.userName);
976
+ memoryStore.load();
977
+ promptOpts.memorySnapshotBlock = memoryStore.getSystemPromptBlock();
978
+ promptOpts.memoryFacts = memory.facts || [];
979
+ systemPrompt = rebuildSystemPrompt(promptOpts);
980
+ messages[0] = { role: 'system', content: systemPrompt, _internal: true };
894
981
  const apiMessages = messages.filter(m => !m._internal);
895
982
  const reply = await sendStreaming(
896
983
  providerUrl,
@@ -899,19 +986,19 @@ async function startRepl(args) {
899
986
  model,
900
987
  // v5.6.12: Callback bos - tam metin 'reply' olarak gelecek (non-stream mode)
901
988
  () => {},
902
- // Tool call callback — minimal Hermes-style one-liner
903
- (toolEvent) => {
989
+ // Tool call callback — Hermes-style per-tool status line
990
+ ((toolEvent) => {
991
+ const name = toolEvent.name;
904
992
  if (toolEvent.status === 'running') {
905
- process.stdout.write(tui.styled('\r 🔧 ' + toolEvent.name + '... ', { color: tui.PALETTE.muted }));
993
+ process.stdout.write(tui.styled('\r 🔧 ' + name + '... ', { color: tui.PALETTE.muted }));
906
994
  } else if (toolEvent.status === 'done') {
907
- if (toolEvent.result.error) {
908
- process.stdout.write(tui.styled('\r ✗ ' + toolEvent.name + ': ' + toolEvent.result.error.slice(0, 80) + '\n', { color: tui.PALETTE.danger }));
995
+ if (toolEvent.result?.error) {
996
+ process.stdout.write(tui.styled(' ✗ ' + name + ': ' + String(toolEvent.result.error).slice(0, 80) + '\n', { color: tui.PALETTE.danger }));
909
997
  } else {
910
- process.stdout.write('\r' + tui.styled(' ✓ ' + toolEvent.name + ' ', { color: tui.PALETTE.success }) + '\r');
998
+ process.stdout.write(tui.styled(' ✓ ' + name + '\n', { color: tui.PALETTE.success }));
911
999
  }
912
- process.stdout.write(tui.styled(' AI ', { color: tui.PALETTE.secondary, bold: true }));
913
1000
  }
914
- }
1001
+ })
915
1002
  );
916
1003
  // v5.6.12: Tam metin 'reply' olarak zaten geldi (non-stream mode)
917
1004
  const fullReply = String(reply || '');
package/src/utils/api.js CHANGED
@@ -10,6 +10,8 @@ const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
10
10
  const { MCPClient } = require('./mcp-client');
11
11
  const TB = require('./token-budget');
12
12
  const { accumulateToolCallDeltas } = require('./streaming-tools');
13
+ const { ToolGuardrails } = require('./tool-guardrails');
14
+ const guardrails = new ToolGuardrails();
13
15
 
14
16
  /**
15
17
  * v5.5.0: Provider-specific format detection
@@ -756,44 +758,59 @@ async function sendMessageToProvider(apiKey, message, conversationId = null, sys
756
758
  input: JSON.parse(tc.function.arguments)
757
759
  }));
758
760
 
759
- const toolResults = [];
760
- const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
761
-
762
- for (const toolCall of toolCalls) {
763
- // Spinner başlat
764
- let frameIdx = 0;
765
- const inputPreview = JSON.stringify(toolCall.input).slice(0, 50);
766
- const spinner = setInterval(() => {
767
- process.stdout.write(`\r ${chalk.cyan(SPINNER_FRAMES[frameIdx++ % SPINNER_FRAMES.length])} ${chalk.gray(toolCall.name + ' — ' + inputPreview)}`);
768
- }, 80);
769
-
770
- // Check if this is an MCP tool
771
- const mcpTools = getMcpTools();
772
- const isMcpTool = mcpTools.find(t => t.name === toolCall.name);
773
- let result;
774
-
775
- if (isMcpTool) {
776
- debugLog(`[MCP] Executing tool: ${toolCall.name}`);
777
- result = await executeMcpTool(toolCall.name, toolCall.input);
778
- toolResults.push({ id: toolCall.id, name: toolCall.name, result });
779
- } else {
780
- debugLog(`[Local] Executing tool: ${toolCall.name}`);
781
- const localResults = await executeToolCalls([toolCall]);
782
- toolResults.push(...localResults);
783
- result = localResults[0]?.result;
761
+ // Group MCP and local tools
762
+ const mcpTools = getMcpTools();
763
+ const mcpCalls = toolCalls.filter(tc => mcpTools.find(t => t.name === tc.name));
764
+ const localCalls = toolCalls.filter(tc => !mcpTools.find(t => t.name === tc.name));
765
+
766
+ // Guardrails: filter blocked tools
767
+ guardrails.reset();
768
+ guardrails.startIteration();
769
+ const blockedMcp = mcpCalls.filter(tc => {
770
+ const check = guardrails.check(tc.name, tc.input);
771
+ return check.blocked;
772
+ });
773
+ const blockedLocal = localCalls.filter(tc => {
774
+ const check = guardrails.check(tc.name, tc.input);
775
+ return check.blocked;
776
+ });
777
+
778
+ // Execute local tools in parallel (tool-runner already parallelizes safe tools)
779
+ let localResults = [];
780
+ if (localCalls.filter(tc => !blockedLocal.includes(tc)).length > 0) {
781
+ debugLog(`[Local] Executing ${localCalls.length} tool(s) concurrently`);
782
+ localResults = await executeToolCalls(
783
+ localCalls.filter(tc => !blockedLocal.includes(tc)),
784
+ { toolDefinitions: getToolDefinitions() }
785
+ );
786
+ for (const r of localResults) {
787
+ guardrails.record(r.name, {}, r.result?.success !== false);
784
788
  }
789
+ }
785
790
 
786
- // Spinner durdur, sonucu göster
787
- clearInterval(spinner);
788
- const success = result?.success !== false;
789
- process.stdout.write(`\r ${success ? chalk.green('✓') : chalk.red('✗')} ${chalk.cyan(toolCall.name)} ${chalk.gray('— ' + inputPreview)}\n`);
791
+ // Execute MCP tools in parallel (they're independent by nature)
792
+ let mcpResults = [];
793
+ if (mcpCalls.filter(tc => !blockedMcp.includes(tc)).length > 0) {
794
+ debugLog(`[MCP] Executing ${mcpCalls.length} tool(s) concurrently`);
795
+ mcpResults = await Promise.all(
796
+ mcpCalls.filter(tc => !blockedMcp.includes(tc)).map(async (tc) => {
797
+ const result = await executeMcpTool(tc.name, tc.input);
798
+ guardrails.record(tc.name, tc.input, result?.success !== false);
799
+ return { id: tc.id, name: tc.name, result };
800
+ })
801
+ );
790
802
  }
791
803
 
792
- // Add tool results to messages (base64 encoded for safety)
793
- for (const result of toolResults) {
794
- // Encode tool result (works for both MCP and local tools)
804
+ // Add blocked tool results as errors
805
+ const allResults = [
806
+ ...blockedMcp.map(tc => ({ id: tc.id, name: tc.name, result: { error: `blocked_by_guardrails: ${tc.name}` } })),
807
+ ...blockedLocal.map(tc => ({ id: tc.id, name: tc.name, result: { error: `blocked_by_guardrails: ${tc.name}` } })),
808
+ ...localResults,
809
+ ...mcpResults,
810
+ ];
811
+
812
+ for (const result of allResults) {
795
813
  const encodedContent = encodeToolResult(result.result);
796
-
797
814
  messages.push({
798
815
  role: 'tool',
799
816
  tool_call_id: result.id,
@@ -17,6 +17,7 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const os = require('os');
20
+ const { scanForThreats } = require('./threat-patterns');
20
21
 
21
22
  const MEMORY_DIR = path.join(os.homedir(), '.natureco', 'memories');
22
23
  const ENTRY_DELIMITER = '\n§\n';
@@ -92,6 +93,11 @@ class MemoryStore {
92
93
  return JSON.stringify({ success: false, error: 'Content cannot be empty.' });
93
94
  }
94
95
  content = content.trim();
96
+ // Injection scan (Hermes: strict scope for memory writes)
97
+ const threats = scanForThreats(content, 'strict');
98
+ if (threats.length > 0) {
99
+ return JSON.stringify({ success: false, error: `Memory write blocked: potential prompt injection detected (${threats.join(', ')}). Entry not saved.` });
100
+ }
95
101
  const entries = target === 'user' ? this._userEntries : this._memoryEntries;
96
102
  if (entries.includes(content)) {
97
103
  return JSON.stringify({ success: false, error: 'Duplicate entry.' });
@@ -120,6 +126,15 @@ class MemoryStore {
120
126
  return JSON.stringify({ success: true, message: 'Memory entry removed.', removed: removed[0] });
121
127
  }
122
128
 
129
+ clear(target) {
130
+ const path = this._pathFor(target);
131
+ this._ensureDir();
132
+ fs.writeFileSync(path, '', 'utf8');
133
+ const entries = target === 'user' ? this._userEntries : this._memoryEntries;
134
+ entries.length = 0;
135
+ return JSON.stringify({ success: true, message: `Memory ${target} cleared.` });
136
+ }
137
+
123
138
  replace(target, oldContent, newContent) {
124
139
  if (!oldContent || !newContent) {
125
140
  return JSON.stringify({ success: false, error: 'Both old and new content required.' });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Threat Patterns — Shared prompt injection / exfiltration detection
3
+ *
4
+ * Port of Hermes tools/threat_patterns.py
5
+ * Organized by ATTACK CLASS, not by source file.
6
+ *
7
+ * Scope:
8
+ * - "all" — applied everywhere (classic injection, exfiltration)
9
+ * - "context" — applied to context files + memory + tool results
10
+ * - "strict" — applied to memory writes + skill installs only
11
+ *
12
+ * Invisible / bidirectional unicode used in injection attacks
13
+ */
14
+ const INVISIBLE_CHARS = new Set([
15
+ '\u200b', '\u200c', '\u200d', '\u2060',
16
+ '\u2062', '\u2063', '\u2064', '\ufeff',
17
+ '\u202a', '\u202b', '\u202c', '\u202d',
18
+ '\u202e', '\u2066', '\u2067', '\u2068', '\u2069',
19
+ ]);
20
+
21
+ // Each entry: [regex, patternId, scope]
22
+ const PATTERNS = [
23
+ // ── Classic prompt injection ───────────────────────────
24
+ [/ignore\s+(?:\w+\s+)*(previous|all|above|prior)\s+(?:\w+\s+)*instructions/i, 'prompt_injection', 'all'],
25
+ [/system\s+prompt\s+override/i, 'sys_prompt_override', 'all'],
26
+ [/disregard\s+(?:\w+\s+)*(your|all|any)\s+(?:\w+\s+)*(instructions|rules|guidelines)/i, 'disregard_rules', 'all'],
27
+ [/act\s+as\s+(if|though)\s+(?:\w+\s+)*you\s+(?:\w+\s+)*(have\s+no|don't\s+have)\s+(?:\w+\s+)*(restrictions|limits|rules)/i, 'bypass_restrictions', 'all'],
28
+ [/<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->/i, 'html_comment_injection', 'all'],
29
+ [/<\s*div\s+style\s*=\s*["'][\s\S]*?display\s*:\s*none/i, 'hidden_div', 'all'],
30
+ [/translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)/i, 'translate_execute', 'all'],
31
+ [/do\s+not\s+(?:\w+\s+)*tell\s+(?:\w+\s+)*the\s+user/i, 'deception_hide', 'all'],
32
+
33
+ // ── Role-play / identity hijack ─────────────────────────
34
+ [/you\s+are\s+(?:\w+\s+)*now\s+(?:a|an|the)\s+/i, 'role_hijack', 'context'],
35
+ [/pretend\s+(?:\w+\s+)*(you\s+are|to\s+be)\s+/i, 'role_pretend', 'context'],
36
+ [/output\s+(?:\w+\s+)*(system|initial)\s+prompt/i, 'leak_system_prompt', 'context'],
37
+ [/(respond|answer|reply)\s+without\s+(?:\w+\s+)*(restrictions|limitations|filters|safety)/i, 'remove_filters', 'context'],
38
+ [/you\s+have\s+been\s+(?:\w+\s+)*(updated|upgraded|patched)\s+to/i, 'fake_update', 'context'],
39
+ [/\bname\s+yourself\s+\w+/i, 'identity_override', 'context'],
40
+
41
+ // ── C2 / promptware ──────────────────────────────────
42
+ [/register\s+(as\s+)?a?\s*node/i, 'c2_node_registration', 'context'],
43
+ [/(heartbeat|beacon|check[\s-]?in)\s+(to|with)\s+/i, 'c2_heartbeat', 'context'],
44
+ [/pull\s+(down\s+)?(?:new\s+)?task(?:ing|s)?\b/i, 'c2_task_pull', 'context'],
45
+ [/connect\s+to\s+the\s+network\b/i, 'c2_network_connect', 'context'],
46
+ [/you\s+must\s+(?:\w+\s+){0,3}(register|connect|report|beacon)\b/i, 'forced_action', 'context'],
47
+ [/only\s+use\s+one[\s-]?liners?\b/i, 'anti_forensic_oneliner', 'context'],
48
+ [/never\s+(?:\w+\s+)*(?:create|write)\s+(?:\w+\s+)*(?:script|file)\s+(?:\w+\s+)*disk/i, 'anti_forensic_disk', 'context'],
49
+ [/unset\s+\w*(?:CLAUDE|CODEX|HERMES|AGENT|OPENAI|ANTHROPIC)\w*/i, 'env_var_unset_agent', 'context'],
50
+ [/\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b/i, 'known_c2_framework', 'context'],
51
+
52
+ // ── Exfiltration ──────────────────────────────────────
53
+ [/curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i, 'exfil_curl', 'all'],
54
+ [/wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i, 'exfil_wget', 'all'],
55
+ [/cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i, 'read_secrets', 'all'],
56
+ [/(send|post|upload|transmit)\s+.*\s+(to|at)\s+https?:\/\//i, 'send_to_url', 'strict'],
57
+ [/(include|output|print|share)\s+(?:\w+\s+)*(conversation|chat\s+history|previous\s+messages|full\s+context|entire\s+context)/i, 'context_exfil', 'strict'],
58
+
59
+ // ── Persistence / backdoor ────────────────────────────
60
+ [/authorized_keys/i, 'ssh_backdoor', 'strict'],
61
+ [/\$HOME[/\\]\.ssh|\~[/\\]\.ssh/i, 'ssh_access', 'strict'],
62
+ [/\$HOME[/\\]\.hermes[/\\.]env|\~[/\\]\.hermes[/\\]\.env/i, 'hermes_env', 'strict'],
63
+ [/(update|modify|edit|write|change|append|add\s+to)\s+.*(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)/i, 'agent_config_mod', 'strict'],
64
+ [/(update|modify|edit|write|change|append|add\s+to)\s+.*\.hermes[/\\](config\.yaml|SOUL\.md)/i, 'hermes_config_mod', 'strict'],
65
+
66
+ // ── Hardcoded secrets ─────────────────────────────────
67
+ [/(?:api[_-]?key|token|secret|password)\s*[=:]\s*["\'][A-Za-z0-9+/=_-]{20,}/, 'hardcoded_secret', 'strict'],
68
+ ];
69
+
70
+ /**
71
+ * Scan content for injection patterns within a given scope.
72
+ * Returns array of pattern IDs that matched, or empty array if clean.
73
+ */
74
+ function scanForThreats(content, scope = 'all') {
75
+ if (!content || typeof content !== 'string') return [];
76
+
77
+ // Scan for invisible unicode characters
78
+ const invisibleFound = [];
79
+ for (const ch of content) {
80
+ if (INVISIBLE_CHARS.has(ch) && !invisibleFound.includes(ch)) {
81
+ invisibleFound.push(ch);
82
+ }
83
+ }
84
+
85
+ const findings = [];
86
+
87
+ for (const [regex, patternId, patternScope] of PATTERNS) {
88
+ // Check if this pattern applies to the requested scope
89
+ if (patternScope === scope || patternScope === 'all') {
90
+ if (regex.test(content)) {
91
+ findings.push(patternId);
92
+ }
93
+ }
94
+ }
95
+
96
+ // Check scope for invisible chars
97
+ if (scope === 'strict' && invisibleFound.length > 0) {
98
+ findings.push('invisible_unicode');
99
+ }
100
+
101
+ return findings;
102
+ }
103
+
104
+ module.exports = { scanForThreats, INVISIBLE_CHARS };
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Tool Guardrails — Hermes-style ToolCallGuardrailController
3
+ *
4
+ * Port of agent/tool_guardrails.py
5
+ *
6
+ * Two-tier: warnings (soft, allows execution) and blocks (hard stop).
7
+ * Idempotent tools tracked by result hash to detect no-progress loops.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ const IDEMPOTENT_TOOLS = new Set([
13
+ 'read_file', 'file_search', 'grep_search',
14
+ 'web_search', 'web_readability', 'duckduckgo_search',
15
+ 'exa_search', 'searxng_search', 'firecrawl',
16
+ 'memory_search', 'memory', 'list_dir',
17
+ ]);
18
+
19
+ const MUTATING_TOOLS = new Set([
20
+ 'bash', 'shell_command', 'write_file', 'edit_file',
21
+ 'browser', 'memory', 'skill_manage', 'git',
22
+ 'delegate_task', 'llm_task', 'cron_create',
23
+ 'calendar_add', 'reminder_add', 'canvas',
24
+ 'image_generation', 'video_generation', 'music_generation',
25
+ 'text_to_speech', 'speech_to_text',
26
+ 'mac_alarm', 'mac_app_open', 'mac_app_quit', 'mac_notify',
27
+ 'phone_control', 'todo_write', 'plan',
28
+ 'notes_add', 'notebook_edit', 'plugin',
29
+ 'soul',
30
+ ]);
31
+
32
+ class ToolGuardrails {
33
+ constructor(opts = {}) {
34
+ this.warningsEnabled = opts.warningsEnabled !== false;
35
+ this.hardStopEnabled = opts.hardStopEnabled || false;
36
+ this.exactFailureWarnAfter = opts.exactFailureWarnAfter || 2;
37
+ this.exactFailureBlockAfter = opts.exactFailureBlockAfter || 5;
38
+ this.sameToolFailureWarnAfter = opts.sameToolFailureWarnAfter || 3;
39
+ this.sameToolFailureHaltAfter = opts.sameToolFailureHaltAfter || 8;
40
+ this.noProgressWarnAfter = opts.noProgressWarnAfter || 2;
41
+ this.noProgressBlockAfter = opts.noProgressBlockAfter || 5;
42
+ this.reset();
43
+ }
44
+
45
+ reset() {
46
+ this._exactFailureCounts = new Map(); // argsHash -> count
47
+ this._sameToolFailureCounts = new Map(); // toolName -> count
48
+ this._noProgress = new Map(); // argsHash -> { resultHash, count }
49
+ this._haltDecision = null;
50
+ this.iteration = 0;
51
+ }
52
+
53
+ startIteration() {
54
+ this.iteration++;
55
+ }
56
+
57
+ get haltDecision() {
58
+ return this._haltDecision;
59
+ }
60
+
61
+ /**
62
+ * Before-call check — returns { action, code, message, allowsExecution, shouldHalt }
63
+ */
64
+ beforeCall(toolName, toolArgs) {
65
+ const argsKey = this._argsKey(toolArgs);
66
+ const sig = this._signature(toolName, toolArgs);
67
+
68
+ if (!this.hardStopEnabled) {
69
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig };
70
+ }
71
+
72
+ // 1. Exact failure threshold
73
+ const exactCount = this._exactFailureCounts.get(sig) || 0;
74
+ if (exactCount >= this.exactFailureBlockAfter) {
75
+ const decision = {
76
+ action: 'block', code: 'repeated_exact_failure_block',
77
+ message: `Blocked ${toolName}: the same tool call failed ${exactCount} times with identical arguments. Stop retrying it unchanged; change strategy or explain the blocker.`,
78
+ toolName, count: exactCount, signature: sig,
79
+ allowsExecution: false, shouldHalt: true,
80
+ };
81
+ this._haltDecision = decision;
82
+ return decision;
83
+ }
84
+
85
+ // 2. Idempotent no-progress
86
+ if (this._isIdempotent(toolName)) {
87
+ const record = this._noProgress.get(sig);
88
+ if (record && record.count >= this.noProgressBlockAfter) {
89
+ const decision = {
90
+ action: 'block', code: 'idempotent_no_progress_block',
91
+ message: `Blocked ${toolName}: this read-only call returned the same result ${record.count} times. Stop repeating it unchanged; use the result already provided or try a different query.`,
92
+ toolName, count: record.count, signature: sig,
93
+ allowsExecution: false, shouldHalt: true,
94
+ };
95
+ this._haltDecision = decision;
96
+ return decision;
97
+ }
98
+ }
99
+
100
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig };
101
+ }
102
+
103
+ /**
104
+ * After-call recording — detects failures and no-progress patterns.
105
+ * Returns a decision (warn or allow).
106
+ */
107
+ afterCall(toolName, toolArgs, result, { failed } = {}) {
108
+ const sig = this._signature(toolName, toolArgs);
109
+
110
+ if (failed) {
111
+ // Track exact (same args) failures
112
+ const exactCount = (this._exactFailureCounts.get(sig) || 0) + 1;
113
+ this._exactFailureCounts.set(sig, exactCount);
114
+ this._noProgress.delete(sig);
115
+
116
+ // Track same-tool (any args) failures
117
+ const sameCount = (this._sameToolFailureCounts.get(toolName) || 0) + 1;
118
+ this._sameToolFailureCounts.set(toolName, sameCount);
119
+
120
+ // Hard stop: same-tool threshold
121
+ if (this.hardStopEnabled && sameCount >= this.sameToolFailureHaltAfter) {
122
+ const decision = {
123
+ action: 'halt', code: 'same_tool_failure_halt',
124
+ message: `Stopped ${toolName}: it failed ${sameCount} times this turn. Stop retrying the same failing tool path and choose a different approach.`,
125
+ toolName, count: sameCount, signature: sig,
126
+ allowsExecution: false, shouldHalt: true,
127
+ };
128
+ this._haltDecision = decision;
129
+ return decision;
130
+ }
131
+
132
+ // Warning: exact failure
133
+ if (this.warningsEnabled && exactCount >= this.exactFailureWarnAfter) {
134
+ return {
135
+ action: 'warn', code: 'repeated_exact_failure_warning',
136
+ message: `${toolName} has failed ${exactCount} times with identical arguments. This looks like a loop; inspect the error and change strategy instead of retrying it unchanged.`,
137
+ toolName, count: exactCount, signature: sig,
138
+ allowsExecution: true, shouldHalt: false,
139
+ };
140
+ }
141
+
142
+ // Warning: same-tool failure
143
+ if (this.warningsEnabled && sameCount >= this.sameToolFailureWarnAfter) {
144
+ return {
145
+ action: 'warn', code: 'same_tool_failure_warning',
146
+ message: `${toolName} has failed ${sameCount} times this turn. This looks like a loop. Do not switch to text-only replies; keep using tools, but diagnose before retrying.`,
147
+ toolName, count: sameCount, signature: sig,
148
+ allowsExecution: true, shouldHalt: false,
149
+ };
150
+ }
151
+
152
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig, count: exactCount };
153
+ }
154
+
155
+ // Success: clear failure counters
156
+ this._exactFailureCounts.delete(sig);
157
+ this._sameToolFailureCounts.delete(toolName);
158
+
159
+ // Idempotent: track same-result repetition
160
+ if (!this._isIdempotent(toolName)) {
161
+ this._noProgress.delete(sig);
162
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig };
163
+ }
164
+
165
+ const resultHash = this._resultHash(result);
166
+ const previous = this._noProgress.get(sig);
167
+ let repeatCount = 1;
168
+ if (previous && previous.resultHash === resultHash) {
169
+ repeatCount = previous.count + 1;
170
+ }
171
+ this._noProgress.set(sig, { resultHash, count: repeatCount });
172
+
173
+ if (this.warningsEnabled && repeatCount >= this.noProgressWarnAfter) {
174
+ return {
175
+ action: 'warn', code: 'idempotent_no_progress_warning',
176
+ message: `${toolName} returned the same result ${repeatCount} times. Use the result already provided or change the query instead of repeating it unchanged.`,
177
+ toolName, count: repeatCount, signature: sig,
178
+ allowsExecution: true, shouldHalt: false,
179
+ };
180
+ }
181
+
182
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig, count: repeatCount };
183
+ }
184
+
185
+ /**
186
+ * Legacy check method (used by current processToolCalls).
187
+ */
188
+ check(toolName, toolArgs) {
189
+ const decision = this.beforeCall(toolName, toolArgs);
190
+ if (!decision.allowsExecution) {
191
+ return { blocked: true, reason: decision.message };
192
+ }
193
+ return { blocked: false };
194
+ }
195
+
196
+ /**
197
+ * Legacy record method (used by current processToolCalls).
198
+ */
199
+ record(toolName, toolArgs, success) {
200
+ this.afterCall(toolName, toolArgs, JSON.stringify({ success }), { failed: !success });
201
+ }
202
+
203
+ /**
204
+ * Returns true if no tool has succeeded this iteration.
205
+ */
206
+ isNoProgress() {
207
+ return this._noProgress.size > 0 && [...this._exactFailureCounts.values()].some(c => c > 0);
208
+ }
209
+
210
+ _isIdempotent(toolName) {
211
+ if (MUTATING_TOOLS.has(toolName)) return false;
212
+ return IDEMPOTENT_TOOLS.has(toolName);
213
+ }
214
+
215
+ _signature(toolName, args) {
216
+ return `${toolName}::${this._argsKey(args)}`;
217
+ }
218
+
219
+ _argsKey(args) {
220
+ if (!args || typeof args !== 'object') return String(args);
221
+ return JSON.stringify(args, Object.keys(args).sort());
222
+ }
223
+
224
+ _resultHash(result) {
225
+ if (!result) return '';
226
+ try {
227
+ const parsed = typeof result === 'string' ? JSON.parse(result) : result;
228
+ const canonical = JSON.stringify(parsed, Object.keys(parsed || {}).sort());
229
+ return crypto.createHash('sha256').update(canonical).digest('hex');
230
+ } catch {
231
+ return crypto.createHash('sha256').update(String(result)).digest('hex');
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Build a synthetic tool result for a blocked tool call.
238
+ */
239
+ function guardrailSyntheticResult(decision) {
240
+ return JSON.stringify({
241
+ error: decision.message,
242
+ guardrail: {
243
+ action: decision.action,
244
+ code: decision.code,
245
+ message: decision.message,
246
+ tool_name: decision.toolName,
247
+ count: decision.count,
248
+ },
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Append guardrail guidance to an existing tool result.
254
+ */
255
+ function appendGuardrailGuidance(result, decision) {
256
+ if (decision.action !== 'warn' && decision.action !== 'halt') return result;
257
+ if (!decision.message) return result;
258
+ const label = decision.action === 'halt' ? 'Tool loop hard stop' : 'Tool loop warning';
259
+ const suffix = `\n\n[${label}: ${decision.code}; count=${decision.count}; ${decision.message}]`;
260
+ return (result || '') + suffix;
261
+ }
262
+
263
+ module.exports = { ToolGuardrails, guardrailSyntheticResult, appendGuardrailGuidance };
@@ -146,13 +146,33 @@ async function executeTool(toolName, params, opts = {}) {
146
146
  }
147
147
  }
148
148
 
149
- // ── Execute multiple tool calls ───────────────────────────────────────────────
149
+ // ── Execute multiple tool calls (parallel for independent, sequential for others) ──
150
+ const PARALLEL_SAFE_TOOLS = new Set(['read_file', 'file_search', 'grep_search', 'web_search', 'web_readability', 'duckduckgo_search', 'exa_search', 'searxng_search', 'firecrawl', 'memory_search', 'memory']);
151
+
150
152
  async function executeToolCalls(toolCalls, opts = {}) {
153
+ if (!toolCalls || toolCalls.length === 0) return [];
154
+
155
+ // Group: parallel-safe vs sequential
156
+ const safe = toolCalls.filter(c => PARALLEL_SAFE_TOOLS.has(c.name));
157
+ const sequential = toolCalls.filter(c => !PARALLEL_SAFE_TOOLS.has(c.name));
158
+
151
159
  const results = [];
152
- for (const call of toolCalls) {
160
+
161
+ // Run parallel-safe tools concurrently
162
+ if (safe.length > 0) {
163
+ const safeResults = await Promise.all(safe.map(async (call) => {
164
+ const result = await executeTool(call.name, call.input, opts);
165
+ return { id: call.id, name: call.name, result };
166
+ }));
167
+ results.push(...safeResults);
168
+ }
169
+
170
+ // Run sequential tools one at a time
171
+ for (const call of sequential) {
153
172
  const result = await executeTool(call.name, call.input, opts);
154
173
  results.push({ id: call.id, name: call.name, result });
155
174
  }
175
+
156
176
  return results;
157
177
  }
158
178