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 +1 -1
- package/src/commands/repl.js +117 -30
- package/src/utils/api.js +50 -33
- package/src/utils/memory-store.js +15 -0
- package/src/utils/threat-patterns.js +104 -0
- package/src/utils/tool-guardrails.js +263 -0
- package/src/utils/tool-runner.js +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "natureco-cli",
|
|
3
|
-
"version": "5.
|
|
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"
|
package/src/commands/repl.js
CHANGED
|
@@ -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
|
|
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:
|
|
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 (!
|
|
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
|
-
//
|
|
412
|
-
|
|
413
|
-
const
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
587
|
-
|
|
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 =
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
|
|
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 —
|
|
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 🔧 ' +
|
|
993
|
+
process.stdout.write(tui.styled('\r 🔧 ' + name + '... ', { color: tui.PALETTE.muted }));
|
|
906
994
|
} else if (toolEvent.status === 'done') {
|
|
907
|
-
if (toolEvent.result
|
|
908
|
-
process.stdout.write(tui.styled('
|
|
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(
|
|
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
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
|
793
|
-
|
|
794
|
-
|
|
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 };
|
package/src/utils/tool-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|