natureco-cli 5.8.0 → 5.9.1

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.8.0",
3
+ "version": "5.9.1",
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"
@@ -279,7 +279,7 @@ async function sendStreaming(providerUrl, providerApiKey, messages, model, onChu
279
279
  let currentMessages = messages;
280
280
  let fullText = '';
281
281
  let iterations = 0;
282
- const MAX_TOOL_ITERATIONS = 5;
282
+ const MAX_TOOL_ITERATIONS = isMM ? 12 : 8;
283
283
  const MAX_CONTEXT_TOKENS = 32000; // safety limit before compression
284
284
 
285
285
  // v5.7.18: Preflight compression — if context too long, compress middle messages
@@ -497,6 +497,25 @@ async function processToolCalls(toolCalls, onToolCall) {
497
497
  if (onToolCall) onToolCall({ name: '_no_progress', args: null, status: 'done', result: { error: 'All tools failed this iteration' } });
498
498
  }
499
499
 
500
+ // Same-tool loop detection: if same tool called >3x this iteration (regardless of success)
501
+ const toolCallCounts = {};
502
+ for (const { name } of results) {
503
+ toolCallCounts[name] = (toolCallCounts[name] || 0) + 1;
504
+ }
505
+ for (const [name, count] of Object.entries(toolCallCounts)) {
506
+ if (count > 3) {
507
+ const loopResult = results.find(r => r.name === name);
508
+ if (loopResult && !loopResult.result?.error) {
509
+ const warnContent = JSON.stringify({
510
+ _loop_warning: true,
511
+ message: `${name} called ${count}x this turn. If you're not making progress, try a different approach or report the result.`,
512
+ tool: name, call_count: count,
513
+ });
514
+ results.push({ name: '_loop_warning', id: `loop_${Date.now()}`, result: { result: warnContent } });
515
+ }
516
+ }
517
+ }
518
+
500
519
  // Notify UI done + build messages
501
520
  const messages = [];
502
521
  for (const { name, id, result } of results) {
@@ -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.' });
@@ -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 };
@@ -1,85 +1,264 @@
1
1
  /**
2
2
  * Tool Guardrails — Hermes-style ToolCallGuardrailController
3
3
  *
4
- * Tracks per-iteration:
5
- * - failure count per tool
6
- * - repeated identical calls (same name + same args)
7
- * - no-progress detection (all tools failing)
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
8
  */
9
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
+ 'browser', // browser snapshots are idempotent
18
+ ]);
19
+
20
+ const MUTATING_TOOLS = new Set([
21
+ 'bash', 'shell_command', 'write_file', 'edit_file',
22
+ 'browser', 'memory', 'skill_manage', 'git',
23
+ 'delegate_task', 'llm_task', 'cron_create',
24
+ 'calendar_add', 'reminder_add', 'canvas',
25
+ 'image_generation', 'video_generation', 'music_generation',
26
+ 'text_to_speech', 'speech_to_text',
27
+ 'mac_alarm', 'mac_app_open', 'mac_app_quit', 'mac_notify',
28
+ 'phone_control', 'todo_write', 'plan',
29
+ 'notes_add', 'notebook_edit', 'plugin',
30
+ 'soul',
31
+ ]);
32
+
10
33
  class ToolGuardrails {
11
- constructor() {
34
+ constructor(opts = {}) {
35
+ this.warningsEnabled = opts.warningsEnabled !== false;
36
+ this.hardStopEnabled = opts.hardStopEnabled || false;
37
+ this.exactFailureWarnAfter = opts.exactFailureWarnAfter || 2;
38
+ this.exactFailureBlockAfter = opts.exactFailureBlockAfter || 5;
39
+ this.sameToolFailureWarnAfter = opts.sameToolFailureWarnAfter || 3;
40
+ this.sameToolFailureHaltAfter = opts.sameToolFailureHaltAfter || 8;
41
+ this.noProgressWarnAfter = opts.noProgressWarnAfter || 2;
42
+ this.noProgressBlockAfter = opts.noProgressBlockAfter || 5;
12
43
  this.reset();
13
44
  }
14
45
 
15
46
  reset() {
16
- this.callLog = []; // [{ name, argsKey, iteration, success }]
17
- this.failureCounts = {}; // { toolName: count }
47
+ this._exactFailureCounts = new Map(); // argsHash -> count
48
+ this._sameToolFailureCounts = new Map(); // toolName -> count
49
+ this._noProgress = new Map(); // argsHash -> { resultHash, count }
50
+ this._haltDecision = null;
18
51
  this.iteration = 0;
19
- this.blockedTools = new Set();
20
- this.consecutiveFailures = {};
21
52
  }
22
53
 
23
54
  startIteration() {
24
55
  this.iteration++;
25
- // Decay consecutive failures each iteration (Hermes: decay factor 0.5)
26
- for (const name of Object.keys(this.consecutiveFailures)) {
27
- this.consecutiveFailures[name] = Math.floor(this.consecutiveFailures[name] * 0.5);
28
- if (this.consecutiveFailures[name] <= 0) {
29
- delete this.consecutiveFailures[name];
30
- this.blockedTools.delete(name);
56
+ }
57
+
58
+ get haltDecision() {
59
+ return this._haltDecision;
60
+ }
61
+
62
+ /**
63
+ * Before-call check — returns { action, code, message, allowsExecution, shouldHalt }
64
+ */
65
+ beforeCall(toolName, toolArgs) {
66
+ const argsKey = this._argsKey(toolArgs);
67
+ const sig = this._signature(toolName, toolArgs);
68
+
69
+ if (!this.hardStopEnabled) {
70
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig };
71
+ }
72
+
73
+ // 1. Exact failure threshold
74
+ const exactCount = this._exactFailureCounts.get(sig) || 0;
75
+ if (exactCount >= this.exactFailureBlockAfter) {
76
+ const decision = {
77
+ action: 'block', code: 'repeated_exact_failure_block',
78
+ message: `Blocked ${toolName}: the same tool call failed ${exactCount} times with identical arguments. Stop retrying it unchanged; change strategy or explain the blocker.`,
79
+ toolName, count: exactCount, signature: sig,
80
+ allowsExecution: false, shouldHalt: true,
81
+ };
82
+ this._haltDecision = decision;
83
+ return decision;
84
+ }
85
+
86
+ // 2. Idempotent no-progress
87
+ if (this._isIdempotent(toolName)) {
88
+ const record = this._noProgress.get(sig);
89
+ if (record && record.count >= this.noProgressBlockAfter) {
90
+ const decision = {
91
+ action: 'block', code: 'idempotent_no_progress_block',
92
+ 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.`,
93
+ toolName, count: record.count, signature: sig,
94
+ allowsExecution: false, shouldHalt: true,
95
+ };
96
+ this._haltDecision = decision;
97
+ return decision;
31
98
  }
32
99
  }
100
+
101
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig };
33
102
  }
34
103
 
35
104
  /**
36
- * Returns blocked tool names if guardrails trigger.
105
+ * After-call recording detects failures and no-progress patterns.
106
+ * Returns a decision (warn or allow).
37
107
  */
38
- check(toolName, toolArgs) {
39
- const argsKey = JSON.stringify(toolArgs || {});
40
-
41
- // 1. Too many repeated identical calls (Hermes: same name+args in last 3 calls)
42
- const identicalCount = this.callLog.filter(
43
- c => c.name === toolName && c.argsKey === argsKey && c.iteration >= this.iteration - 2
44
- ).length;
45
- if (identicalCount >= 2) {
46
- return { blocked: true, reason: `repeated_call: ${toolName} called ${identicalCount + 1}x with same args` };
108
+ afterCall(toolName, toolArgs, result, { failed } = {}) {
109
+ const sig = this._signature(toolName, toolArgs);
110
+
111
+ if (failed) {
112
+ // Track exact (same args) failures
113
+ const exactCount = (this._exactFailureCounts.get(sig) || 0) + 1;
114
+ this._exactFailureCounts.set(sig, exactCount);
115
+ this._noProgress.delete(sig);
116
+
117
+ // Track same-tool (any args) failures
118
+ const sameCount = (this._sameToolFailureCounts.get(toolName) || 0) + 1;
119
+ this._sameToolFailureCounts.set(toolName, sameCount);
120
+
121
+ // Hard stop: same-tool threshold
122
+ if (this.hardStopEnabled && sameCount >= this.sameToolFailureHaltAfter) {
123
+ const decision = {
124
+ action: 'halt', code: 'same_tool_failure_halt',
125
+ message: `Stopped ${toolName}: it failed ${sameCount} times this turn. Stop retrying the same failing tool path and choose a different approach.`,
126
+ toolName, count: sameCount, signature: sig,
127
+ allowsExecution: false, shouldHalt: true,
128
+ };
129
+ this._haltDecision = decision;
130
+ return decision;
131
+ }
132
+
133
+ // Warning: exact failure
134
+ if (this.warningsEnabled && exactCount >= this.exactFailureWarnAfter) {
135
+ return {
136
+ action: 'warn', code: 'repeated_exact_failure_warning',
137
+ 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.`,
138
+ toolName, count: exactCount, signature: sig,
139
+ allowsExecution: true, shouldHalt: false,
140
+ };
141
+ }
142
+
143
+ // Warning: same-tool failure
144
+ if (this.warningsEnabled && sameCount >= this.sameToolFailureWarnAfter) {
145
+ return {
146
+ action: 'warn', code: 'same_tool_failure_warning',
147
+ 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.`,
148
+ toolName, count: sameCount, signature: sig,
149
+ allowsExecution: true, shouldHalt: false,
150
+ };
151
+ }
152
+
153
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig, count: exactCount };
47
154
  }
48
155
 
49
- // 2. Too many failures (Hermes: 2+ failures = blocked for this iteration)
50
- const recentFailures = this.callLog.filter(
51
- c => c.name === toolName && !c.success && c.iteration >= this.iteration - 1
52
- ).length;
53
- if (recentFailures >= 2 || (this.consecutiveFailures[toolName] || 0) >= 2) {
54
- this.blockedTools.add(toolName);
55
- return { blocked: true, reason: `too_many_failures: ${toolName} failed ${recentFailures + 1}x` };
156
+ // Success: clear failure counters
157
+ this._exactFailureCounts.delete(sig);
158
+ this._sameToolFailureCounts.delete(toolName);
159
+
160
+ // Idempotent: track same-result repetition
161
+ if (!this._isIdempotent(toolName)) {
162
+ this._noProgress.delete(sig);
163
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig };
56
164
  }
57
165
 
166
+ const resultHash = this._resultHash(result);
167
+ const previous = this._noProgress.get(sig);
168
+ let repeatCount = 1;
169
+ if (previous && previous.resultHash === resultHash) {
170
+ repeatCount = previous.count + 1;
171
+ }
172
+ this._noProgress.set(sig, { resultHash, count: repeatCount });
173
+
174
+ if (this.warningsEnabled && repeatCount >= this.noProgressWarnAfter) {
175
+ return {
176
+ action: 'warn', code: 'idempotent_no_progress_warning',
177
+ message: `${toolName} returned the same result ${repeatCount} times. Use the result already provided or change the query instead of repeating it unchanged.`,
178
+ toolName, count: repeatCount, signature: sig,
179
+ allowsExecution: true, shouldHalt: false,
180
+ };
181
+ }
182
+
183
+ return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig, count: repeatCount };
184
+ }
185
+
186
+ /**
187
+ * Legacy check method (used by current processToolCalls).
188
+ */
189
+ check(toolName, toolArgs) {
190
+ const decision = this.beforeCall(toolName, toolArgs);
191
+ if (!decision.allowsExecution) {
192
+ return { blocked: true, reason: decision.message };
193
+ }
58
194
  return { blocked: false };
59
195
  }
60
196
 
61
197
  /**
62
- * Record a tool call result.
198
+ * Legacy record method (used by current processToolCalls).
63
199
  */
64
200
  record(toolName, toolArgs, success) {
65
- const argsKey = JSON.stringify(toolArgs || {});
66
- this.callLog.push({ name: toolName, argsKey, iteration: this.iteration, success });
67
-
68
- if (!success) {
69
- this.failureCounts[toolName] = (this.failureCounts[toolName] || 0) + 1;
70
- this.consecutiveFailures[toolName] = (this.consecutiveFailures[toolName] || 0) + 1;
71
- } else {
72
- this.consecutiveFailures[toolName] = 0;
73
- }
201
+ this.afterCall(toolName, toolArgs, JSON.stringify({ success }), { failed: !success });
74
202
  }
75
203
 
76
204
  /**
77
- * Returns true if no tool has succeeded this iteration (no-progress).
205
+ * Returns true if no tool has succeeded this iteration.
78
206
  */
79
207
  isNoProgress() {
80
- const thisIter = this.callLog.filter(c => c.iteration === this.iteration);
81
- return thisIter.length > 0 && thisIter.every(c => !c.success);
208
+ return this._noProgress.size > 0 && [...this._exactFailureCounts.values()].some(c => c > 0);
209
+ }
210
+
211
+ _isIdempotent(toolName) {
212
+ if (MUTATING_TOOLS.has(toolName)) return false;
213
+ return IDEMPOTENT_TOOLS.has(toolName);
214
+ }
215
+
216
+ _signature(toolName, args) {
217
+ return `${toolName}::${this._argsKey(args)}`;
82
218
  }
219
+
220
+ _argsKey(args) {
221
+ if (!args || typeof args !== 'object') return String(args);
222
+ return JSON.stringify(args, Object.keys(args).sort());
223
+ }
224
+
225
+ _resultHash(result) {
226
+ if (!result) return '';
227
+ try {
228
+ const parsed = typeof result === 'string' ? JSON.parse(result) : result;
229
+ const canonical = JSON.stringify(parsed, Object.keys(parsed || {}).sort());
230
+ return crypto.createHash('sha256').update(canonical).digest('hex');
231
+ } catch {
232
+ return crypto.createHash('sha256').update(String(result)).digest('hex');
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Build a synthetic tool result for a blocked tool call.
239
+ */
240
+ function guardrailSyntheticResult(decision) {
241
+ return JSON.stringify({
242
+ error: decision.message,
243
+ guardrail: {
244
+ action: decision.action,
245
+ code: decision.code,
246
+ message: decision.message,
247
+ tool_name: decision.toolName,
248
+ count: decision.count,
249
+ },
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Append guardrail guidance to an existing tool result.
255
+ */
256
+ function appendGuardrailGuidance(result, decision) {
257
+ if (decision.action !== 'warn' && decision.action !== 'halt') return result;
258
+ if (!decision.message) return result;
259
+ const label = decision.action === 'halt' ? 'Tool loop hard stop' : 'Tool loop warning';
260
+ const suffix = `\n\n[${label}: ${decision.code}; count=${decision.count}; ${decision.message}]`;
261
+ return (result || '') + suffix;
83
262
  }
84
263
 
85
- module.exports = { ToolGuardrails };
264
+ module.exports = { ToolGuardrails, guardrailSyntheticResult, appendGuardrailGuidance };