@intflows/genkit-guard 0.0.9-alpha.1 → 0.0.10
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 +68 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/middleware/middleware.d.ts +117 -1
- package/dist/middleware/middleware.js +31 -15
- package/dist/pii/storage.d.ts +20 -0
- package/dist/pii/storage.js +26 -0
- package/dist/pii/tokenizer.d.ts +14 -5
- package/dist/pii/tokenizer.js +39 -11
- package/package.json +7 -6
- package/scripts/test-concurrent-vault.js +96 -0
package/README.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# **@intflows/genkit-guard**
|
|
2
2
|
|
|
3
|
-
#### _This version uses OpenAI/privacy-filter instead of bert-base-NER_
|
|
4
|
-
|
|
5
3
|
### **Lightweight Intent, PII, and Safety Guardrails for Genkit**
|
|
6
4
|
|
|
7
5
|
`@intflows/genkit-guard` provides a modular guardrail layer for Genkit flows.
|
|
@@ -114,6 +112,29 @@ const response = await ai.generate({
|
|
|
114
112
|
|
|
115
113
|

|
|
116
114
|
|
|
115
|
+
---
|
|
116
|
+
## Example
|
|
117
|
+
|
|
118
|
+
An example genkit flow is present in `example` directory.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
git clone https://github.com/IntFlows/genkit-guard.git
|
|
122
|
+
cd genkit-guard/example
|
|
123
|
+
npm install
|
|
124
|
+
node node_modules/@intflows/genkit-guard/scripts/download-model.js
|
|
125
|
+
npx tsx src/index.ts
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Or you can run the flow with genkit dev UI
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git clone https://github.com/IntFlows/genkit-guard.git
|
|
132
|
+
cd genkit-guard/example
|
|
133
|
+
npm install
|
|
134
|
+
node node_modules/@intflows/genkit-guard/scripts/download-model.js
|
|
135
|
+
genkit start -- npx tsx src/index.ts
|
|
136
|
+
```
|
|
137
|
+
|
|
117
138
|
---
|
|
118
139
|
|
|
119
140
|
## 🧠 How It Works
|
|
@@ -177,6 +198,51 @@ pii: {
|
|
|
177
198
|
}
|
|
178
199
|
```
|
|
179
200
|
|
|
201
|
+
### **PII Vault Isolation and External Storage**
|
|
202
|
+
|
|
203
|
+
By default, PII is stored in an in-memory vault scoped to a single tokenizer instance. Tokens include a generated vault scope:
|
|
204
|
+
|
|
205
|
+
```txt
|
|
206
|
+
"Email john.doe@example.com" -> "Email [[EMAIL_<namespace>_0]]"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
That generated namespace prevents two concurrent calls from sharing the same visible placeholder names. Vault lookups are isolated by the configured storage scope, so User A and User B can safely produce their own email tokens without cross-resolving each other's PII.
|
|
210
|
+
|
|
211
|
+
For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { guard, type PiiVaultStorage } from "@intflows/genkit-guard";
|
|
215
|
+
|
|
216
|
+
class RedisPiiVaultStorage implements PiiVaultStorage {
|
|
217
|
+
constructor(private redis: RedisClient) {}
|
|
218
|
+
|
|
219
|
+
async get(scopeId: string, token: string) {
|
|
220
|
+
return this.redis.hget(`pii:${scopeId}`, token);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async set(scopeId: string, token: string, value: string) {
|
|
224
|
+
await this.redis.hset(`pii:${scopeId}`, token, value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async entries(scopeId: string) {
|
|
228
|
+
const values = await this.redis.hgetall(`pii:${scopeId}`);
|
|
229
|
+
return Object.entries(values).map(([token, value]) => ({ token, value }));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
guard({
|
|
234
|
+
pii: {
|
|
235
|
+
reversible: true,
|
|
236
|
+
vault: {
|
|
237
|
+
storage: new RedisPiiVaultStorage(redis),
|
|
238
|
+
scopeId: (req, ctx) => ctx?.auth?.sessionId ?? req?.metadata?.requestId
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Choose a `scopeId` that matches your isolation boundary, such as request ID, session ID, tenant/user ID, or a combination like `tenantId:userId:requestId`. A shared external backend should never ignore `scopeId`, because placeholders are only safe when resolved against the correct vault scope. The placeholder sent to the model uses an opaque generated namespace rather than exposing your `scopeId`.
|
|
245
|
+
|
|
180
246
|
---
|
|
181
247
|
|
|
182
248
|
## 🛡️ Why This Library Exists
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { guard, guardAction, guardMiddleware, guardPlugin } from './middleware/middleware.js';
|
|
2
|
+
export { InMemoryPiiVaultStorage, defaultPiiVaultStorage } from './pii/storage.js';
|
|
3
|
+
export type { PiiVaultEntry, PiiVaultStorage } from './pii/storage.js';
|
|
2
4
|
export * from './core/types.js';
|
|
3
5
|
/**
|
|
4
6
|
* Pre-load the model to avoid cold-start delay on first user request.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ModelSingleton } from './util/singleton.js';
|
|
2
2
|
// export { intentGuard, piiGuard } from './middleware/middleware.js';
|
|
3
3
|
export { guard, guardAction, guardMiddleware, guardPlugin } from './middleware/middleware.js';
|
|
4
|
+
export { InMemoryPiiVaultStorage, defaultPiiVaultStorage } from './pii/storage.js';
|
|
4
5
|
export * from './core/types.js';
|
|
5
6
|
function logGuardEvent(eventName, body, attributes = {}) {
|
|
6
7
|
console.log(JSON.stringify({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'genkit';
|
|
2
|
+
import { type PiiVaultStorage } from '../pii/storage.js';
|
|
2
3
|
declare const guardConfigSchema: z.ZodObject<{
|
|
3
4
|
intent: z.ZodOptional<z.ZodObject<{
|
|
4
5
|
mode: z.ZodOptional<z.ZodString>;
|
|
@@ -32,14 +33,32 @@ declare const guardConfigSchema: z.ZodObject<{
|
|
|
32
33
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
33
34
|
model: z.ZodOptional<z.ZodString>;
|
|
34
35
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
36
|
+
vault: z.ZodOptional<z.ZodObject<{
|
|
37
|
+
storage: z.ZodOptional<z.ZodAny>;
|
|
38
|
+
scopeId: z.ZodOptional<z.ZodAny>;
|
|
39
|
+
}, "strip", z.ZodTypeAny, {
|
|
40
|
+
storage?: any;
|
|
41
|
+
scopeId?: any;
|
|
42
|
+
}, {
|
|
43
|
+
storage?: any;
|
|
44
|
+
scopeId?: any;
|
|
45
|
+
}>>;
|
|
35
46
|
}, "strip", z.ZodTypeAny, {
|
|
36
47
|
mode?: "ner" | "classifier" | undefined;
|
|
37
48
|
reversible?: boolean | undefined;
|
|
38
49
|
model?: string | undefined;
|
|
50
|
+
vault?: {
|
|
51
|
+
storage?: any;
|
|
52
|
+
scopeId?: any;
|
|
53
|
+
} | undefined;
|
|
39
54
|
}, {
|
|
40
55
|
mode?: "ner" | "classifier" | undefined;
|
|
41
56
|
reversible?: boolean | undefined;
|
|
42
57
|
model?: string | undefined;
|
|
58
|
+
vault?: {
|
|
59
|
+
storage?: any;
|
|
60
|
+
scopeId?: any;
|
|
61
|
+
} | undefined;
|
|
43
62
|
}>>;
|
|
44
63
|
logging: z.ZodOptional<z.ZodObject<{
|
|
45
64
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -94,14 +113,32 @@ declare const guardConfigSchema: z.ZodObject<{
|
|
|
94
113
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
95
114
|
model: z.ZodOptional<z.ZodString>;
|
|
96
115
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
116
|
+
vault: z.ZodOptional<z.ZodObject<{
|
|
117
|
+
storage: z.ZodOptional<z.ZodAny>;
|
|
118
|
+
scopeId: z.ZodOptional<z.ZodAny>;
|
|
119
|
+
}, "strip", z.ZodTypeAny, {
|
|
120
|
+
storage?: any;
|
|
121
|
+
scopeId?: any;
|
|
122
|
+
}, {
|
|
123
|
+
storage?: any;
|
|
124
|
+
scopeId?: any;
|
|
125
|
+
}>>;
|
|
97
126
|
}, "strip", z.ZodTypeAny, {
|
|
98
127
|
mode?: "ner" | "classifier" | undefined;
|
|
99
128
|
reversible?: boolean | undefined;
|
|
100
129
|
model?: string | undefined;
|
|
130
|
+
vault?: {
|
|
131
|
+
storage?: any;
|
|
132
|
+
scopeId?: any;
|
|
133
|
+
} | undefined;
|
|
101
134
|
}, {
|
|
102
135
|
mode?: "ner" | "classifier" | undefined;
|
|
103
136
|
reversible?: boolean | undefined;
|
|
104
137
|
model?: string | undefined;
|
|
138
|
+
vault?: {
|
|
139
|
+
storage?: any;
|
|
140
|
+
scopeId?: any;
|
|
141
|
+
} | undefined;
|
|
105
142
|
}>>;
|
|
106
143
|
logging: z.ZodOptional<z.ZodObject<{
|
|
107
144
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -156,14 +193,32 @@ declare const guardConfigSchema: z.ZodObject<{
|
|
|
156
193
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
157
194
|
model: z.ZodOptional<z.ZodString>;
|
|
158
195
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
196
|
+
vault: z.ZodOptional<z.ZodObject<{
|
|
197
|
+
storage: z.ZodOptional<z.ZodAny>;
|
|
198
|
+
scopeId: z.ZodOptional<z.ZodAny>;
|
|
199
|
+
}, "strip", z.ZodTypeAny, {
|
|
200
|
+
storage?: any;
|
|
201
|
+
scopeId?: any;
|
|
202
|
+
}, {
|
|
203
|
+
storage?: any;
|
|
204
|
+
scopeId?: any;
|
|
205
|
+
}>>;
|
|
159
206
|
}, "strip", z.ZodTypeAny, {
|
|
160
207
|
mode?: "ner" | "classifier" | undefined;
|
|
161
208
|
reversible?: boolean | undefined;
|
|
162
209
|
model?: string | undefined;
|
|
210
|
+
vault?: {
|
|
211
|
+
storage?: any;
|
|
212
|
+
scopeId?: any;
|
|
213
|
+
} | undefined;
|
|
163
214
|
}, {
|
|
164
215
|
mode?: "ner" | "classifier" | undefined;
|
|
165
216
|
reversible?: boolean | undefined;
|
|
166
217
|
model?: string | undefined;
|
|
218
|
+
vault?: {
|
|
219
|
+
storage?: any;
|
|
220
|
+
scopeId?: any;
|
|
221
|
+
} | undefined;
|
|
167
222
|
}>>;
|
|
168
223
|
logging: z.ZodOptional<z.ZodObject<{
|
|
169
224
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -186,7 +241,14 @@ declare const guardConfigSchema: z.ZodObject<{
|
|
|
186
241
|
extractor?: string | undefined;
|
|
187
242
|
}>>;
|
|
188
243
|
}, z.ZodTypeAny, "passthrough">>;
|
|
189
|
-
type GuardConfig = z.infer<typeof guardConfigSchema
|
|
244
|
+
type GuardConfig = z.infer<typeof guardConfigSchema> & {
|
|
245
|
+
pii?: {
|
|
246
|
+
vault?: {
|
|
247
|
+
storage?: PiiVaultStorage;
|
|
248
|
+
scopeId?: string | ((req: any, ctx: any) => string | undefined);
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
};
|
|
190
252
|
export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodObject<{
|
|
191
253
|
intent: z.ZodOptional<z.ZodObject<{
|
|
192
254
|
mode: z.ZodOptional<z.ZodString>;
|
|
@@ -220,14 +282,32 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
220
282
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
221
283
|
model: z.ZodOptional<z.ZodString>;
|
|
222
284
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
285
|
+
vault: z.ZodOptional<z.ZodObject<{
|
|
286
|
+
storage: z.ZodOptional<z.ZodAny>;
|
|
287
|
+
scopeId: z.ZodOptional<z.ZodAny>;
|
|
288
|
+
}, "strip", z.ZodTypeAny, {
|
|
289
|
+
storage?: any;
|
|
290
|
+
scopeId?: any;
|
|
291
|
+
}, {
|
|
292
|
+
storage?: any;
|
|
293
|
+
scopeId?: any;
|
|
294
|
+
}>>;
|
|
223
295
|
}, "strip", z.ZodTypeAny, {
|
|
224
296
|
mode?: "ner" | "classifier" | undefined;
|
|
225
297
|
reversible?: boolean | undefined;
|
|
226
298
|
model?: string | undefined;
|
|
299
|
+
vault?: {
|
|
300
|
+
storage?: any;
|
|
301
|
+
scopeId?: any;
|
|
302
|
+
} | undefined;
|
|
227
303
|
}, {
|
|
228
304
|
mode?: "ner" | "classifier" | undefined;
|
|
229
305
|
reversible?: boolean | undefined;
|
|
230
306
|
model?: string | undefined;
|
|
307
|
+
vault?: {
|
|
308
|
+
storage?: any;
|
|
309
|
+
scopeId?: any;
|
|
310
|
+
} | undefined;
|
|
231
311
|
}>>;
|
|
232
312
|
logging: z.ZodOptional<z.ZodObject<{
|
|
233
313
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -282,14 +362,32 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
282
362
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
283
363
|
model: z.ZodOptional<z.ZodString>;
|
|
284
364
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
365
|
+
vault: z.ZodOptional<z.ZodObject<{
|
|
366
|
+
storage: z.ZodOptional<z.ZodAny>;
|
|
367
|
+
scopeId: z.ZodOptional<z.ZodAny>;
|
|
368
|
+
}, "strip", z.ZodTypeAny, {
|
|
369
|
+
storage?: any;
|
|
370
|
+
scopeId?: any;
|
|
371
|
+
}, {
|
|
372
|
+
storage?: any;
|
|
373
|
+
scopeId?: any;
|
|
374
|
+
}>>;
|
|
285
375
|
}, "strip", z.ZodTypeAny, {
|
|
286
376
|
mode?: "ner" | "classifier" | undefined;
|
|
287
377
|
reversible?: boolean | undefined;
|
|
288
378
|
model?: string | undefined;
|
|
379
|
+
vault?: {
|
|
380
|
+
storage?: any;
|
|
381
|
+
scopeId?: any;
|
|
382
|
+
} | undefined;
|
|
289
383
|
}, {
|
|
290
384
|
mode?: "ner" | "classifier" | undefined;
|
|
291
385
|
reversible?: boolean | undefined;
|
|
292
386
|
model?: string | undefined;
|
|
387
|
+
vault?: {
|
|
388
|
+
storage?: any;
|
|
389
|
+
scopeId?: any;
|
|
390
|
+
} | undefined;
|
|
293
391
|
}>>;
|
|
294
392
|
logging: z.ZodOptional<z.ZodObject<{
|
|
295
393
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -344,14 +442,32 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
344
442
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
345
443
|
model: z.ZodOptional<z.ZodString>;
|
|
346
444
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
445
|
+
vault: z.ZodOptional<z.ZodObject<{
|
|
446
|
+
storage: z.ZodOptional<z.ZodAny>;
|
|
447
|
+
scopeId: z.ZodOptional<z.ZodAny>;
|
|
448
|
+
}, "strip", z.ZodTypeAny, {
|
|
449
|
+
storage?: any;
|
|
450
|
+
scopeId?: any;
|
|
451
|
+
}, {
|
|
452
|
+
storage?: any;
|
|
453
|
+
scopeId?: any;
|
|
454
|
+
}>>;
|
|
347
455
|
}, "strip", z.ZodTypeAny, {
|
|
348
456
|
mode?: "ner" | "classifier" | undefined;
|
|
349
457
|
reversible?: boolean | undefined;
|
|
350
458
|
model?: string | undefined;
|
|
459
|
+
vault?: {
|
|
460
|
+
storage?: any;
|
|
461
|
+
scopeId?: any;
|
|
462
|
+
} | undefined;
|
|
351
463
|
}, {
|
|
352
464
|
mode?: "ner" | "classifier" | undefined;
|
|
353
465
|
reversible?: boolean | undefined;
|
|
354
466
|
model?: string | undefined;
|
|
467
|
+
vault?: {
|
|
468
|
+
storage?: any;
|
|
469
|
+
scopeId?: any;
|
|
470
|
+
} | undefined;
|
|
355
471
|
}>>;
|
|
356
472
|
logging: z.ZodOptional<z.ZodObject<{
|
|
357
473
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -2,6 +2,7 @@ import { generateMiddleware, z } from 'genkit';
|
|
|
2
2
|
import { analyzeIntentStructured, detectInjection } from '../intent/intentAnalyzer.js';
|
|
3
3
|
import { detectPII } from '../pii/detector.js';
|
|
4
4
|
import { PiiTokenizer } from '../pii/tokenizer.js';
|
|
5
|
+
import { defaultPiiVaultStorage } from '../pii/storage.js';
|
|
5
6
|
const GUARD_CONTEXT_KEY = '__genkitGuard';
|
|
6
7
|
const guardConfigSchema = z.object({
|
|
7
8
|
intent: z.object({
|
|
@@ -16,6 +17,10 @@ const guardConfigSchema = z.object({
|
|
|
16
17
|
reversible: z.boolean().optional(),
|
|
17
18
|
model: z.string().optional(),
|
|
18
19
|
mode: z.enum(['ner', 'classifier']).optional(),
|
|
20
|
+
vault: z.object({
|
|
21
|
+
storage: z.any().optional(),
|
|
22
|
+
scopeId: z.any().optional(),
|
|
23
|
+
}).optional(),
|
|
19
24
|
}).optional(),
|
|
20
25
|
logging: z.object({
|
|
21
26
|
enabled: z.boolean().optional(),
|
|
@@ -90,9 +95,10 @@ function createGuardHooks(config) {
|
|
|
90
95
|
const textForPii = collectModelRequestText(req);
|
|
91
96
|
const piiResponse = await scanPII(textForPii, config);
|
|
92
97
|
const piiMatches = piiResponse?.matches || [];
|
|
93
|
-
const tokenizer =
|
|
98
|
+
const tokenizer = createTokenizer(config, req, ctx);
|
|
94
99
|
const piiTypes = uniqueTypes(piiMatches);
|
|
95
|
-
|
|
100
|
+
await tokenizer.importTokens(textForPii);
|
|
101
|
+
await maskModelRequest(req, tokenizer, piiMatches);
|
|
96
102
|
pushTokenizer(ctx, tokenizer);
|
|
97
103
|
logger(piiMatches.length > 0 ? 'warn' : 'info', 'guard.model.pii.masked', 'PII scan completed for model request', {
|
|
98
104
|
piiDetected: piiMatches.length > 0,
|
|
@@ -113,7 +119,7 @@ function createGuardHooks(config) {
|
|
|
113
119
|
piiClassifierOutput: piiResponse.classifier,
|
|
114
120
|
};
|
|
115
121
|
const res = await next(req, ctx);
|
|
116
|
-
const unmaskedResponse = unmaskObject(res,
|
|
122
|
+
const unmaskedResponse = await unmaskObject(res, getGuardState(ctx).tokenizers);
|
|
117
123
|
logger('info', 'guard.model.response.unmasked', 'Model response unmasked for downstream execution', {
|
|
118
124
|
piiTypes,
|
|
119
125
|
});
|
|
@@ -123,7 +129,7 @@ function createGuardHooks(config) {
|
|
|
123
129
|
const state = getGuardState(ctx);
|
|
124
130
|
const toolName = req?.toolRequest?.name;
|
|
125
131
|
if (req?.toolRequest && 'input' in req.toolRequest) {
|
|
126
|
-
req.toolRequest.input = unmaskObject(req.toolRequest.input, state.tokenizers);
|
|
132
|
+
req.toolRequest.input = await unmaskObject(req.toolRequest.input, state.tokenizers);
|
|
127
133
|
}
|
|
128
134
|
const toolInputText = collectStrings(req?.toolRequest?.input).join('\n');
|
|
129
135
|
const piiResponse = await scanPII(toolInputText, config);
|
|
@@ -178,39 +184,39 @@ function collectModelRequestText(req) {
|
|
|
178
184
|
...collectStrings(req?.docs),
|
|
179
185
|
].join('\n');
|
|
180
186
|
}
|
|
181
|
-
function maskModelRequest(req, tokenizer, matches) {
|
|
187
|
+
async function maskModelRequest(req, tokenizer, matches) {
|
|
182
188
|
if (typeof req.prompt === 'string') {
|
|
183
|
-
req.prompt = tokenizer.mask(req.prompt, matches).maskedText;
|
|
189
|
+
req.prompt = (await tokenizer.mask(req.prompt, matches)).maskedText;
|
|
184
190
|
}
|
|
185
191
|
if (req.messages) {
|
|
186
|
-
req.messages = transformStrings(req.messages, (value) => tokenizer.mask(value, matches).maskedText);
|
|
192
|
+
req.messages = await transformStrings(req.messages, async (value) => (await tokenizer.mask(value, matches)).maskedText);
|
|
187
193
|
}
|
|
188
194
|
if (req.docs) {
|
|
189
|
-
req.docs = transformStrings(req.docs, (value) => tokenizer.mask(value, matches).maskedText);
|
|
195
|
+
req.docs = await transformStrings(req.docs, async (value) => (await tokenizer.mask(value, matches)).maskedText);
|
|
190
196
|
}
|
|
191
197
|
}
|
|
192
|
-
function unmaskObject(obj, tokenizers) {
|
|
193
|
-
return transformStrings(obj, (value) => {
|
|
198
|
+
async function unmaskObject(obj, tokenizers) {
|
|
199
|
+
return transformStrings(obj, async (value) => {
|
|
194
200
|
let result = value;
|
|
195
201
|
for (const tokenizer of tokenizers) {
|
|
196
|
-
result = tokenizer.unmask(result);
|
|
202
|
+
result = await tokenizer.unmask(result);
|
|
197
203
|
}
|
|
198
204
|
return result;
|
|
199
205
|
});
|
|
200
206
|
}
|
|
201
|
-
function transformStrings(obj, transform) {
|
|
207
|
+
async function transformStrings(obj, transform) {
|
|
202
208
|
if (typeof obj === 'string') {
|
|
203
|
-
return transform(obj);
|
|
209
|
+
return await transform(obj);
|
|
204
210
|
}
|
|
205
211
|
if (Array.isArray(obj)) {
|
|
206
212
|
for (let i = 0; i < obj.length; i++) {
|
|
207
|
-
obj[i] = transformStrings(obj[i], transform);
|
|
213
|
+
obj[i] = await transformStrings(obj[i], transform);
|
|
208
214
|
}
|
|
209
215
|
return obj;
|
|
210
216
|
}
|
|
211
217
|
if (obj !== null && typeof obj === 'object') {
|
|
212
218
|
for (const key of Object.keys(obj)) {
|
|
213
|
-
obj[key] = transformStrings(obj[key], transform);
|
|
219
|
+
obj[key] = await transformStrings(obj[key], transform);
|
|
214
220
|
}
|
|
215
221
|
return obj;
|
|
216
222
|
}
|
|
@@ -249,6 +255,16 @@ function pushTokenizer(ctx, tokenizer) {
|
|
|
249
255
|
const state = getGuardState(ctx);
|
|
250
256
|
state.tokenizers.push(tokenizer);
|
|
251
257
|
}
|
|
258
|
+
function createTokenizer(config, req, ctx) {
|
|
259
|
+
const configuredScope = config?.pii?.vault?.scopeId;
|
|
260
|
+
const scopeId = typeof configuredScope === 'function'
|
|
261
|
+
? configuredScope(req, ctx)
|
|
262
|
+
: configuredScope;
|
|
263
|
+
return new PiiTokenizer({
|
|
264
|
+
scopeId: typeof scopeId === 'string' ? scopeId : undefined,
|
|
265
|
+
storage: config?.pii?.vault?.storage ?? defaultPiiVaultStorage,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
252
268
|
function uniqueTypes(matches) {
|
|
253
269
|
return Array.from(new Set(matches.map((match) => match.type.toLowerCase())));
|
|
254
270
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type PiiVaultEntry = {
|
|
2
|
+
token: string;
|
|
3
|
+
value: string;
|
|
4
|
+
};
|
|
5
|
+
export interface PiiVaultStorage {
|
|
6
|
+
get(scopeId: string, token: string): string | undefined | Promise<string | undefined>;
|
|
7
|
+
getByToken?(token: string): string | undefined | Promise<string | undefined>;
|
|
8
|
+
set(scopeId: string, token: string, value: string): void | Promise<void>;
|
|
9
|
+
entries(scopeId: string): PiiVaultEntry[] | Promise<PiiVaultEntry[]>;
|
|
10
|
+
}
|
|
11
|
+
export declare class InMemoryPiiVaultStorage implements PiiVaultStorage {
|
|
12
|
+
private scopes;
|
|
13
|
+
private tokenIndex;
|
|
14
|
+
get(scopeId: string, token: string): string | undefined;
|
|
15
|
+
getByToken(token: string): string | undefined;
|
|
16
|
+
set(scopeId: string, token: string, value: string): void;
|
|
17
|
+
entries(scopeId: string): PiiVaultEntry[];
|
|
18
|
+
private getScope;
|
|
19
|
+
}
|
|
20
|
+
export declare const defaultPiiVaultStorage: InMemoryPiiVaultStorage;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class InMemoryPiiVaultStorage {
|
|
2
|
+
scopes = new Map();
|
|
3
|
+
tokenIndex = new Map();
|
|
4
|
+
get(scopeId, token) {
|
|
5
|
+
return this.scopes.get(scopeId)?.get(token);
|
|
6
|
+
}
|
|
7
|
+
getByToken(token) {
|
|
8
|
+
return this.tokenIndex.get(token);
|
|
9
|
+
}
|
|
10
|
+
set(scopeId, token, value) {
|
|
11
|
+
this.getScope(scopeId).set(token, value);
|
|
12
|
+
this.tokenIndex.set(token, value);
|
|
13
|
+
}
|
|
14
|
+
entries(scopeId) {
|
|
15
|
+
return Array.from(this.getScope(scopeId), ([token, value]) => ({ token, value }));
|
|
16
|
+
}
|
|
17
|
+
getScope(scopeId) {
|
|
18
|
+
let scope = this.scopes.get(scopeId);
|
|
19
|
+
if (!scope) {
|
|
20
|
+
scope = new Map();
|
|
21
|
+
this.scopes.set(scopeId, scope);
|
|
22
|
+
}
|
|
23
|
+
return scope;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const defaultPiiVaultStorage = new InMemoryPiiVaultStorage();
|
package/dist/pii/tokenizer.d.ts
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
|
+
import { type PiiVaultStorage } from './storage.js';
|
|
1
2
|
export type PiiResult = {
|
|
2
3
|
maskedText: string;
|
|
3
4
|
pii: Record<string, string>;
|
|
4
5
|
piiTypes: string[];
|
|
5
6
|
};
|
|
7
|
+
export type PiiTokenizerOptions = {
|
|
8
|
+
scopeId?: string;
|
|
9
|
+
storage?: PiiVaultStorage;
|
|
10
|
+
};
|
|
6
11
|
export declare class PiiTokenizer {
|
|
7
|
-
private vault;
|
|
8
12
|
private valueToToken;
|
|
9
13
|
private counter;
|
|
10
14
|
private piiTypes;
|
|
15
|
+
private scopeId;
|
|
16
|
+
private tokenNamespace;
|
|
17
|
+
private storage;
|
|
18
|
+
constructor(options?: PiiTokenizerOptions);
|
|
11
19
|
private createToken;
|
|
12
20
|
mask(text: string, matches: {
|
|
13
21
|
type: string;
|
|
14
22
|
value: string;
|
|
15
|
-
}[]): PiiResult
|
|
16
|
-
unmask(text: string): string
|
|
17
|
-
|
|
23
|
+
}[]): Promise<PiiResult>;
|
|
24
|
+
unmask(text: string): Promise<string>;
|
|
25
|
+
importTokens(text: string): Promise<void>;
|
|
26
|
+
getVault(): Promise<{
|
|
18
27
|
[k: string]: string;
|
|
19
|
-
}
|
|
28
|
+
}>;
|
|
20
29
|
}
|
package/dist/pii/tokenizer.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { defaultPiiVaultStorage } from './storage.js';
|
|
1
2
|
export class PiiTokenizer {
|
|
2
|
-
vault = new Map();
|
|
3
3
|
valueToToken = new Map();
|
|
4
4
|
counter = 0;
|
|
5
5
|
piiTypes = new Set();
|
|
6
|
+
scopeId;
|
|
7
|
+
tokenNamespace;
|
|
8
|
+
storage;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.scopeId = options.scopeId ?? createVaultScopeId();
|
|
11
|
+
this.tokenNamespace = createVaultScopeId();
|
|
12
|
+
this.storage = options.storage ?? defaultPiiVaultStorage;
|
|
13
|
+
}
|
|
6
14
|
createToken(type) {
|
|
7
|
-
return `[[${type}_${this.counter++}]]`;
|
|
15
|
+
return `[[${type}_${this.tokenNamespace}_${this.counter++}]]`;
|
|
8
16
|
}
|
|
9
|
-
mask(text, matches) {
|
|
17
|
+
async mask(text, matches) {
|
|
10
18
|
let masked = text;
|
|
11
19
|
for (const match of matches) {
|
|
12
20
|
if (!masked.includes(match.value)) {
|
|
@@ -17,25 +25,45 @@ export class PiiTokenizer {
|
|
|
17
25
|
if (!token) {
|
|
18
26
|
token = this.createToken(match.type);
|
|
19
27
|
this.valueToToken.set(key, token);
|
|
20
|
-
this.
|
|
28
|
+
await this.storage.set(this.scopeId, token, match.value);
|
|
21
29
|
}
|
|
22
30
|
this.piiTypes.add(match.type.toLowerCase());
|
|
23
31
|
masked = masked.split(match.value).join(token);
|
|
24
32
|
}
|
|
25
33
|
return {
|
|
26
34
|
maskedText: masked,
|
|
27
|
-
pii:
|
|
35
|
+
pii: await this.getVault(),
|
|
28
36
|
piiTypes: Array.from(this.piiTypes)
|
|
29
37
|
};
|
|
30
38
|
}
|
|
31
|
-
unmask(text) {
|
|
39
|
+
async unmask(text) {
|
|
32
40
|
let result = text;
|
|
33
|
-
this.
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
const entries = await this.storage.entries(this.scopeId);
|
|
42
|
+
for (const { token, value } of entries) {
|
|
43
|
+
result = result.split(token).join(value);
|
|
44
|
+
}
|
|
36
45
|
return result;
|
|
37
46
|
}
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
async importTokens(text) {
|
|
48
|
+
if (!this.storage.getByToken) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const tokenMatches = Array.from(text.matchAll(/\[\[[A-Z_]+_[A-Za-z0-9]+_\d+\]\]/g));
|
|
52
|
+
for (const [token] of tokenMatches) {
|
|
53
|
+
const value = await this.storage.getByToken(token);
|
|
54
|
+
if (value) {
|
|
55
|
+
await this.storage.set(this.scopeId, token, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async getVault() {
|
|
60
|
+
const entries = await this.storage.entries(this.scopeId);
|
|
61
|
+
return Object.fromEntries(entries.map(({ token, value }) => [token, value]));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function createVaultScopeId() {
|
|
65
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
66
|
+
return crypto.randomUUID().replace(/-/g, '');
|
|
40
67
|
}
|
|
68
|
+
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
41
69
|
}
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"huggingface"
|
|
23
23
|
],
|
|
24
24
|
"license": "Apache-2.0",
|
|
25
|
-
"version": "0.0.
|
|
25
|
+
"version": "0.0.10",
|
|
26
26
|
"type": "module",
|
|
27
27
|
"exports": "./dist/index.js",
|
|
28
28
|
"types": "./dist/index.d.ts",
|
|
@@ -32,11 +32,12 @@
|
|
|
32
32
|
"scripts/",
|
|
33
33
|
"dist/"
|
|
34
34
|
],
|
|
35
|
-
"scripts": {
|
|
36
|
-
"prepare-models": "node scripts/download-model.js",
|
|
37
|
-
"build": "tsc",
|
|
38
|
-
"
|
|
39
|
-
|
|
35
|
+
"scripts": {
|
|
36
|
+
"prepare-models": "node scripts/download-model.js",
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"test:concurrency": "npm run build && node scripts/test-concurrent-vault.js",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"@huggingface/transformers": "^4.2.0",
|
|
42
43
|
"zod": "^4.4.3"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { InMemoryPiiVaultStorage } from '../dist/index.js';
|
|
3
|
+
import { PiiTokenizer } from '../dist/pii/tokenizer.js';
|
|
4
|
+
|
|
5
|
+
const storage = new InMemoryPiiVaultStorage();
|
|
6
|
+
const userCount = 100;
|
|
7
|
+
|
|
8
|
+
const users = Array.from({ length: userCount }, (_, index) => ({
|
|
9
|
+
scopeId: `tenantA:user${index}`,
|
|
10
|
+
email: `user${index}@example.com`,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const results = await Promise.all(users.map(async ({ scopeId, email }) => {
|
|
14
|
+
const tokenizer = new PiiTokenizer({ scopeId, storage });
|
|
15
|
+
const prompt = `Fetch metadata for the blob named 'sensitive-user-data-${email}.json' inside Azure Blob Storage.`;
|
|
16
|
+
const masked = await tokenizer.mask(prompt, [{ type: 'EMAIL', value: email }]);
|
|
17
|
+
|
|
18
|
+
const simulatedModelToolInput = {
|
|
19
|
+
blobName: masked.maskedText.match(/'([^']+)'/)?.[1],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const toolInput = {
|
|
23
|
+
blobName: await tokenizer.unmask(simulatedModelToolInput.blobName),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const simulatedModelResponse = `The blob belongs to ${masked.maskedText.match(/\[\[EMAIL_[^\]]+\]\]/)?.[0]}.`;
|
|
27
|
+
const response = await tokenizer.unmask(simulatedModelResponse);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
scopeId,
|
|
31
|
+
email,
|
|
32
|
+
prompt,
|
|
33
|
+
maskedText: masked.maskedText,
|
|
34
|
+
toolInput,
|
|
35
|
+
response,
|
|
36
|
+
};
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const placeholders = new Set(results.map((result) => result.maskedText));
|
|
40
|
+
assert.equal(placeholders.size, userCount, 'each concurrent user should get a unique scoped placeholder');
|
|
41
|
+
|
|
42
|
+
for (const result of results) {
|
|
43
|
+
assert.equal(result.toolInput.blobName, `sensitive-user-data-${result.email}.json`);
|
|
44
|
+
assert.equal(result.response, `The blob belongs to ${result.email}.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const first = results[0];
|
|
48
|
+
const second = results[1];
|
|
49
|
+
const firstTokenizer = new PiiTokenizer({ scopeId: first.scopeId, storage });
|
|
50
|
+
assert.equal(
|
|
51
|
+
await firstTokenizer.unmask(second.maskedText),
|
|
52
|
+
second.maskedText,
|
|
53
|
+
'one user scope must not resolve another user scope placeholder'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const promptTokenizer = new PiiTokenizer({ scopeId: 'trace:request', storage });
|
|
57
|
+
const promptMasked = await promptTokenizer.mask(
|
|
58
|
+
"Fetch metadata for the blob named 'sensitive-user-data-john.doe@example.com.json' inside Azure Blob Storage.",
|
|
59
|
+
[{ type: 'EMAIL', value: 'john.doe@example.com' }]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const toolResponseTokenizer = new PiiTokenizer({ scopeId: 'trace:request', storage });
|
|
63
|
+
await toolResponseTokenizer.mask(
|
|
64
|
+
'john.doe@example.com',
|
|
65
|
+
[{ type: 'EMAIL', value: 'john.doe@example.com' }]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const finalModelResponse = `The metadata for the blob '${promptMasked.maskedText.match(/\[\[EMAIL_[^\]]+\]\]/)?.[0]}' has been successfully retrieved.`;
|
|
69
|
+
let unmaskedFinalResponse = finalModelResponse;
|
|
70
|
+
for (const tokenizer of [promptTokenizer, toolResponseTokenizer]) {
|
|
71
|
+
unmaskedFinalResponse = await tokenizer.unmask(unmaskedFinalResponse);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
assert.equal(
|
|
75
|
+
unmaskedFinalResponse,
|
|
76
|
+
"The metadata for the blob 'john.doe@example.com' has been successfully retrieved.",
|
|
77
|
+
'final model output should unmask placeholders created by earlier middleware passes'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const firstMiddlewarePass = new PiiTokenizer({ storage });
|
|
81
|
+
const firstPassMasked = await firstMiddlewarePass.mask(
|
|
82
|
+
"Fetch metadata for the blob named 'sensitive-user-data-jane.doe@example.com.json' inside Azure Blob Storage.",
|
|
83
|
+
[{ type: 'EMAIL', value: 'sensitive-user-data-jane.doe@example.com.json' }]
|
|
84
|
+
);
|
|
85
|
+
const laterMiddlewarePass = new PiiTokenizer({ storage });
|
|
86
|
+
const crossContextResponse = `The metadata for the blob '${firstPassMasked.maskedText.match(/\[\[EMAIL_[^\]]+\]\]/)?.[0]}' has been successfully retrieved.`;
|
|
87
|
+
|
|
88
|
+
await laterMiddlewarePass.importTokens(firstPassMasked.maskedText);
|
|
89
|
+
|
|
90
|
+
assert.equal(
|
|
91
|
+
await laterMiddlewarePass.unmask(crossContextResponse),
|
|
92
|
+
"The metadata for the blob 'sensitive-user-data-jane.doe@example.com.json' has been successfully retrieved.",
|
|
93
|
+
'a later middleware context should resolve an earlier opaque token from the shared default vault'
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
console.log(`Concurrent PII vault simulation passed for ${userCount} isolated scopes.`);
|