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