shroud-privacy 2.2.0 → 2.2.1

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
@@ -234,33 +234,19 @@ The CLI reads live stats from `/tmp/shroud-stats.json` (override with `SHROUD_ST
234
234
 
235
235
  ### How privacy works
236
236
 
237
- Shroud uses runtime prototype patches**no OpenClaw files are modified**:
237
+ Shroud uses **one `globalThis.fetch` intercept** for both directions — no OpenClaw file modifications required:
238
238
 
239
- **Outbound (PII → LLM):** Patches `globalThis.fetch` to intercept all POST requests to LLM API endpoints (`/messages`, `/chat/completions`). Obfuscates every message in the request body — user, assistant, system, tool results — before the request leaves the process. Strips Slack `<mailto:>` markup to prevent PII leaking through chat formatting. Re-obfuscates deobfuscated assistant messages in conversation history to prevent multi-turn PII leaks. Works for every LLM provider.
239
+ **Outbound (PII → LLM):** The fetch intercept catches all POST requests to LLM API endpoints (`/v1/messages`, `/chat/completions`, `:generateContent`, etc.). Every message in the request body — user, assistant, system, tool results — is obfuscated before the request leaves the process. Slack `<mailto:>` markup is stripped to prevent PII leaking through chat formatting. Assistant messages from previous turns are re-obfuscated to prevent multi-turn PII leaks.
240
240
 
241
- **Inbound (LLM → User):** Patches `EventStream.prototype.push()` to deobfuscate streaming responses in real-time. Fake values are replaced with real values as they stream.
241
+ **Inbound (LLM → User):** The same fetch intercept wraps the LLM's SSE streaming response with a per-block flushing `TransformStream`. Text deltas are buffered per content block. When `content_block_stop` arrives, the accumulated text is deobfuscated and flushed — the first delta receives the full real text, subsequent deltas are emptied. Non-PII blocks stream with zero delay. PII blocks delay by ~0.5-1s (time for one content block to complete). JSON (non-streaming) responses are parsed and deobfuscated directly.
242
242
 
243
- **Channel delivery:** Shroud registers `globalThis.__shroudDeobfuscate(text)` a single global function that converts fake values back to real values. OpenClaw calls this once in its generic message delivery function, before sending to any channel. If Shroud isn't loaded, the function doesn't exist transparent no-op. The `message_sending` hook provides a backup deobfuscation path.
243
+ **Result:** OpenClaw receives already-deobfuscated events from the LLM response it never sees fake text. Every delivery path (Slack, WhatsApp, TUI, Telegram, Discord, Signal, cron, subagents, web) gets real text automatically. Zero OpenClaw patches required. Works with `streaming: "on"` and `streaming: "off"`, and with every LLM provider.
244
244
 
245
- All patches are applied once at plugin load and are idempotent — subsequent loads detect and skip them.
246
-
247
- ### OpenClaw channel delivery patch
248
-
249
- Shroud requires a one-line addition to OpenClaw's message delivery function the single point where all outbound channel messages pass through. This covers ALL channels (Slack, WhatsApp, Signal, Telegram, web, etc.) with one change:
250
-
251
- ```js
252
- // In OpenClaw's generic message delivery function:
253
- const deob = globalThis.__shroudDeobfuscate;
254
- if (deob && typeof text === 'string') text = deob(text);
255
- ```
256
-
257
- **Why this works:**
258
- - **One change, all channels** — no per-channel hooks needed
259
- - **Transparent** — if Shroud isn't loaded, `globalThis.__shroudDeobfuscate` is `undefined`, the `if` is false, zero overhead
260
- - **All releases** — works on any OpenClaw version that sends channel messages through a common delivery path
261
- - **Last-moment deobfuscation** — fakes are replaced with real values at the latest possible point, just before the HTTP API call
262
-
263
- The `deploy-local.sh` script includes a post-install verification step that tests the full chain.
245
+ **Defense-in-depth layers:**
246
+ 1. `EventStream.prototype.push()` patch — deobfuscates content blocks in `message_end` events
247
+ 2. `globalThis.__shroudDeobfuscate` available for on-demand deobfuscation
248
+ 3. `message_sending` hook — deobfuscates outbound message content when fired by OpenClaw
249
+ 4. `before_message_write` hookdeobfuscates assistant messages in the transcript
264
250
 
265
251
  ### Rule hit counters
266
252
 
package/dist/config.d.ts CHANGED
@@ -10,6 +10,8 @@ import { ShroudConfig } from "./types.js";
10
10
  *
11
11
  * Priority: env vars > pluginConfig > defaults.
12
12
  */
13
+ export declare const STATS_FILE: string;
14
+ export declare const IS_TEST: boolean;
13
15
  export declare function resolveConfig(pluginConfig?: unknown): ShroudConfig;
14
16
  /** Validation issue severity. */
15
17
  export type ConfigSeverity = "error" | "warning" | "info";
package/dist/config.js CHANGED
@@ -10,6 +10,8 @@ import { randomBytes } from "node:crypto";
10
10
  *
11
11
  * Priority: env vars > pluginConfig > defaults.
12
12
  */
13
+ export const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
14
+ export const IS_TEST = process.env.NODE_ENV === "test";
13
15
  export function resolveConfig(pluginConfig) {
14
16
  const raw = pluginConfig != null && typeof pluginConfig === "object"
15
17
  ? pluginConfig
package/dist/hooks.d.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  /**
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
- * Registers 5 hooks + 1 global streaming deobfuscation hook:
5
- * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
- * 2. before_message_write (SYNC) -- bidirectional: obfuscate non-assistant,
7
- * DEOBFUSCATE assistant messages
4
+ * Privacy architecture two directions, one fetch intercept:
5
+ * Outbound (obfuscation): globalThis.fetch intercept replaces real PII
6
+ * with deterministic fakes before ANY LLM API call.
7
+ * Inbound (deobfuscation): Same fetch intercept buffers the LLM's SSE
8
+ * response per content block, deobfuscates fakes
9
+ * back to real values, and returns clean events.
10
+ * OpenClaw never sees fakes — all channels, sessions,
11
+ * and delivery paths receive real text automatically.
12
+ *
13
+ * Hooks:
14
+ * 1. before_prompt_build (async) -- pre-seed mapping store for the fetch intercept
15
+ * 2. before_message_write (SYNC) -- deobfuscate assistant messages for transcript
8
16
  * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
9
17
  * 4. tool_result_persist (SYNC) -- obfuscate tool result message
10
- * 5. message_sending (async) -- deobfuscate outbound message content
11
- * 6. globalThis.__shroudStreamDeobfuscate -- global function called by pi-ai's
12
- * patched EventStream.push() to deobfuscate
13
- * streaming text_delta events from ALL LLM
14
- * providers before OpenClaw processes them
18
+ * 5. message_sending (async) -- deobfuscate outbound message content (backup)
19
+ * 6. globalThis.__shroudStreamDeobfuscate -- streaming event deobfuscation hook
20
+ * 7. globalThis.__shroudDeobfuscate -- channel delivery deobfuscation hook
21
+ * 8. globalThis.fetch intercept -- obfuscates requests, deobfuscates responses
15
22
  */
16
23
  import { Obfuscator } from "./obfuscator.js";
17
24
  export interface PluginApi {
package/dist/hooks.js CHANGED
@@ -1,26 +1,32 @@
1
1
  /**
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
- * Registers 5 hooks + 1 global streaming deobfuscation hook:
5
- * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
- * 2. before_message_write (SYNC) -- bidirectional: obfuscate non-assistant,
7
- * DEOBFUSCATE assistant messages
4
+ * Privacy architecture two directions, one fetch intercept:
5
+ * Outbound (obfuscation): globalThis.fetch intercept replaces real PII
6
+ * with deterministic fakes before ANY LLM API call.
7
+ * Inbound (deobfuscation): Same fetch intercept buffers the LLM's SSE
8
+ * response per content block, deobfuscates fakes
9
+ * back to real values, and returns clean events.
10
+ * OpenClaw never sees fakes — all channels, sessions,
11
+ * and delivery paths receive real text automatically.
12
+ *
13
+ * Hooks:
14
+ * 1. before_prompt_build (async) -- pre-seed mapping store for the fetch intercept
15
+ * 2. before_message_write (SYNC) -- deobfuscate assistant messages for transcript
8
16
  * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
9
17
  * 4. tool_result_persist (SYNC) -- obfuscate tool result message
10
- * 5. message_sending (async) -- deobfuscate outbound message content
11
- * 6. globalThis.__shroudStreamDeobfuscate -- global function called by pi-ai's
12
- * patched EventStream.push() to deobfuscate
13
- * streaming text_delta events from ALL LLM
14
- * providers before OpenClaw processes them
18
+ * 5. message_sending (async) -- deobfuscate outbound message content (backup)
19
+ * 6. globalThis.__shroudStreamDeobfuscate -- streaming event deobfuscation hook
20
+ * 7. globalThis.__shroudDeobfuscate -- channel delivery deobfuscation hook
21
+ * 8. globalThis.fetch intercept -- obfuscates requests, deobfuscates responses
15
22
  */
16
23
  import { createHash, randomBytes } from "node:crypto";
17
- import { readFileSync, writeFileSync } from "node:fs";
24
+ import { writeFileSync } from "node:fs";
18
25
  import { BUILTIN_PATTERNS } from "./detectors/regex.js";
19
- const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
26
+ import { STATS_FILE, IS_TEST } from "./config.js";
20
27
  function getSharedObfuscator(fallback) {
21
28
  return globalThis.__shroudObfuscator || fallback;
22
29
  }
23
- const MAPPINGS_FILE = process.env.SHROUD_MAPPINGS_FILE || "/tmp/shroud-mappings.json";
24
30
  function dumpStatsFile(fallback) {
25
31
  try {
26
32
  const ob = getSharedObfuscator(fallback);
@@ -33,30 +39,6 @@ function dumpStatsFile(fallback) {
33
39
  catch {
34
40
  // best-effort
35
41
  }
36
- // Also dump mapping table for child process fetch intercept
37
- try {
38
- const ob = getSharedObfuscator(fallback);
39
- const store = ob._store;
40
- if (store?.allMappings) {
41
- const allMap = store.allMappings();
42
- const mappings = {};
43
- for (const [real, fake] of allMap) {
44
- mappings[real] = fake;
45
- }
46
- const config = ob.config;
47
- writeFileSync(MAPPINGS_FILE, JSON.stringify({
48
- mappings,
49
- secretKey: config.secretKey,
50
- persistentSalt: config.persistentSalt,
51
- }) + "\n");
52
- }
53
- }
54
- catch (e) {
55
- try {
56
- writeFileSync(MAPPINGS_FILE + ".error", String(e) + "\n");
57
- }
58
- catch { }
59
- }
60
42
  }
61
43
  // ---------------------------------------------------------------------------
62
44
  // Hashing utilities (audit proof hashes)
@@ -200,7 +182,7 @@ export function registerHooks(api, obfuscator) {
200
182
  text = text.replace(/<(https?:\/\/[^>]+)>/g, "$1");
201
183
  return text;
202
184
  }
203
- if (process.env.NODE_ENV !== "test") {
185
+ if (!IS_TEST) {
204
186
  const g = globalThis;
205
187
  if (g.__shroudObfuscator) {
206
188
  obfuscator = g.__shroudObfuscator;
@@ -214,16 +196,6 @@ export function registerHooks(api, obfuscator) {
214
196
  const ob = () => getSharedObfuscator(obfuscator);
215
197
  const config = ob().config;
216
198
  const auditActive = config.auditEnabled || config.verboseLogging;
217
- // Write initial config to mappings file so the fetch preload in child
218
- // processes can create a compatible obfuscator on the first API call.
219
- try {
220
- writeFileSync(MAPPINGS_FILE, JSON.stringify({
221
- mappings: {},
222
- secretKey: config.secretKey,
223
- persistentSalt: config.persistentSalt,
224
- }) + "\n");
225
- }
226
- catch { /* best-effort */ }
227
199
  // -----------------------------------------------------------------------
228
200
  // 1. before_prompt_build (async): obfuscate user prompt
229
201
  // -----------------------------------------------------------------------
@@ -246,9 +218,8 @@ export function registerHooks(api, obfuscator) {
246
218
  }
247
219
  }
248
220
  // Pre-create mappings for PII in user messages WITHOUT mutating them.
249
- // This ensures the fetch preload (in child process) has the mappings
250
- // before the API call. The actual replacement happens in the preload.
251
- api.logger?.info(`[shroud] event keys: ${Object.keys(event || {}).join(",")}`);
221
+ // This seeds the mapping store so the fetch intercept's response
222
+ // deobfuscation can replace fakes with the correct real values.
252
223
  if (Array.isArray(event?.messages)) {
253
224
  for (const msg of event.messages) {
254
225
  const texts = [];
@@ -292,26 +263,6 @@ export function registerHooks(api, obfuscator) {
292
263
  const role = msg.role ?? "";
293
264
  // --- Assistant messages: DEOBFUSCATE (fakes → real values) ---
294
265
  if (role === "assistant") {
295
- // Import mappings created by the fetch preload (child process) so we can
296
- // deobfuscate fakes that the preload generated independently.
297
- try {
298
- const raw = readFileSync(MAPPINGS_FILE, "utf-8");
299
- const data = JSON.parse(raw.trim());
300
- const preloadMappings = data?.mappings;
301
- if (preloadMappings && typeof preloadMappings === "object") {
302
- const store = ob()._store;
303
- if (store?.put) {
304
- for (const [real, fake] of Object.entries(preloadMappings)) {
305
- if (typeof fake === "string" && store.getFake(real) === undefined) {
306
- store.put(real, fake, "preload");
307
- }
308
- }
309
- }
310
- }
311
- }
312
- catch {
313
- // best-effort — file may not exist yet
314
- }
315
266
  const _raw = typeof msg.content === "string" ? msg.content :
316
267
  Array.isArray(msg.content) ? msg.content.map((b) => b?.text || "").join("") : "";
317
268
  if (_raw.length < 500)
@@ -515,52 +466,46 @@ export function registerHooks(api, obfuscator) {
515
466
  api.on("message_sending", async (event) => {
516
467
  if (!event?.content)
517
468
  return;
518
- // String content — direct deobfuscation
469
+ // String content — direct deobfuscation.
470
+ // IMPORTANT: Always return { content } even if deobfuscation is a no-op.
471
+ // OpenClaw may pass already-deobfuscated text here (from before_message_write
472
+ // modifying the message in place) while the original delivery payload still
473
+ // has fake text. Returning { content } forces OpenClaw to use our text
474
+ // instead of falling back to the original payload.
519
475
  if (typeof event.content === "string") {
520
- if (auditActive) {
521
- const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(event.content);
522
- if (deobfuscated === event.content)
523
- return;
524
- try {
525
- emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), replacementCount);
476
+ const deobfuscated = ob().deobfuscate(event.content);
477
+ if (deobfuscated !== event.content) {
478
+ api.logger?.info("[shroud] message_sending: deobfuscated outbound message");
479
+ if (auditActive) {
480
+ try {
481
+ const { replacementCount } = ob().deobfuscateWithStats(event.content);
482
+ emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), replacementCount);
483
+ }
484
+ catch { /* best-effort */ }
526
485
  }
527
- catch { /* best-effort */ }
528
486
  dumpStatsFile(obfuscator);
529
- return { content: deobfuscated };
530
487
  }
531
- const deobfuscated = ob().deobfuscate(event.content);
532
- if (deobfuscated === event.content)
533
- return;
534
- api.logger?.info("[shroud] message_sending: deobfuscated outbound message");
535
- dumpStatsFile(obfuscator);
488
+ // Always return content to override the original payload
536
489
  return { content: deobfuscated };
537
490
  }
538
- // Array content (blocks) — walk and deobfuscate all text leaves
491
+ // Array content (blocks) — walk and deobfuscate all text leaves.
492
+ // Always return content to override the original payload (same reason as above).
539
493
  if (Array.isArray(event.content)) {
540
- let changed = false;
541
494
  const newContent = event.content.map((block) => {
542
495
  if (block && typeof block === "object") {
543
496
  if (typeof block.text === "string") {
544
497
  const deob = ob().deobfuscate(block.text);
545
- if (deob !== block.text) {
546
- changed = true;
498
+ if (deob !== block.text)
547
499
  return { ...block, text: deob };
548
- }
549
500
  }
550
501
  if (typeof block.content === "string") {
551
502
  const deob = ob().deobfuscate(block.content);
552
- if (deob !== block.content) {
553
- changed = true;
503
+ if (deob !== block.content)
554
504
  return { ...block, content: deob };
555
- }
556
505
  }
557
506
  }
558
507
  return block;
559
508
  });
560
- if (!changed)
561
- return;
562
- api.logger?.info("[shroud] message_sending: deobfuscated outbound blocks");
563
- dumpStatsFile(obfuscator);
564
509
  return { content: newContent };
565
510
  }
566
511
  });
@@ -621,67 +566,32 @@ export function registerHooks(api, obfuscator) {
621
566
  // but the final message will be correct.
622
567
  const SHROUD_BUF = Symbol("shroudStreamBuf");
623
568
  globalThis.__shroudStreamDeobfuscate = (stream, event) => {
624
- // Streaming deobfuscation is DISABLED. On OpenClaw 2026.3.24+ with
625
- // streaming: off, before_message_write handles all deobfuscation.
626
- // The streaming buffer causes text artifacts when fake/real lengths
627
- // differ accumulated partial fakes persist in channel delivery
628
- // alongside the corrected final text, producing duplicate content.
629
- //
630
- // The message_end handler below still runs to deobfuscate the final
631
- // content blocks (partial/message), ensuring the delivered message
632
- // has real values.
569
+ // Streaming event hook called by patched EventStream.prototype.push().
570
+ // Text deltas pass through unchanged (deobfuscation happens at the fetch
571
+ // response level via per-block SSE flushing). The message_end handler
572
+ // deobfuscates content blocks as a defense-in-depth measure.
633
573
  const isTextDelta = event.type === "text_delta";
634
574
  const isMessageUpdateTextDelta = event.type === "message_update" &&
635
575
  event.assistantMessageEvent?.type === "text_delta";
636
576
  if (isTextDelta || isMessageUpdateTextDelta) {
577
+ // Pass through text_delta events unchanged.
637
578
  let buf = stream[SHROUD_BUF];
638
579
  if (!buf) {
639
- buf = { raw: "", emitted: 0, deobCount: 0 };
580
+ buf = { raw: "", deobCount: 0 };
640
581
  stream[SHROUD_BUF] = buf;
641
582
  }
642
583
  const src = isMessageUpdateTextDelta ? event.assistantMessageEvent : event;
643
584
  const chunk = typeof src.delta === "string" ? src.delta
644
585
  : typeof src.text === "string" ? src.text : "";
645
- if (!chunk)
646
- return event;
647
- buf.raw += chunk;
648
- const deob = ob().deobfuscate(buf.raw);
649
- // Emit the new portion of the deobfuscated buffer
650
- let newText;
651
- if (deob.length > buf.emitted) {
652
- newText = deob.slice(buf.emitted);
653
- buf.emitted = deob.length;
654
- }
655
- else {
656
- // Deobfuscated text is shorter — fake was replaced with shorter real.
657
- // Emit empty for this chunk; the accumulated delivery text already
658
- // has some fake chars that will be corrected on message_end.
659
- newText = "";
660
- buf.emitted = deob.length;
661
- }
662
- if (newText !== chunk) {
663
- buf.deobCount = (buf.deobCount || 0) + 1;
664
- // Also increment the obfuscator's counter directly
665
- const obInst = ob();
666
- if (typeof obInst._deobfuscationEvents === "number") {
667
- obInst._deobfuscationEvents++;
668
- obInst._totalReplacementsDeobfuscated++;
669
- }
670
- if (isMessageUpdateTextDelta) {
671
- const patched = { ...src, delta: newText };
672
- if (typeof src.text === "string")
673
- patched.text = newText;
674
- event = { ...event, assistantMessageEvent: patched };
675
- }
676
- else {
677
- event = { ...event, delta: newText };
678
- if (typeof event.text === "string")
679
- event.text = newText;
680
- }
681
- }
586
+ if (chunk)
587
+ buf.raw += chunk;
588
+ return event;
682
589
  }
683
- // On message_end/done: deobfuscate the full content in the partial/message
684
- // to correct any partial fakes from streaming
590
+ // On message_end/done: deobfuscate content blocks in the final message.
591
+ // This is critical for streaming delivery — OpenClaw uses the content
592
+ // blocks from the done/message_end event as the authoritative text for
593
+ // channel delivery. Text_delta deob is disabled (causes garbled
594
+ // concatenation), but message_end content block deob must remain.
685
595
  const isEnd = event.type === "done" || event.type === "message_end" ||
686
596
  event.type === "error" || event.type === "agent_end" ||
687
597
  (event.type === "message_update" && (event.assistantMessageEvent?.type === "text_end"));
@@ -703,15 +613,6 @@ export function registerHooks(api, obfuscator) {
703
613
  }
704
614
  }
705
615
  }
706
- // Audit: use the replacement count accumulated during streaming
707
- const buf = stream[SHROUD_BUF];
708
- const streamDeobCount = buf?.deobCount ?? 0;
709
- if (streamDeobCount > 0 && auditActive) {
710
- try {
711
- emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), streamDeobCount);
712
- }
713
- catch { /* best-effort */ }
714
- }
715
616
  dumpStatsFile(obfuscator);
716
617
  delete stream[SHROUD_BUF];
717
618
  }
@@ -719,15 +620,10 @@ export function registerHooks(api, obfuscator) {
719
620
  };
720
621
  api.logger?.info("[shroud] Installed global streaming deobfuscation hook");
721
622
  // -----------------------------------------------------------------------
722
- // 7. Global deobfuscation hook for OpenClaw channel delivery.
723
- // OpenClaw calls this once, in its generic message-delivery function,
724
- // before sending to ANY channel (Slack, WhatsApp, Signal, web, etc.).
725
- // Transparent: if Shroud isn't loaded the global doesn't exist — no-op.
726
- // Works on all past + present releases with a single 3-line patch:
727
- //
728
- // const deob = globalThis.__shroudDeobfuscate;
729
- // if (deob && typeof text === 'string') text = deob(text);
730
- //
623
+ // 7. Global deobfuscation hook for channel delivery (defense-in-depth).
624
+ // Primary deobfuscation happens in the fetch response interceptor (8).
625
+ // This hook is available for any code that calls
626
+ // globalThis.__shroudDeobfuscate(text) directly.
731
627
  // -----------------------------------------------------------------------
732
628
  globalThis.__shroudDeobfuscate = (text) => {
733
629
  if (typeof text !== "string")
@@ -736,14 +632,18 @@ export function registerHooks(api, obfuscator) {
736
632
  };
737
633
  api.logger?.info("[shroud] Registered globalThis.__shroudDeobfuscate for channel delivery");
738
634
  // -----------------------------------------------------------------------
739
- // 8. Outbound fetch intercept: obfuscate ALL user message content before
740
- // it reaches any LLM API. This is the last line of defense — it works
741
- // regardless of which hooks fire, which OpenClaw version is running,
742
- // and which LLM provider is used (Anthropic, OpenAI, Google, etc.).
635
+ // 8. Fetch intercept the universal privacy boundary.
636
+ //
637
+ // REQUEST (obfuscation): Patches globalThis.fetch to intercept outbound
638
+ // POST requests to LLM API paths (/v1/messages, /chat/completions, etc.).
639
+ // Obfuscates all message content before it leaves the process.
743
640
  //
744
- // Patches globalThis.fetch to inspect outbound POST requests to known
745
- // LLM API paths (/messages, /chat/completions). If the request body
746
- // contains a messages array with user role content, obfuscate it.
641
+ // RESPONSE (deobfuscation): Wraps the LLM's SSE response with a
642
+ // per-block flushing TransformStream. Text deltas are buffered per
643
+ // content block. On content_block_stop, the accumulated text is
644
+ // deobfuscated and flushed — first delta gets the full real text,
645
+ // subsequent deltas are emptied. Non-PII blocks stream with zero delay.
646
+ // OpenClaw receives clean events; every channel gets real text.
747
647
  // -----------------------------------------------------------------------
748
648
  const LLM_API_PATHS = [
749
649
  "/v1/messages", // Anthropic
@@ -876,10 +776,9 @@ export function registerHooks(api, obfuscator) {
876
776
  // Strip Slack markup first so PII detection works on clean text
877
777
  const cleaned = stripSlackLinks(text);
878
778
  const textChanged = cleaned !== text;
879
- if (!needsObfuscation(cleaned)) {
880
- // Even if no known reals found, return cleaned text if Slack links were stripped
881
- return textChanged ? { text: cleaned, modified: true } : { text, modified: false };
882
- }
779
+ // Always run obfuscation — the needsObfuscation check was skipping
780
+ // NEW PII that wasn't in the store (e.g., LLM-generated emails
781
+ // echoed back by the user). Detection must run on every message.
883
782
  const result = ob().obfuscate(cleaned);
884
783
  return result.entities.length > 0 || textChanged
885
784
  ? { text: result.obfuscated, modified: true }
@@ -954,14 +853,277 @@ export function registerHooks(api, obfuscator) {
954
853
  headers.set("content-length", String(new TextEncoder().encode(newBody).length));
955
854
  newInit.headers = headers;
956
855
  }
957
- return originalFetch.call(globalThis, input, newInit);
856
+ return deobfuscateResponse(originalFetch.call(globalThis, input, newInit));
958
857
  }
959
858
  }
960
859
  catch {
961
860
  // JSON parse failed or other error — pass through unmodified
962
861
  }
963
- return originalFetch.call(globalThis, input, init);
862
+ return deobfuscateResponse(originalFetch.call(globalThis, input, init));
964
863
  };
864
+ // ── Response deobfuscation ──────────────────────────────
865
+ // Wraps the LLM response to replace fakes with reals BEFORE
866
+ // OpenClaw processes it. This is the universal deobfuscation
867
+ // point — OpenClaw receives clean text, so ALL channels,
868
+ // sessions, and delivery paths get real values automatically.
869
+ //
870
+ // For streaming (SSE): buffers the response body, deobfuscates
871
+ // all text content, returns a new Response with clean data.
872
+ // For JSON: wraps response.json() to deobfuscate.
873
+ async function deobfuscateResponse(fetchPromise) {
874
+ const response = await fetchPromise;
875
+ if (!response.ok || !response.body)
876
+ return response;
877
+ const contentType = response.headers.get("content-type") || "";
878
+ // SSE streaming response — per-block flushing.
879
+ // Non-PII events pass through immediately. Text deltas are buffered
880
+ // per content block. When content_block_stop arrives, the block's
881
+ // accumulated text is deobfuscated and all buffered events for that
882
+ // block are flushed — first delta gets the full deobbed text,
883
+ // subsequent deltas get empty strings. Preserves streaming UX:
884
+ // non-PII blocks stream normally, PII blocks delay by ~0.5-1s.
885
+ if (contentType.includes("text/event-stream")) {
886
+ // Per-block state
887
+ const blockAccum = new Map();
888
+ const blockBuffer = new Map();
889
+ // OpenAI per-choice state
890
+ const choiceAccum = new Map();
891
+ const choiceBuffer = new Map();
892
+ let sseRemainder = "";
893
+ const transform = new TransformStream({
894
+ transform(chunk, controller) {
895
+ const text = sseRemainder + new TextDecoder().decode(chunk);
896
+ // SSE events are separated by \n\n
897
+ const parts = text.split("\n\n");
898
+ // Last part may be incomplete — save for next chunk
899
+ sseRemainder = parts.pop() || "";
900
+ for (const part of parts) {
901
+ if (!part.trim()) {
902
+ controller.enqueue(new TextEncoder().encode("\n\n"));
903
+ continue;
904
+ }
905
+ const dataLine = part.split("\n").find((l) => l.startsWith("data: "));
906
+ if (!dataLine) {
907
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
908
+ continue;
909
+ }
910
+ let json;
911
+ try {
912
+ json = JSON.parse(dataLine.slice(6));
913
+ }
914
+ catch {
915
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
916
+ continue;
917
+ }
918
+ // Anthropic text_delta: buffer until block_stop
919
+ if (json.type === "content_block_delta" && json.delta?.type === "text_delta") {
920
+ const idx = json.index ?? 0;
921
+ blockAccum.set(idx, (blockAccum.get(idx) || "") + (json.delta.text || ""));
922
+ if (!blockBuffer.has(idx))
923
+ blockBuffer.set(idx, []);
924
+ blockBuffer.get(idx).push(part);
925
+ // Don't flush yet — wait for content_block_stop
926
+ continue;
927
+ }
928
+ // Anthropic content_block_stop: flush buffered deltas for this block
929
+ if (json.type === "content_block_stop") {
930
+ const idx = json.index ?? 0;
931
+ const accumulated = blockAccum.get(idx);
932
+ const buffered = blockBuffer.get(idx);
933
+ if (accumulated && buffered && buffered.length > 0) {
934
+ const deobbed = ob().deobfuscate(accumulated);
935
+ // First buffered delta gets the full deobbed text
936
+ let first = true;
937
+ for (const eventStr of buffered) {
938
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
939
+ if (dLine) {
940
+ try {
941
+ const dJson = JSON.parse(dLine.slice(6));
942
+ if (dJson.delta?.type === "text_delta") {
943
+ dJson.delta.text = first ? deobbed : "";
944
+ first = false;
945
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
946
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
947
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
948
+ continue;
949
+ }
950
+ }
951
+ catch { }
952
+ }
953
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
954
+ }
955
+ blockAccum.delete(idx);
956
+ blockBuffer.delete(idx);
957
+ }
958
+ // Emit the stop event itself
959
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
960
+ continue;
961
+ }
962
+ // OpenAI delta.content: buffer until finish_reason
963
+ if (Array.isArray(json.choices)) {
964
+ let buffered = false;
965
+ for (const choice of json.choices) {
966
+ const idx = choice.index ?? 0;
967
+ if (typeof choice.delta?.content === "string") {
968
+ choiceAccum.set(idx, (choiceAccum.get(idx) || "") + choice.delta.content);
969
+ if (!choiceBuffer.has(idx))
970
+ choiceBuffer.set(idx, []);
971
+ choiceBuffer.get(idx).push(part);
972
+ buffered = true;
973
+ }
974
+ // finish_reason signals block complete — flush
975
+ if (choice.finish_reason) {
976
+ const accumulated = choiceAccum.get(idx);
977
+ const buf = choiceBuffer.get(idx);
978
+ if (accumulated && buf && buf.length > 0) {
979
+ const deobbed = ob().deobfuscate(accumulated);
980
+ let first = true;
981
+ for (const eventStr of buf) {
982
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
983
+ if (dLine) {
984
+ try {
985
+ const dJson = JSON.parse(dLine.slice(6));
986
+ if (Array.isArray(dJson.choices)) {
987
+ for (const c of dJson.choices) {
988
+ if (typeof c.delta?.content === "string") {
989
+ c.delta.content = first ? deobbed : "";
990
+ first = false;
991
+ }
992
+ }
993
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
994
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
995
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
996
+ continue;
997
+ }
998
+ }
999
+ catch { }
1000
+ }
1001
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
1002
+ }
1003
+ choiceAccum.delete(idx);
1004
+ choiceBuffer.delete(idx);
1005
+ }
1006
+ buffered = false;
1007
+ }
1008
+ }
1009
+ if (buffered)
1010
+ continue;
1011
+ }
1012
+ // Deobfuscate content blocks in message events (message_start etc)
1013
+ if (Array.isArray(json.message?.content)) {
1014
+ for (const block of json.message.content) {
1015
+ if (block?.type === "text" && typeof block.text === "string") {
1016
+ block.text = ob().deobfuscate(block.text);
1017
+ }
1018
+ }
1019
+ const nonDataLines = part.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
1020
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(json);
1021
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
1022
+ continue;
1023
+ }
1024
+ // All other events pass through unchanged
1025
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
1026
+ }
1027
+ },
1028
+ flush(controller) {
1029
+ // Flush any remaining buffered content (stream ended mid-block)
1030
+ for (const [idx, buffered] of blockBuffer) {
1031
+ const accumulated = blockAccum.get(idx) || "";
1032
+ const deobbed = ob().deobfuscate(accumulated);
1033
+ let first = true;
1034
+ for (const eventStr of buffered) {
1035
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
1036
+ if (dLine) {
1037
+ try {
1038
+ const dJson = JSON.parse(dLine.slice(6));
1039
+ if (dJson.delta?.type === "text_delta") {
1040
+ dJson.delta.text = first ? deobbed : "";
1041
+ first = false;
1042
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
1043
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
1044
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
1045
+ continue;
1046
+ }
1047
+ }
1048
+ catch { }
1049
+ }
1050
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
1051
+ }
1052
+ }
1053
+ for (const [idx, buffered] of choiceBuffer) {
1054
+ const accumulated = choiceAccum.get(idx) || "";
1055
+ const deobbed = ob().deobfuscate(accumulated);
1056
+ let first = true;
1057
+ for (const eventStr of buffered) {
1058
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
1059
+ if (dLine) {
1060
+ try {
1061
+ const dJson = JSON.parse(dLine.slice(6));
1062
+ if (Array.isArray(dJson.choices)) {
1063
+ for (const c of dJson.choices) {
1064
+ if (typeof c.delta?.content === "string") {
1065
+ c.delta.content = first ? deobbed : "";
1066
+ first = false;
1067
+ }
1068
+ }
1069
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
1070
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
1071
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
1072
+ continue;
1073
+ }
1074
+ }
1075
+ catch { }
1076
+ }
1077
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
1078
+ }
1079
+ }
1080
+ if (sseRemainder.trim()) {
1081
+ controller.enqueue(new TextEncoder().encode(sseRemainder));
1082
+ }
1083
+ },
1084
+ });
1085
+ const newBody = response.body.pipeThrough(transform);
1086
+ return new Response(newBody, {
1087
+ status: response.status,
1088
+ statusText: response.statusText,
1089
+ headers: response.headers,
1090
+ });
1091
+ }
1092
+ // JSON response (non-streaming)
1093
+ if (contentType.includes("application/json")) {
1094
+ const text = await response.text();
1095
+ try {
1096
+ const json = JSON.parse(text);
1097
+ if (Array.isArray(json.content)) {
1098
+ for (const block of json.content) {
1099
+ if (block?.type === "text" && typeof block.text === "string") {
1100
+ block.text = ob().deobfuscate(block.text);
1101
+ }
1102
+ }
1103
+ }
1104
+ if (Array.isArray(json.choices)) {
1105
+ for (const choice of json.choices) {
1106
+ if (typeof choice.message?.content === "string") {
1107
+ choice.message.content = ob().deobfuscate(choice.message.content);
1108
+ }
1109
+ }
1110
+ }
1111
+ return new Response(JSON.stringify(json), {
1112
+ status: response.status,
1113
+ statusText: response.statusText,
1114
+ headers: response.headers,
1115
+ });
1116
+ }
1117
+ catch {
1118
+ return new Response(text, {
1119
+ status: response.status,
1120
+ statusText: response.statusText,
1121
+ headers: response.headers,
1122
+ });
1123
+ }
1124
+ }
1125
+ return response;
1126
+ }
965
1127
  api.logger?.info("[shroud] Installed outbound fetch intercept — PII obfuscated before ALL LLM API calls");
966
1128
  }
967
1129
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.2.0",
4
+ "version": "2.2.1",
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.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Privacy obfuscation for AI agents — detects PII and replaces with deterministic fake values before anything reaches the LLM. Works with OpenClaw (plugin) or any agent (APP protocol).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",