shroud-privacy 2.2.0 → 2.2.2

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,8 @@ 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
+ const isFirstLoad = !globalThis.__shroudObfuscator;
186
+ if (!IS_TEST) {
204
187
  const g = globalThis;
205
188
  if (g.__shroudObfuscator) {
206
189
  obfuscator = g.__shroudObfuscator;
@@ -214,16 +197,6 @@ export function registerHooks(api, obfuscator) {
214
197
  const ob = () => getSharedObfuscator(obfuscator);
215
198
  const config = ob().config;
216
199
  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
200
  // -----------------------------------------------------------------------
228
201
  // 1. before_prompt_build (async): obfuscate user prompt
229
202
  // -----------------------------------------------------------------------
@@ -246,9 +219,8 @@ export function registerHooks(api, obfuscator) {
246
219
  }
247
220
  }
248
221
  // 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(",")}`);
222
+ // This seeds the mapping store so the fetch intercept's response
223
+ // deobfuscation can replace fakes with the correct real values.
252
224
  if (Array.isArray(event?.messages)) {
253
225
  for (const msg of event.messages) {
254
226
  const texts = [];
@@ -292,26 +264,6 @@ export function registerHooks(api, obfuscator) {
292
264
  const role = msg.role ?? "";
293
265
  // --- Assistant messages: DEOBFUSCATE (fakes → real values) ---
294
266
  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
267
  const _raw = typeof msg.content === "string" ? msg.content :
316
268
  Array.isArray(msg.content) ? msg.content.map((b) => b?.text || "").join("") : "";
317
269
  if (_raw.length < 500)
@@ -515,52 +467,46 @@ export function registerHooks(api, obfuscator) {
515
467
  api.on("message_sending", async (event) => {
516
468
  if (!event?.content)
517
469
  return;
518
- // String content — direct deobfuscation
470
+ // String content — direct deobfuscation.
471
+ // IMPORTANT: Always return { content } even if deobfuscation is a no-op.
472
+ // OpenClaw may pass already-deobfuscated text here (from before_message_write
473
+ // modifying the message in place) while the original delivery payload still
474
+ // has fake text. Returning { content } forces OpenClaw to use our text
475
+ // instead of falling back to the original payload.
519
476
  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);
477
+ const deobfuscated = ob().deobfuscate(event.content);
478
+ if (deobfuscated !== event.content) {
479
+ api.logger?.info("[shroud] message_sending: deobfuscated outbound message");
480
+ if (auditActive) {
481
+ try {
482
+ const { replacementCount } = ob().deobfuscateWithStats(event.content);
483
+ emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), replacementCount);
484
+ }
485
+ catch { /* best-effort */ }
526
486
  }
527
- catch { /* best-effort */ }
528
487
  dumpStatsFile(obfuscator);
529
- return { content: deobfuscated };
530
488
  }
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);
489
+ // Always return content to override the original payload
536
490
  return { content: deobfuscated };
537
491
  }
538
- // Array content (blocks) — walk and deobfuscate all text leaves
492
+ // Array content (blocks) — walk and deobfuscate all text leaves.
493
+ // Always return content to override the original payload (same reason as above).
539
494
  if (Array.isArray(event.content)) {
540
- let changed = false;
541
495
  const newContent = event.content.map((block) => {
542
496
  if (block && typeof block === "object") {
543
497
  if (typeof block.text === "string") {
544
498
  const deob = ob().deobfuscate(block.text);
545
- if (deob !== block.text) {
546
- changed = true;
499
+ if (deob !== block.text)
547
500
  return { ...block, text: deob };
548
- }
549
501
  }
550
502
  if (typeof block.content === "string") {
551
503
  const deob = ob().deobfuscate(block.content);
552
- if (deob !== block.content) {
553
- changed = true;
504
+ if (deob !== block.content)
554
505
  return { ...block, content: deob };
555
- }
556
506
  }
557
507
  }
558
508
  return block;
559
509
  });
560
- if (!changed)
561
- return;
562
- api.logger?.info("[shroud] message_sending: deobfuscated outbound blocks");
563
- dumpStatsFile(obfuscator);
564
510
  return { content: newContent };
565
511
  }
566
512
  });
@@ -621,67 +567,32 @@ export function registerHooks(api, obfuscator) {
621
567
  // but the final message will be correct.
622
568
  const SHROUD_BUF = Symbol("shroudStreamBuf");
623
569
  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.
570
+ // Streaming event hook called by patched EventStream.prototype.push().
571
+ // Text deltas pass through unchanged (deobfuscation happens at the fetch
572
+ // response level via per-block SSE flushing). The message_end handler
573
+ // deobfuscates content blocks as a defense-in-depth measure.
633
574
  const isTextDelta = event.type === "text_delta";
634
575
  const isMessageUpdateTextDelta = event.type === "message_update" &&
635
576
  event.assistantMessageEvent?.type === "text_delta";
636
577
  if (isTextDelta || isMessageUpdateTextDelta) {
578
+ // Pass through text_delta events unchanged.
637
579
  let buf = stream[SHROUD_BUF];
638
580
  if (!buf) {
639
- buf = { raw: "", emitted: 0, deobCount: 0 };
581
+ buf = { raw: "", deobCount: 0 };
640
582
  stream[SHROUD_BUF] = buf;
641
583
  }
642
584
  const src = isMessageUpdateTextDelta ? event.assistantMessageEvent : event;
643
585
  const chunk = typeof src.delta === "string" ? src.delta
644
586
  : 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
- }
587
+ if (chunk)
588
+ buf.raw += chunk;
589
+ return event;
682
590
  }
683
- // On message_end/done: deobfuscate the full content in the partial/message
684
- // to correct any partial fakes from streaming
591
+ // On message_end/done: deobfuscate content blocks in the final message.
592
+ // This is critical for streaming delivery — OpenClaw uses the content
593
+ // blocks from the done/message_end event as the authoritative text for
594
+ // channel delivery. Text_delta deob is disabled (causes garbled
595
+ // concatenation), but message_end content block deob must remain.
685
596
  const isEnd = event.type === "done" || event.type === "message_end" ||
686
597
  event.type === "error" || event.type === "agent_end" ||
687
598
  (event.type === "message_update" && (event.assistantMessageEvent?.type === "text_end"));
@@ -703,47 +614,35 @@ export function registerHooks(api, obfuscator) {
703
614
  }
704
615
  }
705
616
  }
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
617
  dumpStatsFile(obfuscator);
716
618
  delete stream[SHROUD_BUF];
717
619
  }
718
620
  return event;
719
621
  };
720
- 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")
734
630
  return text;
735
631
  return ob().deobfuscate(text);
736
632
  };
737
- api.logger?.info("[shroud] Registered globalThis.__shroudDeobfuscate for channel delivery");
738
633
  // -----------------------------------------------------------------------
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.).
634
+ // 8. Fetch intercept the universal privacy boundary.
635
+ //
636
+ // REQUEST (obfuscation): Patches globalThis.fetch to intercept outbound
637
+ // POST requests to LLM API paths (/v1/messages, /chat/completions, etc.).
638
+ // Obfuscates all message content before it leaves the process.
743
639
  //
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.
640
+ // RESPONSE (deobfuscation): Wraps the LLM's SSE response with a
641
+ // per-block flushing TransformStream. Text deltas are buffered per
642
+ // content block. On content_block_stop, the accumulated text is
643
+ // deobfuscated and flushed — first delta gets the full real text,
644
+ // subsequent deltas are emptied. Non-PII blocks stream with zero delay.
645
+ // OpenClaw receives clean events; every channel gets real text.
747
646
  // -----------------------------------------------------------------------
748
647
  const LLM_API_PATHS = [
749
648
  "/v1/messages", // Anthropic
@@ -758,7 +657,6 @@ export function registerHooks(api, obfuscator) {
758
657
  "/v1/models/", // Google Vertex AI
759
658
  ];
760
659
  const originalFetch = globalThis.fetch;
761
- api.logger?.info(`[shroud][fetch-guard] hasFetch=${!!originalFetch} patched=${!!globalThis.__shroudFetchPatched}`);
762
660
  if (originalFetch && !(globalThis.__shroudFetchPatched)) {
763
661
  globalThis.__shroudFetchPatched = true;
764
662
  globalThis.fetch = async function shroudFetchInterceptor(input, init) {
@@ -876,10 +774,9 @@ export function registerHooks(api, obfuscator) {
876
774
  // Strip Slack markup first so PII detection works on clean text
877
775
  const cleaned = stripSlackLinks(text);
878
776
  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
- }
777
+ // Always run obfuscation — the needsObfuscation check was skipping
778
+ // NEW PII that wasn't in the store (e.g., LLM-generated emails
779
+ // echoed back by the user). Detection must run on every message.
883
780
  const result = ob().obfuscate(cleaned);
884
781
  return result.entities.length > 0 || textChanged
885
782
  ? { text: result.obfuscated, modified: true }
@@ -954,14 +851,276 @@ export function registerHooks(api, obfuscator) {
954
851
  headers.set("content-length", String(new TextEncoder().encode(newBody).length));
955
852
  newInit.headers = headers;
956
853
  }
957
- return originalFetch.call(globalThis, input, newInit);
854
+ return deobfuscateResponse(originalFetch.call(globalThis, input, newInit));
958
855
  }
959
856
  }
960
857
  catch {
961
858
  // JSON parse failed or other error — pass through unmodified
962
859
  }
963
- return originalFetch.call(globalThis, input, init);
860
+ return deobfuscateResponse(originalFetch.call(globalThis, input, init));
964
861
  };
965
- api.logger?.info("[shroud] Installed outbound fetch intercept — PII obfuscated before ALL LLM API calls");
862
+ // ── Response deobfuscation ──────────────────────────────
863
+ // Wraps the LLM response to replace fakes with reals BEFORE
864
+ // OpenClaw processes it. This is the universal deobfuscation
865
+ // point — OpenClaw receives clean text, so ALL channels,
866
+ // sessions, and delivery paths get real values automatically.
867
+ //
868
+ // For streaming (SSE): buffers the response body, deobfuscates
869
+ // all text content, returns a new Response with clean data.
870
+ // For JSON: wraps response.json() to deobfuscate.
871
+ async function deobfuscateResponse(fetchPromise) {
872
+ const response = await fetchPromise;
873
+ if (!response.ok || !response.body)
874
+ return response;
875
+ const contentType = response.headers.get("content-type") || "";
876
+ // SSE streaming response — per-block flushing.
877
+ // Non-PII events pass through immediately. Text deltas are buffered
878
+ // per content block. When content_block_stop arrives, the block's
879
+ // accumulated text is deobfuscated and all buffered events for that
880
+ // block are flushed — first delta gets the full deobbed text,
881
+ // subsequent deltas get empty strings. Preserves streaming UX:
882
+ // non-PII blocks stream normally, PII blocks delay by ~0.5-1s.
883
+ if (contentType.includes("text/event-stream")) {
884
+ // Per-block state
885
+ const blockAccum = new Map();
886
+ const blockBuffer = new Map();
887
+ // OpenAI per-choice state
888
+ const choiceAccum = new Map();
889
+ const choiceBuffer = new Map();
890
+ let sseRemainder = "";
891
+ const transform = new TransformStream({
892
+ transform(chunk, controller) {
893
+ const text = sseRemainder + new TextDecoder().decode(chunk);
894
+ // SSE events are separated by \n\n
895
+ const parts = text.split("\n\n");
896
+ // Last part may be incomplete — save for next chunk
897
+ sseRemainder = parts.pop() || "";
898
+ for (const part of parts) {
899
+ if (!part.trim()) {
900
+ controller.enqueue(new TextEncoder().encode("\n\n"));
901
+ continue;
902
+ }
903
+ const dataLine = part.split("\n").find((l) => l.startsWith("data: "));
904
+ if (!dataLine) {
905
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
906
+ continue;
907
+ }
908
+ let json;
909
+ try {
910
+ json = JSON.parse(dataLine.slice(6));
911
+ }
912
+ catch {
913
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
914
+ continue;
915
+ }
916
+ // Anthropic text_delta: buffer until block_stop
917
+ if (json.type === "content_block_delta" && json.delta?.type === "text_delta") {
918
+ const idx = json.index ?? 0;
919
+ blockAccum.set(idx, (blockAccum.get(idx) || "") + (json.delta.text || ""));
920
+ if (!blockBuffer.has(idx))
921
+ blockBuffer.set(idx, []);
922
+ blockBuffer.get(idx).push(part);
923
+ // Don't flush yet — wait for content_block_stop
924
+ continue;
925
+ }
926
+ // Anthropic content_block_stop: flush buffered deltas for this block
927
+ if (json.type === "content_block_stop") {
928
+ const idx = json.index ?? 0;
929
+ const accumulated = blockAccum.get(idx);
930
+ const buffered = blockBuffer.get(idx);
931
+ if (accumulated && buffered && buffered.length > 0) {
932
+ const deobbed = ob().deobfuscate(accumulated);
933
+ // First buffered delta gets the full deobbed text
934
+ let first = true;
935
+ for (const eventStr of buffered) {
936
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
937
+ if (dLine) {
938
+ try {
939
+ const dJson = JSON.parse(dLine.slice(6));
940
+ if (dJson.delta?.type === "text_delta") {
941
+ dJson.delta.text = first ? deobbed : "";
942
+ first = false;
943
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
944
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
945
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
946
+ continue;
947
+ }
948
+ }
949
+ catch { }
950
+ }
951
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
952
+ }
953
+ blockAccum.delete(idx);
954
+ blockBuffer.delete(idx);
955
+ }
956
+ // Emit the stop event itself
957
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
958
+ continue;
959
+ }
960
+ // OpenAI delta.content: buffer until finish_reason
961
+ if (Array.isArray(json.choices)) {
962
+ let buffered = false;
963
+ for (const choice of json.choices) {
964
+ const idx = choice.index ?? 0;
965
+ if (typeof choice.delta?.content === "string") {
966
+ choiceAccum.set(idx, (choiceAccum.get(idx) || "") + choice.delta.content);
967
+ if (!choiceBuffer.has(idx))
968
+ choiceBuffer.set(idx, []);
969
+ choiceBuffer.get(idx).push(part);
970
+ buffered = true;
971
+ }
972
+ // finish_reason signals block complete — flush
973
+ if (choice.finish_reason) {
974
+ const accumulated = choiceAccum.get(idx);
975
+ const buf = choiceBuffer.get(idx);
976
+ if (accumulated && buf && buf.length > 0) {
977
+ const deobbed = ob().deobfuscate(accumulated);
978
+ let first = true;
979
+ for (const eventStr of buf) {
980
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
981
+ if (dLine) {
982
+ try {
983
+ const dJson = JSON.parse(dLine.slice(6));
984
+ if (Array.isArray(dJson.choices)) {
985
+ for (const c of dJson.choices) {
986
+ if (typeof c.delta?.content === "string") {
987
+ c.delta.content = first ? deobbed : "";
988
+ first = false;
989
+ }
990
+ }
991
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
992
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
993
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
994
+ continue;
995
+ }
996
+ }
997
+ catch { }
998
+ }
999
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
1000
+ }
1001
+ choiceAccum.delete(idx);
1002
+ choiceBuffer.delete(idx);
1003
+ }
1004
+ buffered = false;
1005
+ }
1006
+ }
1007
+ if (buffered)
1008
+ continue;
1009
+ }
1010
+ // Deobfuscate content blocks in message events (message_start etc)
1011
+ if (Array.isArray(json.message?.content)) {
1012
+ for (const block of json.message.content) {
1013
+ if (block?.type === "text" && typeof block.text === "string") {
1014
+ block.text = ob().deobfuscate(block.text);
1015
+ }
1016
+ }
1017
+ const nonDataLines = part.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
1018
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(json);
1019
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
1020
+ continue;
1021
+ }
1022
+ // All other events pass through unchanged
1023
+ controller.enqueue(new TextEncoder().encode(part + "\n\n"));
1024
+ }
1025
+ },
1026
+ flush(controller) {
1027
+ // Flush any remaining buffered content (stream ended mid-block)
1028
+ for (const [idx, buffered] of blockBuffer) {
1029
+ const accumulated = blockAccum.get(idx) || "";
1030
+ const deobbed = ob().deobfuscate(accumulated);
1031
+ let first = true;
1032
+ for (const eventStr of buffered) {
1033
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
1034
+ if (dLine) {
1035
+ try {
1036
+ const dJson = JSON.parse(dLine.slice(6));
1037
+ if (dJson.delta?.type === "text_delta") {
1038
+ dJson.delta.text = first ? deobbed : "";
1039
+ first = false;
1040
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
1041
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
1042
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
1043
+ continue;
1044
+ }
1045
+ }
1046
+ catch { }
1047
+ }
1048
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
1049
+ }
1050
+ }
1051
+ for (const [idx, buffered] of choiceBuffer) {
1052
+ const accumulated = choiceAccum.get(idx) || "";
1053
+ const deobbed = ob().deobfuscate(accumulated);
1054
+ let first = true;
1055
+ for (const eventStr of buffered) {
1056
+ const dLine = eventStr.split("\n").find((l) => l.startsWith("data: "));
1057
+ if (dLine) {
1058
+ try {
1059
+ const dJson = JSON.parse(dLine.slice(6));
1060
+ if (Array.isArray(dJson.choices)) {
1061
+ for (const c of dJson.choices) {
1062
+ if (typeof c.delta?.content === "string") {
1063
+ c.delta.content = first ? deobbed : "";
1064
+ first = false;
1065
+ }
1066
+ }
1067
+ const nonDataLines = eventStr.split("\n").filter((l) => !l.startsWith("data: ")).join("\n");
1068
+ const rebuilt = (nonDataLines ? nonDataLines + "\n" : "") + "data: " + JSON.stringify(dJson);
1069
+ controller.enqueue(new TextEncoder().encode(rebuilt + "\n\n"));
1070
+ continue;
1071
+ }
1072
+ }
1073
+ catch { }
1074
+ }
1075
+ controller.enqueue(new TextEncoder().encode(eventStr + "\n\n"));
1076
+ }
1077
+ }
1078
+ if (sseRemainder.trim()) {
1079
+ controller.enqueue(new TextEncoder().encode(sseRemainder));
1080
+ }
1081
+ },
1082
+ });
1083
+ const newBody = response.body.pipeThrough(transform);
1084
+ return new Response(newBody, {
1085
+ status: response.status,
1086
+ statusText: response.statusText,
1087
+ headers: response.headers,
1088
+ });
1089
+ }
1090
+ // JSON response (non-streaming)
1091
+ if (contentType.includes("application/json")) {
1092
+ const text = await response.text();
1093
+ try {
1094
+ const json = JSON.parse(text);
1095
+ if (Array.isArray(json.content)) {
1096
+ for (const block of json.content) {
1097
+ if (block?.type === "text" && typeof block.text === "string") {
1098
+ block.text = ob().deobfuscate(block.text);
1099
+ }
1100
+ }
1101
+ }
1102
+ if (Array.isArray(json.choices)) {
1103
+ for (const choice of json.choices) {
1104
+ if (typeof choice.message?.content === "string") {
1105
+ choice.message.content = ob().deobfuscate(choice.message.content);
1106
+ }
1107
+ }
1108
+ }
1109
+ return new Response(JSON.stringify(json), {
1110
+ status: response.status,
1111
+ statusText: response.statusText,
1112
+ headers: response.headers,
1113
+ });
1114
+ }
1115
+ catch {
1116
+ return new Response(text, {
1117
+ status: response.status,
1118
+ statusText: response.statusText,
1119
+ headers: response.headers,
1120
+ });
1121
+ }
1122
+ }
1123
+ return response;
1124
+ }
966
1125
  }
967
1126
  }
package/dist/index.js CHANGED
@@ -87,18 +87,14 @@ function patchEventStreamPrototype(logger) {
87
87
  // Try requiring from the file's own directory (no exports restriction from parent)
88
88
  const mod = localRequire("./event-stream.js");
89
89
  EventStream = mod?.EventStream ?? mod?.default?.EventStream;
90
- if (EventStream) {
91
- logger?.info(`[shroud] Found EventStream via direct file path: ${candidate}`);
90
+ if (EventStream)
92
91
  break;
93
- }
94
92
  }
95
93
  catch { /* try next */ }
96
94
  }
97
95
  }
98
- if (!EventStream?.prototype?.push) {
99
- logger?.info("[shroud] Could not locate EventStream class — streaming deobfuscation unavailable");
96
+ if (!EventStream?.prototype?.push)
100
97
  return;
101
- }
102
98
  // Wrap prototype.push with the deobfuscation hook
103
99
  const originalPush = EventStream.prototype.push;
104
100
  EventStream.prototype.push = function shroudPatchedPush(event) {
@@ -110,7 +106,6 @@ function patchEventStreamPrototype(logger) {
110
106
  };
111
107
  // Mark as patched to prevent double-wrapping
112
108
  globalThis[PATCH_MARKER] = true;
113
- logger?.info("[shroud] Patched EventStream.prototype.push — zero-file streaming deobfuscation active");
114
109
  }
115
110
  export default {
116
111
  id: "shroud-privacy",
@@ -160,6 +155,11 @@ export default {
160
155
  };
161
156
  },
162
157
  });
163
- api.logger?.info("[shroud] Plugin loadednative TypeScript, no proxy required.");
158
+ // Single load confirmation used by test harness to verify plugin loaded.
159
+ // Only logs once per process (suppressed on subsequent agent loads).
160
+ if (!globalThis.__shroudLoadLogged) {
161
+ globalThis.__shroudLoadLogged = true;
162
+ api.logger?.info("[shroud] Plugin loaded.");
163
+ }
164
164
  },
165
165
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.2.0",
4
+ "version": "2.2.2",
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.2",
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",