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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "natureco-cli",
3
- "version": "5.8.0",
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
- * 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
+ ]);
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.callLog = []; // [{ name, argsKey, iteration, success }]
17
- this.failureCounts = {}; // { toolName: count }
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
- // 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);
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
- * Returns blocked tool names if guardrails trigger.
104
+ * After-call recording detects failures and no-progress patterns.
105
+ * Returns a decision (warn or allow).
37
106
  */
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` };
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
- // 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` };
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
- * Record a tool call result.
197
+ * Legacy record method (used by current processToolCalls).
63
198
  */
64
199
  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
- }
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 (no-progress).
204
+ * Returns true if no tool has succeeded this iteration.
78
205
  */
79
206
  isNoProgress() {
80
- const thisIter = this.callLog.filter(c => c.iteration === this.iteration);
81
- return thisIter.length > 0 && thisIter.every(c => !c.success);
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 };