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 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
- // NOTE: OpenClaw's hook API only supports prependContext (not prompt
183
- // replacement). The raw user text still reaches the LLM alongside
184
- // the obfuscated version. This is a known limitation tracked as a
185
- // feature request for OpenClaw's before_prompt_build hook.
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
- prependContext: [
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" && typeof block.text === "string") {
227
- const deobfuscated = obfuscator.deobfuscate(block.text);
228
- if (deobfuscated !== block.text) {
229
- changed = true;
230
- return { ...block, text: deobfuscated };
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" && typeof block.text === "string") {
263
- const result = obfuscator.obfuscate(block.text);
264
- if (result.entities.length > 0) {
265
- changed = true;
266
- allResults.push(result);
267
- return { ...block, text: result.obfuscated };
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;
@@ -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
- for (let pass = 0; pass < MAX_PASSES; pass++) {
393
- let passReplacements = 0;
394
- if (combinedRe) {
395
- combinedRe.lastIndex = 0;
396
- result = result.replace(combinedRe, (match) => {
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
- for (let pass = 0; pass < MAX_PASSES; pass++) {
464
- let passReplacements = 0;
465
- if (combinedRe) {
466
- combinedRe.lastIndex = 0;
467
- result = result.replace(combinedRe, (match) => {
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) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.0.18",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shroud-privacy",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
4
4
  "description": "Privacy obfuscation plugin for OpenClaw — detects sensitive data and replaces with deterministic fake values",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",