natureco-cli 5.8.0 → 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/utils/memory-store.js +6 -0
- package/src/utils/threat-patterns.js +104 -0
- package/src/utils/tool-guardrails.js +224 -46
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"
|
|
@@ -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,263 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool Guardrails — Hermes-style ToolCallGuardrailController
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
+
]);
|
|
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
|
+
|
|
10
32
|
class ToolGuardrails {
|
|
11
|
-
constructor() {
|
|
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;
|
|
12
42
|
this.reset();
|
|
13
43
|
}
|
|
14
44
|
|
|
15
45
|
reset() {
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
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;
|
|
18
50
|
this.iteration = 0;
|
|
19
|
-
this.blockedTools = new Set();
|
|
20
|
-
this.consecutiveFailures = {};
|
|
21
51
|
}
|
|
22
52
|
|
|
23
53
|
startIteration() {
|
|
24
54
|
this.iteration++;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
|
31
97
|
}
|
|
32
98
|
}
|
|
99
|
+
|
|
100
|
+
return { action: 'allow', code: 'allow', message: '', allowsExecution: true, shouldHalt: false, signature: sig };
|
|
33
101
|
}
|
|
34
102
|
|
|
35
103
|
/**
|
|
36
|
-
*
|
|
104
|
+
* After-call recording — detects failures and no-progress patterns.
|
|
105
|
+
* Returns a decision (warn or allow).
|
|
37
106
|
*/
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 };
|
|
47
153
|
}
|
|
48
154
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 };
|
|
56
163
|
}
|
|
57
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
|
+
}
|
|
58
193
|
return { blocked: false };
|
|
59
194
|
}
|
|
60
195
|
|
|
61
196
|
/**
|
|
62
|
-
*
|
|
197
|
+
* Legacy record method (used by current processToolCalls).
|
|
63
198
|
*/
|
|
64
199
|
record(toolName, toolArgs, success) {
|
|
65
|
-
|
|
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
|
-
}
|
|
200
|
+
this.afterCall(toolName, toolArgs, JSON.stringify({ success }), { failed: !success });
|
|
74
201
|
}
|
|
75
202
|
|
|
76
203
|
/**
|
|
77
|
-
* Returns true if no tool has succeeded this iteration
|
|
204
|
+
* Returns true if no tool has succeeded this iteration.
|
|
78
205
|
*/
|
|
79
206
|
isNoProgress() {
|
|
80
|
-
|
|
81
|
-
|
|
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)}`;
|
|
82
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;
|
|
83
261
|
}
|
|
84
262
|
|
|
85
|
-
module.exports = { ToolGuardrails };
|
|
263
|
+
module.exports = { ToolGuardrails, guardrailSyntheticResult, appendGuardrailGuidance };
|