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 +9 -11
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +143 -244
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
-
| `
|
|
20
|
-
| `
|
|
19
|
+
| `before_message_write` | Any → History | Obfuscate every message written to the session transcript |
|
|
20
|
+
| `before_llm_send` | Context → LLM | 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 (
|
|
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 |
|
|
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 |
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
//
|
|
41
|
+
// Audit log emitter — per-message (used by before_message_write)
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
165
|
-
if (
|
|
58
|
+
const fakesSample = [];
|
|
59
|
+
if (config.auditMaxFakesSample > 0) {
|
|
166
60
|
for (const fake of Object.values(result.mappingsUsed)) {
|
|
167
|
-
if (
|
|
61
|
+
if (fakesSample.length >= config.auditMaxFakesSample)
|
|
168
62
|
break;
|
|
169
|
-
|
|
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.
|
|
68
|
+
event: "shroud.audit.obfuscate",
|
|
195
69
|
req: requestId,
|
|
196
70
|
ts: new Date().toISOString(),
|
|
197
71
|
modified,
|
|
198
|
-
totalEntities:
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
205
|
-
byRule
|
|
76
|
+
byCategory: byCat,
|
|
77
|
+
byRule,
|
|
206
78
|
};
|
|
207
79
|
if (config.auditIncludeProofHashes) {
|
|
208
|
-
obj.proofIn =
|
|
209
|
-
obj.proofOut =
|
|
80
|
+
obj.proofIn = proofIn;
|
|
81
|
+
obj.proofOut = proofOut;
|
|
210
82
|
}
|
|
211
|
-
if (
|
|
212
|
-
obj.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=${
|
|
220
|
-
`
|
|
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=${
|
|
100
|
+
parts.push(`proof_in=${proofIn} proof_out=${proofOut}`);
|
|
229
101
|
}
|
|
230
|
-
if (
|
|
231
|
-
parts.push(`fakes=[${
|
|
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
|
|
108
|
+
function emitDeobfuscationAudit(logger, config, requestId, replacementCount) {
|
|
237
109
|
if (config.auditLogFormat === "json") {
|
|
238
110
|
logger?.info(JSON.stringify({
|
|
239
|
-
event: "shroud.audit.
|
|
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
|
|
322
|
-
//
|
|
323
|
-
//
|
|
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
|
-
|
|
249
|
+
const messages = event?.messages;
|
|
250
|
+
if (!Array.isArray(messages))
|
|
327
251
|
return;
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shroud-privacy",
|
|
3
3
|
"name": "Shroud",
|
|
4
|
-
"version": "2.0.
|
|
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