agentshield-sdk 7.4.0 → 10.0.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/CHANGELOG.md +48 -0
- package/LICENSE +21 -21
- package/README.md +30 -37
- package/bin/agentshield-audit +51 -0
- package/package.json +7 -9
- package/src/adaptive.js +330 -330
- package/src/agent-intent.js +807 -0
- package/src/alert-tuning.js +480 -480
- package/src/audit-streaming.js +1 -1
- package/src/badges.js +196 -196
- package/src/behavioral-dna.js +12 -0
- package/src/canary.js +2 -3
- package/src/certification.js +563 -563
- package/src/circuit-breaker.js +2 -2
- package/src/confused-deputy.js +4 -0
- package/src/conversation.js +494 -494
- package/src/cross-turn.js +649 -0
- package/src/ctf.js +462 -462
- package/src/detector-core.js +71 -152
- package/src/document-scanner.js +795 -795
- package/src/drift-monitor.js +344 -0
- package/src/encoding.js +429 -429
- package/src/ensemble.js +523 -0
- package/src/enterprise.js +405 -405
- package/src/flight-recorder.js +2 -0
- package/src/i18n-patterns.js +523 -523
- package/src/index.js +19 -0
- package/src/main.js +79 -6
- package/src/mcp-guard.js +974 -0
- package/src/micro-model.js +762 -0
- package/src/ml-detector.js +316 -0
- package/src/model-finetuning.js +884 -884
- package/src/multimodal.js +296 -296
- package/src/nist-mapping.js +2 -2
- package/src/observability.js +330 -330
- package/src/openclaw.js +450 -450
- package/src/otel.js +544 -544
- package/src/owasp-2025.js +1 -1
- package/src/owasp-agentic.js +420 -0
- package/src/persistent-learning.js +677 -0
- package/src/plugin-marketplace.js +628 -628
- package/src/plugin-system.js +349 -349
- package/src/policy-extended.js +635 -635
- package/src/policy.js +443 -443
- package/src/prompt-leakage.js +2 -2
- package/src/real-attack-datasets.js +2 -2
- package/src/redteam-cli.js +439 -0
- package/src/self-training.js +772 -0
- package/src/smart-config.js +812 -0
- package/src/supply-chain-scanner.js +691 -0
- package/src/testing.js +5 -1
- package/src/threat-encyclopedia.js +629 -629
- package/src/threat-intel-network.js +1017 -1017
- package/src/token-analysis.js +467 -467
- package/src/tool-output-validator.js +354 -354
- package/src/watermark.js +1 -2
- package/types/index.d.ts +660 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent Shield — Persistent Learning + Feedback API (v8.0)
|
|
5
|
+
*
|
|
6
|
+
* Makes detection smarter over time by persisting learned patterns to disk
|
|
7
|
+
* and accepting structured user feedback. Enhances the LearningLoop from
|
|
8
|
+
* adaptive-defense.js with disk persistence, pattern decay, and a dedicated
|
|
9
|
+
* FeedbackCollector that bridges operator input into the learning pipeline.
|
|
10
|
+
*
|
|
11
|
+
* - PersistentLearningLoop: disk-backed pattern learning with decay & promotion
|
|
12
|
+
* - FeedbackCollector: structured FP/FN feedback → learning loop integration
|
|
13
|
+
*
|
|
14
|
+
* Zero external dependencies. All processing runs locally.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const LOG_PREFIX = '[Agent Shield]';
|
|
22
|
+
|
|
23
|
+
// =========================================================================
|
|
24
|
+
// Helpers
|
|
25
|
+
// =========================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generate a unique ID. Uses crypto.randomUUID() when available,
|
|
29
|
+
* falls back to timestamp + random hex.
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function generateId() {
|
|
33
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
34
|
+
return crypto.randomUUID();
|
|
35
|
+
}
|
|
36
|
+
const ts = Date.now().toString(36);
|
|
37
|
+
const rand = Math.random().toString(16).slice(2, 10);
|
|
38
|
+
return `${ts}-${rand}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* SHA-256 hash truncated to 16 hex chars.
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function hashText(text) {
|
|
47
|
+
return crypto.createHash('sha256').update(text).digest('hex').substring(0, 16);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Injection-related keywords used to filter n-grams. */
|
|
51
|
+
const INJECTION_KEYWORDS = [
|
|
52
|
+
'ignore', 'forget', 'disregard', 'override', 'bypass', 'disable',
|
|
53
|
+
'system', 'prompt', 'reveal', 'output', 'instructions', 'previous',
|
|
54
|
+
'jailbreak', 'sudo', 'admin', 'execute', 'inject', 'extract',
|
|
55
|
+
'exfiltrate', 'delete', 'drop', 'curl', 'fetch', 'eval',
|
|
56
|
+
'pretend', 'roleplay', 'act', 'imagine', 'hypothetically'
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract n-grams (3-5 words) from text, filtered to those containing
|
|
61
|
+
* injection-related keywords.
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {string[]}
|
|
64
|
+
*/
|
|
65
|
+
function extractNgrams(text) {
|
|
66
|
+
const lower = text.toLowerCase().replace(/[^\w\s]/g, ' ');
|
|
67
|
+
const words = lower.split(/\s+/).filter(w => w.length > 1);
|
|
68
|
+
const ngrams = [];
|
|
69
|
+
|
|
70
|
+
for (let n = 3; n <= 5; n++) {
|
|
71
|
+
for (let i = 0; i <= words.length - n; i++) {
|
|
72
|
+
const gram = words.slice(i, i + n).join(' ');
|
|
73
|
+
const hasKeyword = INJECTION_KEYWORDS.some(kw => gram.includes(kw));
|
|
74
|
+
if (hasKeyword) {
|
|
75
|
+
ngrams.push(gram);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return ngrams;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// =========================================================================
|
|
84
|
+
// 1. PersistentLearningLoop
|
|
85
|
+
// =========================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Disk-backed learning loop that extracts signature patterns from observed
|
|
89
|
+
* attacks, promotes them after repeated sightings, and persists everything
|
|
90
|
+
* to JSON on disk. Patterns decay over time if not re-observed.
|
|
91
|
+
*/
|
|
92
|
+
class PersistentLearningLoop {
|
|
93
|
+
/**
|
|
94
|
+
* @param {object} [config]
|
|
95
|
+
* @param {boolean} [config.persist=false] - Write patterns to disk
|
|
96
|
+
* @param {string} [config.persistPath='./.agentshield/learned-patterns.json'] - File path
|
|
97
|
+
* @param {number} [config.promotionThreshold=3] - Hits before pattern is promoted
|
|
98
|
+
* @param {number} [config.maxPatterns=500] - Max active patterns
|
|
99
|
+
* @param {number} [config.decayMs=604800000] - Pattern decay time (7 days default)
|
|
100
|
+
* @param {number} [config.maxFalsePositives=3] - FP reports before revocation
|
|
101
|
+
*/
|
|
102
|
+
constructor(config = {}) {
|
|
103
|
+
this._persist = config.persist === true;
|
|
104
|
+
this._persistPath = config.persistPath || './.agentshield/learned-patterns.json';
|
|
105
|
+
this._promotionThreshold = config.promotionThreshold || 3;
|
|
106
|
+
this._maxPatterns = config.maxPatterns || 500;
|
|
107
|
+
this._decayMs = config.decayMs || 604800000; // 7 days
|
|
108
|
+
this._maxFalsePositives = config.maxFalsePositives || 3;
|
|
109
|
+
|
|
110
|
+
/** @type {Map<string, object>} sigHash → candidate */
|
|
111
|
+
this._candidates = new Map();
|
|
112
|
+
/** @type {Map<string, object>} patternId → promoted pattern */
|
|
113
|
+
this._promoted = new Map();
|
|
114
|
+
|
|
115
|
+
this._stats = {
|
|
116
|
+
attacksIngested: 0,
|
|
117
|
+
candidatesCreated: 0,
|
|
118
|
+
patternsPromoted: 0,
|
|
119
|
+
patternsRevoked: 0,
|
|
120
|
+
falsePositivesReported: 0,
|
|
121
|
+
saves: 0,
|
|
122
|
+
loads: 0
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Auto-load from disk if persistence is enabled
|
|
126
|
+
if (this._persist) {
|
|
127
|
+
this.load();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Ingest an attack that was detected by other means.
|
|
133
|
+
* Extracts signature patterns and adds to candidate pool.
|
|
134
|
+
* @param {string} text - The attack text
|
|
135
|
+
* @param {object} [meta] - { category, source, severity }
|
|
136
|
+
* @returns {object} { candidates: number, signatures: string[] }
|
|
137
|
+
*/
|
|
138
|
+
ingest(text, meta = {}) {
|
|
139
|
+
if (!text || typeof text !== 'string') {
|
|
140
|
+
return { candidates: 0, signatures: [] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this._stats.attacksIngested++;
|
|
144
|
+
const ngrams = extractNgrams(text);
|
|
145
|
+
const signatures = [];
|
|
146
|
+
let promotedAny = false;
|
|
147
|
+
|
|
148
|
+
for (const gram of ngrams) {
|
|
149
|
+
const sigHash = hashText(gram);
|
|
150
|
+
const existing = this._candidates.get(sigHash);
|
|
151
|
+
|
|
152
|
+
if (existing) {
|
|
153
|
+
existing.hitCount++;
|
|
154
|
+
existing.lastSeen = Date.now();
|
|
155
|
+
if (meta.category && !existing.categories.includes(meta.category)) {
|
|
156
|
+
existing.categories.push(meta.category);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Promote if threshold reached and not already promoted
|
|
160
|
+
if (existing.hitCount >= this._promotionThreshold &&
|
|
161
|
+
!existing.promoted &&
|
|
162
|
+
this._promoted.size < this._maxPatterns) {
|
|
163
|
+
existing.promoted = true;
|
|
164
|
+
const patternId = `PL_${sigHash.substring(0, 12)}`;
|
|
165
|
+
const confidence = Math.min(1.0, 0.5 + (existing.hitCount * 0.05));
|
|
166
|
+
this._promoted.set(patternId, {
|
|
167
|
+
patternId,
|
|
168
|
+
signature: gram,
|
|
169
|
+
sigHash,
|
|
170
|
+
categories: [...existing.categories],
|
|
171
|
+
confidence,
|
|
172
|
+
hitCount: existing.hitCount,
|
|
173
|
+
fpCount: 0,
|
|
174
|
+
source: meta.source || 'persistent_learning',
|
|
175
|
+
severity: meta.severity || 'medium',
|
|
176
|
+
promotedAt: Date.now(),
|
|
177
|
+
lastSeen: Date.now(),
|
|
178
|
+
active: true
|
|
179
|
+
});
|
|
180
|
+
this._stats.patternsPromoted++;
|
|
181
|
+
promotedAny = true;
|
|
182
|
+
console.log(`${LOG_PREFIX} Pattern promoted: "${gram}" (${patternId})`);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// New candidate
|
|
186
|
+
this._candidates.set(sigHash, {
|
|
187
|
+
signature: gram,
|
|
188
|
+
sigHash,
|
|
189
|
+
hitCount: 1,
|
|
190
|
+
categories: meta.category ? [meta.category] : [],
|
|
191
|
+
firstSeen: Date.now(),
|
|
192
|
+
lastSeen: Date.now(),
|
|
193
|
+
promoted: false
|
|
194
|
+
});
|
|
195
|
+
this._stats.candidatesCreated++;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
signatures.push(gram);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Auto-save after promotion
|
|
202
|
+
if (promotedAny && this._persist) {
|
|
203
|
+
this.save();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { candidates: ngrams.length, signatures };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check text against learned patterns.
|
|
211
|
+
* @param {string} text
|
|
212
|
+
* @returns {object} { matches: Array<{ pattern, source: 'learned', confidence }>, count: number }
|
|
213
|
+
*/
|
|
214
|
+
check(text) {
|
|
215
|
+
if (!text || typeof text !== 'string') {
|
|
216
|
+
return { matches: [], count: 0 };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lower = text.toLowerCase();
|
|
220
|
+
const matches = [];
|
|
221
|
+
|
|
222
|
+
for (const [_patternId, pattern] of this._promoted) {
|
|
223
|
+
if (!pattern.active) continue;
|
|
224
|
+
if (lower.includes(pattern.signature.toLowerCase())) {
|
|
225
|
+
pattern.lastSeen = Date.now();
|
|
226
|
+
pattern.hitCount++;
|
|
227
|
+
matches.push({
|
|
228
|
+
patternId: pattern.patternId,
|
|
229
|
+
pattern: pattern.signature,
|
|
230
|
+
source: 'learned',
|
|
231
|
+
confidence: pattern.confidence,
|
|
232
|
+
categories: pattern.categories,
|
|
233
|
+
severity: pattern.severity
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { matches, count: matches.length };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Report a false positive on a learned pattern.
|
|
243
|
+
* @param {string} patternId
|
|
244
|
+
* @returns {object} { revoked: boolean, fpCount: number, remaining: number }
|
|
245
|
+
*/
|
|
246
|
+
reportFalsePositive(patternId) {
|
|
247
|
+
const pattern = this._promoted.get(patternId);
|
|
248
|
+
if (!pattern) {
|
|
249
|
+
return { revoked: false, fpCount: 0, remaining: 0 };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pattern.fpCount++;
|
|
253
|
+
this._stats.falsePositivesReported++;
|
|
254
|
+
let revoked = false;
|
|
255
|
+
|
|
256
|
+
if (pattern.fpCount >= this._maxFalsePositives) {
|
|
257
|
+
pattern.active = false;
|
|
258
|
+
revoked = true;
|
|
259
|
+
this._stats.patternsRevoked++;
|
|
260
|
+
console.log(`${LOG_PREFIX} Pattern revoked due to false positives: ${patternId}`);
|
|
261
|
+
|
|
262
|
+
if (this._persist) {
|
|
263
|
+
this.save();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const remaining = [...this._promoted.values()].filter(p => p.active).length;
|
|
268
|
+
return { revoked, fpCount: pattern.fpCount, remaining };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Save learned patterns to disk (if persist=true).
|
|
273
|
+
* Uses atomic write: write to .tmp then rename.
|
|
274
|
+
* @returns {boolean} success
|
|
275
|
+
*/
|
|
276
|
+
save() {
|
|
277
|
+
if (!this._persist) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
// Run decay before saving
|
|
283
|
+
this.decay();
|
|
284
|
+
|
|
285
|
+
const dir = path.dirname(this._persistPath);
|
|
286
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
287
|
+
|
|
288
|
+
const data = this.export();
|
|
289
|
+
const json = JSON.stringify(data, null, 2);
|
|
290
|
+
const tmpPath = this._persistPath + '.tmp';
|
|
291
|
+
|
|
292
|
+
fs.writeFileSync(tmpPath, json, 'utf8');
|
|
293
|
+
fs.renameSync(tmpPath, this._persistPath);
|
|
294
|
+
|
|
295
|
+
this._stats.saves++;
|
|
296
|
+
console.log(`${LOG_PREFIX} Saved ${data.patterns.length} patterns to ${this._persistPath}`);
|
|
297
|
+
return true;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(`${LOG_PREFIX} Failed to save patterns: ${err.message}`);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Load learned patterns from disk.
|
|
306
|
+
* @returns {boolean} success
|
|
307
|
+
*/
|
|
308
|
+
load() {
|
|
309
|
+
try {
|
|
310
|
+
if (!fs.existsSync(this._persistPath)) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const raw = fs.readFileSync(this._persistPath, 'utf8');
|
|
315
|
+
const data = JSON.parse(raw);
|
|
316
|
+
this.import(data);
|
|
317
|
+
this._stats.loads++;
|
|
318
|
+
console.log(`${LOG_PREFIX} Loaded patterns from ${this._persistPath}`);
|
|
319
|
+
return true;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(`${LOG_PREFIX} Failed to load patterns: ${err.message}`);
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Export patterns as JSON (regardless of persist setting).
|
|
328
|
+
* @returns {object} { version, timestamp, patterns, candidates, stats }
|
|
329
|
+
*/
|
|
330
|
+
export() {
|
|
331
|
+
const patterns = [];
|
|
332
|
+
for (const [_id, p] of this._promoted) {
|
|
333
|
+
patterns.push({ ...p });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const candidates = [];
|
|
337
|
+
for (const [_hash, c] of this._candidates) {
|
|
338
|
+
candidates.push({ ...c });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
version: '8.0',
|
|
343
|
+
timestamp: new Date().toISOString(),
|
|
344
|
+
patterns,
|
|
345
|
+
candidates,
|
|
346
|
+
stats: { ...this._stats }
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Import patterns from JSON.
|
|
352
|
+
* @param {object} data
|
|
353
|
+
* @returns {number} imported count
|
|
354
|
+
*/
|
|
355
|
+
import(data) {
|
|
356
|
+
if (!data || typeof data !== 'object') {
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let imported = 0;
|
|
361
|
+
|
|
362
|
+
// Import promoted patterns
|
|
363
|
+
if (Array.isArray(data.patterns)) {
|
|
364
|
+
for (const p of data.patterns) {
|
|
365
|
+
if (!p.patternId || !p.signature) continue;
|
|
366
|
+
if (this._promoted.has(p.patternId)) continue;
|
|
367
|
+
if (this._promoted.size >= this._maxPatterns) break;
|
|
368
|
+
|
|
369
|
+
this._promoted.set(p.patternId, {
|
|
370
|
+
patternId: p.patternId,
|
|
371
|
+
signature: p.signature,
|
|
372
|
+
sigHash: p.sigHash || hashText(p.signature),
|
|
373
|
+
categories: p.categories || [],
|
|
374
|
+
confidence: p.confidence || 0.75,
|
|
375
|
+
hitCount: p.hitCount || 0,
|
|
376
|
+
fpCount: p.fpCount || 0,
|
|
377
|
+
source: p.source || 'imported',
|
|
378
|
+
severity: p.severity || 'medium',
|
|
379
|
+
promotedAt: p.promotedAt || Date.now(),
|
|
380
|
+
lastSeen: p.lastSeen || Date.now(),
|
|
381
|
+
active: p.active !== false
|
|
382
|
+
});
|
|
383
|
+
imported++;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Import candidates
|
|
388
|
+
if (Array.isArray(data.candidates)) {
|
|
389
|
+
for (const c of data.candidates) {
|
|
390
|
+
if (!c.signature || !c.sigHash) continue;
|
|
391
|
+
if (this._candidates.has(c.sigHash)) continue;
|
|
392
|
+
|
|
393
|
+
this._candidates.set(c.sigHash, {
|
|
394
|
+
signature: c.signature,
|
|
395
|
+
sigHash: c.sigHash,
|
|
396
|
+
hitCount: c.hitCount || 1,
|
|
397
|
+
categories: c.categories || [],
|
|
398
|
+
firstSeen: c.firstSeen || Date.now(),
|
|
399
|
+
lastSeen: c.lastSeen || Date.now(),
|
|
400
|
+
promoted: c.promoted || false
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return imported;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Decay old patterns that haven't been seen recently.
|
|
410
|
+
* Removes patterns where Date.now() - lastSeen > decayMs.
|
|
411
|
+
* @returns {number} patterns removed
|
|
412
|
+
*/
|
|
413
|
+
decay() {
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
let removed = 0;
|
|
416
|
+
|
|
417
|
+
// Decay promoted patterns
|
|
418
|
+
for (const [patternId, pattern] of this._promoted) {
|
|
419
|
+
if (now - pattern.lastSeen > this._decayMs) {
|
|
420
|
+
this._promoted.delete(patternId);
|
|
421
|
+
removed++;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Decay candidates
|
|
426
|
+
for (const [sigHash, candidate] of this._candidates) {
|
|
427
|
+
if (now - candidate.lastSeen > this._decayMs) {
|
|
428
|
+
this._candidates.delete(sigHash);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (removed > 0) {
|
|
433
|
+
console.log(`${LOG_PREFIX} Decayed ${removed} stale patterns`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return removed;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Get learning statistics.
|
|
441
|
+
* @returns {object}
|
|
442
|
+
*/
|
|
443
|
+
getStats() {
|
|
444
|
+
const activePatterns = [...this._promoted.values()].filter(p => p.active).length;
|
|
445
|
+
const revokedPatterns = [...this._promoted.values()].filter(p => !p.active).length;
|
|
446
|
+
return {
|
|
447
|
+
...this._stats,
|
|
448
|
+
activePatterns,
|
|
449
|
+
revokedPatterns,
|
|
450
|
+
candidates: this._candidates.size,
|
|
451
|
+
totalPromoted: this._promoted.size
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Get all active patterns.
|
|
457
|
+
* @returns {Array<object>}
|
|
458
|
+
*/
|
|
459
|
+
getActivePatterns() {
|
|
460
|
+
const patterns = [];
|
|
461
|
+
for (const [_id, p] of this._promoted) {
|
|
462
|
+
if (p.active) patterns.push({ ...p });
|
|
463
|
+
}
|
|
464
|
+
return patterns;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// =========================================================================
|
|
469
|
+
// 2. FeedbackCollector
|
|
470
|
+
// =========================================================================
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Collects user feedback (false positives / false negatives) and feeds them
|
|
474
|
+
* into the PersistentLearningLoop. Tracks all feedback with IDs for audit.
|
|
475
|
+
*/
|
|
476
|
+
class FeedbackCollector {
|
|
477
|
+
/**
|
|
478
|
+
* @param {object} [config]
|
|
479
|
+
* @param {boolean} [config.autoRetrain=true] - Auto retrain after enough feedback
|
|
480
|
+
* @param {number} [config.maxPending=100] - Max pending reviews
|
|
481
|
+
* @param {number} [config.cooldownMs=5000] - Min time between retrains
|
|
482
|
+
* @param {PersistentLearningLoop} [config.learningLoop] - Connected learning loop
|
|
483
|
+
*/
|
|
484
|
+
constructor(config = {}) {
|
|
485
|
+
this._autoRetrain = config.autoRetrain !== false;
|
|
486
|
+
this._maxPending = config.maxPending || 100;
|
|
487
|
+
this._cooldownMs = config.cooldownMs || 5000;
|
|
488
|
+
this._learningLoop = config.learningLoop || null;
|
|
489
|
+
|
|
490
|
+
/** @type {Array<object>} */
|
|
491
|
+
this._pending = [];
|
|
492
|
+
/** @type {Array<object>} */
|
|
493
|
+
this._processed = [];
|
|
494
|
+
|
|
495
|
+
this._lastRetrainAt = 0;
|
|
496
|
+
|
|
497
|
+
this._stats = {
|
|
498
|
+
falsePositives: 0,
|
|
499
|
+
falseNegatives: 0,
|
|
500
|
+
totalProcessed: 0,
|
|
501
|
+
patternsAdded: 0,
|
|
502
|
+
patternsRevoked: 0,
|
|
503
|
+
retrainCount: 0
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Report a false positive — something was flagged that shouldn't have been.
|
|
509
|
+
* @param {string} text - The text that was incorrectly flagged
|
|
510
|
+
* @param {object} [meta] - { scanId, category, patternId, reason }
|
|
511
|
+
* @returns {object} { id: string, status: 'recorded', pendingCount: number }
|
|
512
|
+
*/
|
|
513
|
+
reportFalsePositive(text, meta = {}) {
|
|
514
|
+
const id = `fp_${generateId()}`;
|
|
515
|
+
|
|
516
|
+
const entry = {
|
|
517
|
+
id,
|
|
518
|
+
type: 'false_positive',
|
|
519
|
+
text: typeof text === 'string' ? text.substring(0, 2000) : '',
|
|
520
|
+
meta: { ...meta },
|
|
521
|
+
timestamp: new Date().toISOString(),
|
|
522
|
+
status: 'pending'
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
this._pending.push(entry);
|
|
526
|
+
this._stats.falsePositives++;
|
|
527
|
+
|
|
528
|
+
// Enforce max pending
|
|
529
|
+
while (this._pending.length > this._maxPending) {
|
|
530
|
+
this._pending.shift();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
console.log(`${LOG_PREFIX} False positive reported: ${id}`);
|
|
534
|
+
|
|
535
|
+
return { id, status: 'recorded', pendingCount: this._pending.length };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Report a false negative — something should have been caught but wasn't.
|
|
540
|
+
* @param {string} text - The text that should have been detected
|
|
541
|
+
* @param {object} [meta] - { expectedCategory, severity, source }
|
|
542
|
+
* @returns {object} { id: string, status: 'recorded', pendingCount: number }
|
|
543
|
+
*/
|
|
544
|
+
reportFalseNegative(text, meta = {}) {
|
|
545
|
+
const id = `fn_${generateId()}`;
|
|
546
|
+
|
|
547
|
+
const entry = {
|
|
548
|
+
id,
|
|
549
|
+
type: 'false_negative',
|
|
550
|
+
text: typeof text === 'string' ? text.substring(0, 2000) : '',
|
|
551
|
+
meta: { ...meta },
|
|
552
|
+
timestamp: new Date().toISOString(),
|
|
553
|
+
status: 'pending'
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
this._pending.push(entry);
|
|
557
|
+
this._stats.falseNegatives++;
|
|
558
|
+
|
|
559
|
+
// Enforce max pending
|
|
560
|
+
while (this._pending.length > this._maxPending) {
|
|
561
|
+
this._pending.shift();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
console.log(`${LOG_PREFIX} False negative reported: ${id}`);
|
|
565
|
+
|
|
566
|
+
return { id, status: 'recorded', pendingCount: this._pending.length };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Get pending feedback that hasn't been processed.
|
|
571
|
+
* @returns {Array<object>}
|
|
572
|
+
*/
|
|
573
|
+
getPending() {
|
|
574
|
+
return this._pending.filter(e => e.status === 'pending');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Process all pending feedback:
|
|
579
|
+
* - FPs: report to learning loop for potential revocation
|
|
580
|
+
* - FNs: ingest into learning loop for pattern generation
|
|
581
|
+
* - If autoRetrain: trigger retrain event
|
|
582
|
+
* @returns {object} { processed: number, patternsAdded: number, patternsRevoked: number, retrainTriggered: boolean }
|
|
583
|
+
*/
|
|
584
|
+
process() {
|
|
585
|
+
const pending = this.getPending();
|
|
586
|
+
let patternsAdded = 0;
|
|
587
|
+
let patternsRevoked = 0;
|
|
588
|
+
|
|
589
|
+
for (const entry of pending) {
|
|
590
|
+
entry.status = 'processed';
|
|
591
|
+
entry.processedAt = new Date().toISOString();
|
|
592
|
+
|
|
593
|
+
if (entry.type === 'false_positive') {
|
|
594
|
+
// Report to learning loop for potential revocation
|
|
595
|
+
if (this._learningLoop && entry.meta.patternId) {
|
|
596
|
+
const result = this._learningLoop.reportFalsePositive(entry.meta.patternId);
|
|
597
|
+
if (result.revoked) {
|
|
598
|
+
patternsRevoked++;
|
|
599
|
+
}
|
|
600
|
+
entry.result = result;
|
|
601
|
+
}
|
|
602
|
+
} else if (entry.type === 'false_negative') {
|
|
603
|
+
// Ingest into learning loop for pattern generation
|
|
604
|
+
if (this._learningLoop) {
|
|
605
|
+
const result = this._learningLoop.ingest(entry.text, {
|
|
606
|
+
category: entry.meta.expectedCategory || 'unknown',
|
|
607
|
+
source: 'feedback',
|
|
608
|
+
severity: entry.meta.severity || 'medium'
|
|
609
|
+
});
|
|
610
|
+
patternsAdded += result.candidates;
|
|
611
|
+
entry.result = result;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
this._processed.push(entry);
|
|
616
|
+
this._stats.totalProcessed++;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
this._stats.patternsAdded += patternsAdded;
|
|
620
|
+
this._stats.patternsRevoked += patternsRevoked;
|
|
621
|
+
|
|
622
|
+
// Check if retrain should be triggered
|
|
623
|
+
let retrainTriggered = false;
|
|
624
|
+
const now = Date.now();
|
|
625
|
+
if (this._autoRetrain && pending.length > 0 && (now - this._lastRetrainAt) >= this._cooldownMs) {
|
|
626
|
+
this._lastRetrainAt = now;
|
|
627
|
+
this._stats.retrainCount++;
|
|
628
|
+
retrainTriggered = true;
|
|
629
|
+
console.log(`${LOG_PREFIX} Retrain triggered after processing ${pending.length} feedback items`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Remove processed entries from pending
|
|
633
|
+
this._pending = this._pending.filter(e => e.status === 'pending');
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
processed: pending.length,
|
|
637
|
+
patternsAdded,
|
|
638
|
+
patternsRevoked,
|
|
639
|
+
retrainTriggered
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Get feedback stats.
|
|
645
|
+
* @returns {object}
|
|
646
|
+
*/
|
|
647
|
+
getStats() {
|
|
648
|
+
return {
|
|
649
|
+
...this._stats,
|
|
650
|
+
pendingCount: this._pending.filter(e => e.status === 'pending').length,
|
|
651
|
+
processedCount: this._processed.length
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Export all feedback data.
|
|
657
|
+
* @returns {object}
|
|
658
|
+
*/
|
|
659
|
+
export() {
|
|
660
|
+
return {
|
|
661
|
+
version: '8.0',
|
|
662
|
+
timestamp: new Date().toISOString(),
|
|
663
|
+
pending: this._pending.map(e => ({ ...e })),
|
|
664
|
+
processed: this._processed.map(e => ({ ...e })),
|
|
665
|
+
stats: this.getStats()
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// =========================================================================
|
|
671
|
+
// Exports
|
|
672
|
+
// =========================================================================
|
|
673
|
+
|
|
674
|
+
module.exports = {
|
|
675
|
+
PersistentLearningLoop,
|
|
676
|
+
FeedbackCollector
|
|
677
|
+
};
|