shroud-privacy 2.5.3 → 2.5.5
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 +4 -4
- package/app-server.mjs +85 -16
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/hooks.js +216 -58
- package/dist/obfuscator.d.ts +5 -0
- package/dist/obfuscator.js +10 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,14 +78,14 @@ Shroud does not guarantee compliance — regex-based detection has limitations (
|
|
|
78
78
|
|
|
79
79
|
> **How it works:** Shroud intercepts ALL outbound LLM API calls (Anthropic, OpenAI, Google, any provider) at the `fetch` level and obfuscates detected entities in every message — including assistant history and Slack `<mailto:>` markup — before it leaves the process. On the response side, SSE streaming is deobfuscated per content block with buffered flushing. Every delivery path (Slack, WhatsApp, TUI, Telegram, Discord, Signal, web) gets real text automatically. Zero host patches required.
|
|
80
80
|
|
|
81
|
-
> **Requires OpenClaw 2026.3.
|
|
81
|
+
> **Requires OpenClaw 2026.3.24 or later.**
|
|
82
82
|
|
|
83
83
|
### OpenClaw support policy
|
|
84
84
|
|
|
85
85
|
- **Formal minimum supported version:** `2026.3.24` (from `openclaw.plugin.json` `minOpenClawVersion`).
|
|
86
86
|
- **Release validation matrix (this release):**
|
|
87
87
|
- **Baseline:** `2026.3.28` (includes WhatsApp E2E path)
|
|
88
|
-
- **Latest-at-release:** `2026.4.
|
|
88
|
+
- **Latest-at-release:** `2026.4.14` (Slack E2E pass)
|
|
89
89
|
- **Latest caveat:** on OpenClaw builds where WhatsApp provisioning via `channels add` is unsupported, latest-focused compat runs skip WhatsApp E2E and validate Slack E2E.
|
|
90
90
|
- **Source of truth for current matrix:** `docs/ci-current-state.md` and `CHANGELOG.md`.
|
|
91
91
|
|
|
@@ -93,10 +93,10 @@ Shroud does not guarantee compliance — regex-based detection has limitations (
|
|
|
93
93
|
|
|
94
94
|
## Install
|
|
95
95
|
|
|
96
|
-
### OpenClaw (2026.3.
|
|
96
|
+
### OpenClaw (2026.3.24+)
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
|
-
openclaw --version # ensure 2026.3.
|
|
99
|
+
openclaw --version # ensure 2026.3.24+
|
|
100
100
|
openclaw plugins install shroud-privacy
|
|
101
101
|
```
|
|
102
102
|
|
package/app-server.mjs
CHANGED
|
@@ -19,8 +19,9 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
19
19
|
import { createInterface } from "node:readline";
|
|
20
20
|
import { pathToFileURL } from "node:url";
|
|
21
21
|
import { resolve, dirname } from "node:path";
|
|
22
|
-
import { writeFileSync, readFileSync } from "node:fs";
|
|
22
|
+
import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { createServer as createNetServer } from "node:net";
|
|
24
25
|
|
|
25
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
const shroudDist = process.argv[2] || resolve(__dirname, "dist");
|
|
@@ -424,21 +425,22 @@ const METHODS = {
|
|
|
424
425
|
setPartition: handleSetPartition,
|
|
425
426
|
};
|
|
426
427
|
|
|
427
|
-
function dispatch(line) {
|
|
428
|
+
function dispatch(line, writeFn) {
|
|
428
429
|
if (!line.trim()) return;
|
|
430
|
+
const write = writeFn || ((s) => process.stdout.write(s));
|
|
429
431
|
|
|
430
432
|
let req;
|
|
431
433
|
try {
|
|
432
434
|
req = JSON.parse(line);
|
|
433
435
|
} catch (e) {
|
|
434
|
-
|
|
436
|
+
write(jsonError(null, ERR_PARSE, `Parse error: ${e.message}`) + "\n");
|
|
435
437
|
return;
|
|
436
438
|
}
|
|
437
439
|
|
|
438
440
|
const { id, method, params } = req;
|
|
439
441
|
|
|
440
442
|
if (id === undefined || id === null || !method) {
|
|
441
|
-
|
|
443
|
+
write(
|
|
442
444
|
jsonError(id ?? null, ERR_INVALID_REQ, "Missing required field: id and method") + "\n"
|
|
443
445
|
);
|
|
444
446
|
return;
|
|
@@ -446,7 +448,7 @@ function dispatch(line) {
|
|
|
446
448
|
|
|
447
449
|
const handler = METHODS[method];
|
|
448
450
|
if (!handler) {
|
|
449
|
-
|
|
451
|
+
write(
|
|
450
452
|
jsonError(id, ERR_NO_METHOD, `Method not found: ${method}`) + "\n"
|
|
451
453
|
);
|
|
452
454
|
return;
|
|
@@ -459,10 +461,10 @@ function dispatch(line) {
|
|
|
459
461
|
const response = handler(id, params);
|
|
460
462
|
// shutdown writes its own response and exits
|
|
461
463
|
if (method !== "shutdown") {
|
|
462
|
-
|
|
464
|
+
write(response + "\n");
|
|
463
465
|
}
|
|
464
466
|
} catch (e) {
|
|
465
|
-
|
|
467
|
+
write(
|
|
466
468
|
jsonError(id, ERR_ENGINE, `Engine error: ${e.message}`) + "\n"
|
|
467
469
|
);
|
|
468
470
|
}
|
|
@@ -519,16 +521,83 @@ const handshake = {
|
|
|
519
521
|
};
|
|
520
522
|
|
|
521
523
|
process.stderr.write(`[app-server] Starting APP server v${engineVersion}\n`);
|
|
522
|
-
process.stdout.write(JSON.stringify(handshake) + "\n");
|
|
523
524
|
|
|
524
525
|
// ---------------------------------------------------------------------------
|
|
525
|
-
//
|
|
526
|
+
// Socket listener mode (--listen <path>)
|
|
526
527
|
// ---------------------------------------------------------------------------
|
|
527
528
|
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
529
|
+
const listenFlag = process.argv.indexOf("--listen");
|
|
530
|
+
const SOCKET_PATH = listenFlag !== -1 ? (process.argv[listenFlag + 1] || "/tmp/shroud-app.sock") : null;
|
|
531
|
+
|
|
532
|
+
if (SOCKET_PATH) {
|
|
533
|
+
// Remove stale socket file
|
|
534
|
+
try { unlinkSync(SOCKET_PATH); } catch {}
|
|
535
|
+
|
|
536
|
+
let socketClients = 0;
|
|
537
|
+
const socketServer = createNetServer((conn) => {
|
|
538
|
+
socketClients++;
|
|
539
|
+
const clientId = socketClients;
|
|
540
|
+
process.stderr.write(`[app-server] Socket client #${clientId} connected\n`);
|
|
541
|
+
|
|
542
|
+
// Send handshake to this client
|
|
543
|
+
conn.write(JSON.stringify(handshake) + "\n");
|
|
544
|
+
|
|
545
|
+
let connected = true;
|
|
546
|
+
const connRl = createInterface({ input: conn, crlfDelay: Infinity });
|
|
547
|
+
const connWrite = (s) => { if (connected) try { conn.write(s); } catch {} };
|
|
548
|
+
|
|
549
|
+
connRl.on("line", (line) => dispatch(line, connWrite));
|
|
550
|
+
connRl.on("error", () => {});
|
|
551
|
+
conn.on("error", () => { connected = false; });
|
|
552
|
+
conn.on("close", () => {
|
|
553
|
+
connected = false;
|
|
554
|
+
process.stderr.write(`[app-server] Socket client #${clientId} disconnected\n`);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
socketServer.listen(SOCKET_PATH, () => {
|
|
559
|
+
process.stderr.write(`[app-server] Listening on socket: ${SOCKET_PATH}\n`);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
socketServer.on("error", (err) => {
|
|
563
|
+
process.stderr.write(`[app-server] Socket error: ${err.message}\n`);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Also still serve stdin for backwards compat
|
|
568
|
+
process.stdout.write(JSON.stringify(handshake) + "\n");
|
|
569
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
570
|
+
rl.on("line", (line) => dispatch(line));
|
|
571
|
+
rl.on("close", () => {
|
|
572
|
+
try { unlinkSync(SOCKET_PATH); } catch {}
|
|
573
|
+
socketServer.close();
|
|
574
|
+
clearInterval(heartbeatInterval);
|
|
575
|
+
dumpStats();
|
|
576
|
+
process.exit(0);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// Cleanup socket on exit
|
|
580
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
581
|
+
process.on(sig, () => {
|
|
582
|
+
try { unlinkSync(SOCKET_PATH); } catch {}
|
|
583
|
+
socketServer.close();
|
|
584
|
+
clearInterval(heartbeatInterval);
|
|
585
|
+
dumpStats();
|
|
586
|
+
process.exit(0);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
// Default: stdin/stdout mode
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
process.stdout.write(JSON.stringify(handshake) + "\n");
|
|
595
|
+
|
|
596
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
597
|
+
rl.on("line", (line) => dispatch(line));
|
|
598
|
+
rl.on("close", () => {
|
|
599
|
+
clearInterval(heartbeatInterval);
|
|
600
|
+
dumpStats();
|
|
601
|
+
process.exit(0);
|
|
602
|
+
});
|
|
603
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ShroudConfig } from "./types.js";
|
|
|
11
11
|
* Priority: env vars > pluginConfig > defaults.
|
|
12
12
|
*/
|
|
13
13
|
export declare const STATS_FILE: string;
|
|
14
|
+
export declare const STORE_FILE: string;
|
|
14
15
|
export declare const IS_TEST: boolean;
|
|
15
16
|
export declare function resolveConfig(pluginConfig?: unknown): ShroudConfig;
|
|
16
17
|
/** Validation issue severity. */
|
package/dist/config.js
CHANGED
|
@@ -11,6 +11,8 @@ import { randomBytes } from "node:crypto";
|
|
|
11
11
|
* Priority: env vars > pluginConfig > defaults.
|
|
12
12
|
*/
|
|
13
13
|
export const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
|
|
14
|
+
export const STORE_FILE = process.env.SHROUD_STORE_FILE
|
|
15
|
+
|| ((process.env.HOME || "/root") + "/.openclaw/shroud-store.json");
|
|
14
16
|
export const IS_TEST = process.env.NODE_ENV === "test";
|
|
15
17
|
export function resolveConfig(pluginConfig) {
|
|
16
18
|
const raw = pluginConfig != null && typeof pluginConfig === "object"
|
package/dist/hooks.js
CHANGED
|
@@ -21,14 +21,63 @@
|
|
|
21
21
|
* 8. globalThis.fetch intercept -- obfuscates requests, deobfuscates responses
|
|
22
22
|
*/
|
|
23
23
|
import { createHash, randomBytes } from "node:crypto";
|
|
24
|
-
import { writeFileSync } from "node:fs";
|
|
24
|
+
import { writeFileSync, readFileSync, mkdirSync, statSync } from "node:fs";
|
|
25
25
|
import { BUILTIN_PATTERNS } from "./detectors/regex.js";
|
|
26
|
-
import { STATS_FILE, IS_TEST } from "./config.js";
|
|
26
|
+
import { STATS_FILE, STORE_FILE, IS_TEST } from "./config.js";
|
|
27
27
|
import { DnsCache } from "./dns-cache.js";
|
|
28
28
|
import { FieldScopeResolver } from "./field-scope.js";
|
|
29
|
+
import { dirname } from "node:path";
|
|
29
30
|
function getSharedObfuscator(fallback) {
|
|
30
31
|
return globalThis.__shroudObfuscator || fallback;
|
|
31
32
|
}
|
|
33
|
+
const storeSyncMtime = new WeakMap();
|
|
34
|
+
const SHROUD_REPLY_DISPATCHER_PATCH_MARK = Symbol.for("shroud.replyDispatcherPatched");
|
|
35
|
+
function shouldUseDiskStoreSync() {
|
|
36
|
+
return !IS_TEST || globalThis.__shroudEnableDiskStoreSync === true;
|
|
37
|
+
}
|
|
38
|
+
function persistStore(ob) {
|
|
39
|
+
try {
|
|
40
|
+
if (ob.getStats().storeMappings === 0)
|
|
41
|
+
return;
|
|
42
|
+
const data = ob.exportStore();
|
|
43
|
+
mkdirSync(dirname(STORE_FILE), { recursive: true });
|
|
44
|
+
writeFileSync(STORE_FILE, JSON.stringify(data) + "\n");
|
|
45
|
+
try {
|
|
46
|
+
storeSyncMtime.set(ob, statSync(STORE_FILE).mtimeMs);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
storeSyncMtime.delete(ob);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// best-effort
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function restoreStore(ob, logger, options) {
|
|
57
|
+
try {
|
|
58
|
+
if (!shouldUseDiskStoreSync())
|
|
59
|
+
return 0;
|
|
60
|
+
const stat = statSync(STORE_FILE);
|
|
61
|
+
const lastSync = storeSyncMtime.get(ob);
|
|
62
|
+
if (!options?.force && lastSync === stat.mtimeMs)
|
|
63
|
+
return 0;
|
|
64
|
+
const raw = readFileSync(STORE_FILE, "utf-8");
|
|
65
|
+
const data = JSON.parse(raw);
|
|
66
|
+
if (!data || !Array.isArray(data.mappings)) {
|
|
67
|
+
storeSyncMtime.set(ob, stat.mtimeMs);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
const imported = ob.importStore(data);
|
|
71
|
+
storeSyncMtime.set(ob, stat.mtimeMs);
|
|
72
|
+
if (imported > 0 && logger) {
|
|
73
|
+
logger.info(`[shroud] Restored ${imported} mappings from disk (${STORE_FILE})`);
|
|
74
|
+
}
|
|
75
|
+
return imported;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
32
81
|
function dumpStatsFile(fallback) {
|
|
33
82
|
try {
|
|
34
83
|
const ob = getSharedObfuscator(fallback);
|
|
@@ -42,6 +91,47 @@ function dumpStatsFile(fallback) {
|
|
|
42
91
|
// best-effort
|
|
43
92
|
}
|
|
44
93
|
}
|
|
94
|
+
function deobfuscateVisibleReplyPayload(value, deobfuscate) {
|
|
95
|
+
if (typeof value === "string")
|
|
96
|
+
return deobfuscate(value);
|
|
97
|
+
if (Array.isArray(value))
|
|
98
|
+
return value.map((item) => deobfuscateVisibleReplyPayload(item, deobfuscate));
|
|
99
|
+
if (!value || typeof value !== "object")
|
|
100
|
+
return value;
|
|
101
|
+
const proto = Object.getPrototypeOf(value);
|
|
102
|
+
if (proto !== Object.prototype && proto !== null)
|
|
103
|
+
return value;
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
106
|
+
out[key] = deobfuscateVisibleReplyPayload(entry, deobfuscate);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function wrapReplyDispatcherMethod(method, deobfuscate) {
|
|
111
|
+
if (typeof method !== "function")
|
|
112
|
+
return method;
|
|
113
|
+
if (method[SHROUD_REPLY_DISPATCHER_PATCH_MARK])
|
|
114
|
+
return method;
|
|
115
|
+
const wrapped = function shroudPatchedReplyDispatcher(payload, ...args) {
|
|
116
|
+
return method.call(this, deobfuscateVisibleReplyPayload(payload, deobfuscate), ...args);
|
|
117
|
+
};
|
|
118
|
+
Object.defineProperty(wrapped, SHROUD_REPLY_DISPATCHER_PATCH_MARK, { value: true });
|
|
119
|
+
return wrapped;
|
|
120
|
+
}
|
|
121
|
+
function patchReplyDispatcher(dispatcher, deobfuscate) {
|
|
122
|
+
if (!dispatcher || typeof dispatcher !== "object")
|
|
123
|
+
return false;
|
|
124
|
+
let patched = false;
|
|
125
|
+
for (const key of ["sendBlockReply", "sendFinalReply", "sendToolResult"]) {
|
|
126
|
+
const method = dispatcher[key];
|
|
127
|
+
const wrapped = wrapReplyDispatcherMethod(method, deobfuscate);
|
|
128
|
+
if (wrapped !== method) {
|
|
129
|
+
dispatcher[key] = wrapped;
|
|
130
|
+
patched = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return patched;
|
|
134
|
+
}
|
|
45
135
|
// ---------------------------------------------------------------------------
|
|
46
136
|
// Hashing utilities (audit proof hashes)
|
|
47
137
|
// ---------------------------------------------------------------------------
|
|
@@ -223,6 +313,9 @@ export function registerHooks(api, obfuscator) {
|
|
|
223
313
|
else {
|
|
224
314
|
g.__shroudObfuscator = obfuscator;
|
|
225
315
|
}
|
|
316
|
+
if (isFirstLoad) {
|
|
317
|
+
restoreStore(obfuscator, api.logger, { force: true });
|
|
318
|
+
}
|
|
226
319
|
// DNS cache for public URL detection — shared across plugin instances
|
|
227
320
|
if (!g.__shroudDnsCache) {
|
|
228
321
|
const cache = new DnsCache();
|
|
@@ -257,6 +350,9 @@ export function registerHooks(api, obfuscator) {
|
|
|
257
350
|
// OpenClaw loads the plugin multiple times; only one instance has the mappings.
|
|
258
351
|
const ob = () => getSharedObfuscator(obfuscator);
|
|
259
352
|
const sessionScope = globalThis;
|
|
353
|
+
const syncStore = (force = false, logger) => {
|
|
354
|
+
restoreStore(ob(), logger, { force });
|
|
355
|
+
};
|
|
260
356
|
const config = ob().config;
|
|
261
357
|
let _fieldScopeResolver;
|
|
262
358
|
let _fieldScopeConfigRef;
|
|
@@ -357,62 +453,80 @@ export function registerHooks(api, obfuscator) {
|
|
|
357
453
|
totalEntities += result.entities.length;
|
|
358
454
|
}
|
|
359
455
|
}
|
|
360
|
-
// Obfuscate ALL messages
|
|
361
|
-
//
|
|
362
|
-
// This is critical when the LLM SDK (e.g. OpenAI v6) captures fetch at
|
|
363
|
-
// construction time, bypassing Shroud's globalThis.fetch intercept.
|
|
364
|
-
// The fetch intercept is still the primary path for SDKs that use
|
|
365
|
-
// globalThis.fetch (Anthropic) — double-obfuscation is safe because
|
|
366
|
-
// already-obfuscated text has no detectable PII entities.
|
|
456
|
+
// Obfuscate ALL messages via copies so delivery code never sees partially
|
|
457
|
+
// mutated objects that OpenClaw may still reference elsewhere.
|
|
367
458
|
if (Array.isArray(event?.messages)) {
|
|
368
|
-
for (
|
|
459
|
+
for (let i = 0; i < event.messages.length; i++) {
|
|
460
|
+
const msg = event.messages[i];
|
|
461
|
+
let msgCopy = null;
|
|
369
462
|
// String content (Anthropic/OpenAI)
|
|
370
463
|
if (typeof msg.content === "string") {
|
|
371
464
|
const cleaned = stripSlackLinksForHook(msg.content);
|
|
372
465
|
const result = ob().obfuscate(cleaned, undefined, _exemptCats);
|
|
373
466
|
totalEntities += result.entities.length;
|
|
374
467
|
if (result.entities.length > 0 || cleaned !== msg.content) {
|
|
375
|
-
msg
|
|
468
|
+
msgCopy = { ...msg, content: result.entities.length > 0 ? result.obfuscated : cleaned };
|
|
376
469
|
}
|
|
377
470
|
}
|
|
378
471
|
// Array content blocks
|
|
379
472
|
else if (Array.isArray(msg.content)) {
|
|
380
|
-
|
|
381
|
-
|
|
473
|
+
let blockChanged = false;
|
|
474
|
+
const newBlocks = msg.content.map((b) => {
|
|
475
|
+
if (!b || typeof b !== "object")
|
|
476
|
+
return b;
|
|
477
|
+
let newBlock = b;
|
|
478
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
382
479
|
const cleaned = stripSlackLinksForHook(b.text);
|
|
383
480
|
const result = ob().obfuscate(cleaned, undefined, _exemptCats);
|
|
384
481
|
totalEntities += result.entities.length;
|
|
385
482
|
if (result.entities.length > 0 || cleaned !== b.text) {
|
|
386
|
-
|
|
483
|
+
newBlock = { ...newBlock, text: result.entities.length > 0 ? result.obfuscated : cleaned };
|
|
484
|
+
blockChanged = true;
|
|
387
485
|
}
|
|
388
486
|
}
|
|
389
487
|
// tool_result blocks with string content
|
|
390
|
-
if (typeof b
|
|
488
|
+
if (typeof b.content === "string") {
|
|
391
489
|
const cleaned = stripSlackLinksForHook(b.content);
|
|
392
490
|
const result = ob().obfuscate(cleaned, undefined, _exemptCats);
|
|
393
491
|
totalEntities += result.entities.length;
|
|
394
492
|
if (result.entities.length > 0 || cleaned !== b.content) {
|
|
395
|
-
|
|
493
|
+
newBlock = { ...newBlock, content: result.entities.length > 0 ? result.obfuscated : cleaned };
|
|
494
|
+
blockChanged = true;
|
|
396
495
|
}
|
|
397
496
|
}
|
|
497
|
+
return newBlock;
|
|
498
|
+
});
|
|
499
|
+
if (blockChanged) {
|
|
500
|
+
msgCopy = { ...msg, content: newBlocks };
|
|
398
501
|
}
|
|
399
502
|
}
|
|
400
503
|
// OpenAI tool_calls in assistant messages
|
|
401
504
|
if (Array.isArray(msg.tool_calls)) {
|
|
402
|
-
|
|
505
|
+
let tcChanged = false;
|
|
506
|
+
const newToolCalls = msg.tool_calls.map((tc) => {
|
|
403
507
|
if (typeof tc.function?.arguments === "string") {
|
|
404
508
|
const result = ob().obfuscate(tc.function.arguments, undefined, _exemptCats);
|
|
405
509
|
totalEntities += result.entities.length;
|
|
406
|
-
if (result.entities.length > 0)
|
|
407
|
-
|
|
510
|
+
if (result.entities.length > 0) {
|
|
511
|
+
tcChanged = true;
|
|
512
|
+
return { ...tc, function: { ...tc.function, arguments: result.obfuscated } };
|
|
513
|
+
}
|
|
408
514
|
}
|
|
515
|
+
return tc;
|
|
516
|
+
});
|
|
517
|
+
if (tcChanged) {
|
|
518
|
+
msgCopy = msgCopy ? { ...msgCopy, tool_calls: newToolCalls } : { ...msg, tool_calls: newToolCalls };
|
|
409
519
|
}
|
|
410
520
|
}
|
|
521
|
+
if (msgCopy) {
|
|
522
|
+
event.messages[i] = msgCopy;
|
|
523
|
+
}
|
|
411
524
|
}
|
|
412
525
|
}
|
|
413
526
|
if (totalEntities === 0)
|
|
414
527
|
return;
|
|
415
528
|
dumpStatsFile(obfuscator);
|
|
529
|
+
persistStore(ob());
|
|
416
530
|
api.logger?.info(`[shroud] before_prompt_build: obfuscated ${totalEntities} entities (mappings synced)`);
|
|
417
531
|
return obfuscatedPrompt ? { systemPrompt: obfuscatedPrompt } : undefined;
|
|
418
532
|
});
|
|
@@ -434,41 +548,45 @@ export function registerHooks(api, obfuscator) {
|
|
|
434
548
|
const role = msg.role ?? "";
|
|
435
549
|
// --- Assistant messages: DEOBFUSCATE (fakes → real values) ---
|
|
436
550
|
if (role === "assistant") {
|
|
551
|
+
syncStore();
|
|
437
552
|
const _raw = typeof msg.content === "string" ? msg.content :
|
|
438
553
|
Array.isArray(msg.content) ? msg.content.map((b) => b?.text || "").join("") : "";
|
|
439
554
|
if (_raw.length < 500)
|
|
440
555
|
api.logger?.info(`[shroud][raw-assistant] ${_raw}`);
|
|
441
556
|
if (typeof msg.content === "string") {
|
|
442
557
|
const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(msg.content);
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
558
|
+
if (replacementCount > 0) {
|
|
559
|
+
api.logger?.info("[shroud] before_message_write: deobfuscated assistant message");
|
|
560
|
+
if (auditActive) {
|
|
561
|
+
try {
|
|
562
|
+
emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), replacementCount);
|
|
563
|
+
}
|
|
564
|
+
catch { }
|
|
449
565
|
}
|
|
450
|
-
|
|
566
|
+
dumpStatsFile(obfuscator);
|
|
451
567
|
}
|
|
452
|
-
dumpStatsFile(obfuscator);
|
|
453
568
|
return { message: { ...msg, content: deobfuscated } };
|
|
454
569
|
}
|
|
455
570
|
if (Array.isArray(msg.content)) {
|
|
456
571
|
let changed = false;
|
|
572
|
+
let deobCount = 0;
|
|
457
573
|
const newContent = msg.content.map((block) => {
|
|
458
574
|
if (block && typeof block === "object") {
|
|
459
575
|
// Handle blocks with .text (text content blocks)
|
|
460
576
|
if (typeof block.text === "string") {
|
|
461
|
-
const deobfuscated = ob().
|
|
577
|
+
const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(block.text);
|
|
462
578
|
if (deobfuscated !== block.text) {
|
|
463
579
|
changed = true;
|
|
580
|
+
deobCount += replacementCount;
|
|
464
581
|
return { ...block, text: deobfuscated };
|
|
465
582
|
}
|
|
466
583
|
}
|
|
467
584
|
// Handle blocks with .content as string (tool_result blocks)
|
|
468
585
|
if (typeof block.content === "string") {
|
|
469
|
-
const deobfuscated = ob().
|
|
586
|
+
const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(block.content);
|
|
470
587
|
if (deobfuscated !== block.content) {
|
|
471
588
|
changed = true;
|
|
589
|
+
deobCount += replacementCount;
|
|
472
590
|
return { ...block, content: deobfuscated };
|
|
473
591
|
}
|
|
474
592
|
}
|
|
@@ -477,9 +595,10 @@ export function registerHooks(api, obfuscator) {
|
|
|
477
595
|
let innerChanged = false;
|
|
478
596
|
const newInner = block.content.map((inner) => {
|
|
479
597
|
if (inner && typeof inner === "object" && typeof inner.text === "string") {
|
|
480
|
-
const deobfuscated = ob().
|
|
598
|
+
const { text: deobfuscated, replacementCount } = ob().deobfuscateWithStats(inner.text);
|
|
481
599
|
if (deobfuscated !== inner.text) {
|
|
482
600
|
innerChanged = true;
|
|
601
|
+
deobCount += replacementCount;
|
|
483
602
|
return { ...inner, text: deobfuscated };
|
|
484
603
|
}
|
|
485
604
|
}
|
|
@@ -493,13 +612,13 @@ export function registerHooks(api, obfuscator) {
|
|
|
493
612
|
}
|
|
494
613
|
return block;
|
|
495
614
|
});
|
|
496
|
-
if (
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
return { message: { ...msg, content: newContent } };
|
|
615
|
+
if (deobCount > 0) {
|
|
616
|
+
api.logger?.info("[shroud] before_message_write: deobfuscated assistant blocks");
|
|
617
|
+
dumpStatsFile(obfuscator);
|
|
618
|
+
}
|
|
619
|
+
return { message: { ...msg, content: changed ? newContent : msg.content.map((b) => ({ ...b })) } };
|
|
501
620
|
}
|
|
502
|
-
return;
|
|
621
|
+
return { message: { ...msg } };
|
|
503
622
|
}
|
|
504
623
|
// --- Non-assistant messages: OBFUSCATE (real values → fakes) ---
|
|
505
624
|
if (typeof msg.content === "string") {
|
|
@@ -507,6 +626,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
507
626
|
if (result.entities.length === 0)
|
|
508
627
|
return;
|
|
509
628
|
dumpStatsFile(obfuscator);
|
|
629
|
+
persistStore(ob());
|
|
510
630
|
if (auditActive) {
|
|
511
631
|
try {
|
|
512
632
|
emitObfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), result, msg.content, result.obfuscated);
|
|
@@ -566,6 +686,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
566
686
|
if (!changed)
|
|
567
687
|
return;
|
|
568
688
|
dumpStatsFile(obfuscator);
|
|
689
|
+
persistStore(ob());
|
|
569
690
|
if (auditActive) {
|
|
570
691
|
for (const result of allResults) {
|
|
571
692
|
try {
|
|
@@ -582,6 +703,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
582
703
|
// 3. before_tool_call (async): deobfuscate tool params + track depth
|
|
583
704
|
// -----------------------------------------------------------------------
|
|
584
705
|
api.on("before_tool_call", async (event) => {
|
|
706
|
+
syncStore();
|
|
585
707
|
if (!event?.params || typeof event.params !== "object")
|
|
586
708
|
return;
|
|
587
709
|
// Block the message tool for send actions. The gateway auto-delivers
|
|
@@ -632,6 +754,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
632
754
|
return result.obfuscated;
|
|
633
755
|
}, shouldScan);
|
|
634
756
|
dumpStatsFile(obfuscator);
|
|
757
|
+
persistStore(ob());
|
|
635
758
|
return { message: obfuscated };
|
|
636
759
|
});
|
|
637
760
|
// -----------------------------------------------------------------------
|
|
@@ -640,6 +763,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
640
763
|
// sends blocks in the first call, text in the second).
|
|
641
764
|
// -----------------------------------------------------------------------
|
|
642
765
|
api.on("message_sending", async (event) => {
|
|
766
|
+
syncStore();
|
|
643
767
|
if (!event?.content)
|
|
644
768
|
return;
|
|
645
769
|
// String content — direct deobfuscation.
|
|
@@ -685,6 +809,10 @@ export function registerHooks(api, obfuscator) {
|
|
|
685
809
|
return { content: newContent };
|
|
686
810
|
}
|
|
687
811
|
});
|
|
812
|
+
api.on("reply_dispatch", (_event, ctx) => {
|
|
813
|
+
syncStore();
|
|
814
|
+
patchReplyDispatcher(ctx?.dispatcher, (text) => ob().deobfuscate(text));
|
|
815
|
+
});
|
|
688
816
|
// -----------------------------------------------------------------------
|
|
689
817
|
// Tool: shroud-stats — rulebase view with hit counters
|
|
690
818
|
// -----------------------------------------------------------------------
|
|
@@ -741,26 +869,62 @@ export function registerHooks(api, obfuscator) {
|
|
|
741
869
|
// overflow chunks. Partial fakes may briefly appear during streaming
|
|
742
870
|
// but the final message will be correct.
|
|
743
871
|
const SHROUD_BUF = Symbol("shroudStreamBuf");
|
|
872
|
+
const deobfuscateStreamTarget = (target) => {
|
|
873
|
+
if (!target || typeof target !== "object")
|
|
874
|
+
return;
|
|
875
|
+
if (typeof target.content === "string") {
|
|
876
|
+
const { text: deob } = ob().deobfuscateWithStats(target.content);
|
|
877
|
+
target.content = deob;
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!Array.isArray(target.content))
|
|
881
|
+
return;
|
|
882
|
+
for (const block of target.content) {
|
|
883
|
+
if (!block || typeof block !== "object")
|
|
884
|
+
continue;
|
|
885
|
+
if (typeof block.text === "string") {
|
|
886
|
+
const { text: deob } = ob().deobfuscateWithStats(block.text);
|
|
887
|
+
block.text = deob;
|
|
888
|
+
}
|
|
889
|
+
if (typeof block.content === "string") {
|
|
890
|
+
const { text: deob } = ob().deobfuscateWithStats(block.content);
|
|
891
|
+
block.content = deob;
|
|
892
|
+
}
|
|
893
|
+
if (Array.isArray(block.content)) {
|
|
894
|
+
for (const inner of block.content) {
|
|
895
|
+
if (!inner || typeof inner !== "object" || typeof inner.text !== "string")
|
|
896
|
+
continue;
|
|
897
|
+
const { text: deob } = ob().deobfuscateWithStats(inner.text);
|
|
898
|
+
inner.text = deob;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
744
903
|
globalThis.__shroudStreamDeobfuscate = (stream, event) => {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (isTextDelta || isMessageUpdateTextDelta) {
|
|
753
|
-
// Pass through text_delta events unchanged.
|
|
904
|
+
const eventTextType = event.type === "message_update"
|
|
905
|
+
? event.assistantMessageEvent?.type
|
|
906
|
+
: event.type;
|
|
907
|
+
const isTextPhaseUpdate = eventTextType === "text_start" ||
|
|
908
|
+
eventTextType === "text_delta" ||
|
|
909
|
+
eventTextType === "text_end";
|
|
910
|
+
if (isTextPhaseUpdate) {
|
|
754
911
|
let buf = stream[SHROUD_BUF];
|
|
755
912
|
if (!buf) {
|
|
756
913
|
buf = { raw: "", deobCount: 0 };
|
|
757
914
|
stream[SHROUD_BUF] = buf;
|
|
758
915
|
}
|
|
759
|
-
const src =
|
|
916
|
+
const src = event.type === "message_update" ? event.assistantMessageEvent : event;
|
|
760
917
|
const chunk = typeof src.delta === "string" ? src.delta
|
|
761
918
|
: typeof src.text === "string" ? src.text : "";
|
|
762
919
|
if (chunk)
|
|
763
920
|
buf.raw += chunk;
|
|
921
|
+
const targets = [
|
|
922
|
+
event.partial, event.message,
|
|
923
|
+
event.assistantMessageEvent?.partial,
|
|
924
|
+
event.assistantMessageEvent?.message,
|
|
925
|
+
];
|
|
926
|
+
for (const target of targets)
|
|
927
|
+
deobfuscateStreamTarget(target);
|
|
764
928
|
return event;
|
|
765
929
|
}
|
|
766
930
|
// On message_end/done: deobfuscate content blocks in the final message.
|
|
@@ -772,23 +936,14 @@ export function registerHooks(api, obfuscator) {
|
|
|
772
936
|
event.type === "error" || event.type === "agent_end" ||
|
|
773
937
|
(event.type === "message_update" && (event.assistantMessageEvent?.type === "text_end"));
|
|
774
938
|
if (isEnd) {
|
|
939
|
+
syncStore();
|
|
775
940
|
const targets = [
|
|
776
941
|
event.message, event.partial,
|
|
777
942
|
event.assistantMessageEvent?.partial,
|
|
778
943
|
event.assistantMessageEvent?.message,
|
|
779
944
|
];
|
|
780
|
-
for (const target of targets)
|
|
781
|
-
|
|
782
|
-
for (const block of target.content) {
|
|
783
|
-
if (block?.type === "text" && typeof block.text === "string") {
|
|
784
|
-
const deob = ob().deobfuscate(block.text);
|
|
785
|
-
if (deob !== block.text) {
|
|
786
|
-
block.text = deob;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
}
|
|
945
|
+
for (const target of targets)
|
|
946
|
+
deobfuscateStreamTarget(target);
|
|
792
947
|
dumpStatsFile(obfuscator);
|
|
793
948
|
delete stream[SHROUD_BUF];
|
|
794
949
|
}
|
|
@@ -801,6 +956,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
801
956
|
// globalThis.__shroudDeobfuscate(text) directly.
|
|
802
957
|
// -----------------------------------------------------------------------
|
|
803
958
|
globalThis.__shroudDeobfuscate = (text) => {
|
|
959
|
+
syncStore();
|
|
804
960
|
if (typeof text !== "string")
|
|
805
961
|
return text;
|
|
806
962
|
return ob().deobfuscate(text);
|
|
@@ -915,6 +1071,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
915
1071
|
: null;
|
|
916
1072
|
if (!messageArray) {
|
|
917
1073
|
if (modified) {
|
|
1074
|
+
persistStore(ob());
|
|
918
1075
|
const newBody = JSON.stringify(body);
|
|
919
1076
|
return originalFetch.call(globalThis, input, { ...init, body: newBody });
|
|
920
1077
|
}
|
|
@@ -1039,6 +1196,7 @@ export function registerHooks(api, obfuscator) {
|
|
|
1039
1196
|
}
|
|
1040
1197
|
}
|
|
1041
1198
|
if (modified) {
|
|
1199
|
+
persistStore(ob());
|
|
1042
1200
|
const newBody = JSON.stringify(body);
|
|
1043
1201
|
const newInit = { ...init, body: newBody };
|
|
1044
1202
|
// Update content-length if present
|
package/dist/obfuscator.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* tool_result_persist hook which is sync-only.
|
|
6
6
|
*/
|
|
7
7
|
import { DetectedEntity, ObfuscationResult, ShroudConfig } from "./types.js";
|
|
8
|
+
import { SerializedStore } from "./store.js";
|
|
8
9
|
import { BaseDetector } from "./detectors/base.js";
|
|
9
10
|
export declare class Obfuscator {
|
|
10
11
|
config: ShroudConfig;
|
|
@@ -95,6 +96,10 @@ export declare class Obfuscator {
|
|
|
95
96
|
maxFakeLength(): number;
|
|
96
97
|
/** Return stats from audit logger and store. */
|
|
97
98
|
getStats(): object;
|
|
99
|
+
/** Export the mapping store for persistence across restarts. */
|
|
100
|
+
exportStore(): SerializedStore;
|
|
101
|
+
/** Import mappings from a persisted store. Returns count of new mappings added. */
|
|
102
|
+
importStore(data: SerializedStore): number;
|
|
98
103
|
}
|
|
99
104
|
/** Remove overlapping entities, keeping higher confidence ones. */
|
|
100
105
|
export declare function resolveOverlaps(entities: DetectedEntity[]): DetectedEntity[];
|
package/dist/obfuscator.js
CHANGED
|
@@ -852,6 +852,16 @@ export class Obfuscator {
|
|
|
852
852
|
};
|
|
853
853
|
return stats;
|
|
854
854
|
}
|
|
855
|
+
/** Export the mapping store for persistence across restarts. */
|
|
856
|
+
exportStore() {
|
|
857
|
+
return this._store.export(this._mapping.salt);
|
|
858
|
+
}
|
|
859
|
+
/** Import mappings from a persisted store. Returns count of new mappings added. */
|
|
860
|
+
importStore(data) {
|
|
861
|
+
const before = this._store.size();
|
|
862
|
+
this._store.import(data);
|
|
863
|
+
return this._store.size() - before;
|
|
864
|
+
}
|
|
855
865
|
}
|
|
856
866
|
/** Remove overlapping entities, keeping higher confidence ones. */
|
|
857
867
|
export function resolveOverlaps(entities) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shroud-privacy",
|
|
3
3
|
"name": "Shroud",
|
|
4
|
-
"version": "2.5.
|
|
4
|
+
"version": "2.5.5",
|
|
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.5.
|
|
3
|
+
"version": "2.5.5",
|
|
4
4
|
"description": "Privacy and infrastructure protection for AI agents — detects sensitive data (PII, network topology, credentials, OT/SCADA) and replaces with deterministic fakes before anything reaches the LLM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|