shroud-privacy 2.0.3 → 2.0.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 CHANGED
@@ -16,11 +16,10 @@ Privacy obfuscation plugin for [OpenClaw](https://openclaw.ai). Detects sensitiv
16
16
  | Hook | Direction | What happens |
17
17
  |------|-----------|-------------|
18
18
  | `before_prompt_build` | User → LLM | Obfuscate user prompt, prepend privacy context |
19
- | `before_llm_send` | UserLLM | Obfuscate all messages + install `transformResponse` |
20
- | `transformResponse` | LLM → User | Deobfuscate LLM output (auto-reply, WhatsApp, etc.) |
19
+ | `before_message_write` | AnyHistory | Obfuscate every message written to the session transcript |
21
20
  | `before_tool_call` | LLM → Tool | Deobfuscate tool parameters + track tool chain depth |
22
21
  | `tool_result_persist` | Tool → History | Obfuscate tool results before storing |
23
- | `message_sending` | Agent → User | Deobfuscate outbound messages (fallback path) |
22
+ | `message_sending` | Agent → User | Deobfuscate outbound messages (WhatsApp, auto-reply, etc.) |
24
23
 
25
24
  ## Install
26
25
 
@@ -232,13 +231,13 @@ tail -f ~/.openclaw/logs/openclaw.log \
232
231
  You should see:
233
232
 
234
233
  ```
235
- [shroud][audit] OBFUSCATE req=dc5f9199cfb0d835 | entities=4 | touched=2/5 | blocks=2 | chars=1200->1218 (delta=+18) | modified=YES | byCat=email:1,ip_address:2,hostname:1
234
+ [shroud][audit] OBFUSCATE req=dc5f9199cfb0d835 | entities=4 | chars=1200->1218 (delta=+18) | modified=YES | byCat=email:1,ip_address:2,hostname:1 | byRule=regex:email:1,regex:ipv4:2,regex:hostname:1
236
235
  ```
237
236
 
238
237
  With proof hashes enabled:
239
238
 
240
239
  ```
241
- [shroud][audit] OBFUSCATE req=a3f1bc9e02d4e7f1 | entities=4 | touched=2/5 | blocks=2 | chars=1200->1218 (delta=+18) | modified=YES | byCat=email:1,ip_address:2,hostname:1 | proof_in=8a3c1f0e2b4d proof_out=f7d2a1c9e084 | fakes=[jsmith@corp.net|100.64.0.12|SW-LAB-01]
240
+ [shroud][audit] OBFUSCATE req=a3f1bc9e02d4e7f1 | entities=4 | chars=1200->1218 (delta=+18) | modified=YES | byCat=email:1,ip_address:2,hostname:1 | byRule=regex:email:1,regex:ipv4:2,regex:hostname:1 | proof_in=8a3c1f0e2b4d proof_out=f7d2a1c9e084 | fakes=[jsmith@corp.net|100.64.0.12|SW-LAB-01]
242
241
  ```
243
242
 
244
243
  ### Audit field reference
@@ -247,16 +246,14 @@ With proof hashes enabled:
247
246
  |-------|---------|
248
247
  | `req` | Random request ID (hex) — correlates obfuscate ↔ deobfuscate |
249
248
  | `entities` | Total entities detected and replaced |
250
- | `touched` | Messages with replacements / total messages |
251
- | `blocks` | Content blocks with replacements |
252
249
  | `chars` | Input → output character count |
253
250
  | `delta` | Character count change (fakes may be longer/shorter) |
254
251
  | `modified` | `YES` if text was changed, `NO` if pass-through |
255
252
  | `byCat` | Entity counts by category |
256
253
  | `byRule` | Entity counts by detector rule |
257
- | `proof_in` | Truncated salted SHA-256 of input text |
258
- | `proof_out` | Truncated salted SHA-256 of output text |
259
- | `fakes` | Sample of fake replacement values (never real values) |
254
+ | `proof_in` | Truncated salted SHA-256 of input text (opt-in) |
255
+ | `proof_out` | Truncated salted SHA-256 of output text (opt-in) |
256
+ | `fakes` | Sample of fake replacement values (opt-in, never real values) |
260
257
 
261
258
  ### Note on log duplication
262
259
 
@@ -266,7 +263,7 @@ OpenClaw logs each plugin message twice (once under the plugin subsystem logger,
266
263
 
267
264
  ```bash
268
265
  npm install
269
- npm test # run vitest (210 tests)
266
+ npm test # run vitest (203 tests)
270
267
  npm run build # compile TypeScript
271
268
  npm run lint # type-check without emitting
272
269
  ```
package/dist/hooks.d.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
4
  * Registers 5 hooks:
5
- * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
- * 2. before_llm_send (async) -- obfuscate LLM input messages + return transformResponse for deobfuscation
7
- * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
8
- * 4. tool_result_persist (SYNC) -- obfuscate tool result message
9
- * 5. message_sending (async) -- deobfuscate outbound message content (fallback)
5
+ * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
+ * 2. before_message_write (SYNC) -- obfuscate every message written to the session transcript
7
+ * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
8
+ * 4. tool_result_persist (SYNC) -- obfuscate tool result message
9
+ * 5. message_sending (async) -- deobfuscate outbound message content
10
10
  */
11
11
  import { Obfuscator } from "./obfuscator.js";
12
12
  export interface PluginApi {
package/dist/hooks.js CHANGED
@@ -2,11 +2,11 @@
2
2
  * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
3
  *
4
4
  * Registers 5 hooks:
5
- * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
- * 2. before_llm_send (async) -- obfuscate LLM input messages + return transformResponse for deobfuscation
7
- * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
8
- * 4. tool_result_persist (SYNC) -- obfuscate tool result message
9
- * 5. message_sending (async) -- deobfuscate outbound message content (fallback)
5
+ * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
+ * 2. before_message_write (SYNC) -- obfuscate every message written to the session transcript
7
+ * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
8
+ * 4. tool_result_persist (SYNC) -- obfuscate tool result message
9
+ * 5. message_sending (async) -- deobfuscate outbound message content
10
10
  */
11
11
  import { createHash, randomBytes } from "node:crypto";
12
12
  import { writeFileSync } from "node:fs";
@@ -37,205 +37,77 @@ function truncateHash(hash, n) {
37
37
  return hash.slice(0, n);
38
38
  }
39
39
  // ---------------------------------------------------------------------------
40
- // String walking helper
40
+ // Audit log emitter — per-message (used by before_message_write)
41
41
  // ---------------------------------------------------------------------------
42
- /**
43
- * Deep-walk an unknown message structure and obfuscate/deobfuscate all
44
- * string leaves. OpenClaw message payloads can be a plain string, an
45
- * array of content blocks, or a nested object — this handles all three.
46
- */
47
- function walkStrings(value, fn) {
48
- if (typeof value === "string")
49
- return fn(value);
50
- if (Array.isArray(value)) {
51
- return value.map((item) => {
52
- if (typeof item === "object" && item !== null && "text" in item && typeof item.text === "string") {
53
- return { ...item, text: fn(item.text) };
54
- }
55
- return walkStrings(item, fn);
56
- });
57
- }
58
- if (typeof value === "object" && value !== null) {
59
- const out = {};
60
- for (const [k, v] of Object.entries(value)) {
61
- if (typeof v === "string") {
62
- out[k] = fn(v);
63
- }
64
- else {
65
- out[k] = v; // don't recurse arbitrarily into unknown objects
66
- }
67
- }
68
- return out;
42
+ function emitObfuscationAudit(logger, config, requestId, result, inputText, outputText) {
43
+ const byCat = {};
44
+ const byRule = {};
45
+ for (const e of result.entities) {
46
+ byCat[e.category] = (byCat[e.category] || 0) + 1;
47
+ byRule[e.detector] = (byRule[e.detector] || 0) + 1;
69
48
  }
70
- return value;
71
- }
72
- // ---------------------------------------------------------------------------
73
- // obfuscateMessages (original, no stats)
74
- // ---------------------------------------------------------------------------
75
- function obfuscateMessages(messages, obfuscator) {
76
- return messages.map((msg) => {
77
- if (!msg || typeof msg !== "object")
78
- return msg;
79
- if (typeof msg.content === "string") {
80
- const result = obfuscator.obfuscate(msg.content);
81
- if (result.entities.length === 0)
82
- return msg;
83
- return { ...msg, content: result.obfuscated };
84
- }
85
- if (Array.isArray(msg.content)) {
86
- const newContent = msg.content.map((block) => {
87
- if (block && typeof block === "object" && typeof block.text === "string") {
88
- const result = obfuscator.obfuscate(block.text);
89
- if (result.entities.length === 0)
90
- return block;
91
- return { ...block, text: result.obfuscated };
92
- }
93
- return block;
94
- });
95
- return { ...msg, content: newContent };
96
- }
97
- return msg;
98
- });
99
- }
100
- // ---------------------------------------------------------------------------
101
- // obfuscateMessagesWithStats (audit-aware)
102
- // ---------------------------------------------------------------------------
103
- function obfuscateMessagesWithStats(messages, obfuscator, config) {
104
- const stats = {
105
- totalEntities: 0,
106
- byCategory: {},
107
- byRule: {},
108
- messagesTouched: 0,
109
- blocksTouched: 0,
110
- inputChars: 0,
111
- outputChars: 0,
112
- fakesSample: [],
113
- };
114
- const maxFakes = config.auditMaxFakesSample;
115
- const obfuscatedMessages = messages.map((msg) => {
116
- if (!msg || typeof msg !== "object")
117
- return msg;
118
- if (typeof msg.content === "string") {
119
- const result = obfuscator.obfuscate(msg.content);
120
- stats.inputChars += msg.content.length;
121
- stats.outputChars += result.obfuscated.length;
122
- if (result.entities.length > 0) {
123
- stats.messagesTouched++;
124
- stats.blocksTouched++;
125
- accumulateStats(stats, result, maxFakes);
126
- }
127
- if (result.entities.length === 0)
128
- return msg;
129
- return { ...msg, content: result.obfuscated };
130
- }
131
- if (Array.isArray(msg.content)) {
132
- let msgTouched = false;
133
- const newContent = msg.content.map((block) => {
134
- if (block && typeof block === "object" && typeof block.text === "string") {
135
- const result = obfuscator.obfuscate(block.text);
136
- stats.inputChars += block.text.length;
137
- stats.outputChars += result.obfuscated.length;
138
- if (result.entities.length > 0) {
139
- msgTouched = true;
140
- stats.blocksTouched++;
141
- accumulateStats(stats, result, maxFakes);
142
- }
143
- if (result.entities.length === 0)
144
- return block;
145
- return { ...block, text: result.obfuscated };
146
- }
147
- return block;
148
- });
149
- if (msgTouched)
150
- stats.messagesTouched++;
151
- return { ...msg, content: newContent };
152
- }
153
- return msg;
154
- });
155
- return { messages: obfuscatedMessages, stats };
156
- }
157
- function accumulateStats(stats, result, maxFakes) {
158
- for (const entity of result.entities) {
159
- stats.totalEntities++;
160
- stats.byCategory[entity.category] = (stats.byCategory[entity.category] || 0) + 1;
161
- stats.byRule[entity.detector] = (stats.byRule[entity.detector] || 0) + 1;
49
+ const modified = result.entities.length > 0;
50
+ const charDelta = outputText.length - inputText.length;
51
+ let proofIn = "";
52
+ let proofOut = "";
53
+ if (config.auditIncludeProofHashes) {
54
+ proofIn = truncateHash(safeHash(inputText, config.auditHashSalt), config.auditHashTruncate);
55
+ proofOut = truncateHash(safeHash(outputText, config.auditHashSalt), config.auditHashTruncate);
162
56
  }
163
- // Collect fake values only (never real values)
164
- if (maxFakes > 0 && stats.fakesSample.length < maxFakes) {
57
+ const fakesSample = [];
58
+ if (config.auditMaxFakesSample > 0) {
165
59
  for (const fake of Object.values(result.mappingsUsed)) {
166
- if (stats.fakesSample.length >= maxFakes)
60
+ if (fakesSample.length >= config.auditMaxFakesSample)
167
61
  break;
168
- stats.fakesSample.push(fake);
62
+ fakesSample.push(fake);
169
63
  }
170
64
  }
171
- }
172
- // ---------------------------------------------------------------------------
173
- // Audit log emitters
174
- // ---------------------------------------------------------------------------
175
- function emitAuditLog(logger, config, requestId, stats, totalMessages, concatenatedOriginal, concatenatedObfuscated) {
176
- const byCatStr = Object.entries(stats.byCategory)
177
- .map(([k, v]) => `${k}:${v}`)
178
- .join(",");
179
- const byRuleStr = Object.entries(stats.byRule)
180
- .map(([k, v]) => `${k}:${v}`)
181
- .join(",");
182
- const modified = stats.inputChars !== stats.outputChars || stats.totalEntities > 0;
183
- const charDelta = stats.outputChars - stats.inputChars;
184
- // Always compute proof hashes when proofs enabled
185
- let proofHashIn = "";
186
- let proofHashOut = "";
187
- if (config.auditIncludeProofHashes) {
188
- proofHashIn = truncateHash(safeHash(concatenatedOriginal, config.auditHashSalt), config.auditHashTruncate);
189
- proofHashOut = truncateHash(safeHash(concatenatedObfuscated, config.auditHashSalt), config.auditHashTruncate);
190
- }
191
65
  if (config.auditLogFormat === "json") {
192
66
  const obj = {
193
- event: "shroud.audit.before_llm_send",
67
+ event: "shroud.audit.obfuscate",
194
68
  req: requestId,
195
69
  ts: new Date().toISOString(),
196
70
  modified,
197
- totalEntities: stats.totalEntities,
198
- messagesTouched: stats.messagesTouched,
199
- blocksTouched: stats.blocksTouched,
200
- inputChars: stats.inputChars,
201
- outputChars: stats.outputChars,
71
+ totalEntities: result.entities.length,
72
+ inputChars: inputText.length,
73
+ outputChars: outputText.length,
202
74
  charDelta,
203
- byCategory: stats.byCategory,
204
- byRule: stats.byRule,
75
+ byCategory: byCat,
76
+ byRule,
205
77
  };
206
78
  if (config.auditIncludeProofHashes) {
207
- obj.proofIn = proofHashIn;
208
- obj.proofOut = proofHashOut;
79
+ obj.proofIn = proofIn;
80
+ obj.proofOut = proofOut;
209
81
  }
210
- if (config.auditMaxFakesSample > 0 && stats.fakesSample.length > 0) {
211
- obj.fakesSample = stats.fakesSample;
82
+ if (fakesSample.length > 0) {
83
+ obj.fakesSample = fakesSample;
212
84
  }
213
85
  logger?.info(JSON.stringify(obj));
214
86
  }
215
87
  else {
88
+ const byCatStr = Object.entries(byCat).map(([k, v]) => `${k}:${v}`).join(",");
89
+ const byRuleStr = Object.entries(byRule).map(([k, v]) => `${k}:${v}`).join(",");
216
90
  const parts = [
217
91
  `[shroud][audit] OBFUSCATE req=${requestId}`,
218
- `entities=${stats.totalEntities}`,
219
- `touched=${stats.messagesTouched}/${totalMessages}`,
220
- `blocks=${stats.blocksTouched}`,
221
- `chars=${stats.inputChars}->${stats.outputChars} (delta=${charDelta >= 0 ? "+" : ""}${charDelta})`,
92
+ `entities=${result.entities.length}`,
93
+ `chars=${inputText.length}->${outputText.length} (delta=${charDelta >= 0 ? "+" : ""}${charDelta})`,
222
94
  `modified=${modified ? "YES" : "NO"}`,
223
95
  `byCat=${byCatStr || "none"}`,
224
96
  `byRule=${byRuleStr || "none"}`,
225
97
  ];
226
98
  if (config.auditIncludeProofHashes) {
227
- parts.push(`proof_in=${proofHashIn} proof_out=${proofHashOut}`);
99
+ parts.push(`proof_in=${proofIn} proof_out=${proofOut}`);
228
100
  }
229
- if (config.auditMaxFakesSample > 0 && stats.fakesSample.length > 0) {
230
- parts.push(`fakes=[${stats.fakesSample.join("|")}]`);
101
+ if (fakesSample.length > 0) {
102
+ parts.push(`fakes=[${fakesSample.join("|")}]`);
231
103
  }
232
104
  logger?.info(parts.join(" | "));
233
105
  }
234
106
  }
235
- function emitDeobfuscationAuditLog(logger, config, requestId, replacementCount) {
107
+ function emitDeobfuscationAudit(logger, config, requestId, replacementCount) {
236
108
  if (config.auditLogFormat === "json") {
237
109
  logger?.info(JSON.stringify({
238
- event: "shroud.audit.deobfuscation",
110
+ event: "shroud.audit.deobfuscate",
239
111
  req: requestId,
240
112
  ts: new Date().toISOString(),
241
113
  modified: replacementCount > 0,
@@ -247,6 +119,39 @@ function emitDeobfuscationAuditLog(logger, config, requestId, replacementCount)
247
119
  }
248
120
  }
249
121
  // ---------------------------------------------------------------------------
122
+ // String walking helper
123
+ // ---------------------------------------------------------------------------
124
+ /**
125
+ * Deep-walk an unknown message structure and obfuscate/deobfuscate all
126
+ * string leaves. OpenClaw message payloads can be a plain string, an
127
+ * array of content blocks, or a nested object — this handles all three.
128
+ */
129
+ function walkStrings(value, fn) {
130
+ if (typeof value === "string")
131
+ return fn(value);
132
+ if (Array.isArray(value)) {
133
+ return value.map((item) => {
134
+ if (typeof item === "object" && item !== null && "text" in item && typeof item.text === "string") {
135
+ return { ...item, text: fn(item.text) };
136
+ }
137
+ return walkStrings(item, fn);
138
+ });
139
+ }
140
+ if (typeof value === "object" && value !== null) {
141
+ const out = {};
142
+ for (const [k, v] of Object.entries(value)) {
143
+ if (typeof v === "string") {
144
+ out[k] = fn(v);
145
+ }
146
+ else {
147
+ out[k] = v; // don't recurse arbitrarily into unknown objects
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+ return value;
153
+ }
154
+ // ---------------------------------------------------------------------------
250
155
  // Hook registration
251
156
  // ---------------------------------------------------------------------------
252
157
  export function registerHooks(api, obfuscator) {
@@ -256,6 +161,11 @@ export function registerHooks(api, obfuscator) {
256
161
  // 1. before_prompt_build (async): obfuscate user prompt
257
162
  // -----------------------------------------------------------------------
258
163
  api.on("before_prompt_build", async (event) => {
164
+ // Reset tool depth at the start of each turn — tool calls from the
165
+ // previous turn are complete, so the counter should not carry over.
166
+ if (obfuscator.toolDepth > 0) {
167
+ obfuscator.resetToolDepth();
168
+ }
259
169
  const prompt = event?.prompt;
260
170
  if (typeof prompt !== "string" || !prompt)
261
171
  return;
@@ -276,87 +186,57 @@ export function registerHooks(api, obfuscator) {
276
186
  };
277
187
  });
278
188
  // -----------------------------------------------------------------------
279
- // 2. before_llm_send (async): obfuscate LLM input + provide response deobfuscator
189
+ // 2. before_message_write (SYNC): obfuscate every message written to session
190
+ // This ensures the LLM always sees obfuscated history, even on platforms
191
+ // that don't support before_llm_send.
280
192
  // -----------------------------------------------------------------------
281
- api.on("before_llm_send", async (event) => {
282
- if (!Array.isArray(event?.messages))
193
+ api.on("before_message_write", (event) => {
194
+ if (!event?.message || typeof event.message !== "object")
283
195
  return;
284
- // Reset tool depth at the start of each LLM turn — tool calls from the
285
- // previous turn are complete, so the counter should not carry over.
286
- if (obfuscator.toolDepth > 0) {
287
- obfuscator.resetToolDepth();
288
- }
289
- const totalMessages = event.messages.length;
290
- let obfuscatedMessages;
291
- let requestId = "";
292
- if (auditActive) {
293
- requestId = randomBytes(8).toString("hex");
294
- const { messages: msgs, stats } = obfuscateMessagesWithStats(event.messages, obfuscator, config);
295
- obfuscatedMessages = msgs;
296
- // Build concatenated texts for proof hashes
297
- let concatenatedOriginal = "";
298
- let concatenatedObfuscated = "";
299
- if (config.auditIncludeProofHashes) {
300
- for (let i = 0; i < event.messages.length; i++) {
301
- const orig = event.messages[i];
302
- const obf = obfuscatedMessages[i];
303
- if (typeof orig?.content === "string") {
304
- concatenatedOriginal += orig.content;
305
- concatenatedObfuscated += (obf?.content ?? "");
306
- }
307
- else if (Array.isArray(orig?.content)) {
308
- for (let j = 0; j < orig.content.length; j++) {
309
- const origBlock = orig.content[j];
310
- const obfBlock = obf?.content?.[j];
311
- if (typeof origBlock?.text === "string") {
312
- concatenatedOriginal += origBlock.text;
313
- concatenatedObfuscated += (obfBlock?.text ?? "");
314
- }
315
- }
316
- }
196
+ const msg = event.message;
197
+ // Obfuscate string content
198
+ if (typeof msg.content === "string") {
199
+ const result = obfuscator.obfuscate(msg.content);
200
+ if (result.entities.length === 0)
201
+ return;
202
+ dumpStatsFile(obfuscator);
203
+ if (auditActive) {
204
+ try {
205
+ emitObfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), result, msg.content, result.obfuscated);
317
206
  }
207
+ catch { /* best-effort */ }
318
208
  }
319
- try {
320
- emitAuditLog(api.logger, config, requestId, stats, totalMessages, concatenatedOriginal, concatenatedObfuscated);
321
- }
322
- catch {
323
- // Logging is best-effort
324
- }
325
- }
326
- else {
327
- obfuscatedMessages = obfuscateMessages(event.messages, obfuscator);
209
+ return { message: { ...msg, content: result.obfuscated } };
328
210
  }
329
- dumpStatsFile(obfuscator);
330
- api.logger?.info("[shroud] before_llm_send: obfuscated messages + installed transformResponse");
331
- const capturedReqId = requestId;
332
- return {
333
- messages: obfuscatedMessages,
334
- transformResponse: (text) => {
335
- if (auditActive) {
336
- try {
337
- const { text: deobfuscated, replacementCount } = obfuscator.deobfuscateWithStats(text);
338
- if (replacementCount > 0) {
339
- try {
340
- emitDeobfuscationAuditLog(api.logger, config, capturedReqId, replacementCount);
341
- }
342
- catch {
343
- // best-effort
344
- }
345
- }
346
- dumpStatsFile(obfuscator);
347
- return deobfuscated;
211
+ // Obfuscate array-of-blocks content
212
+ if (Array.isArray(msg.content)) {
213
+ let changed = false;
214
+ const allResults = [];
215
+ const newContent = msg.content.map((block) => {
216
+ if (block && typeof block === "object" && typeof block.text === "string") {
217
+ const result = obfuscator.obfuscate(block.text);
218
+ if (result.entities.length > 0) {
219
+ changed = true;
220
+ allResults.push(result);
221
+ return { ...block, text: result.obfuscated };
348
222
  }
349
- catch {
350
- const result = obfuscator.deobfuscate(text);
351
- dumpStatsFile(obfuscator);
352
- return result;
223
+ }
224
+ return block;
225
+ });
226
+ if (!changed)
227
+ return;
228
+ dumpStatsFile(obfuscator);
229
+ if (auditActive) {
230
+ for (const result of allResults) {
231
+ try {
232
+ const origText = Object.keys(result.mappingsUsed).join(" ");
233
+ emitObfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), result, origText, result.obfuscated);
353
234
  }
235
+ catch { /* best-effort */ }
354
236
  }
355
- const result = obfuscator.deobfuscate(text);
356
- dumpStatsFile(obfuscator);
357
- return result;
358
- },
359
- };
237
+ }
238
+ return { message: { ...msg, content: newContent } };
239
+ }
360
240
  });
361
241
  // -----------------------------------------------------------------------
362
242
  // 3. before_tool_call (async): deobfuscate tool params + track depth
@@ -405,6 +285,17 @@ export function registerHooks(api, obfuscator) {
405
285
  api.on("message_sending", async (event) => {
406
286
  if (typeof event?.content !== "string")
407
287
  return;
288
+ if (auditActive) {
289
+ const { text: deobfuscated, replacementCount } = obfuscator.deobfuscateWithStats(event.content);
290
+ if (deobfuscated === event.content)
291
+ return;
292
+ try {
293
+ emitDeobfuscationAudit(api.logger, config, randomBytes(8).toString("hex"), replacementCount);
294
+ }
295
+ catch { /* best-effort */ }
296
+ dumpStatsFile(obfuscator);
297
+ return { content: deobfuscated };
298
+ }
408
299
  const deobfuscated = obfuscator.deobfuscate(event.content);
409
300
  if (deobfuscated === event.content)
410
301
  return;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.0.3",
4
+ "version": "2.0.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.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Privacy obfuscation plugin for OpenClaw — detects sensitive data and replaces with deterministic fake values",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",