shroud-privacy 2.0.6 → 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
@@ -264,7 +264,7 @@ OpenClaw logs each plugin message twice (once under the plugin subsystem logger,
264
264
 
265
265
  ```bash
266
266
  npm install
267
- npm test # run vitest (208 tests)
267
+ npm test # run vitest (215 tests)
268
268
  npm run build # compile TypeScript
269
269
  npm run lint # type-check without emitting
270
270
  ```
package/dist/hooks.d.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
- * Registers 6 hooks (version-adaptive — unused hooks are silently ignored):
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_llm_send (async) -- obfuscate LLM messages + install transformResponse for deobfuscation (>=2026.3.14)
7
+ * 3. before_llm_send (async) -- obfuscate LLM messages + install transformResponse (>=2026.3.14)
8
8
  * 4. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
9
9
  * 5. tool_result_persist (SYNC) -- obfuscate tool result message
10
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.
11
19
  */
12
20
  import { Obfuscator } from "./obfuscator.js";
13
21
  export interface PluginApi {
package/dist/hooks.js CHANGED
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
- * Registers 6 hooks (version-adaptive — unused hooks are silently ignored):
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_llm_send (async) -- obfuscate LLM messages + install transformResponse for deobfuscation (>=2026.3.14)
7
+ * 3. before_llm_send (async) -- obfuscate LLM messages + install transformResponse (>=2026.3.14)
8
8
  * 4. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
9
9
  * 5. tool_result_persist (SYNC) -- obfuscate tool result message
10
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.
11
19
  */
12
20
  import { createHash, randomBytes } from "node:crypto";
21
+ import { createRequire } from "node:module";
13
22
  import { writeFileSync } from "node:fs";
14
23
  import { BUILTIN_PATTERNS } from "./detectors/regex.js";
15
24
  const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
@@ -153,6 +162,74 @@ function walkStrings(value, fn) {
153
162
  return value;
154
163
  }
155
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
+ // ---------------------------------------------------------------------------
156
233
  // Hook registration
157
234
  // ---------------------------------------------------------------------------
158
235
  export function registerHooks(api, obfuscator) {
@@ -397,4 +474,10 @@ export function registerHooks(api, obfuscator) {
397
474
  return { content: [{ type: "text", text: lines.join("\n") }] };
398
475
  },
399
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);
400
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.6",
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.6",
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",