shroud-privacy 2.0.0
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/LICENSE +190 -0
- package/NOTICE +7 -0
- package/README.md +369 -0
- package/dist/audit.d.ts +46 -0
- package/dist/audit.js +127 -0
- package/dist/canary.d.ts +31 -0
- package/dist/canary.js +73 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +123 -0
- package/dist/detectors/base.d.ts +8 -0
- package/dist/detectors/base.js +2 -0
- package/dist/detectors/code.d.ts +25 -0
- package/dist/detectors/code.js +144 -0
- package/dist/detectors/context.d.ts +31 -0
- package/dist/detectors/context.js +357 -0
- package/dist/detectors/patterns.d.ts +15 -0
- package/dist/detectors/patterns.js +58 -0
- package/dist/detectors/regex.d.ts +28 -0
- package/dist/detectors/regex.js +955 -0
- package/dist/generators/base.d.ts +6 -0
- package/dist/generators/base.js +2 -0
- package/dist/generators/codes.d.ts +20 -0
- package/dist/generators/codes.js +231 -0
- package/dist/generators/names.d.ts +29 -0
- package/dist/generators/names.js +194 -0
- package/dist/generators/network.d.ts +86 -0
- package/dist/generators/network.js +477 -0
- package/dist/hooks.d.ts +27 -0
- package/dist/hooks.js +457 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +58 -0
- package/dist/mapping.d.ts +33 -0
- package/dist/mapping.js +72 -0
- package/dist/obfuscator.d.ts +78 -0
- package/dist/obfuscator.js +603 -0
- package/dist/redaction.d.ts +26 -0
- package/dist/redaction.js +76 -0
- package/dist/store.d.ts +40 -0
- package/dist/store.js +79 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +35 -0
- package/ncg_adapter.py +530 -0
- package/openclaw.plugin.json +72 -0
- package/package.json +56 -0
- package/shroud_bridge.mjs +225 -0
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw lifecycle hooks for the Shroud privacy plugin.
|
|
3
|
+
*
|
|
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)
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
12
|
+
import { writeFileSync } from "node:fs";
|
|
13
|
+
import { BUILTIN_PATTERNS } from "./detectors/regex.js";
|
|
14
|
+
const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
|
|
15
|
+
function dumpStatsFile(obfuscator) {
|
|
16
|
+
try {
|
|
17
|
+
const stats = obfuscator.getStats();
|
|
18
|
+
stats.updatedAt = new Date().toISOString();
|
|
19
|
+
stats.source = "openclaw";
|
|
20
|
+
stats.pid = process.pid;
|
|
21
|
+
writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2) + "\n");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// best-effort
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Hashing utilities (audit proof hashes)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
function sha256Hex(text) {
|
|
31
|
+
return createHash("sha256").update(text).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
function safeHash(text, salt) {
|
|
34
|
+
return sha256Hex(salt + text);
|
|
35
|
+
}
|
|
36
|
+
function truncateHash(hash, n) {
|
|
37
|
+
return hash.slice(0, n);
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// String walking helper
|
|
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;
|
|
69
|
+
}
|
|
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;
|
|
162
|
+
}
|
|
163
|
+
// Collect fake values only (never real values)
|
|
164
|
+
if (maxFakes > 0 && stats.fakesSample.length < maxFakes) {
|
|
165
|
+
for (const fake of Object.values(result.mappingsUsed)) {
|
|
166
|
+
if (stats.fakesSample.length >= maxFakes)
|
|
167
|
+
break;
|
|
168
|
+
stats.fakesSample.push(fake);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
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
|
+
if (config.auditLogFormat === "json") {
|
|
192
|
+
const obj = {
|
|
193
|
+
event: "shroud.audit.before_llm_send",
|
|
194
|
+
req: requestId,
|
|
195
|
+
ts: new Date().toISOString(),
|
|
196
|
+
modified,
|
|
197
|
+
totalEntities: stats.totalEntities,
|
|
198
|
+
messagesTouched: stats.messagesTouched,
|
|
199
|
+
blocksTouched: stats.blocksTouched,
|
|
200
|
+
inputChars: stats.inputChars,
|
|
201
|
+
outputChars: stats.outputChars,
|
|
202
|
+
charDelta,
|
|
203
|
+
byCategory: stats.byCategory,
|
|
204
|
+
byRule: stats.byRule,
|
|
205
|
+
};
|
|
206
|
+
if (config.auditIncludeProofHashes) {
|
|
207
|
+
obj.proofIn = proofHashIn;
|
|
208
|
+
obj.proofOut = proofHashOut;
|
|
209
|
+
}
|
|
210
|
+
if (config.auditMaxFakesSample > 0 && stats.fakesSample.length > 0) {
|
|
211
|
+
obj.fakesSample = stats.fakesSample;
|
|
212
|
+
}
|
|
213
|
+
logger?.info(JSON.stringify(obj));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const parts = [
|
|
217
|
+
`[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})`,
|
|
222
|
+
`modified=${modified ? "YES" : "NO"}`,
|
|
223
|
+
`byCat=${byCatStr || "none"}`,
|
|
224
|
+
`byRule=${byRuleStr || "none"}`,
|
|
225
|
+
];
|
|
226
|
+
if (config.auditIncludeProofHashes) {
|
|
227
|
+
parts.push(`proof_in=${proofHashIn} proof_out=${proofHashOut}`);
|
|
228
|
+
}
|
|
229
|
+
if (config.auditMaxFakesSample > 0 && stats.fakesSample.length > 0) {
|
|
230
|
+
parts.push(`fakes=[${stats.fakesSample.join("|")}]`);
|
|
231
|
+
}
|
|
232
|
+
logger?.info(parts.join(" | "));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function emitDeobfuscationAuditLog(logger, config, requestId, replacementCount) {
|
|
236
|
+
if (config.auditLogFormat === "json") {
|
|
237
|
+
logger?.info(JSON.stringify({
|
|
238
|
+
event: "shroud.audit.deobfuscation",
|
|
239
|
+
req: requestId,
|
|
240
|
+
ts: new Date().toISOString(),
|
|
241
|
+
modified: replacementCount > 0,
|
|
242
|
+
deobfuscations: replacementCount,
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
logger?.info(`[shroud][audit] DEOBFUSCATE req=${requestId} | replacements=${replacementCount} | modified=${replacementCount > 0 ? "YES" : "NO"}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Hook registration
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
export function registerHooks(api, obfuscator) {
|
|
253
|
+
const config = obfuscator.config;
|
|
254
|
+
const auditActive = config.auditEnabled || config.verboseLogging;
|
|
255
|
+
// -----------------------------------------------------------------------
|
|
256
|
+
// 1. before_prompt_build (async): obfuscate user prompt
|
|
257
|
+
// -----------------------------------------------------------------------
|
|
258
|
+
api.on("before_prompt_build", async (event) => {
|
|
259
|
+
const prompt = event?.prompt;
|
|
260
|
+
if (typeof prompt !== "string" || !prompt)
|
|
261
|
+
return;
|
|
262
|
+
const result = obfuscator.obfuscate(prompt);
|
|
263
|
+
if (result.entities.length === 0)
|
|
264
|
+
return;
|
|
265
|
+
dumpStatsFile(obfuscator);
|
|
266
|
+
api.logger?.info(`[shroud] before_prompt_build: obfuscated ${result.entities.length} entities`);
|
|
267
|
+
return {
|
|
268
|
+
prependContext: [
|
|
269
|
+
"--- SHROUD PRIVACY LAYER ---",
|
|
270
|
+
"The following user message has been privacy-filtered.",
|
|
271
|
+
"Use ONLY the sanitized version below. Do NOT reference the original values.",
|
|
272
|
+
"",
|
|
273
|
+
result.obfuscated,
|
|
274
|
+
"--- END SHROUD ---",
|
|
275
|
+
].join("\n"),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
// -----------------------------------------------------------------------
|
|
279
|
+
// 2. before_llm_send (async): obfuscate LLM input + provide response deobfuscator
|
|
280
|
+
// -----------------------------------------------------------------------
|
|
281
|
+
api.on("before_llm_send", async (event) => {
|
|
282
|
+
if (!Array.isArray(event?.messages))
|
|
283
|
+
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
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
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);
|
|
328
|
+
}
|
|
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;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
const result = obfuscator.deobfuscate(text);
|
|
351
|
+
dumpStatsFile(obfuscator);
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const result = obfuscator.deobfuscate(text);
|
|
356
|
+
dumpStatsFile(obfuscator);
|
|
357
|
+
return result;
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
// -----------------------------------------------------------------------
|
|
362
|
+
// 3. before_tool_call (async): deobfuscate tool params + track depth
|
|
363
|
+
// -----------------------------------------------------------------------
|
|
364
|
+
api.on("before_tool_call", async (event) => {
|
|
365
|
+
if (!event?.params || typeof event.params !== "object")
|
|
366
|
+
return;
|
|
367
|
+
// Tool chain depth tracking
|
|
368
|
+
const depth = obfuscator.enterToolCall();
|
|
369
|
+
if (depth > config.maxToolDepth) {
|
|
370
|
+
api.logger?.warn(`[shroud][depth] Tool chain depth ${depth} exceeds max ${config.maxToolDepth} — possible infinite recursion`);
|
|
371
|
+
}
|
|
372
|
+
if (depth > 1) {
|
|
373
|
+
api.logger?.info(`[shroud][depth] Nested tool call at depth ${depth}: ${event.toolName ?? "?"}`);
|
|
374
|
+
}
|
|
375
|
+
const serialized = JSON.stringify(event.params);
|
|
376
|
+
const deobfuscated = obfuscator.deobfuscate(serialized);
|
|
377
|
+
if (serialized === deobfuscated)
|
|
378
|
+
return;
|
|
379
|
+
api.logger?.info(`[shroud] before_tool_call(${event.toolName ?? "?"}): deobfuscated params`);
|
|
380
|
+
try {
|
|
381
|
+
return { params: JSON.parse(deobfuscated) };
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
api.logger?.warn("[shroud] Failed to parse deobfuscated tool params");
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
// -----------------------------------------------------------------------
|
|
388
|
+
// 4. tool_result_persist (SYNC): obfuscate tool result message
|
|
389
|
+
// -----------------------------------------------------------------------
|
|
390
|
+
api.on("tool_result_persist", (event) => {
|
|
391
|
+
if (!event?.message)
|
|
392
|
+
return;
|
|
393
|
+
// Exit tool depth
|
|
394
|
+
obfuscator.exitToolCall();
|
|
395
|
+
const obfuscated = walkStrings(event.message, (s) => {
|
|
396
|
+
const result = obfuscator.obfuscate(s);
|
|
397
|
+
return result.obfuscated;
|
|
398
|
+
});
|
|
399
|
+
dumpStatsFile(obfuscator);
|
|
400
|
+
return { message: obfuscated };
|
|
401
|
+
});
|
|
402
|
+
// -----------------------------------------------------------------------
|
|
403
|
+
// 5. message_sending (async): deobfuscate outbound message content
|
|
404
|
+
// -----------------------------------------------------------------------
|
|
405
|
+
api.on("message_sending", async (event) => {
|
|
406
|
+
if (typeof event?.content !== "string")
|
|
407
|
+
return;
|
|
408
|
+
const deobfuscated = obfuscator.deobfuscate(event.content);
|
|
409
|
+
if (deobfuscated === event.content)
|
|
410
|
+
return;
|
|
411
|
+
api.logger?.info("[shroud] message_sending: deobfuscated outbound message");
|
|
412
|
+
dumpStatsFile(obfuscator);
|
|
413
|
+
return { content: deobfuscated };
|
|
414
|
+
});
|
|
415
|
+
// -----------------------------------------------------------------------
|
|
416
|
+
// Tool: shroud-stats — rulebase view with hit counters
|
|
417
|
+
// -----------------------------------------------------------------------
|
|
418
|
+
api.registerTool({
|
|
419
|
+
name: "shroud-stats",
|
|
420
|
+
description: "Show Shroud privacy plugin status: active rules, per-rule hit counts, store size, and config summary.",
|
|
421
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
422
|
+
handler: async () => {
|
|
423
|
+
const stats = obfuscator.config;
|
|
424
|
+
const overrides = stats.detectorOverrides;
|
|
425
|
+
const obStats = obfuscator.getStats();
|
|
426
|
+
const rules = BUILTIN_PATTERNS.map((p) => {
|
|
427
|
+
const ov = overrides[p.name];
|
|
428
|
+
const enabled = ov?.enabled !== false;
|
|
429
|
+
const confidence = ov?.confidence ?? p.confidence;
|
|
430
|
+
const hits = obStats.ruleHits[`regex:${p.name}`] ?? 0;
|
|
431
|
+
return { name: p.name, category: p.category, enabled, confidence, hits };
|
|
432
|
+
});
|
|
433
|
+
rules.sort((a, b) => b.hits - a.hits);
|
|
434
|
+
const maxName = Math.max(...rules.map((r) => r.name.length), 4);
|
|
435
|
+
const maxCat = Math.max(...rules.map((r) => r.category.length), 8);
|
|
436
|
+
const header = `${"Rule".padEnd(maxName)} ${"Category".padEnd(maxCat)} Status Conf Hits`;
|
|
437
|
+
const sep = "─".repeat(header.length);
|
|
438
|
+
const rows = rules.map((r) => {
|
|
439
|
+
const status = r.enabled ? "active" : "DISABLED";
|
|
440
|
+
const bar = r.hits > 0 ? " " + "█".repeat(Math.min(Math.ceil(Math.log2(r.hits + 1)), 16)) : "";
|
|
441
|
+
return `${r.name.padEnd(maxName)} ${r.category.padEnd(maxCat)} ${status.padEnd(8)} ${r.confidence.toFixed(2).padStart(4)} ${String(r.hits).padStart(5)}${bar}`;
|
|
442
|
+
});
|
|
443
|
+
const lines = [
|
|
444
|
+
`Shroud Rule Hits (since gateway start)`,
|
|
445
|
+
sep,
|
|
446
|
+
header,
|
|
447
|
+
sep,
|
|
448
|
+
...rows,
|
|
449
|
+
sep,
|
|
450
|
+
`Store: ${obStats.storeMappings} active mappings`,
|
|
451
|
+
`Audit: ${stats.auditEnabled || stats.verboseLogging ? "enabled" : "disabled"}`,
|
|
452
|
+
`Redaction: ${stats.redactionLevel}`,
|
|
453
|
+
];
|
|
454
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shroud -- OpenClaw privacy plugin.
|
|
3
|
+
*
|
|
4
|
+
* Automatically obfuscates sensitive data before it reaches the LLM
|
|
5
|
+
* and deobfuscates responses before they reach the user.
|
|
6
|
+
*/
|
|
7
|
+
declare const _default: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
register(api: any): void;
|
|
11
|
+
};
|
|
12
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shroud -- OpenClaw privacy plugin.
|
|
3
|
+
*
|
|
4
|
+
* Automatically obfuscates sensitive data before it reaches the LLM
|
|
5
|
+
* and deobfuscates responses before they reach the user.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveConfig } from "./config.js";
|
|
8
|
+
import { Obfuscator } from "./obfuscator.js";
|
|
9
|
+
import { registerHooks } from "./hooks.js";
|
|
10
|
+
export default {
|
|
11
|
+
id: "shroud-privacy",
|
|
12
|
+
name: "Shroud",
|
|
13
|
+
register(api) {
|
|
14
|
+
const config = resolveConfig(api.pluginConfig);
|
|
15
|
+
const obfuscator = new Obfuscator(config);
|
|
16
|
+
registerHooks(api, obfuscator);
|
|
17
|
+
// Register shroud_status tool
|
|
18
|
+
api.registerTool({
|
|
19
|
+
name: "shroud_status",
|
|
20
|
+
description: "Show Shroud privacy stats: entity counts, session info, audit status",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {},
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
handler: async () => ({
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "text",
|
|
30
|
+
text: JSON.stringify(obfuscator.getStats(), null, 2),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
// Register shroud_reset tool
|
|
36
|
+
api.registerTool({
|
|
37
|
+
name: "shroud_reset",
|
|
38
|
+
description: "Clear all Shroud mappings and start a fresh privacy session",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {},
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
handler: async () => {
|
|
45
|
+
obfuscator.reset();
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: "Shroud session reset. All mappings cleared.",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
api.logger?.info("[shroud] Plugin loaded — native TypeScript, no proxy required.");
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic mapping engine using HMAC + salt for irreversible obfuscation.
|
|
3
|
+
*
|
|
4
|
+
* Uses HMAC-SHA256(secretKey, salt + tenantId + value) to produce a seed, then
|
|
5
|
+
* indexes into the appropriate generator's fake value pool. The salt adds
|
|
6
|
+
* randomness so that the same real value produces different fake values across
|
|
7
|
+
* sessions (unless the same salt is reused), preventing inference attacks.
|
|
8
|
+
*
|
|
9
|
+
* When tenantId is set, it's incorporated into the HMAC message, ensuring the
|
|
10
|
+
* same value produces different fakes for different tenants.
|
|
11
|
+
*/
|
|
12
|
+
import { Category } from "./types.js";
|
|
13
|
+
import type { BaseGenerator } from "./generators/base.js";
|
|
14
|
+
import { SubnetMapper } from "./generators/network.js";
|
|
15
|
+
export declare class MappingEngine {
|
|
16
|
+
private readonly _secretKey;
|
|
17
|
+
private readonly _salt;
|
|
18
|
+
private readonly _tenantId;
|
|
19
|
+
private readonly _generators;
|
|
20
|
+
constructor(secretKey: string, salt?: string, subnetMapper?: SubnetMapper, tenantId?: string);
|
|
21
|
+
/** Return the current salt (for persistence/restore). */
|
|
22
|
+
get salt(): string;
|
|
23
|
+
private _registerDefaults;
|
|
24
|
+
/** Register a custom generator for a category. */
|
|
25
|
+
registerGenerator(category: Category, generator: BaseGenerator): void;
|
|
26
|
+
/**
|
|
27
|
+
* Compute a deterministic seed from a real value using HMAC + salt + tenantId.
|
|
28
|
+
* Reads first 6 bytes as unsigned int (stays in safe integer range).
|
|
29
|
+
*/
|
|
30
|
+
computeSeed(value: string): number;
|
|
31
|
+
/** Map a real value to a fake value, preserving format of original. */
|
|
32
|
+
mapValue(value: string, category: Category): string;
|
|
33
|
+
}
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic mapping engine using HMAC + salt for irreversible obfuscation.
|
|
3
|
+
*
|
|
4
|
+
* Uses HMAC-SHA256(secretKey, salt + tenantId + value) to produce a seed, then
|
|
5
|
+
* indexes into the appropriate generator's fake value pool. The salt adds
|
|
6
|
+
* randomness so that the same real value produces different fake values across
|
|
7
|
+
* sessions (unless the same salt is reused), preventing inference attacks.
|
|
8
|
+
*
|
|
9
|
+
* When tenantId is set, it's incorporated into the HMAC message, ensuring the
|
|
10
|
+
* same value produces different fakes for different tenants.
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
13
|
+
import { NameGenerator } from "./generators/names.js";
|
|
14
|
+
import { NetworkGenerator, SubnetMapper } from "./generators/network.js";
|
|
15
|
+
import { CodeGenerator } from "./generators/codes.js";
|
|
16
|
+
export class MappingEngine {
|
|
17
|
+
_secretKey;
|
|
18
|
+
_salt;
|
|
19
|
+
_tenantId;
|
|
20
|
+
_generators = new Map();
|
|
21
|
+
constructor(secretKey, salt, subnetMapper, tenantId) {
|
|
22
|
+
this._secretKey = Buffer.from(secretKey, "utf-8");
|
|
23
|
+
this._salt = Buffer.from(salt ?? randomBytes(16).toString("hex"), "utf-8");
|
|
24
|
+
this._tenantId = tenantId ?? "";
|
|
25
|
+
this._registerDefaults(subnetMapper);
|
|
26
|
+
}
|
|
27
|
+
/** Return the current salt (for persistence/restore). */
|
|
28
|
+
get salt() {
|
|
29
|
+
return this._salt.toString("utf-8");
|
|
30
|
+
}
|
|
31
|
+
_registerDefaults(subnetMapper) {
|
|
32
|
+
const generators = [
|
|
33
|
+
new NameGenerator(),
|
|
34
|
+
new NetworkGenerator(subnetMapper ?? new SubnetMapper()),
|
|
35
|
+
new CodeGenerator(),
|
|
36
|
+
];
|
|
37
|
+
for (const gen of generators) {
|
|
38
|
+
for (const cat of gen.categories) {
|
|
39
|
+
this._generators.set(cat, gen);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Register a custom generator for a category. */
|
|
44
|
+
registerGenerator(category, generator) {
|
|
45
|
+
this._generators.set(category, generator);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compute a deterministic seed from a real value using HMAC + salt + tenantId.
|
|
49
|
+
* Reads first 6 bytes as unsigned int (stays in safe integer range).
|
|
50
|
+
*/
|
|
51
|
+
computeSeed(value) {
|
|
52
|
+
const parts = [this._salt];
|
|
53
|
+
if (this._tenantId) {
|
|
54
|
+
parts.push(Buffer.from(this._tenantId, "utf-8"));
|
|
55
|
+
}
|
|
56
|
+
parts.push(Buffer.from(value, "utf-8"));
|
|
57
|
+
const msg = Buffer.concat(parts);
|
|
58
|
+
const digest = createHmac("sha256", this._secretKey).update(msg).digest();
|
|
59
|
+
return digest.readUIntBE(0, 6);
|
|
60
|
+
}
|
|
61
|
+
/** Map a real value to a fake value, preserving format of original. */
|
|
62
|
+
mapValue(value, category) {
|
|
63
|
+
const gen = this._generators.get(category);
|
|
64
|
+
if (gen === undefined) {
|
|
65
|
+
// Fallback: hash-based opaque token
|
|
66
|
+
const seed = this.computeSeed(value);
|
|
67
|
+
return `[REDACTED-${category}-${String(seed % 10000).padStart(4, "0")}]`;
|
|
68
|
+
}
|
|
69
|
+
const seed = this.computeSeed(value);
|
|
70
|
+
return gen.generate(category, seed, value);
|
|
71
|
+
}
|
|
72
|
+
}
|