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