shroud-privacy 2.0.4 → 2.0.6

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