shroud-privacy 2.0.5 → 2.0.7

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
@@ -17,6 +17,7 @@ Privacy obfuscation plugin for [OpenClaw](https://openclaw.ai). Detects sensitiv
17
17
  |------|-----------|-------------|
18
18
  | `before_prompt_build` | User → LLM | Obfuscate user prompt, prepend privacy context |
19
19
  | `before_message_write` | Any → History | Obfuscate every message written to the session transcript |
20
+ | `before_llm_send` | Context → LLM | Obfuscate LLM messages + install `transformResponse` for deobfuscation (>=2026.3.14) |
20
21
  | `before_tool_call` | LLM → Tool | Deobfuscate tool parameters + track tool chain depth |
21
22
  | `tool_result_persist` | Tool → History | Obfuscate tool results before storing |
22
23
  | `message_sending` | Agent → User | Deobfuscate outbound messages (WhatsApp, auto-reply, etc.) |
@@ -263,7 +264,7 @@ OpenClaw logs each plugin message twice (once under the plugin subsystem logger,
263
264
 
264
265
  ```bash
265
266
  npm install
266
- npm test # run vitest (203 tests)
267
+ npm test # run vitest (215 tests)
267
268
  npm run build # compile TypeScript
268
269
  npm run lint # type-check without emitting
269
270
  ```
package/dist/hooks.d.ts CHANGED
@@ -1,12 +1,21 @@
1
1
  /**
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
- * Registers 5 hooks:
4
+ * Registers 6 hooks + 1 transport interceptor (version-adaptive):
5
5
  * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
6
  * 2. before_message_write (SYNC) -- obfuscate every message written to the session transcript
7
- * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
8
- * 4. tool_result_persist (SYNC) -- obfuscate tool result message
9
- * 5. message_sending (async) -- deobfuscate outbound message content
7
+ * 3. before_llm_send (async) -- obfuscate LLM messages + install transformResponse (>=2026.3.14)
8
+ * 4. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
9
+ * 5. tool_result_persist (SYNC) -- obfuscate tool result message
10
+ * 6. message_sending (async) -- deobfuscate outbound message content
11
+ * 7. Transport interceptor -- wraps Slack WebClient.apiCall for universal deobfuscation
12
+ *
13
+ * The transport interceptor is the universal fallback: it deobfuscates Slack
14
+ * messages at the API-call level, independent of which OpenClaw hooks fire.
15
+ * On >=2026.3.14, transformResponse handles deobfuscation first (for ALL
16
+ * channels); the transport interceptor is a no-op since the text is already
17
+ * deobfuscated. On older versions where message_sending doesn't fire for
18
+ * Slack, the interceptor catches it.
10
19
  */
11
20
  import { Obfuscator } from "./obfuscator.js";
12
21
  export interface PluginApi {
package/dist/hooks.js CHANGED
@@ -1,14 +1,24 @@
1
1
  /**
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
- * Registers 5 hooks:
4
+ * Registers 6 hooks + 1 transport interceptor (version-adaptive):
5
5
  * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
6
  * 2. before_message_write (SYNC) -- obfuscate every message written to the session transcript
7
- * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
8
- * 4. tool_result_persist (SYNC) -- obfuscate tool result message
9
- * 5. message_sending (async) -- deobfuscate outbound message content
7
+ * 3. before_llm_send (async) -- obfuscate LLM messages + install transformResponse (>=2026.3.14)
8
+ * 4. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
9
+ * 5. tool_result_persist (SYNC) -- obfuscate tool result message
10
+ * 6. message_sending (async) -- deobfuscate outbound message content
11
+ * 7. Transport interceptor -- wraps Slack WebClient.apiCall for universal deobfuscation
12
+ *
13
+ * The transport interceptor is the universal fallback: it deobfuscates Slack
14
+ * messages at the API-call level, independent of which OpenClaw hooks fire.
15
+ * On >=2026.3.14, transformResponse handles deobfuscation first (for ALL
16
+ * channels); the transport interceptor is a no-op since the text is already
17
+ * deobfuscated. On older versions where message_sending doesn't fire for
18
+ * Slack, the interceptor catches it.
10
19
  */
11
20
  import { createHash, randomBytes } from "node:crypto";
21
+ import { createRequire } from "node:module";
12
22
  import { writeFileSync } from "node:fs";
13
23
  import { BUILTIN_PATTERNS } from "./detectors/regex.js";
14
24
  const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
@@ -152,6 +162,74 @@ function walkStrings(value, fn) {
152
162
  return value;
153
163
  }
154
164
  // ---------------------------------------------------------------------------
165
+ // Transport-level deobfuscation interceptor (fallback for all versions)
166
+ // ---------------------------------------------------------------------------
167
+ /**
168
+ * Wraps the Slack WebClient.prototype.apiCall to deobfuscate outbound message
169
+ * text before it hits the Slack API. This is the universal fallback that works
170
+ * on ANY OpenClaw version — it doesn't depend on hook dispatch at all.
171
+ *
172
+ * The @slack/web-api package is CJS and already loaded by OpenClaw, so it lives
173
+ * in require.cache. We find it there, wrap the prototype once, done.
174
+ */
175
+ function installTransportInterceptor(obfuscator, logger) {
176
+ try {
177
+ const esmRequire = createRequire(import.meta.url);
178
+ const cache = esmRequire.cache;
179
+ if (!cache)
180
+ return;
181
+ for (const key of Object.keys(cache)) {
182
+ if (!key.includes("@slack/web-api"))
183
+ continue;
184
+ const mod = cache[key];
185
+ const WebClient = mod?.exports?.WebClient;
186
+ if (typeof WebClient !== "function")
187
+ continue;
188
+ const proto = WebClient.prototype;
189
+ if (typeof proto.apiCall !== "function")
190
+ continue;
191
+ if (proto.__shroudPatched)
192
+ return; // already wrapped
193
+ const origApiCall = proto.apiCall;
194
+ proto.apiCall = async function shroudApiCall(method, options) {
195
+ if ((method === "chat.postMessage" || method === "chat.update") &&
196
+ options) {
197
+ // Deobfuscate the plain-text fallback
198
+ if (typeof options.text === "string") {
199
+ const original = options.text;
200
+ const deobfuscated = obfuscator.deobfuscate(original);
201
+ if (deobfuscated !== original) {
202
+ options = { ...options, text: deobfuscated };
203
+ logger?.info(`[shroud][transport] deobfuscated Slack ${method}`);
204
+ }
205
+ }
206
+ // Deobfuscate blocks (rich text) — walk all text elements
207
+ if (Array.isArray(options.blocks)) {
208
+ const json = JSON.stringify(options.blocks);
209
+ const deobJson = obfuscator.deobfuscate(json);
210
+ if (deobJson !== json) {
211
+ try {
212
+ options = { ...options, blocks: JSON.parse(deobJson) };
213
+ }
214
+ catch {
215
+ // If JSON parse fails, leave blocks unchanged
216
+ }
217
+ }
218
+ }
219
+ }
220
+ return origApiCall.call(this, method, options);
221
+ };
222
+ proto.__shroudPatched = true;
223
+ logger?.info("[shroud] Installed Slack transport interceptor (universal deobfuscation fallback)");
224
+ return;
225
+ }
226
+ logger?.info("[shroud] Slack WebClient not found in require.cache — transport interceptor not installed (hooks-only mode)");
227
+ }
228
+ catch (err) {
229
+ logger?.warn(`[shroud] Failed to install transport interceptor: ${String(err)}`);
230
+ }
231
+ }
232
+ // ---------------------------------------------------------------------------
155
233
  // Hook registration
156
234
  // ---------------------------------------------------------------------------
157
235
  export function registerHooks(api, obfuscator) {
@@ -239,7 +317,57 @@ export function registerHooks(api, obfuscator) {
239
317
  }
240
318
  });
241
319
  // -----------------------------------------------------------------------
242
- // 3. before_tool_call (async): deobfuscate tool params + track depth
320
+ // 3. before_llm_send (async): obfuscate LLM context + install transformResponse
321
+ // Available on OpenClaw >=2026.3.14. Silently ignored on older versions.
322
+ // This is the most reliable deobfuscation path — transformResponse catches
323
+ // ALL LLM output text including streaming deltas.
324
+ // -----------------------------------------------------------------------
325
+ api.on("before_llm_send", async (event) => {
326
+ const messages = event?.messages;
327
+ if (!Array.isArray(messages))
328
+ return;
329
+ // Obfuscate all string content in the message array
330
+ let totalEntities = 0;
331
+ const obfuscatedMessages = messages.map((msg) => {
332
+ if (!msg || typeof msg !== "object")
333
+ return msg;
334
+ const walked = walkStrings(msg.content, (s) => {
335
+ const result = obfuscator.obfuscate(s);
336
+ totalEntities += result.entities.length;
337
+ return result.obfuscated;
338
+ });
339
+ if (walked === msg.content)
340
+ return msg;
341
+ return { ...msg, content: walked };
342
+ });
343
+ if (totalEntities > 0) {
344
+ dumpStatsFile(obfuscator);
345
+ api.logger?.info(`[shroud] before_llm_send: obfuscated ${totalEntities} entities in ${messages.length} messages`);
346
+ }
347
+ // Install transformResponse — this deobfuscates LLM output text.
348
+ // It's a synchronous function called on every response chunk.
349
+ const requestId = randomBytes(8).toString("hex");
350
+ const transformResponse = (text) => {
351
+ if (auditActive) {
352
+ const { text: deobfuscated, replacementCount } = obfuscator.deobfuscateWithStats(text);
353
+ if (deobfuscated !== text) {
354
+ try {
355
+ emitDeobfuscationAudit(api.logger, config, requestId, replacementCount);
356
+ }
357
+ catch { /* best-effort */ }
358
+ dumpStatsFile(obfuscator);
359
+ }
360
+ return deobfuscated;
361
+ }
362
+ return obfuscator.deobfuscate(text);
363
+ };
364
+ return {
365
+ messages: totalEntities > 0 ? obfuscatedMessages : undefined,
366
+ transformResponse,
367
+ };
368
+ });
369
+ // -----------------------------------------------------------------------
370
+ // 4. before_tool_call (async): deobfuscate tool params + track depth
243
371
  // -----------------------------------------------------------------------
244
372
  api.on("before_tool_call", async (event) => {
245
373
  if (!event?.params || typeof event.params !== "object")
@@ -265,7 +393,7 @@ export function registerHooks(api, obfuscator) {
265
393
  }
266
394
  });
267
395
  // -----------------------------------------------------------------------
268
- // 4. tool_result_persist (SYNC): obfuscate tool result message
396
+ // 5. tool_result_persist (SYNC): obfuscate tool result message
269
397
  // -----------------------------------------------------------------------
270
398
  api.on("tool_result_persist", (event) => {
271
399
  if (!event?.message)
@@ -280,7 +408,8 @@ export function registerHooks(api, obfuscator) {
280
408
  return { message: obfuscated };
281
409
  });
282
410
  // -----------------------------------------------------------------------
283
- // 5. message_sending (async): deobfuscate outbound message content
411
+ // 6. message_sending (async): deobfuscate outbound message content
412
+ // Fallback for versions without before_llm_send/transformResponse.
284
413
  // -----------------------------------------------------------------------
285
414
  api.on("message_sending", async (event) => {
286
415
  if (typeof event?.content !== "string")
@@ -345,4 +474,10 @@ export function registerHooks(api, obfuscator) {
345
474
  return { content: [{ type: "text", text: lines.join("\n") }] };
346
475
  },
347
476
  });
477
+ // -----------------------------------------------------------------------
478
+ // 7. Transport interceptor: universal deobfuscation fallback
479
+ // Wraps Slack WebClient.apiCall so outbound messages are deobfuscated
480
+ // regardless of which OpenClaw hooks fire (or don't).
481
+ // -----------------------------------------------------------------------
482
+ installTransportInterceptor(obfuscator, api.logger);
348
483
  }
@@ -78,6 +78,21 @@ function compressIPv6(addr) {
78
78
  }
79
79
  return groups.join(":");
80
80
  }
81
+ /**
82
+ * Strip Slack/chat mrkdwn link formatting to recover plain text.
83
+ * Slack wraps emails as `<mailto:X|DISPLAY>` and URLs as `<URL|DISPLAY>`,
84
+ * which splits entities across tag boundaries and breaks regex detection.
85
+ * This converts display-text links back to their visible form.
86
+ */
87
+ function stripSlackLinks(text) {
88
+ // <mailto:X|DISPLAY> → DISPLAY (email links)
89
+ text = text.replace(/<mailto:[^|>]+\|([^>]*)>/g, "$1");
90
+ // <URL|DISPLAY> → DISPLAY (URL links with display text)
91
+ text = text.replace(/<https?:\/\/[^|>]+\|([^>]*)>/g, "$1");
92
+ // <URL> → URL (bare URL links, no display text)
93
+ text = text.replace(/<(https?:\/\/[^>]+)>/g, "$1");
94
+ return text;
95
+ }
81
96
  /**
82
97
  * Build a single combined regex from an array of literal strings.
83
98
  * Strings are escaped and joined with alternation (|), sorted longest-first
@@ -197,6 +212,10 @@ export class Obfuscator {
197
212
  */
198
213
  obfuscate(text, context) {
199
214
  const startTime = Date.now();
215
+ // 0. Strip Slack/chat mrkdwn link formatting so detection sees clean text.
216
+ // Slack wraps emails as <mailto:X|DISPLAY> and URLs as <URL|DISPLAY>,
217
+ // which splits entities across tag boundaries and breaks regex matching.
218
+ text = stripSlackLinks(text);
200
219
  // 1. Learn subnet context from CIDR notation and masks in text
201
220
  this._subnetMapper.learnSubnetsFromText(text);
202
221
  // 2. Detect all entities from all detectors
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.0.5",
4
+ "version": "2.0.7",
5
5
  "description": "Privacy obfuscation with deterministic fake values and deobfuscation — PII never reaches the LLM, tool calls still work",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shroud-privacy",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "Privacy obfuscation plugin for OpenClaw — detects sensitive data and replaces with deterministic fake values",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",