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 +2 -1
- package/dist/hooks.d.ts +13 -4
- package/dist/hooks.js +142 -7
- package/dist/obfuscator.js +19 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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 (
|
|
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
|
|
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.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
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
|
|
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.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
}
|
package/dist/obfuscator.js
CHANGED
|
@@ -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
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shroud-privacy",
|
|
3
3
|
"name": "Shroud",
|
|
4
|
-
"version": "2.0.
|
|
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