shroud-privacy 2.1.0 → 2.2.0

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
@@ -26,10 +26,11 @@
26
26
  | `before_tool_call` | LLM → Tool | Deobfuscate tool parameters + track tool chain depth |
27
27
  | `tool_result_persist` | Tool → History | Obfuscate tool results before storing |
28
28
  | `message_sending` | Agent → User | Deobfuscate outbound messages (all channels) |
29
+ | `globalThis.__shroudDeobfuscate` | Agent → Channel | Global deobfuscation hook — called by OpenClaw before ANY channel send |
29
30
 
30
- > **Privacy guarantee:** Shroud intercepts ALL outbound LLM API calls (Anthropic, OpenAI, Google, any provider) at the `fetch` level and obfuscates PII in every message — including assistant history and Slack `<mailto:>` markup — before it leaves the process. No PII reaches the LLM. On the inbound side, streaming responses are deobfuscated in real-time via `EventStream.prototype.push()` no OpenClaw file modifications needed.
31
+ > **Privacy guarantee:** Shroud intercepts ALL outbound LLM API calls (Anthropic, OpenAI, Google, any provider) at the `fetch` level and obfuscates PII in every message — including assistant history and Slack `<mailto:>` markup — before it leaves the process. No PII reaches the LLM. On the channel delivery side, Shroud registers `globalThis.__shroudDeobfuscate` a single function that OpenClaw calls before sending to ANY channel (Slack, WhatsApp, Signal, web, etc.). One hook, all channels, transparent no-op if Shroud isn't loaded.
31
32
 
32
- > **Requires OpenClaw 2026.3.24 or later.** Older versions do not call `message_sending` for Slack/WhatsApp channels, causing duplicate messages with fake tokens. Shroud 2.1+ is tested exclusively against OpenClaw 2026.3.24.
33
+ > **Requires OpenClaw 2026.3.24 or later** with the channel delivery patch (see [OpenClaw patch](#openclaw-channel-delivery-patch) below).
33
34
 
34
35
  ## Install
35
36
 
@@ -103,38 +104,16 @@ Other methods: `reset`, `stats`, `health`, `configure`, `shutdown`.
103
104
  git clone https://github.com/walterkeating-stack/shroud.git
104
105
  cd shroud
105
106
  npm install && npm run build
106
- bash deploy-local.sh # OpenClaw (~/.openclaw/extensions/)
107
+ openclaw plugins install --path .
108
+ openclaw gateway restart
107
109
  ```
108
110
 
109
111
  ## Updating
110
112
 
111
- OpenClaw doesn't have a `plugins update` command yet, so updating requires removing the old install first. A helper script is included:
112
-
113
- ```bash
114
- # Update to latest version (preserves your config)
115
- bash scripts/update-openclaw-plugin.sh
116
-
117
- # Update to a specific version
118
- bash scripts/update-openclaw-plugin.sh <version>
119
- ```
120
-
121
- The script saves your plugin config from `openclaw.json`, removes the old extension, reinstalls from npm, restores your config, and restarts the gateway.
122
-
123
- ### Manual update
124
-
125
- If you prefer to do it manually:
126
-
127
113
  ```bash
128
- # 1. Remove old plugin files
129
- rm -rf ~/.openclaw/extensions/shroud-privacy
130
-
131
- # 2. Reinstall (this resets your plugin config to defaults)
114
+ # Remove old plugin, reinstall from npm, restart
115
+ openclaw plugins remove shroud-privacy
132
116
  openclaw plugins install shroud-privacy
133
-
134
- # 3. Re-apply your config in ~/.openclaw/openclaw.json
135
- # (under plugins.entries."shroud-privacy".config)
136
-
137
- # 4. Restart
138
117
  openclaw gateway restart
139
118
  ```
140
119
 
@@ -261,10 +240,28 @@ Shroud uses runtime prototype patches — **no OpenClaw files are modified**:
261
240
 
262
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.
263
242
 
264
- **Channel delivery:** On OpenClaw 2026.3.24+, the `message_sending` hook fires for ALL channels (Slack, WhatsApp, Telegram, etc.) and deobfuscates outbound messages. Older OpenClaw versions skip this hook for some channels, causing fake tokens in channel output.
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.
265
244
 
266
245
  All patches are applied once at plugin load and are idempotent — subsequent loads detect and skip them.
267
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.
264
+
268
265
  ### Rule hit counters
269
266
 
270
267
  Shroud tracks per-rule match counts for the lifetime of the process. Counters appear in three places:
@@ -478,7 +475,7 @@ npm run lint # type-check without emitting
478
475
 
479
476
  ```bash
480
477
  npm run build
481
- bash deploy-local.sh # OpenClaw (~/.openclaw/extensions/shroud-privacy/)
478
+ openclaw plugins install --path .
482
479
  openclaw gateway restart
483
480
  ```
484
481
 
@@ -491,8 +488,8 @@ openclaw gateway restart
491
488
  # 2. Update CHANGELOG.md
492
489
  # 3. Commit and tag
493
490
  git add -A
494
- git commit -m "Release v1.x.y"
495
- git tag v1.x.y
491
+ git commit -m "release: vX.Y.Z"
492
+ git tag vX.Y.Z
496
493
  git push && git push --tags
497
494
  ```
498
495
 
package/dist/hooks.js CHANGED
@@ -14,12 +14,13 @@
14
14
  * providers before OpenClaw processes them
15
15
  */
16
16
  import { createHash, randomBytes } from "node:crypto";
17
- import { writeFileSync } from "node:fs";
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
18
  import { BUILTIN_PATTERNS } from "./detectors/regex.js";
19
19
  const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
20
20
  function getSharedObfuscator(fallback) {
21
21
  return globalThis.__shroudObfuscator || fallback;
22
22
  }
23
+ const MAPPINGS_FILE = process.env.SHROUD_MAPPINGS_FILE || "/tmp/shroud-mappings.json";
23
24
  function dumpStatsFile(fallback) {
24
25
  try {
25
26
  const ob = getSharedObfuscator(fallback);
@@ -32,6 +33,30 @@ function dumpStatsFile(fallback) {
32
33
  catch {
33
34
  // best-effort
34
35
  }
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
+ }
35
60
  }
36
61
  // ---------------------------------------------------------------------------
37
62
  // Hashing utilities (audit proof hashes)
@@ -169,6 +194,12 @@ export function registerHooks(api, obfuscator) {
169
194
  // instance while message_sending fires on another (via the delivery subsystem).
170
195
  // Without sharing, the delivery instance has an empty mapping store and
171
196
  // cannot deobfuscate CGNAT surrogates in outbound channel messages.
197
+ function stripSlackLinksForHook(text) {
198
+ text = text.replace(/<mailto:[^|>]+\|([^>]*)>/g, "$1");
199
+ text = text.replace(/<https?:\/\/[^|>]+\|([^>]*)>/g, "$1");
200
+ text = text.replace(/<(https?:\/\/[^>]+)>/g, "$1");
201
+ return text;
202
+ }
172
203
  if (process.env.NODE_ENV !== "test") {
173
204
  const g = globalThis;
174
205
  if (g.__shroudObfuscator) {
@@ -183,6 +214,16 @@ export function registerHooks(api, obfuscator) {
183
214
  const ob = () => getSharedObfuscator(obfuscator);
184
215
  const config = ob().config;
185
216
  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 */ }
186
227
  // -----------------------------------------------------------------------
187
228
  // 1. before_prompt_build (async): obfuscate user prompt
188
229
  // -----------------------------------------------------------------------
@@ -192,17 +233,46 @@ export function registerHooks(api, obfuscator) {
192
233
  if (ob().toolDepth > 0) {
193
234
  ob().resetToolDepth();
194
235
  }
236
+ let totalEntities = 0;
237
+ // Obfuscate the system prompt
195
238
  const prompt = event?.prompt;
196
- if (typeof prompt !== "string" || !prompt)
197
- return;
198
- const result = ob().obfuscate(prompt);
199
- if (result.entities.length === 0)
239
+ let obfuscatedPrompt;
240
+ if (typeof prompt === "string" && prompt) {
241
+ const cleaned = stripSlackLinksForHook(prompt);
242
+ const result = ob().obfuscate(cleaned);
243
+ if (result.entities.length > 0 || cleaned !== prompt) {
244
+ obfuscatedPrompt = result.entities.length > 0 ? result.obfuscated : cleaned;
245
+ totalEntities += result.entities.length;
246
+ }
247
+ }
248
+ // 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(",")}`);
252
+ if (Array.isArray(event?.messages)) {
253
+ for (const msg of event.messages) {
254
+ const texts = [];
255
+ if (typeof msg.content === "string")
256
+ texts.push(msg.content);
257
+ else if (Array.isArray(msg.content)) {
258
+ for (const b of msg.content) {
259
+ if (b?.type === "text" && typeof b.text === "string")
260
+ texts.push(b.text);
261
+ }
262
+ }
263
+ for (const text of texts) {
264
+ const cleaned = stripSlackLinksForHook(text);
265
+ const result = ob().obfuscate(cleaned);
266
+ totalEntities += result.entities.length;
267
+ // Do NOT mutate — just creating mappings in the store
268
+ }
269
+ }
270
+ }
271
+ if (totalEntities === 0)
200
272
  return;
201
273
  dumpStatsFile(obfuscator);
202
- api.logger?.info(`[shroud] before_prompt_build: obfuscated ${result.entities.length} entities`);
203
- return {
204
- prompt: result.obfuscated,
205
- };
274
+ api.logger?.info(`[shroud] before_prompt_build: obfuscated ${totalEntities} entities (mappings synced)`);
275
+ return obfuscatedPrompt ? { systemPrompt: obfuscatedPrompt } : undefined;
206
276
  });
207
277
  // -----------------------------------------------------------------------
208
278
  // 2. before_message_write (SYNC): bidirectional privacy filter
@@ -222,6 +292,30 @@ export function registerHooks(api, obfuscator) {
222
292
  const role = msg.role ?? "";
223
293
  // --- Assistant messages: DEOBFUSCATE (fakes → real values) ---
224
294
  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
+ const _raw = typeof msg.content === "string" ? msg.content :
316
+ Array.isArray(msg.content) ? msg.content.map((b) => b?.text || "").join("") : "";
317
+ if (_raw.length < 500)
318
+ api.logger?.info(`[shroud][raw-assistant] ${_raw}`);
225
319
  if (typeof msg.content === "string") {
226
320
  const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(msg.content);
227
321
  if (deobfuscated === msg.content)
@@ -368,6 +462,16 @@ export function registerHooks(api, obfuscator) {
368
462
  api.on("before_tool_call", async (event) => {
369
463
  if (!event?.params || typeof event.params !== "object")
370
464
  return;
465
+ // Block the message tool for send actions. The gateway auto-delivers
466
+ // responses — using the message tool causes duplicate messages (one
467
+ // deobfuscated via streaming, one with fakes from the tool call).
468
+ if (event.toolName === "message") {
469
+ api.logger?.info(`[shroud] message tool call: action=${event.params?.action}`);
470
+ if (event.params?.action === "send") {
471
+ api.logger?.info("[shroud] blocked message tool send (prevents duplicate delivery)");
472
+ return { block: true, blockReason: "Response is delivered automatically. Do not use the message tool to send replies." };
473
+ }
474
+ }
371
475
  // Tool chain depth tracking
372
476
  const depth = ob().enterToolCall();
373
477
  if (depth > config.maxToolDepth) {
@@ -517,6 +621,15 @@ export function registerHooks(api, obfuscator) {
517
621
  // but the final message will be correct.
518
622
  const SHROUD_BUF = Symbol("shroudStreamBuf");
519
623
  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.
520
633
  const isTextDelta = event.type === "text_delta";
521
634
  const isMessageUpdateTextDelta = event.type === "message_update" &&
522
635
  event.assistantMessageEvent?.type === "text_delta";
@@ -573,8 +686,6 @@ export function registerHooks(api, obfuscator) {
573
686
  event.type === "error" || event.type === "agent_end" ||
574
687
  (event.type === "message_update" && (event.assistantMessageEvent?.type === "text_end"));
575
688
  if (isEnd) {
576
- // Deobfuscate content blocks in the event's message/partial
577
- // (corrects any partial fakes left from streaming)
578
689
  const targets = [
579
690
  event.message, event.partial,
580
691
  event.assistantMessageEvent?.partial,
@@ -585,8 +696,9 @@ export function registerHooks(api, obfuscator) {
585
696
  for (const block of target.content) {
586
697
  if (block?.type === "text" && typeof block.text === "string") {
587
698
  const deob = ob().deobfuscate(block.text);
588
- if (deob !== block.text)
699
+ if (deob !== block.text) {
589
700
  block.text = deob;
701
+ }
590
702
  }
591
703
  }
592
704
  }
@@ -600,7 +712,6 @@ export function registerHooks(api, obfuscator) {
600
712
  }
601
713
  catch { /* best-effort */ }
602
714
  }
603
- // Always dump stats on message_end to capture any counter changes
604
715
  dumpStatsFile(obfuscator);
605
716
  delete stream[SHROUD_BUF];
606
717
  }
@@ -608,7 +719,24 @@ export function registerHooks(api, obfuscator) {
608
719
  };
609
720
  api.logger?.info("[shroud] Installed global streaming deobfuscation hook");
610
721
  // -----------------------------------------------------------------------
611
- // 7. Outbound fetch intercept: obfuscate ALL user message content before
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
+ //
731
+ // -----------------------------------------------------------------------
732
+ globalThis.__shroudDeobfuscate = (text) => {
733
+ if (typeof text !== "string")
734
+ return text;
735
+ return ob().deobfuscate(text);
736
+ };
737
+ api.logger?.info("[shroud] Registered globalThis.__shroudDeobfuscate for channel delivery");
738
+ // -----------------------------------------------------------------------
739
+ // 8. Outbound fetch intercept: obfuscate ALL user message content before
612
740
  // it reaches any LLM API. This is the last line of defense — it works
613
741
  // regardless of which hooks fire, which OpenClaw version is running,
614
742
  // and which LLM provider is used (Anthropic, OpenAI, Google, etc.).
@@ -630,6 +758,7 @@ export function registerHooks(api, obfuscator) {
630
758
  "/v1/models/", // Google Vertex AI
631
759
  ];
632
760
  const originalFetch = globalThis.fetch;
761
+ api.logger?.info(`[shroud][fetch-guard] hasFetch=${!!originalFetch} patched=${!!globalThis.__shroudFetchPatched}`);
633
762
  if (originalFetch && !(globalThis.__shroudFetchPatched)) {
634
763
  globalThis.__shroudFetchPatched = true;
635
764
  globalThis.fetch = async function shroudFetchInterceptor(input, init) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
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.1.0",
3
+ "version": "2.2.0",
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",