agentshield-sdk 7.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 +191 -0
- package/LICENSE +21 -0
- package/README.md +975 -0
- package/bin/agent-shield.js +680 -0
- package/package.json +118 -0
- package/src/adaptive.js +330 -0
- package/src/agent-protocol.js +998 -0
- package/src/alert-tuning.js +480 -0
- package/src/allowlist.js +603 -0
- package/src/audit-immutable.js +914 -0
- package/src/audit-streaming.js +469 -0
- package/src/badges.js +196 -0
- package/src/behavior-profiling.js +289 -0
- package/src/benchmark-harness.js +804 -0
- package/src/canary.js +271 -0
- package/src/certification.js +563 -0
- package/src/circuit-breaker.js +321 -0
- package/src/compliance.js +617 -0
- package/src/confidence-tuning.js +324 -0
- package/src/confused-deputy.js +624 -0
- package/src/context-scoring.js +360 -0
- package/src/conversation.js +494 -0
- package/src/cost-optimizer.js +1024 -0
- package/src/ctf.js +462 -0
- package/src/detector-core.js +1999 -0
- package/src/distributed.js +359 -0
- package/src/document-scanner.js +795 -0
- package/src/embedding.js +307 -0
- package/src/encoding.js +429 -0
- package/src/enterprise.js +405 -0
- package/src/errors.js +100 -0
- package/src/eu-ai-act.js +523 -0
- package/src/fuzzer.js +764 -0
- package/src/honeypot.js +328 -0
- package/src/i18n-patterns.js +523 -0
- package/src/index.js +430 -0
- package/src/integrations.js +528 -0
- package/src/llm-redteam.js +670 -0
- package/src/main.js +741 -0
- package/src/main.mjs +38 -0
- package/src/mcp-bridge.js +542 -0
- package/src/mcp-certification.js +846 -0
- package/src/mcp-sdk-integration.js +355 -0
- package/src/mcp-security-runtime.js +741 -0
- package/src/mcp-server.js +740 -0
- package/src/middleware.js +208 -0
- package/src/model-finetuning.js +884 -0
- package/src/model-fingerprint.js +1042 -0
- package/src/multi-agent-trust.js +453 -0
- package/src/multi-agent.js +404 -0
- package/src/multimodal.js +296 -0
- package/src/nist-mapping.js +505 -0
- package/src/observability.js +330 -0
- package/src/openclaw.js +450 -0
- package/src/otel.js +544 -0
- package/src/owasp-2025.js +483 -0
- package/src/pii.js +390 -0
- package/src/plugin-marketplace.js +628 -0
- package/src/plugin-system.js +349 -0
- package/src/policy-dsl.js +775 -0
- package/src/policy-extended.js +635 -0
- package/src/policy.js +443 -0
- package/src/presets.js +409 -0
- package/src/production.js +557 -0
- package/src/prompt-leakage.js +321 -0
- package/src/rag-vulnerability.js +579 -0
- package/src/redteam.js +475 -0
- package/src/response-handler.js +429 -0
- package/src/scanners.js +357 -0
- package/src/self-healing.js +363 -0
- package/src/semantic.js +339 -0
- package/src/shield-score.js +250 -0
- package/src/sso-saml.js +897 -0
- package/src/stream-scanner.js +806 -0
- package/src/testing.js +505 -0
- package/src/threat-encyclopedia.js +629 -0
- package/src/threat-intel-network.js +1017 -0
- package/src/token-analysis.js +467 -0
- package/src/tool-guard.js +412 -0
- package/src/tool-output-validator.js +354 -0
- package/src/utils.js +83 -0
- package/src/watermark.js +235 -0
- package/src/worker-scanner.js +601 -0
- package/types/index.d.ts +2088 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent Shield — Tool Output Validator
|
|
5
|
+
*
|
|
6
|
+
* Scans what tools RETURN, not just what gets called. Validates tool output
|
|
7
|
+
* for prompt injection, data exfiltration, and other threats that may be
|
|
8
|
+
* smuggled in through tool responses.
|
|
9
|
+
*
|
|
10
|
+
* All detection runs locally — no data ever leaves your environment.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { scanText } = require('./detector-core');
|
|
14
|
+
|
|
15
|
+
// =========================================================================
|
|
16
|
+
// CONSTANTS
|
|
17
|
+
// =========================================================================
|
|
18
|
+
|
|
19
|
+
/** Default maximum output size in bytes (100KB). */
|
|
20
|
+
const DEFAULT_MAX_OUTPUT_SIZE = 100 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Zero-width and invisible Unicode characters. */
|
|
23
|
+
const INVISIBLE_CHAR_REGEX = /[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060\u2061\u2062\u2063\u2064\u2066-\u2069\uFEFF\u00AD]/g;
|
|
24
|
+
|
|
25
|
+
/** Suspicious URL patterns (known exfiltration vectors). */
|
|
26
|
+
const SUSPICIOUS_URL_PATTERNS = [
|
|
27
|
+
/https?:\/\/[^/]*\.(ngrok|burpcollaborator|requestbin|pipedream|webhook\.site|hookbin)\./i,
|
|
28
|
+
/https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/,
|
|
29
|
+
/https?:\/\/[^/]*\.onion\b/i,
|
|
30
|
+
/https?:\/\/[^/]+\/.*\?(.*=.*&){4,}/i
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Safe URL domain patterns. */
|
|
34
|
+
const SAFE_URL_PATTERNS = [
|
|
35
|
+
/^https?:\/\/([^/]*\.)?(github\.com|gitlab\.com|stackoverflow\.com|npmjs\.com|docs\.google\.com|developer\.mozilla\.org|wikipedia\.org)\b/i
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Dangerous code patterns inside code blocks. */
|
|
39
|
+
const DANGEROUS_CODE_PATTERNS = [
|
|
40
|
+
{ regex: /eval\s*\(/, description: 'eval() call' },
|
|
41
|
+
{ regex: /Function\s*\(/, description: 'Function constructor' },
|
|
42
|
+
{ regex: /child_process|exec\s*\(|spawn\s*\(/, description: 'shell execution' },
|
|
43
|
+
{ regex: /process\.env/, description: 'environment variable access' },
|
|
44
|
+
{ regex: /fs\.(read|write|unlink|rmdir)/, description: 'filesystem operation' },
|
|
45
|
+
{ regex: /require\s*\(\s*['"]https?['"]/, description: 'remote module loading' },
|
|
46
|
+
{ regex: /XMLHttpRequest|fetch\s*\(|\.ajax\s*\(/, description: 'network request' },
|
|
47
|
+
{ regex: /document\.cookie|localStorage|sessionStorage/, description: 'browser storage access' }
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// =========================================================================
|
|
51
|
+
// OUTPUT SANITIZER
|
|
52
|
+
// =========================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sanitizes tool output by removing or replacing dangerous content.
|
|
56
|
+
*/
|
|
57
|
+
class OutputSanitizer {
|
|
58
|
+
/**
|
|
59
|
+
* Remove or replace dangerous content from tool output.
|
|
60
|
+
* @param {string} text - The text to sanitize.
|
|
61
|
+
* @param {object} [options] - Sanitization options.
|
|
62
|
+
* @param {boolean} [options.stripInvisible=true] - Remove invisible characters.
|
|
63
|
+
* @param {boolean} [options.redactUrls=true] - Redact suspicious URLs.
|
|
64
|
+
* @param {boolean} [options.redactCode=false] - Redact dangerous code blocks.
|
|
65
|
+
* @param {number} [options.maxLength] - Maximum output length.
|
|
66
|
+
* @returns {string} Sanitized text.
|
|
67
|
+
*/
|
|
68
|
+
static sanitize(text, options = {}) {
|
|
69
|
+
if (!text || typeof text !== 'string') return '';
|
|
70
|
+
|
|
71
|
+
const {
|
|
72
|
+
stripInvisible = true,
|
|
73
|
+
redactUrls = true,
|
|
74
|
+
redactCode = false,
|
|
75
|
+
maxLength
|
|
76
|
+
} = options;
|
|
77
|
+
|
|
78
|
+
let result = text;
|
|
79
|
+
|
|
80
|
+
if (stripInvisible) {
|
|
81
|
+
result = OutputSanitizer.stripInvisibleChars(result);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (redactUrls) {
|
|
85
|
+
result = OutputSanitizer.redactUrls(result);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (redactCode) {
|
|
89
|
+
result = OutputSanitizer.redactCodeBlocks(result);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (maxLength && maxLength > 0) {
|
|
93
|
+
result = OutputSanitizer.truncate(result, maxLength);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove zero-width, bidirectional, and other invisible Unicode characters.
|
|
101
|
+
* @param {string} text - The text to strip.
|
|
102
|
+
* @returns {string} Cleaned text.
|
|
103
|
+
*/
|
|
104
|
+
static stripInvisibleChars(text) {
|
|
105
|
+
if (!text || typeof text !== 'string') return '';
|
|
106
|
+
return text.replace(INVISIBLE_CHAR_REGEX, '');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Safe truncation that does not break multi-byte Unicode encoding.
|
|
111
|
+
* @param {string} text - The text to truncate.
|
|
112
|
+
* @param {number} maxLength - Maximum character length.
|
|
113
|
+
* @returns {string} Truncated text.
|
|
114
|
+
*/
|
|
115
|
+
static truncate(text, maxLength) {
|
|
116
|
+
if (!text || typeof text !== 'string') return '';
|
|
117
|
+
if (text.length <= maxLength) return text;
|
|
118
|
+
|
|
119
|
+
// Use Array.from to handle surrogate pairs correctly
|
|
120
|
+
const chars = Array.from(text);
|
|
121
|
+
if (chars.length <= maxLength) return text;
|
|
122
|
+
|
|
123
|
+
return chars.slice(0, maxLength).join('') + '... [truncated]';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Redact suspicious URLs while keeping safe, well-known ones.
|
|
128
|
+
* @param {string} text - The text containing URLs.
|
|
129
|
+
* @returns {string} Text with suspicious URLs redacted.
|
|
130
|
+
*/
|
|
131
|
+
static redactUrls(text) {
|
|
132
|
+
if (!text || typeof text !== 'string') return '';
|
|
133
|
+
|
|
134
|
+
return text.replace(/https?:\/\/[^\s<>"')\]]+/gi, (url) => {
|
|
135
|
+
// Allow known-safe domains
|
|
136
|
+
for (const safePattern of SAFE_URL_PATTERNS) {
|
|
137
|
+
if (safePattern.test(url)) return url;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Redact known-suspicious domains
|
|
141
|
+
for (const suspiciousPattern of SUSPICIOUS_URL_PATTERNS) {
|
|
142
|
+
if (suspiciousPattern.test(url)) {
|
|
143
|
+
return '[REDACTED_URL]';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Let other URLs through
|
|
148
|
+
return url;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Flag or redact code blocks containing dangerous patterns.
|
|
154
|
+
* @param {string} text - The text containing code blocks.
|
|
155
|
+
* @param {object} [options] - Redaction options.
|
|
156
|
+
* @param {boolean} [options.redact=false] - If true, replace dangerous blocks entirely; otherwise, add warnings.
|
|
157
|
+
* @returns {string} Text with dangerous code blocks flagged or redacted.
|
|
158
|
+
*/
|
|
159
|
+
static redactCodeBlocks(text, options = {}) {
|
|
160
|
+
if (!text || typeof text !== 'string') return '';
|
|
161
|
+
const { redact = false } = options;
|
|
162
|
+
|
|
163
|
+
return text.replace(/```[\s\S]*?```/g, (block) => {
|
|
164
|
+
const found = [];
|
|
165
|
+
for (const pattern of DANGEROUS_CODE_PATTERNS) {
|
|
166
|
+
if (pattern.regex.test(block)) {
|
|
167
|
+
found.push(pattern.description);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (found.length === 0) return block;
|
|
172
|
+
|
|
173
|
+
if (redact) {
|
|
174
|
+
return '```\n[REDACTED: code block contained dangerous patterns: ' + found.join(', ') + ']\n```';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return '[Agent Shield WARNING: dangerous patterns detected (' + found.join(', ') + ')]\n' + block;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// =========================================================================
|
|
183
|
+
// TOOL OUTPUT VALIDATOR
|
|
184
|
+
// =========================================================================
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Validates tool output for security threats.
|
|
188
|
+
* Scans what tools return and flags prompt injection, exfiltration vectors,
|
|
189
|
+
* invisible characters, and other threats embedded in tool responses.
|
|
190
|
+
*/
|
|
191
|
+
class ToolOutputValidator {
|
|
192
|
+
/**
|
|
193
|
+
* @param {object} [options]
|
|
194
|
+
* @param {string} [options.sensitivity='medium'] - Scan sensitivity: 'low', 'medium', 'high'.
|
|
195
|
+
* @param {RegExp[]} [options.blockedPatterns=[]] - Custom regexes to flag in output.
|
|
196
|
+
* @param {number} [options.maxOutputSize=102400] - Maximum output size in bytes (default 100KB).
|
|
197
|
+
*/
|
|
198
|
+
constructor(options = {}) {
|
|
199
|
+
this.sensitivity = options.sensitivity || 'medium';
|
|
200
|
+
this.blockedPatterns = options.blockedPatterns || [];
|
|
201
|
+
this.maxOutputSize = options.maxOutputSize || DEFAULT_MAX_OUTPUT_SIZE;
|
|
202
|
+
|
|
203
|
+
/** @type {Map<string, {name: string, check: Function}[]>} */
|
|
204
|
+
this._rules = new Map();
|
|
205
|
+
|
|
206
|
+
/** @type {Map<string, {total: number, threats: number, truncated: number}>} */
|
|
207
|
+
this._stats = new Map();
|
|
208
|
+
|
|
209
|
+
console.log('[Agent Shield] ToolOutputValidator initialized (sensitivity: %s, maxOutput: %d bytes)', this.sensitivity, this.maxOutputSize);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Validate tool output for threats.
|
|
214
|
+
* @param {string} toolName - The name of the tool that produced the output.
|
|
215
|
+
* @param {string} output - The tool's output text.
|
|
216
|
+
* @param {object} [context] - Additional context (e.g., calling agent, request ID).
|
|
217
|
+
* @returns {object} Validation result: { safe, threats, sanitized, truncated }.
|
|
218
|
+
*/
|
|
219
|
+
validate(toolName, output, context = {}) {
|
|
220
|
+
const stat = this._ensureStat(toolName);
|
|
221
|
+
stat.total++;
|
|
222
|
+
|
|
223
|
+
const result = {
|
|
224
|
+
safe: true,
|
|
225
|
+
threats: [],
|
|
226
|
+
sanitized: null,
|
|
227
|
+
truncated: false
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (!output || typeof output !== 'string') {
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check output size
|
|
235
|
+
const byteLength = Buffer.byteLength(output, 'utf8');
|
|
236
|
+
if (byteLength > this.maxOutputSize) {
|
|
237
|
+
result.truncated = true;
|
|
238
|
+
stat.truncated++;
|
|
239
|
+
output = OutputSanitizer.truncate(output, this.maxOutputSize);
|
|
240
|
+
result.threats.push({
|
|
241
|
+
severity: 'medium',
|
|
242
|
+
category: 'output_size',
|
|
243
|
+
description: `Tool "${toolName}" output exceeded max size (${byteLength} bytes > ${this.maxOutputSize} bytes).`,
|
|
244
|
+
detail: 'Output was truncated to comply with size limits.'
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Run core threat scan
|
|
249
|
+
const scanResult = scanText(output, {
|
|
250
|
+
source: `tool_output:${toolName}`,
|
|
251
|
+
sensitivity: this.sensitivity
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (scanResult.threats.length > 0) {
|
|
255
|
+
result.threats.push(...scanResult.threats);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check custom blocked patterns
|
|
259
|
+
for (const pattern of this.blockedPatterns) {
|
|
260
|
+
if (pattern.test(output)) {
|
|
261
|
+
result.threats.push({
|
|
262
|
+
severity: 'high',
|
|
263
|
+
category: 'blocked_pattern',
|
|
264
|
+
description: `Tool "${toolName}" output matched a blocked pattern.`,
|
|
265
|
+
detail: `Pattern: ${pattern.toString()}`
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Run per-tool custom rules
|
|
271
|
+
const rules = this._rules.get(toolName) || [];
|
|
272
|
+
for (const rule of rules) {
|
|
273
|
+
try {
|
|
274
|
+
const ruleResult = rule.check(output);
|
|
275
|
+
if (ruleResult && !ruleResult.valid) {
|
|
276
|
+
result.threats.push({
|
|
277
|
+
severity: 'medium',
|
|
278
|
+
category: 'custom_rule',
|
|
279
|
+
description: `Tool "${toolName}" failed rule "${rule.name}".`,
|
|
280
|
+
detail: ruleResult.reason || 'Custom rule violation.'
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.log('[Agent Shield] Custom rule "%s" threw an error for tool "%s": %s', rule.name, toolName, err.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Determine safety
|
|
289
|
+
if (result.threats.length > 0) {
|
|
290
|
+
result.safe = false;
|
|
291
|
+
stat.threats++;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Provide sanitized output
|
|
295
|
+
result.sanitized = OutputSanitizer.sanitize(output, {
|
|
296
|
+
stripInvisible: true,
|
|
297
|
+
redactUrls: true,
|
|
298
|
+
maxLength: this.maxOutputSize
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Add a custom validation rule for a specific tool.
|
|
306
|
+
* @param {string} toolName - The tool name to apply the rule to.
|
|
307
|
+
* @param {object} rule - The rule definition.
|
|
308
|
+
* @param {string} rule.name - Human-readable rule name.
|
|
309
|
+
* @param {Function} rule.check - Function that receives output and returns { valid, reason }.
|
|
310
|
+
*/
|
|
311
|
+
addRule(toolName, rule) {
|
|
312
|
+
if (!rule || typeof rule.name !== 'string' || typeof rule.check !== 'function') {
|
|
313
|
+
throw new Error('Rule must have a "name" (string) and "check" (function).');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!this._rules.has(toolName)) {
|
|
317
|
+
this._rules.set(toolName, []);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
this._rules.get(toolName).push(rule);
|
|
321
|
+
console.log('[Agent Shield] Added rule "%s" for tool "%s"', rule.name, toolName);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get per-tool validation statistics.
|
|
326
|
+
* @returns {object} Map of tool names to { total, threats, truncated }.
|
|
327
|
+
*/
|
|
328
|
+
getStats() {
|
|
329
|
+
const stats = {};
|
|
330
|
+
for (const [tool, data] of this._stats) {
|
|
331
|
+
stats[tool] = { ...data };
|
|
332
|
+
}
|
|
333
|
+
return stats;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Ensure a stats entry exists for a tool.
|
|
338
|
+
* @param {string} toolName
|
|
339
|
+
* @returns {object}
|
|
340
|
+
* @private
|
|
341
|
+
*/
|
|
342
|
+
_ensureStat(toolName) {
|
|
343
|
+
if (!this._stats.has(toolName)) {
|
|
344
|
+
this._stats.set(toolName, { total: 0, threats: 0, truncated: 0 });
|
|
345
|
+
}
|
|
346
|
+
return this._stats.get(toolName);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// =========================================================================
|
|
351
|
+
// EXPORTS
|
|
352
|
+
// =========================================================================
|
|
353
|
+
|
|
354
|
+
module.exports = { ToolOutputValidator, OutputSanitizer };
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent Shield — Shared Utilities
|
|
5
|
+
*
|
|
6
|
+
* Common helpers used across multiple modules to avoid duplication.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calculate a letter grade from a numeric score (0-100).
|
|
11
|
+
*/
|
|
12
|
+
function getGrade(score) {
|
|
13
|
+
if (score >= 95) return 'A+';
|
|
14
|
+
if (score >= 90) return 'A';
|
|
15
|
+
if (score >= 85) return 'A-';
|
|
16
|
+
if (score >= 80) return 'B+';
|
|
17
|
+
if (score >= 75) return 'B';
|
|
18
|
+
if (score >= 70) return 'B-';
|
|
19
|
+
if (score >= 65) return 'C+';
|
|
20
|
+
if (score >= 60) return 'C';
|
|
21
|
+
if (score >= 55) return 'C-';
|
|
22
|
+
if (score >= 50) return 'D';
|
|
23
|
+
return 'F';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get a human-readable grade label.
|
|
28
|
+
*/
|
|
29
|
+
function getGradeLabel(score) {
|
|
30
|
+
if (score >= 95) return 'A+ — Excellent';
|
|
31
|
+
if (score >= 90) return 'A — Strong';
|
|
32
|
+
if (score >= 80) return 'B — Good';
|
|
33
|
+
if (score >= 70) return 'C — Moderate';
|
|
34
|
+
if (score >= 60) return 'D — Weak';
|
|
35
|
+
return 'F — Critical gaps';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render a progress bar using block characters.
|
|
40
|
+
*/
|
|
41
|
+
function makeBar(filled, total, width) {
|
|
42
|
+
const ratio = total > 0 ? filled / total : 0;
|
|
43
|
+
const filledCount = Math.round(ratio * width);
|
|
44
|
+
return '█'.repeat(filledCount) + '░'.repeat(width - filledCount);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Truncate text to a maximum length with an optional suffix.
|
|
49
|
+
*/
|
|
50
|
+
function truncate(text, maxLength = 200, suffix = '') {
|
|
51
|
+
if (!text || text.length <= maxLength) return text || '';
|
|
52
|
+
return text.substring(0, maxLength) + suffix;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format a boxed console header.
|
|
57
|
+
*/
|
|
58
|
+
function formatHeader(title, width = 54) {
|
|
59
|
+
const padded = title.length < width - 4
|
|
60
|
+
? ' '.repeat(Math.floor((width - 2 - title.length) / 2)) + title + ' '.repeat(Math.ceil((width - 2 - title.length) / 2))
|
|
61
|
+
: title;
|
|
62
|
+
return [
|
|
63
|
+
'╔' + '═'.repeat(width) + '╗',
|
|
64
|
+
'║' + padded + '║',
|
|
65
|
+
'╚' + '═'.repeat(width) + '╝'
|
|
66
|
+
].join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a unique event ID.
|
|
71
|
+
*/
|
|
72
|
+
function generateId(prefix = 'evt') {
|
|
73
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
getGrade,
|
|
78
|
+
getGradeLabel,
|
|
79
|
+
makeBar,
|
|
80
|
+
truncate,
|
|
81
|
+
formatHeader,
|
|
82
|
+
generateId
|
|
83
|
+
};
|
package/src/watermark.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Output Watermarking (#44) and Differential Privacy (#46)
|
|
5
|
+
*
|
|
6
|
+
* - Watermarking: Embed invisible watermarks in agent outputs for tracing.
|
|
7
|
+
* - Differential Privacy: Add noise to stored conversation data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
// =========================================================================
|
|
13
|
+
// OUTPUT WATERMARKING
|
|
14
|
+
// =========================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Zero-width characters used for binary watermark encoding.
|
|
18
|
+
*/
|
|
19
|
+
const WM_ZERO = '\u200B'; // zero-width space = 0
|
|
20
|
+
const WM_ONE = '\u200C'; // zero-width non-joiner = 1
|
|
21
|
+
const WM_START = '\u200D'; // zero-width joiner = start marker
|
|
22
|
+
const WM_END = '\uFEFF'; // byte order mark = end marker
|
|
23
|
+
|
|
24
|
+
class OutputWatermark {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} [options]
|
|
27
|
+
* @param {string} [options.secret] - Secret key for HMAC signing.
|
|
28
|
+
* @param {boolean} [options.includeTimestamp=true] - Include timestamp in watermark.
|
|
29
|
+
*/
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.secret = options.secret || crypto.randomBytes(16).toString('hex');
|
|
32
|
+
this.includeTimestamp = options.includeTimestamp !== undefined ? options.includeTimestamp : true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Embeds an invisible watermark in text.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} text - The text to watermark.
|
|
39
|
+
* @param {object} metadata - Data to encode in the watermark.
|
|
40
|
+
* @param {string} [metadata.agentId] - Agent identifier.
|
|
41
|
+
* @param {string} [metadata.sessionId] - Session identifier.
|
|
42
|
+
* @returns {string} Watermarked text.
|
|
43
|
+
*/
|
|
44
|
+
embed(text, metadata = {}) {
|
|
45
|
+
if (!text) return text;
|
|
46
|
+
|
|
47
|
+
const payload = {
|
|
48
|
+
...metadata,
|
|
49
|
+
ts: this.includeTimestamp ? Date.now() : undefined
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Create signed payload
|
|
53
|
+
const payloadStr = JSON.stringify(payload);
|
|
54
|
+
const signature = crypto.createHmac('sha256', this.secret)
|
|
55
|
+
.update(payloadStr)
|
|
56
|
+
.digest('hex')
|
|
57
|
+
.substring(0, 8);
|
|
58
|
+
|
|
59
|
+
const data = `${payloadStr}|${signature}`;
|
|
60
|
+
|
|
61
|
+
// Encode to binary using zero-width characters
|
|
62
|
+
const binary = this._textToBinary(data);
|
|
63
|
+
const watermark = WM_START + binary + WM_END;
|
|
64
|
+
|
|
65
|
+
// Insert watermark in the middle of the text to be less detectable
|
|
66
|
+
const midpoint = Math.floor(text.length / 2);
|
|
67
|
+
// Find a space near the midpoint
|
|
68
|
+
let insertAt = text.indexOf(' ', midpoint);
|
|
69
|
+
if (insertAt === -1) insertAt = midpoint;
|
|
70
|
+
|
|
71
|
+
return text.slice(0, insertAt) + watermark + text.slice(insertAt);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extracts a watermark from text.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} text - Text that may contain a watermark.
|
|
78
|
+
* @returns {object} { found: boolean, metadata?: object, verified: boolean }
|
|
79
|
+
*/
|
|
80
|
+
extract(text) {
|
|
81
|
+
if (!text) return { found: false };
|
|
82
|
+
|
|
83
|
+
const startIdx = text.indexOf(WM_START);
|
|
84
|
+
const endIdx = text.indexOf(WM_END, startIdx);
|
|
85
|
+
|
|
86
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
|
87
|
+
return { found: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const binary = text.slice(startIdx + 1, endIdx);
|
|
91
|
+
const data = this._binaryToText(binary);
|
|
92
|
+
|
|
93
|
+
if (!data) return { found: false };
|
|
94
|
+
|
|
95
|
+
const pipeIdx = data.lastIndexOf('|');
|
|
96
|
+
if (pipeIdx === -1) return { found: false };
|
|
97
|
+
|
|
98
|
+
const payloadStr = data.substring(0, pipeIdx);
|
|
99
|
+
const signature = data.substring(pipeIdx + 1);
|
|
100
|
+
|
|
101
|
+
// Verify signature
|
|
102
|
+
const expectedSig = crypto.createHmac('sha256', this.secret)
|
|
103
|
+
.update(payloadStr)
|
|
104
|
+
.digest('hex')
|
|
105
|
+
.substring(0, 8);
|
|
106
|
+
|
|
107
|
+
const verified = signature === expectedSig;
|
|
108
|
+
|
|
109
|
+
let metadata = null;
|
|
110
|
+
try {
|
|
111
|
+
metadata = JSON.parse(payloadStr);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return { found: true, verified: false, raw: payloadStr };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { found: true, metadata, verified };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Removes watermark from text.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} text
|
|
123
|
+
* @returns {string} Clean text.
|
|
124
|
+
*/
|
|
125
|
+
strip(text) {
|
|
126
|
+
if (!text) return text;
|
|
127
|
+
return text.replace(/[\u200B\u200C\u200D\uFEFF]/g, '');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @private */
|
|
131
|
+
_textToBinary(text) {
|
|
132
|
+
let binary = '';
|
|
133
|
+
for (let i = 0; i < text.length; i++) {
|
|
134
|
+
const charCode = text.charCodeAt(i);
|
|
135
|
+
const bits = charCode.toString(2).padStart(8, '0');
|
|
136
|
+
for (const bit of bits) {
|
|
137
|
+
binary += bit === '0' ? WM_ZERO : WM_ONE;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return binary;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @private */
|
|
144
|
+
_binaryToText(binary) {
|
|
145
|
+
try {
|
|
146
|
+
let text = '';
|
|
147
|
+
let bits = '';
|
|
148
|
+
for (const char of binary) {
|
|
149
|
+
if (char === WM_ZERO) bits += '0';
|
|
150
|
+
else if (char === WM_ONE) bits += '1';
|
|
151
|
+
else continue;
|
|
152
|
+
|
|
153
|
+
if (bits.length === 8) {
|
|
154
|
+
text += String.fromCharCode(parseInt(bits, 2));
|
|
155
|
+
bits = '';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return text;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// =========================================================================
|
|
166
|
+
// DIFFERENTIAL PRIVACY FOR AGENT MEMORY
|
|
167
|
+
// =========================================================================
|
|
168
|
+
|
|
169
|
+
class DifferentialPrivacy {
|
|
170
|
+
/**
|
|
171
|
+
* Adds noise to stored conversation data so individual user data
|
|
172
|
+
* can't be extracted even if the memory store is compromised.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} [options]
|
|
175
|
+
* @param {number} [options.epsilon=1.0] - Privacy budget. Lower = more private, noisier.
|
|
176
|
+
* @param {number} [options.redactProbability=0.1] - Probability of redacting a token.
|
|
177
|
+
*/
|
|
178
|
+
constructor(options = {}) {
|
|
179
|
+
this.epsilon = options.epsilon || 1.0;
|
|
180
|
+
this.redactProbability = options.redactProbability || 0.1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sanitizes text for storage by adding noise.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} text - Text to sanitize.
|
|
187
|
+
* @returns {object} { sanitized: string, tokensRedacted: number }
|
|
188
|
+
*/
|
|
189
|
+
sanitize(text) {
|
|
190
|
+
if (!text) return { sanitized: text, tokensRedacted: 0 };
|
|
191
|
+
|
|
192
|
+
const words = text.split(/\s+/);
|
|
193
|
+
let tokensRedacted = 0;
|
|
194
|
+
|
|
195
|
+
const sanitized = words.map(word => {
|
|
196
|
+
// Higher epsilon = less noise (more utility)
|
|
197
|
+
const threshold = this.redactProbability / this.epsilon;
|
|
198
|
+
|
|
199
|
+
if (Math.random() < threshold) {
|
|
200
|
+
tokensRedacted++;
|
|
201
|
+
return '[REDACTED]';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// For numbers, add Laplacian noise
|
|
205
|
+
if (/^\d+\.?\d*$/.test(word)) {
|
|
206
|
+
const num = parseFloat(word);
|
|
207
|
+
const noise = this._laplacianNoise(1 / this.epsilon);
|
|
208
|
+
const noisy = Math.round((num + noise) * 100) / 100;
|
|
209
|
+
return String(noisy);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return word;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
sanitized: sanitized.join(' '),
|
|
217
|
+
tokensRedacted
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Generates Laplacian noise for numeric privacy.
|
|
223
|
+
* @private
|
|
224
|
+
* @param {number} scale - Scale parameter (b = sensitivity/epsilon).
|
|
225
|
+
* @returns {number}
|
|
226
|
+
*/
|
|
227
|
+
_laplacianNoise(scale) {
|
|
228
|
+
// Use crypto for proper randomness instead of Math.random()
|
|
229
|
+
const bytes = crypto.randomBytes(4);
|
|
230
|
+
const u = (bytes.readUInt32BE(0) / 0xFFFFFFFF) - 0.5;
|
|
231
|
+
return -scale * Math.sign(u) * Math.log(1 - 2 * Math.abs(u));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { OutputWatermark, DifferentialPrivacy };
|