shroud-privacy 2.0.18 → 2.0.19
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/dist/hooks.js +78 -23
- package/dist/obfuscator.js +57 -44
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/hooks.js
CHANGED
|
@@ -179,19 +179,12 @@ export function registerHooks(api, obfuscator) {
|
|
|
179
179
|
return;
|
|
180
180
|
dumpStatsFile(obfuscator);
|
|
181
181
|
api.logger?.info(`[shroud] before_prompt_build: obfuscated ${result.entities.length} entities`);
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// the
|
|
185
|
-
//
|
|
182
|
+
// Return the obfuscated prompt as a full replacement.
|
|
183
|
+
// OpenClaw's deploy-local.sh patches before_prompt_build to support
|
|
184
|
+
// the `prompt` field, which replaces params.prompt entirely so that
|
|
185
|
+
// raw PII never reaches the LLM.
|
|
186
186
|
return {
|
|
187
|
-
|
|
188
|
-
"--- SHROUD PRIVACY LAYER ---",
|
|
189
|
-
"The following user message has been privacy-filtered.",
|
|
190
|
-
"Use ONLY the sanitized version below. Do NOT reference the original values.",
|
|
191
|
-
"",
|
|
192
|
-
result.obfuscated,
|
|
193
|
-
"--- END SHROUD ---",
|
|
194
|
-
].join("\n"),
|
|
187
|
+
prompt: result.obfuscated,
|
|
195
188
|
};
|
|
196
189
|
});
|
|
197
190
|
// -----------------------------------------------------------------------
|
|
@@ -223,11 +216,40 @@ export function registerHooks(api, obfuscator) {
|
|
|
223
216
|
if (Array.isArray(msg.content)) {
|
|
224
217
|
let changed = false;
|
|
225
218
|
const newContent = msg.content.map((block) => {
|
|
226
|
-
if (block && typeof block === "object"
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
219
|
+
if (block && typeof block === "object") {
|
|
220
|
+
// Handle blocks with .text (text content blocks)
|
|
221
|
+
if (typeof block.text === "string") {
|
|
222
|
+
const deobfuscated = obfuscator.deobfuscate(block.text);
|
|
223
|
+
if (deobfuscated !== block.text) {
|
|
224
|
+
changed = true;
|
|
225
|
+
return { ...block, text: deobfuscated };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Handle blocks with .content as string (tool_result blocks)
|
|
229
|
+
if (typeof block.content === "string") {
|
|
230
|
+
const deobfuscated = obfuscator.deobfuscate(block.content);
|
|
231
|
+
if (deobfuscated !== block.content) {
|
|
232
|
+
changed = true;
|
|
233
|
+
return { ...block, content: deobfuscated };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Handle blocks with .content as array (nested content blocks)
|
|
237
|
+
if (Array.isArray(block.content)) {
|
|
238
|
+
let innerChanged = false;
|
|
239
|
+
const newInner = block.content.map((inner) => {
|
|
240
|
+
if (inner && typeof inner === "object" && typeof inner.text === "string") {
|
|
241
|
+
const deobfuscated = obfuscator.deobfuscate(inner.text);
|
|
242
|
+
if (deobfuscated !== inner.text) {
|
|
243
|
+
innerChanged = true;
|
|
244
|
+
return { ...inner, text: deobfuscated };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return inner;
|
|
248
|
+
});
|
|
249
|
+
if (innerChanged) {
|
|
250
|
+
changed = true;
|
|
251
|
+
return { ...block, content: newInner };
|
|
252
|
+
}
|
|
231
253
|
}
|
|
232
254
|
}
|
|
233
255
|
return block;
|
|
@@ -259,12 +281,45 @@ export function registerHooks(api, obfuscator) {
|
|
|
259
281
|
let changed = false;
|
|
260
282
|
const allResults = [];
|
|
261
283
|
const newContent = msg.content.map((block) => {
|
|
262
|
-
if (block && typeof block === "object"
|
|
263
|
-
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
284
|
+
if (block && typeof block === "object") {
|
|
285
|
+
// Handle blocks with .text (text content blocks)
|
|
286
|
+
if (typeof block.text === "string") {
|
|
287
|
+
const result = obfuscator.obfuscate(block.text);
|
|
288
|
+
if (result.entities.length > 0) {
|
|
289
|
+
changed = true;
|
|
290
|
+
allResults.push(result);
|
|
291
|
+
return { ...block, text: result.obfuscated };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Handle blocks with .content as string (tool_result blocks)
|
|
295
|
+
if (typeof block.content === "string") {
|
|
296
|
+
const result = obfuscator.obfuscate(block.content);
|
|
297
|
+
if (result.entities.length > 0) {
|
|
298
|
+
changed = true;
|
|
299
|
+
allResults.push(result);
|
|
300
|
+
return { ...block, content: result.obfuscated };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Handle blocks with .content as array (nested content blocks)
|
|
304
|
+
if (Array.isArray(block.content)) {
|
|
305
|
+
let innerChanged = false;
|
|
306
|
+
const innerResults = [];
|
|
307
|
+
const newInner = block.content.map((inner) => {
|
|
308
|
+
if (inner && typeof inner === "object" && typeof inner.text === "string") {
|
|
309
|
+
const result = obfuscator.obfuscate(inner.text);
|
|
310
|
+
if (result.entities.length > 0) {
|
|
311
|
+
innerChanged = true;
|
|
312
|
+
innerResults.push(result);
|
|
313
|
+
return { ...inner, text: result.obfuscated };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return inner;
|
|
317
|
+
});
|
|
318
|
+
if (innerChanged) {
|
|
319
|
+
changed = true;
|
|
320
|
+
allResults.push(...innerResults);
|
|
321
|
+
return { ...block, content: newInner };
|
|
322
|
+
}
|
|
268
323
|
}
|
|
269
324
|
}
|
|
270
325
|
return block;
|
package/dist/obfuscator.js
CHANGED
|
@@ -118,6 +118,53 @@ function buildCombinedFakeRegex(fakes) {
|
|
|
118
118
|
const escaped = fakes.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
119
119
|
return new RegExp(escaped.join("|"), "g");
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Multi-pass deobfuscation that protects already-replaced regions.
|
|
123
|
+
*
|
|
124
|
+
* Uses placeholder sentinels: each replacement is temporarily stored as a
|
|
125
|
+
* unique placeholder (U+FFFF-based) that cannot match any fake value regex.
|
|
126
|
+
* After all passes complete, placeholders are swapped back to their real
|
|
127
|
+
* values. This prevents cascading replacements when a real value contains
|
|
128
|
+
* a substring that is also a fake value (e.g., fake "ACL-MGMT" maps to
|
|
129
|
+
* real "ACL-MGMT-FILTER" — without protection, later passes would find
|
|
130
|
+
* "ACL-MGMT" inside the restored value and replace it again).
|
|
131
|
+
*/
|
|
132
|
+
function multiPassDeobfuscate(text, combinedRe, reverse, knownFakeSet, maxPasses) {
|
|
133
|
+
if (!combinedRe)
|
|
134
|
+
return { text, replacements: 0 };
|
|
135
|
+
let result = text;
|
|
136
|
+
let totalReplacements = 0;
|
|
137
|
+
// Map placeholder ID -> real value. Placeholders use \uFFFF + index
|
|
138
|
+
// which cannot appear in fake values (they are printable ASCII).
|
|
139
|
+
const placeholders = [];
|
|
140
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
141
|
+
let passReplacements = 0;
|
|
142
|
+
combinedRe.lastIndex = 0;
|
|
143
|
+
result = result.replace(combinedRe, (match) => {
|
|
144
|
+
const real = reverse.get(match);
|
|
145
|
+
if (real !== undefined) {
|
|
146
|
+
passReplacements++;
|
|
147
|
+
knownFakeSet.delete(match);
|
|
148
|
+
// Store real value behind a placeholder to protect it from
|
|
149
|
+
// subsequent passes matching substrings within it.
|
|
150
|
+
const idx = placeholders.length;
|
|
151
|
+
placeholders.push(real);
|
|
152
|
+
return `\uFFFF${idx}\uFFFF`;
|
|
153
|
+
}
|
|
154
|
+
return match;
|
|
155
|
+
});
|
|
156
|
+
totalReplacements += passReplacements;
|
|
157
|
+
if (passReplacements === 0)
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
// Swap placeholders back to real values
|
|
161
|
+
if (placeholders.length > 0) {
|
|
162
|
+
result = result.replace(/\uFFFF(\d+)\uFFFF/g, (_m, idxStr) => {
|
|
163
|
+
return placeholders[parseInt(idxStr, 10)] ?? _m;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return { text: result, replacements: totalReplacements };
|
|
167
|
+
}
|
|
121
168
|
/**
|
|
122
169
|
* Convert a simple wildcard pattern (* and ?) to a RegExp.
|
|
123
170
|
* Caches compiled patterns for reuse. Bounded to 500 entries.
|
|
@@ -381,32 +428,15 @@ export class Obfuscator {
|
|
|
381
428
|
}
|
|
382
429
|
// Build a single combined regex for all fakes (longest-match-first).
|
|
383
430
|
const fakes = [...reverse.keys()].sort((a, b) => b.length - a.length);
|
|
384
|
-
// Recursive deobfuscation — multiple passes for nested structures
|
|
385
|
-
let result = text;
|
|
386
|
-
let totalReplacements = 0;
|
|
387
|
-
const MAX_PASSES = 3;
|
|
388
431
|
// Collect known fakes that were NOT replaced (for residual pass)
|
|
389
432
|
const knownFakeSet = new Set(fakes);
|
|
390
433
|
// Build combined regex: escape each fake, join with alternation
|
|
391
434
|
const combinedRe = buildCombinedFakeRegex(fakes);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const real = reverse.get(match);
|
|
398
|
-
if (real !== undefined) {
|
|
399
|
-
passReplacements++;
|
|
400
|
-
knownFakeSet.delete(match);
|
|
401
|
-
return real;
|
|
402
|
-
}
|
|
403
|
-
return match;
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
totalReplacements += passReplacements;
|
|
407
|
-
if (passReplacements === 0)
|
|
408
|
-
break; // No more replacements possible
|
|
409
|
-
}
|
|
435
|
+
// Multi-pass deobfuscation with protection against cascading replacements
|
|
436
|
+
const MAX_PASSES = 3;
|
|
437
|
+
const deobResult = multiPassDeobfuscate(text, combinedRe, reverse, knownFakeSet, MAX_PASSES);
|
|
438
|
+
let result = deobResult.text;
|
|
439
|
+
let totalReplacements = deobResult.replacements;
|
|
410
440
|
// Subnet-aware deobfuscation: reverse-map CGNAT IPs the LLM derived
|
|
411
441
|
const residual = this._deobfuscateResidualCgnat(result, knownFakeSet);
|
|
412
442
|
if (residual.count > 0) {
|
|
@@ -454,30 +484,13 @@ export class Obfuscator {
|
|
|
454
484
|
reverse.set(fake, real);
|
|
455
485
|
}
|
|
456
486
|
const fakes = [...reverse.keys()].sort((a, b) => b.length - a.length);
|
|
457
|
-
// Recursive deobfuscation
|
|
458
|
-
let result = text;
|
|
459
|
-
let replacementCount = 0;
|
|
460
|
-
const MAX_PASSES = 3;
|
|
461
487
|
const knownFakeSet = new Set(fakes);
|
|
462
488
|
const combinedRe = buildCombinedFakeRegex(fakes);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const real = reverse.get(match);
|
|
469
|
-
if (real !== undefined) {
|
|
470
|
-
passReplacements++;
|
|
471
|
-
knownFakeSet.delete(match);
|
|
472
|
-
return real;
|
|
473
|
-
}
|
|
474
|
-
return match;
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
replacementCount += passReplacements;
|
|
478
|
-
if (passReplacements === 0)
|
|
479
|
-
break;
|
|
480
|
-
}
|
|
489
|
+
// Multi-pass deobfuscation with protection against cascading replacements
|
|
490
|
+
const MAX_PASSES = 3;
|
|
491
|
+
const deobResult = multiPassDeobfuscate(text, combinedRe, reverse, knownFakeSet, MAX_PASSES);
|
|
492
|
+
let result = deobResult.text;
|
|
493
|
+
let replacementCount = deobResult.replacements;
|
|
481
494
|
// Subnet-aware deobfuscation for LLM-derived CGNAT IPs
|
|
482
495
|
const residual = this._deobfuscateResidualCgnat(result, knownFakeSet);
|
|
483
496
|
if (residual.count > 0) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shroud-privacy",
|
|
3
3
|
"name": "Shroud",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.19",
|
|
5
5
|
"description": "Privacy obfuscation with deterministic fake values and deobfuscation — PII never reaches the LLM, tool calls still work",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
package/package.json
CHANGED