shroud-privacy 2.5.4 → 2.5.5

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/README.md CHANGED
@@ -78,14 +78,14 @@ Shroud does not guarantee compliance — regex-based detection has limitations (
78
78
 
79
79
  > **How it works:** Shroud intercepts ALL outbound LLM API calls (Anthropic, OpenAI, Google, any provider) at the `fetch` level and obfuscates detected entities in every message — including assistant history and Slack `<mailto:>` markup — before it leaves the process. On the response side, SSE streaming is deobfuscated per content block with buffered flushing. Every delivery path (Slack, WhatsApp, TUI, Telegram, Discord, Signal, web) gets real text automatically. Zero host patches required.
80
80
 
81
- > **Requires OpenClaw 2026.3.22 or later.**
81
+ > **Requires OpenClaw 2026.3.24 or later.**
82
82
 
83
83
  ### OpenClaw support policy
84
84
 
85
85
  - **Formal minimum supported version:** `2026.3.24` (from `openclaw.plugin.json` `minOpenClawVersion`).
86
86
  - **Release validation matrix (this release):**
87
87
  - **Baseline:** `2026.3.28` (includes WhatsApp E2E path)
88
- - **Latest-at-release:** `2026.4.9` (full 192-scenario E2E pass)
88
+ - **Latest-at-release:** `2026.4.14` (Slack E2E pass)
89
89
  - **Latest caveat:** on OpenClaw builds where WhatsApp provisioning via `channels add` is unsupported, latest-focused compat runs skip WhatsApp E2E and validate Slack E2E.
90
90
  - **Source of truth for current matrix:** `docs/ci-current-state.md` and `CHANGELOG.md`.
91
91
 
@@ -93,10 +93,10 @@ Shroud does not guarantee compliance — regex-based detection has limitations (
93
93
 
94
94
  ## Install
95
95
 
96
- ### OpenClaw (2026.3.22+)
96
+ ### OpenClaw (2026.3.24+)
97
97
 
98
98
  ```bash
99
- openclaw --version # ensure 2026.3.22+
99
+ openclaw --version # ensure 2026.3.24+
100
100
  openclaw plugins install shroud-privacy
101
101
  ```
102
102
 
package/app-server.mjs CHANGED
@@ -19,8 +19,9 @@ import { createHash, randomBytes } from "node:crypto";
19
19
  import { createInterface } from "node:readline";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { resolve, dirname } from "node:path";
22
- import { writeFileSync, readFileSync } from "node:fs";
22
+ import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
23
23
  import { fileURLToPath } from "node:url";
24
+ import { createServer as createNetServer } from "node:net";
24
25
 
25
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
27
  const shroudDist = process.argv[2] || resolve(__dirname, "dist");
@@ -424,21 +425,22 @@ const METHODS = {
424
425
  setPartition: handleSetPartition,
425
426
  };
426
427
 
427
- function dispatch(line) {
428
+ function dispatch(line, writeFn) {
428
429
  if (!line.trim()) return;
430
+ const write = writeFn || ((s) => process.stdout.write(s));
429
431
 
430
432
  let req;
431
433
  try {
432
434
  req = JSON.parse(line);
433
435
  } catch (e) {
434
- process.stdout.write(jsonError(null, ERR_PARSE, `Parse error: ${e.message}`) + "\n");
436
+ write(jsonError(null, ERR_PARSE, `Parse error: ${e.message}`) + "\n");
435
437
  return;
436
438
  }
437
439
 
438
440
  const { id, method, params } = req;
439
441
 
440
442
  if (id === undefined || id === null || !method) {
441
- process.stdout.write(
443
+ write(
442
444
  jsonError(id ?? null, ERR_INVALID_REQ, "Missing required field: id and method") + "\n"
443
445
  );
444
446
  return;
@@ -446,7 +448,7 @@ function dispatch(line) {
446
448
 
447
449
  const handler = METHODS[method];
448
450
  if (!handler) {
449
- process.stdout.write(
451
+ write(
450
452
  jsonError(id, ERR_NO_METHOD, `Method not found: ${method}`) + "\n"
451
453
  );
452
454
  return;
@@ -459,10 +461,10 @@ function dispatch(line) {
459
461
  const response = handler(id, params);
460
462
  // shutdown writes its own response and exits
461
463
  if (method !== "shutdown") {
462
- process.stdout.write(response + "\n");
464
+ write(response + "\n");
463
465
  }
464
466
  } catch (e) {
465
- process.stdout.write(
467
+ write(
466
468
  jsonError(id, ERR_ENGINE, `Engine error: ${e.message}`) + "\n"
467
469
  );
468
470
  }
@@ -519,16 +521,83 @@ const handshake = {
519
521
  };
520
522
 
521
523
  process.stderr.write(`[app-server] Starting APP server v${engineVersion}\n`);
522
- process.stdout.write(JSON.stringify(handshake) + "\n");
523
524
 
524
525
  // ---------------------------------------------------------------------------
525
- // Main loop
526
+ // Socket listener mode (--listen <path>)
526
527
  // ---------------------------------------------------------------------------
527
528
 
528
- const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
529
- rl.on("line", dispatch);
530
- rl.on("close", () => {
531
- clearInterval(heartbeatInterval);
532
- dumpStats();
533
- process.exit(0);
534
- });
529
+ const listenFlag = process.argv.indexOf("--listen");
530
+ const SOCKET_PATH = listenFlag !== -1 ? (process.argv[listenFlag + 1] || "/tmp/shroud-app.sock") : null;
531
+
532
+ if (SOCKET_PATH) {
533
+ // Remove stale socket file
534
+ try { unlinkSync(SOCKET_PATH); } catch {}
535
+
536
+ let socketClients = 0;
537
+ const socketServer = createNetServer((conn) => {
538
+ socketClients++;
539
+ const clientId = socketClients;
540
+ process.stderr.write(`[app-server] Socket client #${clientId} connected\n`);
541
+
542
+ // Send handshake to this client
543
+ conn.write(JSON.stringify(handshake) + "\n");
544
+
545
+ let connected = true;
546
+ const connRl = createInterface({ input: conn, crlfDelay: Infinity });
547
+ const connWrite = (s) => { if (connected) try { conn.write(s); } catch {} };
548
+
549
+ connRl.on("line", (line) => dispatch(line, connWrite));
550
+ connRl.on("error", () => {});
551
+ conn.on("error", () => { connected = false; });
552
+ conn.on("close", () => {
553
+ connected = false;
554
+ process.stderr.write(`[app-server] Socket client #${clientId} disconnected\n`);
555
+ });
556
+ });
557
+
558
+ socketServer.listen(SOCKET_PATH, () => {
559
+ process.stderr.write(`[app-server] Listening on socket: ${SOCKET_PATH}\n`);
560
+ });
561
+
562
+ socketServer.on("error", (err) => {
563
+ process.stderr.write(`[app-server] Socket error: ${err.message}\n`);
564
+ process.exit(1);
565
+ });
566
+
567
+ // Also still serve stdin for backwards compat
568
+ process.stdout.write(JSON.stringify(handshake) + "\n");
569
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
570
+ rl.on("line", (line) => dispatch(line));
571
+ rl.on("close", () => {
572
+ try { unlinkSync(SOCKET_PATH); } catch {}
573
+ socketServer.close();
574
+ clearInterval(heartbeatInterval);
575
+ dumpStats();
576
+ process.exit(0);
577
+ });
578
+
579
+ // Cleanup socket on exit
580
+ for (const sig of ["SIGINT", "SIGTERM"]) {
581
+ process.on(sig, () => {
582
+ try { unlinkSync(SOCKET_PATH); } catch {}
583
+ socketServer.close();
584
+ clearInterval(heartbeatInterval);
585
+ dumpStats();
586
+ process.exit(0);
587
+ });
588
+ }
589
+ } else {
590
+ // ---------------------------------------------------------------------------
591
+ // Default: stdin/stdout mode
592
+ // ---------------------------------------------------------------------------
593
+
594
+ process.stdout.write(JSON.stringify(handshake) + "\n");
595
+
596
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
597
+ rl.on("line", (line) => dispatch(line));
598
+ rl.on("close", () => {
599
+ clearInterval(heartbeatInterval);
600
+ dumpStats();
601
+ process.exit(0);
602
+ });
603
+ }
package/dist/config.d.ts CHANGED
@@ -11,6 +11,7 @@ import { ShroudConfig } from "./types.js";
11
11
  * Priority: env vars > pluginConfig > defaults.
12
12
  */
13
13
  export declare const STATS_FILE: string;
14
+ export declare const STORE_FILE: string;
14
15
  export declare const IS_TEST: boolean;
15
16
  export declare function resolveConfig(pluginConfig?: unknown): ShroudConfig;
16
17
  /** Validation issue severity. */
package/dist/config.js CHANGED
@@ -11,6 +11,8 @@ import { randomBytes } from "node:crypto";
11
11
  * Priority: env vars > pluginConfig > defaults.
12
12
  */
13
13
  export const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
14
+ export const STORE_FILE = process.env.SHROUD_STORE_FILE
15
+ || ((process.env.HOME || "/root") + "/.openclaw/shroud-store.json");
14
16
  export const IS_TEST = process.env.NODE_ENV === "test";
15
17
  export function resolveConfig(pluginConfig) {
16
18
  const raw = pluginConfig != null && typeof pluginConfig === "object"
package/dist/hooks.js CHANGED
@@ -21,14 +21,63 @@
21
21
  * 8. globalThis.fetch intercept -- obfuscates requests, deobfuscates responses
22
22
  */
23
23
  import { createHash, randomBytes } from "node:crypto";
24
- import { writeFileSync } from "node:fs";
24
+ import { writeFileSync, readFileSync, mkdirSync, statSync } from "node:fs";
25
25
  import { BUILTIN_PATTERNS } from "./detectors/regex.js";
26
- import { STATS_FILE, IS_TEST } from "./config.js";
26
+ import { STATS_FILE, STORE_FILE, IS_TEST } from "./config.js";
27
27
  import { DnsCache } from "./dns-cache.js";
28
28
  import { FieldScopeResolver } from "./field-scope.js";
29
+ import { dirname } from "node:path";
29
30
  function getSharedObfuscator(fallback) {
30
31
  return globalThis.__shroudObfuscator || fallback;
31
32
  }
33
+ const storeSyncMtime = new WeakMap();
34
+ const SHROUD_REPLY_DISPATCHER_PATCH_MARK = Symbol.for("shroud.replyDispatcherPatched");
35
+ function shouldUseDiskStoreSync() {
36
+ return !IS_TEST || globalThis.__shroudEnableDiskStoreSync === true;
37
+ }
38
+ function persistStore(ob) {
39
+ try {
40
+ if (ob.getStats().storeMappings === 0)
41
+ return;
42
+ const data = ob.exportStore();
43
+ mkdirSync(dirname(STORE_FILE), { recursive: true });
44
+ writeFileSync(STORE_FILE, JSON.stringify(data) + "\n");
45
+ try {
46
+ storeSyncMtime.set(ob, statSync(STORE_FILE).mtimeMs);
47
+ }
48
+ catch {
49
+ storeSyncMtime.delete(ob);
50
+ }
51
+ }
52
+ catch {
53
+ // best-effort
54
+ }
55
+ }
56
+ function restoreStore(ob, logger, options) {
57
+ try {
58
+ if (!shouldUseDiskStoreSync())
59
+ return 0;
60
+ const stat = statSync(STORE_FILE);
61
+ const lastSync = storeSyncMtime.get(ob);
62
+ if (!options?.force && lastSync === stat.mtimeMs)
63
+ return 0;
64
+ const raw = readFileSync(STORE_FILE, "utf-8");
65
+ const data = JSON.parse(raw);
66
+ if (!data || !Array.isArray(data.mappings)) {
67
+ storeSyncMtime.set(ob, stat.mtimeMs);
68
+ return 0;
69
+ }
70
+ const imported = ob.importStore(data);
71
+ storeSyncMtime.set(ob, stat.mtimeMs);
72
+ if (imported > 0 && logger) {
73
+ logger.info(`[shroud] Restored ${imported} mappings from disk (${STORE_FILE})`);
74
+ }
75
+ return imported;
76
+ }
77
+ catch {
78
+ return 0;
79
+ }
80
+ }
32
81
  function dumpStatsFile(fallback) {
33
82
  try {
34
83
  const ob = getSharedObfuscator(fallback);
@@ -42,6 +91,47 @@ function dumpStatsFile(fallback) {
42
91
  // best-effort
43
92
  }
44
93
  }
94
+ function deobfuscateVisibleReplyPayload(value, deobfuscate) {
95
+ if (typeof value === "string")
96
+ return deobfuscate(value);
97
+ if (Array.isArray(value))
98
+ return value.map((item) => deobfuscateVisibleReplyPayload(item, deobfuscate));
99
+ if (!value || typeof value !== "object")
100
+ return value;
101
+ const proto = Object.getPrototypeOf(value);
102
+ if (proto !== Object.prototype && proto !== null)
103
+ return value;
104
+ const out = {};
105
+ for (const [key, entry] of Object.entries(value)) {
106
+ out[key] = deobfuscateVisibleReplyPayload(entry, deobfuscate);
107
+ }
108
+ return out;
109
+ }
110
+ function wrapReplyDispatcherMethod(method, deobfuscate) {
111
+ if (typeof method !== "function")
112
+ return method;
113
+ if (method[SHROUD_REPLY_DISPATCHER_PATCH_MARK])
114
+ return method;
115
+ const wrapped = function shroudPatchedReplyDispatcher(payload, ...args) {
116
+ return method.call(this, deobfuscateVisibleReplyPayload(payload, deobfuscate), ...args);
117
+ };
118
+ Object.defineProperty(wrapped, SHROUD_REPLY_DISPATCHER_PATCH_MARK, { value: true });
119
+ return wrapped;
120
+ }
121
+ function patchReplyDispatcher(dispatcher, deobfuscate) {
122
+ if (!dispatcher || typeof dispatcher !== "object")
123
+ return false;
124
+ let patched = false;
125
+ for (const key of ["sendBlockReply", "sendFinalReply", "sendToolResult"]) {
126
+ const method = dispatcher[key];
127
+ const wrapped = wrapReplyDispatcherMethod(method, deobfuscate);
128
+ if (wrapped !== method) {
129
+ dispatcher[key] = wrapped;
130
+ patched = true;
131
+ }
132
+ }
133
+ return patched;
134
+ }
45
135
  // ---------------------------------------------------------------------------
46
136
  // Hashing utilities (audit proof hashes)
47
137
  // ---------------------------------------------------------------------------
@@ -223,6 +313,9 @@ export function registerHooks(api, obfuscator) {
223
313
  else {
224
314
  g.__shroudObfuscator = obfuscator;
225
315
  }
316
+ if (isFirstLoad) {
317
+ restoreStore(obfuscator, api.logger, { force: true });
318
+ }
226
319
  // DNS cache for public URL detection — shared across plugin instances
227
320
  if (!g.__shroudDnsCache) {
228
321
  const cache = new DnsCache();
@@ -257,6 +350,9 @@ export function registerHooks(api, obfuscator) {
257
350
  // OpenClaw loads the plugin multiple times; only one instance has the mappings.
258
351
  const ob = () => getSharedObfuscator(obfuscator);
259
352
  const sessionScope = globalThis;
353
+ const syncStore = (force = false, logger) => {
354
+ restoreStore(ob(), logger, { force });
355
+ };
260
356
  const config = ob().config;
261
357
  let _fieldScopeResolver;
262
358
  let _fieldScopeConfigRef;
@@ -357,62 +453,80 @@ export function registerHooks(api, obfuscator) {
357
453
  totalEntities += result.entities.length;
358
454
  }
359
455
  }
360
- // Obfuscate ALL messages in-place seeds the mapping store AND mutates
361
- // the message array so PII is replaced before OpenClaw builds the request.
362
- // This is critical when the LLM SDK (e.g. OpenAI v6) captures fetch at
363
- // construction time, bypassing Shroud's globalThis.fetch intercept.
364
- // The fetch intercept is still the primary path for SDKs that use
365
- // globalThis.fetch (Anthropic) — double-obfuscation is safe because
366
- // already-obfuscated text has no detectable PII entities.
456
+ // Obfuscate ALL messages via copies so delivery code never sees partially
457
+ // mutated objects that OpenClaw may still reference elsewhere.
367
458
  if (Array.isArray(event?.messages)) {
368
- for (const msg of event.messages) {
459
+ for (let i = 0; i < event.messages.length; i++) {
460
+ const msg = event.messages[i];
461
+ let msgCopy = null;
369
462
  // String content (Anthropic/OpenAI)
370
463
  if (typeof msg.content === "string") {
371
464
  const cleaned = stripSlackLinksForHook(msg.content);
372
465
  const result = ob().obfuscate(cleaned, undefined, _exemptCats);
373
466
  totalEntities += result.entities.length;
374
467
  if (result.entities.length > 0 || cleaned !== msg.content) {
375
- msg.content = result.entities.length > 0 ? result.obfuscated : cleaned;
468
+ msgCopy = { ...msg, content: result.entities.length > 0 ? result.obfuscated : cleaned };
376
469
  }
377
470
  }
378
471
  // Array content blocks
379
472
  else if (Array.isArray(msg.content)) {
380
- for (const b of msg.content) {
381
- if (b?.type === "text" && typeof b.text === "string") {
473
+ let blockChanged = false;
474
+ const newBlocks = msg.content.map((b) => {
475
+ if (!b || typeof b !== "object")
476
+ return b;
477
+ let newBlock = b;
478
+ if (b.type === "text" && typeof b.text === "string") {
382
479
  const cleaned = stripSlackLinksForHook(b.text);
383
480
  const result = ob().obfuscate(cleaned, undefined, _exemptCats);
384
481
  totalEntities += result.entities.length;
385
482
  if (result.entities.length > 0 || cleaned !== b.text) {
386
- b.text = result.entities.length > 0 ? result.obfuscated : cleaned;
483
+ newBlock = { ...newBlock, text: result.entities.length > 0 ? result.obfuscated : cleaned };
484
+ blockChanged = true;
387
485
  }
388
486
  }
389
487
  // tool_result blocks with string content
390
- if (typeof b?.content === "string") {
488
+ if (typeof b.content === "string") {
391
489
  const cleaned = stripSlackLinksForHook(b.content);
392
490
  const result = ob().obfuscate(cleaned, undefined, _exemptCats);
393
491
  totalEntities += result.entities.length;
394
492
  if (result.entities.length > 0 || cleaned !== b.content) {
395
- b.content = result.entities.length > 0 ? result.obfuscated : cleaned;
493
+ newBlock = { ...newBlock, content: result.entities.length > 0 ? result.obfuscated : cleaned };
494
+ blockChanged = true;
396
495
  }
397
496
  }
497
+ return newBlock;
498
+ });
499
+ if (blockChanged) {
500
+ msgCopy = { ...msg, content: newBlocks };
398
501
  }
399
502
  }
400
503
  // OpenAI tool_calls in assistant messages
401
504
  if (Array.isArray(msg.tool_calls)) {
402
- for (const tc of msg.tool_calls) {
505
+ let tcChanged = false;
506
+ const newToolCalls = msg.tool_calls.map((tc) => {
403
507
  if (typeof tc.function?.arguments === "string") {
404
508
  const result = ob().obfuscate(tc.function.arguments, undefined, _exemptCats);
405
509
  totalEntities += result.entities.length;
406
- if (result.entities.length > 0)
407
- tc.function.arguments = result.obfuscated;
510
+ if (result.entities.length > 0) {
511
+ tcChanged = true;
512
+ return { ...tc, function: { ...tc.function, arguments: result.obfuscated } };
513
+ }
408
514
  }
515
+ return tc;
516
+ });
517
+ if (tcChanged) {
518
+ msgCopy = msgCopy ? { ...msgCopy, tool_calls: newToolCalls } : { ...msg, tool_calls: newToolCalls };
409
519
  }
410
520
  }
521
+ if (msgCopy) {
522
+ event.messages[i] = msgCopy;
523
+ }
411
524
  }
412
525
  }
413
526
  if (totalEntities === 0)
414
527
  return;
415
528
  dumpStatsFile(obfuscator);
529
+ persistStore(ob());
416
530
  api.logger?.info(`[shroud] before_prompt_build: obfuscated ${totalEntities} entities (mappings synced)`);
417
531
  return obfuscatedPrompt ? { systemPrompt: obfuscatedPrompt } : undefined;
418
532
  });
@@ -434,41 +548,45 @@ export function registerHooks(api, obfuscator) {
434
548
  const role = msg.role ?? "";
435
549
  // --- Assistant messages: DEOBFUSCATE (fakes → real values) ---
436
550
  if (role === "assistant") {
551
+ syncStore();
437
552
  const _raw = typeof msg.content === "string" ? msg.content :
438
553
  Array.isArray(msg.content) ? msg.content.map((b) => b?.text || "").join("") : "";
439
554
  if (_raw.length < 500)
440
555
  api.logger?.info(`[shroud][raw-assistant] ${_raw}`);
441
556
  if (typeof msg.content === "string") {
442
557
  const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(msg.content);
443
- if (deobfuscated === msg.content)
444
- return;
445
- api.logger?.info("[shroud] before_message_write: deobfuscated assistant message");
446
- if (auditActive && replacementCount > 0) {
447
- try {
448
- emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), replacementCount);
558
+ if (replacementCount > 0) {
559
+ api.logger?.info("[shroud] before_message_write: deobfuscated assistant message");
560
+ if (auditActive) {
561
+ try {
562
+ emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), replacementCount);
563
+ }
564
+ catch { }
449
565
  }
450
- catch { }
566
+ dumpStatsFile(obfuscator);
451
567
  }
452
- dumpStatsFile(obfuscator);
453
568
  return { message: { ...msg, content: deobfuscated } };
454
569
  }
455
570
  if (Array.isArray(msg.content)) {
456
571
  let changed = false;
572
+ let deobCount = 0;
457
573
  const newContent = msg.content.map((block) => {
458
574
  if (block && typeof block === "object") {
459
575
  // Handle blocks with .text (text content blocks)
460
576
  if (typeof block.text === "string") {
461
- const deobfuscated = ob().deobfuscate(block.text);
577
+ const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(block.text);
462
578
  if (deobfuscated !== block.text) {
463
579
  changed = true;
580
+ deobCount += replacementCount;
464
581
  return { ...block, text: deobfuscated };
465
582
  }
466
583
  }
467
584
  // Handle blocks with .content as string (tool_result blocks)
468
585
  if (typeof block.content === "string") {
469
- const deobfuscated = ob().deobfuscate(block.content);
586
+ const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(block.content);
470
587
  if (deobfuscated !== block.content) {
471
588
  changed = true;
589
+ deobCount += replacementCount;
472
590
  return { ...block, content: deobfuscated };
473
591
  }
474
592
  }
@@ -477,9 +595,10 @@ export function registerHooks(api, obfuscator) {
477
595
  let innerChanged = false;
478
596
  const newInner = block.content.map((inner) => {
479
597
  if (inner && typeof inner === "object" && typeof inner.text === "string") {
480
- const deobfuscated = ob().deobfuscate(inner.text);
598
+ const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(inner.text);
481
599
  if (deobfuscated !== inner.text) {
482
600
  innerChanged = true;
601
+ deobCount += replacementCount;
483
602
  return { ...inner, text: deobfuscated };
484
603
  }
485
604
  }
@@ -493,13 +612,13 @@ export function registerHooks(api, obfuscator) {
493
612
  }
494
613
  return block;
495
614
  });
496
- if (!changed)
497
- return;
498
- api.logger?.info("[shroud] before_message_write: deobfuscated assistant blocks");
499
- dumpStatsFile(obfuscator);
500
- return { message: { ...msg, content: newContent } };
615
+ if (deobCount > 0) {
616
+ api.logger?.info("[shroud] before_message_write: deobfuscated assistant blocks");
617
+ dumpStatsFile(obfuscator);
618
+ }
619
+ return { message: { ...msg, content: changed ? newContent : msg.content.map((b) => ({ ...b })) } };
501
620
  }
502
- return;
621
+ return { message: { ...msg } };
503
622
  }
504
623
  // --- Non-assistant messages: OBFUSCATE (real values → fakes) ---
505
624
  if (typeof msg.content === "string") {
@@ -507,6 +626,7 @@ export function registerHooks(api, obfuscator) {
507
626
  if (result.entities.length === 0)
508
627
  return;
509
628
  dumpStatsFile(obfuscator);
629
+ persistStore(ob());
510
630
  if (auditActive) {
511
631
  try {
512
632
  emitObfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), result, msg.content, result.obfuscated);
@@ -566,6 +686,7 @@ export function registerHooks(api, obfuscator) {
566
686
  if (!changed)
567
687
  return;
568
688
  dumpStatsFile(obfuscator);
689
+ persistStore(ob());
569
690
  if (auditActive) {
570
691
  for (const result of allResults) {
571
692
  try {
@@ -582,6 +703,7 @@ export function registerHooks(api, obfuscator) {
582
703
  // 3. before_tool_call (async): deobfuscate tool params + track depth
583
704
  // -----------------------------------------------------------------------
584
705
  api.on("before_tool_call", async (event) => {
706
+ syncStore();
585
707
  if (!event?.params || typeof event.params !== "object")
586
708
  return;
587
709
  // Block the message tool for send actions. The gateway auto-delivers
@@ -632,6 +754,7 @@ export function registerHooks(api, obfuscator) {
632
754
  return result.obfuscated;
633
755
  }, shouldScan);
634
756
  dumpStatsFile(obfuscator);
757
+ persistStore(ob());
635
758
  return { message: obfuscated };
636
759
  });
637
760
  // -----------------------------------------------------------------------
@@ -640,6 +763,7 @@ export function registerHooks(api, obfuscator) {
640
763
  // sends blocks in the first call, text in the second).
641
764
  // -----------------------------------------------------------------------
642
765
  api.on("message_sending", async (event) => {
766
+ syncStore();
643
767
  if (!event?.content)
644
768
  return;
645
769
  // String content — direct deobfuscation.
@@ -685,6 +809,10 @@ export function registerHooks(api, obfuscator) {
685
809
  return { content: newContent };
686
810
  }
687
811
  });
812
+ api.on("reply_dispatch", (_event, ctx) => {
813
+ syncStore();
814
+ patchReplyDispatcher(ctx?.dispatcher, (text) => ob().deobfuscate(text));
815
+ });
688
816
  // -----------------------------------------------------------------------
689
817
  // Tool: shroud-stats — rulebase view with hit counters
690
818
  // -----------------------------------------------------------------------
@@ -741,26 +869,62 @@ export function registerHooks(api, obfuscator) {
741
869
  // overflow chunks. Partial fakes may briefly appear during streaming
742
870
  // but the final message will be correct.
743
871
  const SHROUD_BUF = Symbol("shroudStreamBuf");
872
+ const deobfuscateStreamTarget = (target) => {
873
+ if (!target || typeof target !== "object")
874
+ return;
875
+ if (typeof target.content === "string") {
876
+ const { text: deob } = ob().deobfuscateWithStats(target.content);
877
+ target.content = deob;
878
+ return;
879
+ }
880
+ if (!Array.isArray(target.content))
881
+ return;
882
+ for (const block of target.content) {
883
+ if (!block || typeof block !== "object")
884
+ continue;
885
+ if (typeof block.text === "string") {
886
+ const { text: deob } = ob().deobfuscateWithStats(block.text);
887
+ block.text = deob;
888
+ }
889
+ if (typeof block.content === "string") {
890
+ const { text: deob } = ob().deobfuscateWithStats(block.content);
891
+ block.content = deob;
892
+ }
893
+ if (Array.isArray(block.content)) {
894
+ for (const inner of block.content) {
895
+ if (!inner || typeof inner !== "object" || typeof inner.text !== "string")
896
+ continue;
897
+ const { text: deob } = ob().deobfuscateWithStats(inner.text);
898
+ inner.text = deob;
899
+ }
900
+ }
901
+ }
902
+ };
744
903
  globalThis.__shroudStreamDeobfuscate = (stream, event) => {
745
- // Streaming event hook — called by patched EventStream.prototype.push().
746
- // Text deltas pass through unchanged (deobfuscation happens at the fetch
747
- // response level via per-block SSE flushing). The message_end handler
748
- // deobfuscates content blocks as a defense-in-depth measure.
749
- const isTextDelta = event.type === "text_delta";
750
- const isMessageUpdateTextDelta = event.type === "message_update" &&
751
- event.assistantMessageEvent?.type === "text_delta";
752
- if (isTextDelta || isMessageUpdateTextDelta) {
753
- // Pass through text_delta events unchanged.
904
+ const eventTextType = event.type === "message_update"
905
+ ? event.assistantMessageEvent?.type
906
+ : event.type;
907
+ const isTextPhaseUpdate = eventTextType === "text_start" ||
908
+ eventTextType === "text_delta" ||
909
+ eventTextType === "text_end";
910
+ if (isTextPhaseUpdate) {
754
911
  let buf = stream[SHROUD_BUF];
755
912
  if (!buf) {
756
913
  buf = { raw: "", deobCount: 0 };
757
914
  stream[SHROUD_BUF] = buf;
758
915
  }
759
- const src = isMessageUpdateTextDelta ? event.assistantMessageEvent : event;
916
+ const src = event.type === "message_update" ? event.assistantMessageEvent : event;
760
917
  const chunk = typeof src.delta === "string" ? src.delta
761
918
  : typeof src.text === "string" ? src.text : "";
762
919
  if (chunk)
763
920
  buf.raw += chunk;
921
+ const targets = [
922
+ event.partial, event.message,
923
+ event.assistantMessageEvent?.partial,
924
+ event.assistantMessageEvent?.message,
925
+ ];
926
+ for (const target of targets)
927
+ deobfuscateStreamTarget(target);
764
928
  return event;
765
929
  }
766
930
  // On message_end/done: deobfuscate content blocks in the final message.
@@ -772,23 +936,14 @@ export function registerHooks(api, obfuscator) {
772
936
  event.type === "error" || event.type === "agent_end" ||
773
937
  (event.type === "message_update" && (event.assistantMessageEvent?.type === "text_end"));
774
938
  if (isEnd) {
939
+ syncStore();
775
940
  const targets = [
776
941
  event.message, event.partial,
777
942
  event.assistantMessageEvent?.partial,
778
943
  event.assistantMessageEvent?.message,
779
944
  ];
780
- for (const target of targets) {
781
- if (target?.content && Array.isArray(target.content)) {
782
- for (const block of target.content) {
783
- if (block?.type === "text" && typeof block.text === "string") {
784
- const deob = ob().deobfuscate(block.text);
785
- if (deob !== block.text) {
786
- block.text = deob;
787
- }
788
- }
789
- }
790
- }
791
- }
945
+ for (const target of targets)
946
+ deobfuscateStreamTarget(target);
792
947
  dumpStatsFile(obfuscator);
793
948
  delete stream[SHROUD_BUF];
794
949
  }
@@ -801,6 +956,7 @@ export function registerHooks(api, obfuscator) {
801
956
  // globalThis.__shroudDeobfuscate(text) directly.
802
957
  // -----------------------------------------------------------------------
803
958
  globalThis.__shroudDeobfuscate = (text) => {
959
+ syncStore();
804
960
  if (typeof text !== "string")
805
961
  return text;
806
962
  return ob().deobfuscate(text);
@@ -915,6 +1071,7 @@ export function registerHooks(api, obfuscator) {
915
1071
  : null;
916
1072
  if (!messageArray) {
917
1073
  if (modified) {
1074
+ persistStore(ob());
918
1075
  const newBody = JSON.stringify(body);
919
1076
  return originalFetch.call(globalThis, input, { ...init, body: newBody });
920
1077
  }
@@ -1039,6 +1196,7 @@ export function registerHooks(api, obfuscator) {
1039
1196
  }
1040
1197
  }
1041
1198
  if (modified) {
1199
+ persistStore(ob());
1042
1200
  const newBody = JSON.stringify(body);
1043
1201
  const newInit = { ...init, body: newBody };
1044
1202
  // Update content-length if present
@@ -5,6 +5,7 @@
5
5
  * tool_result_persist hook which is sync-only.
6
6
  */
7
7
  import { DetectedEntity, ObfuscationResult, ShroudConfig } from "./types.js";
8
+ import { SerializedStore } from "./store.js";
8
9
  import { BaseDetector } from "./detectors/base.js";
9
10
  export declare class Obfuscator {
10
11
  config: ShroudConfig;
@@ -95,6 +96,10 @@ export declare class Obfuscator {
95
96
  maxFakeLength(): number;
96
97
  /** Return stats from audit logger and store. */
97
98
  getStats(): object;
99
+ /** Export the mapping store for persistence across restarts. */
100
+ exportStore(): SerializedStore;
101
+ /** Import mappings from a persisted store. Returns count of new mappings added. */
102
+ importStore(data: SerializedStore): number;
98
103
  }
99
104
  /** Remove overlapping entities, keeping higher confidence ones. */
100
105
  export declare function resolveOverlaps(entities: DetectedEntity[]): DetectedEntity[];
@@ -852,6 +852,16 @@ export class Obfuscator {
852
852
  };
853
853
  return stats;
854
854
  }
855
+ /** Export the mapping store for persistence across restarts. */
856
+ exportStore() {
857
+ return this._store.export(this._mapping.salt);
858
+ }
859
+ /** Import mappings from a persisted store. Returns count of new mappings added. */
860
+ importStore(data) {
861
+ const before = this._store.size();
862
+ this._store.import(data);
863
+ return this._store.size() - before;
864
+ }
855
865
  }
856
866
  /** Remove overlapping entities, keeping higher confidence ones. */
857
867
  export function resolveOverlaps(entities) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.5.4",
4
+ "version": "2.5.5",
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.5.4",
3
+ "version": "2.5.5",
4
4
  "description": "Privacy and infrastructure protection for AI agents — detects sensitive data (PII, network topology, credentials, OT/SCADA) and replaces with deterministic fakes before anything reaches the LLM.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",