@intflows/genkit-guard 0.0.9 → 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 +3 -1
- package/dist/index.js +24 -3
- package/dist/middleware/middleware.d.ts +197 -1
- package/dist/middleware/middleware.js +220 -47
- package/dist/pii/storage.d.ts +20 -0
- package/dist/pii/storage.js +26 -0
- package/dist/pii/tokenizer.d.ts +15 -5
- package/dist/pii/tokenizer.js +49 -12
- package/dist/util/singleton.js +16 -1
- package/package.json +9 -8
- 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
|
-
export { guard } from './middleware/middleware.js';
|
|
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,12 +1,31 @@
|
|
|
1
1
|
import { ModelSingleton } from './util/singleton.js';
|
|
2
2
|
// export { intentGuard, piiGuard } from './middleware/middleware.js';
|
|
3
|
-
export { guard } from './middleware/middleware.js';
|
|
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';
|
|
6
|
+
function logGuardEvent(eventName, body, attributes = {}) {
|
|
7
|
+
console.log(JSON.stringify({
|
|
8
|
+
timestamp: new Date().toISOString(),
|
|
9
|
+
severityText: 'INFO',
|
|
10
|
+
severityNumber: 9,
|
|
11
|
+
body,
|
|
12
|
+
resource: {
|
|
13
|
+
attributes: {
|
|
14
|
+
'service.name': '@intflows/genkit-guard',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
attributes: {
|
|
18
|
+
'event.name': eventName,
|
|
19
|
+
'code.namespace': 'genkit-guard',
|
|
20
|
+
...attributes,
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
5
24
|
/**
|
|
6
25
|
* Pre-load the model to avoid cold-start delay on first user request.
|
|
7
26
|
*/
|
|
8
27
|
export async function initGuard(config) {
|
|
9
|
-
|
|
28
|
+
logGuardEvent('guard.models.loading', 'Loading local guard models');
|
|
10
29
|
const extractorModel = config?.models?.extractor ?? 'Xenova/all-MiniLM-L6-v2';
|
|
11
30
|
const piiModel = config?.pii?.model;
|
|
12
31
|
const piiMode = config?.pii?.mode ?? 'ner';
|
|
@@ -18,5 +37,7 @@ export async function initGuard(config) {
|
|
|
18
37
|
tasks.push(ModelSingleton.getPIIClassifier(piiModel ?? 'openai/privacy-filter'));
|
|
19
38
|
}
|
|
20
39
|
await Promise.all(tasks);
|
|
21
|
-
|
|
40
|
+
logGuardEvent('guard.models.loaded', 'Local guard models loaded', {
|
|
41
|
+
piiMode,
|
|
42
|
+
});
|
|
22
43
|
}
|
|
@@ -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,45 @@ 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;
|
|
62
|
+
}>>;
|
|
63
|
+
logging: z.ZodOptional<z.ZodObject<{
|
|
64
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
65
|
+
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
66
|
+
serviceName: z.ZodOptional<z.ZodString>;
|
|
67
|
+
}, "strip", z.ZodTypeAny, {
|
|
68
|
+
enabled?: boolean | undefined;
|
|
69
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
70
|
+
serviceName?: string | undefined;
|
|
71
|
+
}, {
|
|
72
|
+
enabled?: boolean | undefined;
|
|
73
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
74
|
+
serviceName?: string | undefined;
|
|
43
75
|
}>>;
|
|
44
76
|
models: z.ZodOptional<z.ZodObject<{
|
|
45
77
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -81,14 +113,45 @@ declare const guardConfigSchema: z.ZodObject<{
|
|
|
81
113
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
82
114
|
model: z.ZodOptional<z.ZodString>;
|
|
83
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
|
+
}>>;
|
|
84
126
|
}, "strip", z.ZodTypeAny, {
|
|
85
127
|
mode?: "ner" | "classifier" | undefined;
|
|
86
128
|
reversible?: boolean | undefined;
|
|
87
129
|
model?: string | undefined;
|
|
130
|
+
vault?: {
|
|
131
|
+
storage?: any;
|
|
132
|
+
scopeId?: any;
|
|
133
|
+
} | undefined;
|
|
88
134
|
}, {
|
|
89
135
|
mode?: "ner" | "classifier" | undefined;
|
|
90
136
|
reversible?: boolean | undefined;
|
|
91
137
|
model?: string | undefined;
|
|
138
|
+
vault?: {
|
|
139
|
+
storage?: any;
|
|
140
|
+
scopeId?: any;
|
|
141
|
+
} | undefined;
|
|
142
|
+
}>>;
|
|
143
|
+
logging: z.ZodOptional<z.ZodObject<{
|
|
144
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
145
|
+
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
146
|
+
serviceName: z.ZodOptional<z.ZodString>;
|
|
147
|
+
}, "strip", z.ZodTypeAny, {
|
|
148
|
+
enabled?: boolean | undefined;
|
|
149
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
150
|
+
serviceName?: string | undefined;
|
|
151
|
+
}, {
|
|
152
|
+
enabled?: boolean | undefined;
|
|
153
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
154
|
+
serviceName?: string | undefined;
|
|
92
155
|
}>>;
|
|
93
156
|
models: z.ZodOptional<z.ZodObject<{
|
|
94
157
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -130,14 +193,45 @@ declare const guardConfigSchema: z.ZodObject<{
|
|
|
130
193
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
131
194
|
model: z.ZodOptional<z.ZodString>;
|
|
132
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
|
+
}>>;
|
|
133
206
|
}, "strip", z.ZodTypeAny, {
|
|
134
207
|
mode?: "ner" | "classifier" | undefined;
|
|
135
208
|
reversible?: boolean | undefined;
|
|
136
209
|
model?: string | undefined;
|
|
210
|
+
vault?: {
|
|
211
|
+
storage?: any;
|
|
212
|
+
scopeId?: any;
|
|
213
|
+
} | undefined;
|
|
137
214
|
}, {
|
|
138
215
|
mode?: "ner" | "classifier" | undefined;
|
|
139
216
|
reversible?: boolean | undefined;
|
|
140
217
|
model?: string | undefined;
|
|
218
|
+
vault?: {
|
|
219
|
+
storage?: any;
|
|
220
|
+
scopeId?: any;
|
|
221
|
+
} | undefined;
|
|
222
|
+
}>>;
|
|
223
|
+
logging: z.ZodOptional<z.ZodObject<{
|
|
224
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
225
|
+
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
226
|
+
serviceName: z.ZodOptional<z.ZodString>;
|
|
227
|
+
}, "strip", z.ZodTypeAny, {
|
|
228
|
+
enabled?: boolean | undefined;
|
|
229
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
230
|
+
serviceName?: string | undefined;
|
|
231
|
+
}, {
|
|
232
|
+
enabled?: boolean | undefined;
|
|
233
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
234
|
+
serviceName?: string | undefined;
|
|
141
235
|
}>>;
|
|
142
236
|
models: z.ZodOptional<z.ZodObject<{
|
|
143
237
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -147,6 +241,14 @@ declare const guardConfigSchema: z.ZodObject<{
|
|
|
147
241
|
extractor?: string | undefined;
|
|
148
242
|
}>>;
|
|
149
243
|
}, z.ZodTypeAny, "passthrough">>;
|
|
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
|
+
};
|
|
150
252
|
export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodObject<{
|
|
151
253
|
intent: z.ZodOptional<z.ZodObject<{
|
|
152
254
|
mode: z.ZodOptional<z.ZodString>;
|
|
@@ -180,14 +282,45 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
180
282
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
181
283
|
model: z.ZodOptional<z.ZodString>;
|
|
182
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
|
+
}>>;
|
|
183
295
|
}, "strip", z.ZodTypeAny, {
|
|
184
296
|
mode?: "ner" | "classifier" | undefined;
|
|
185
297
|
reversible?: boolean | undefined;
|
|
186
298
|
model?: string | undefined;
|
|
299
|
+
vault?: {
|
|
300
|
+
storage?: any;
|
|
301
|
+
scopeId?: any;
|
|
302
|
+
} | undefined;
|
|
187
303
|
}, {
|
|
188
304
|
mode?: "ner" | "classifier" | undefined;
|
|
189
305
|
reversible?: boolean | undefined;
|
|
190
306
|
model?: string | undefined;
|
|
307
|
+
vault?: {
|
|
308
|
+
storage?: any;
|
|
309
|
+
scopeId?: any;
|
|
310
|
+
} | undefined;
|
|
311
|
+
}>>;
|
|
312
|
+
logging: z.ZodOptional<z.ZodObject<{
|
|
313
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
314
|
+
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
315
|
+
serviceName: z.ZodOptional<z.ZodString>;
|
|
316
|
+
}, "strip", z.ZodTypeAny, {
|
|
317
|
+
enabled?: boolean | undefined;
|
|
318
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
319
|
+
serviceName?: string | undefined;
|
|
320
|
+
}, {
|
|
321
|
+
enabled?: boolean | undefined;
|
|
322
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
323
|
+
serviceName?: string | undefined;
|
|
191
324
|
}>>;
|
|
192
325
|
models: z.ZodOptional<z.ZodObject<{
|
|
193
326
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -229,14 +362,45 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
229
362
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
230
363
|
model: z.ZodOptional<z.ZodString>;
|
|
231
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
|
+
}>>;
|
|
232
375
|
}, "strip", z.ZodTypeAny, {
|
|
233
376
|
mode?: "ner" | "classifier" | undefined;
|
|
234
377
|
reversible?: boolean | undefined;
|
|
235
378
|
model?: string | undefined;
|
|
379
|
+
vault?: {
|
|
380
|
+
storage?: any;
|
|
381
|
+
scopeId?: any;
|
|
382
|
+
} | undefined;
|
|
236
383
|
}, {
|
|
237
384
|
mode?: "ner" | "classifier" | undefined;
|
|
238
385
|
reversible?: boolean | undefined;
|
|
239
386
|
model?: string | undefined;
|
|
387
|
+
vault?: {
|
|
388
|
+
storage?: any;
|
|
389
|
+
scopeId?: any;
|
|
390
|
+
} | undefined;
|
|
391
|
+
}>>;
|
|
392
|
+
logging: z.ZodOptional<z.ZodObject<{
|
|
393
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
394
|
+
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
395
|
+
serviceName: z.ZodOptional<z.ZodString>;
|
|
396
|
+
}, "strip", z.ZodTypeAny, {
|
|
397
|
+
enabled?: boolean | undefined;
|
|
398
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
399
|
+
serviceName?: string | undefined;
|
|
400
|
+
}, {
|
|
401
|
+
enabled?: boolean | undefined;
|
|
402
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
403
|
+
serviceName?: string | undefined;
|
|
240
404
|
}>>;
|
|
241
405
|
models: z.ZodOptional<z.ZodObject<{
|
|
242
406
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -278,14 +442,45 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
278
442
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
279
443
|
model: z.ZodOptional<z.ZodString>;
|
|
280
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
|
+
}>>;
|
|
281
455
|
}, "strip", z.ZodTypeAny, {
|
|
282
456
|
mode?: "ner" | "classifier" | undefined;
|
|
283
457
|
reversible?: boolean | undefined;
|
|
284
458
|
model?: string | undefined;
|
|
459
|
+
vault?: {
|
|
460
|
+
storage?: any;
|
|
461
|
+
scopeId?: any;
|
|
462
|
+
} | undefined;
|
|
285
463
|
}, {
|
|
286
464
|
mode?: "ner" | "classifier" | undefined;
|
|
287
465
|
reversible?: boolean | undefined;
|
|
288
466
|
model?: string | undefined;
|
|
467
|
+
vault?: {
|
|
468
|
+
storage?: any;
|
|
469
|
+
scopeId?: any;
|
|
470
|
+
} | undefined;
|
|
471
|
+
}>>;
|
|
472
|
+
logging: z.ZodOptional<z.ZodObject<{
|
|
473
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
474
|
+
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
475
|
+
serviceName: z.ZodOptional<z.ZodString>;
|
|
476
|
+
}, "strip", z.ZodTypeAny, {
|
|
477
|
+
enabled?: boolean | undefined;
|
|
478
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
479
|
+
serviceName?: string | undefined;
|
|
480
|
+
}, {
|
|
481
|
+
enabled?: boolean | undefined;
|
|
482
|
+
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
483
|
+
serviceName?: string | undefined;
|
|
289
484
|
}>>;
|
|
290
485
|
models: z.ZodOptional<z.ZodObject<{
|
|
291
486
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -296,5 +491,6 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
296
491
|
}>>;
|
|
297
492
|
}, z.ZodTypeAny, "passthrough">>, void>;
|
|
298
493
|
export declare const guardPlugin: (pluginOptions: void) => import("@genkit-ai/ai").GenkitPluginV2;
|
|
299
|
-
export declare function guard(config?:
|
|
494
|
+
export declare function guard(config?: GuardConfig): (req: any, ctxOrNext: any, maybeNext?: any) => Promise<any>;
|
|
495
|
+
export declare const guardAction: typeof guard;
|
|
300
496
|
export {};
|
|
@@ -2,6 +2,8 @@ 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';
|
|
6
|
+
const GUARD_CONTEXT_KEY = '__genkitGuard';
|
|
5
7
|
const guardConfigSchema = z.object({
|
|
6
8
|
intent: z.object({
|
|
7
9
|
mode: z.string().optional(),
|
|
@@ -15,6 +17,15 @@ const guardConfigSchema = z.object({
|
|
|
15
17
|
reversible: z.boolean().optional(),
|
|
16
18
|
model: z.string().optional(),
|
|
17
19
|
mode: z.enum(['ner', 'classifier']).optional(),
|
|
20
|
+
vault: z.object({
|
|
21
|
+
storage: z.any().optional(),
|
|
22
|
+
scopeId: z.any().optional(),
|
|
23
|
+
}).optional(),
|
|
24
|
+
}).optional(),
|
|
25
|
+
logging: z.object({
|
|
26
|
+
enabled: z.boolean().optional(),
|
|
27
|
+
level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
|
28
|
+
serviceName: z.string().optional(),
|
|
18
29
|
}).optional(),
|
|
19
30
|
models: z.object({
|
|
20
31
|
extractor: z.string().optional(),
|
|
@@ -22,83 +33,132 @@ const guardConfigSchema = z.object({
|
|
|
22
33
|
}).passthrough();
|
|
23
34
|
export const guardMiddleware = generateMiddleware({
|
|
24
35
|
name: 'genkitGuard',
|
|
25
|
-
description: 'Blocks prompt injection and disallowed intent,
|
|
36
|
+
description: 'Blocks prompt injection and disallowed intent, masks PII before model calls, restores PII for tool calls, and audits tool PII access.',
|
|
26
37
|
configSchema: guardConfigSchema,
|
|
27
38
|
}, ({ config }) => createGuardHooks(config));
|
|
28
39
|
export const guardPlugin = guardMiddleware.plugin;
|
|
29
40
|
export function guard(config) {
|
|
30
41
|
const hooks = createGuardHooks(config);
|
|
31
42
|
const baseMiddleware = guardMiddleware(config);
|
|
32
|
-
// 1. Create the wrapper function runner
|
|
33
43
|
const fnRunner = async (req, ctxOrNext, maybeNext) => {
|
|
34
44
|
if (typeof maybeNext === 'function') {
|
|
35
45
|
return hooks.model(req, ctxOrNext, maybeNext);
|
|
36
46
|
}
|
|
37
47
|
return hooks.model(req, {}, async (modifiedReq) => ctxOrNext(modifiedReq || req));
|
|
38
48
|
};
|
|
39
|
-
// 2. Combine the base middleware properties and custom hooks into a source object
|
|
40
49
|
const source = Object.assign({}, baseMiddleware, hooks);
|
|
41
|
-
// 3. Safely copy properties onto the function runner, explicitly skipping the read-only 'name' property
|
|
42
50
|
for (const key of Object.keys(source)) {
|
|
43
51
|
if (key === 'name')
|
|
44
|
-
continue;
|
|
45
|
-
// Use defineProperty or simple assignment for everything else
|
|
52
|
+
continue;
|
|
46
53
|
Object.defineProperty(fnRunner, key, {
|
|
47
54
|
value: source[key],
|
|
48
55
|
writable: true,
|
|
49
56
|
configurable: true,
|
|
50
|
-
enumerable: true
|
|
57
|
+
enumerable: true,
|
|
51
58
|
});
|
|
52
59
|
}
|
|
53
60
|
return fnRunner;
|
|
54
61
|
}
|
|
62
|
+
export const guardAction = guard;
|
|
55
63
|
function createGuardHooks(config) {
|
|
64
|
+
const logger = createLogger(config);
|
|
56
65
|
return {
|
|
57
66
|
model: async (req, ctx, next) => {
|
|
58
67
|
const input = getInputText(req);
|
|
68
|
+
logger('info', 'guard.model.start', 'Starting guard checks for model request');
|
|
59
69
|
const isInjection = await detectInjection(input);
|
|
60
70
|
if (isInjection) {
|
|
61
|
-
|
|
71
|
+
logger('warn', 'guard.intent.blocked', 'Prompt injection pattern detected', {
|
|
72
|
+
reason: 'pattern_match',
|
|
73
|
+
});
|
|
62
74
|
return block('Prompt injection detected', {
|
|
63
75
|
reason: 'pattern_match',
|
|
64
76
|
});
|
|
65
77
|
}
|
|
66
|
-
|
|
78
|
+
logger('info', 'guard.intent.analysis.start', 'Analyzing request intent');
|
|
67
79
|
const intentResult = await analyzeIntentStructured(input, config?.intent?.semantic?.intents ?? {}, config?.intent?.semantic?.threshold ?? 0.7);
|
|
68
|
-
|
|
80
|
+
logger('info', 'guard.intent.analysis.complete', 'Intent analysis completed', {
|
|
81
|
+
intent: intentResult.intent,
|
|
82
|
+
score: roundScore(intentResult.score),
|
|
83
|
+
allowed: intentResult.allowed,
|
|
84
|
+
});
|
|
69
85
|
if (!intentResult.allowed) {
|
|
70
|
-
|
|
86
|
+
logger('warn', 'guard.intent.blocked', 'Intent not allowed', {
|
|
87
|
+
intent: intentResult.intent,
|
|
88
|
+
score: roundScore(intentResult.score),
|
|
89
|
+
});
|
|
71
90
|
return block('Intent not allowed', {
|
|
72
91
|
intent: intentResult.intent,
|
|
73
92
|
score: intentResult.score,
|
|
74
93
|
});
|
|
75
94
|
}
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
mode: config?.pii?.mode,
|
|
79
|
-
});
|
|
95
|
+
const textForPii = collectModelRequestText(req);
|
|
96
|
+
const piiResponse = await scanPII(textForPii, config);
|
|
80
97
|
const piiMatches = piiResponse?.matches || [];
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
const tokenizer = createTokenizer(config, req, ctx);
|
|
99
|
+
const piiTypes = uniqueTypes(piiMatches);
|
|
100
|
+
await tokenizer.importTokens(textForPii);
|
|
101
|
+
await maskModelRequest(req, tokenizer, piiMatches);
|
|
102
|
+
pushTokenizer(ctx, tokenizer);
|
|
103
|
+
logger(piiMatches.length > 0 ? 'warn' : 'info', 'guard.model.pii.masked', 'PII scan completed for model request', {
|
|
104
|
+
piiDetected: piiMatches.length > 0,
|
|
105
|
+
piiMatchCount: piiMatches.length,
|
|
106
|
+
piiTypes,
|
|
107
|
+
piiMode: config?.pii?.mode ?? 'ner',
|
|
108
|
+
classifierOutputPresent: Boolean(piiResponse.classifier),
|
|
109
|
+
});
|
|
85
110
|
req.metadata = {
|
|
86
111
|
...req.metadata,
|
|
87
|
-
piiTokenizer: tokenizer,
|
|
88
112
|
intent: intentResult.intent,
|
|
89
113
|
score: intentResult.score,
|
|
90
114
|
piiDetected: piiMatches.length > 0,
|
|
91
|
-
piiTypes
|
|
92
|
-
maskedInput:
|
|
115
|
+
piiTypes,
|
|
116
|
+
maskedInput: getInputText(req),
|
|
93
117
|
piiModel: config?.pii?.model,
|
|
94
118
|
piiMode: config?.pii?.mode,
|
|
95
119
|
piiClassifierOutput: piiResponse.classifier,
|
|
96
120
|
};
|
|
97
|
-
replaceInputText(req, piiResult.maskedText);
|
|
98
121
|
const res = await next(req, ctx);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
122
|
+
const unmaskedResponse = await unmaskObject(res, getGuardState(ctx).tokenizers);
|
|
123
|
+
logger('info', 'guard.model.response.unmasked', 'Model response unmasked for downstream execution', {
|
|
124
|
+
piiTypes,
|
|
125
|
+
});
|
|
126
|
+
return unmaskedResponse;
|
|
127
|
+
},
|
|
128
|
+
tool: async (req, ctx, next) => {
|
|
129
|
+
const state = getGuardState(ctx);
|
|
130
|
+
const toolName = req?.toolRequest?.name;
|
|
131
|
+
if (req?.toolRequest && 'input' in req.toolRequest) {
|
|
132
|
+
req.toolRequest.input = await unmaskObject(req.toolRequest.input, state.tokenizers);
|
|
133
|
+
}
|
|
134
|
+
const toolInputText = collectStrings(req?.toolRequest?.input).join('\n');
|
|
135
|
+
const piiResponse = await scanPII(toolInputText, config);
|
|
136
|
+
const piiMatches = piiResponse?.matches || [];
|
|
137
|
+
const piiTypes = uniqueTypes(piiMatches);
|
|
138
|
+
req.metadata = {
|
|
139
|
+
...req.metadata,
|
|
140
|
+
piiDetected: piiMatches.length > 0,
|
|
141
|
+
piiTypes,
|
|
142
|
+
piiMatchCount: piiMatches.length,
|
|
143
|
+
};
|
|
144
|
+
logger(piiMatches.length > 0 ? 'warn' : 'info', 'guard.tool.pii.checked', 'Tool request PII scan completed', {
|
|
145
|
+
toolName,
|
|
146
|
+
piiDetected: piiMatches.length > 0,
|
|
147
|
+
piiMatchCount: piiMatches.length,
|
|
148
|
+
piiTypes,
|
|
149
|
+
});
|
|
150
|
+
const res = await next(req, ctx);
|
|
151
|
+
const toolResponseText = collectStrings(res).join('\n');
|
|
152
|
+
if (toolResponseText) {
|
|
153
|
+
const responsePii = await scanPII(toolResponseText, config);
|
|
154
|
+
const responseMatches = responsePii?.matches || [];
|
|
155
|
+
logger(responseMatches.length > 0 ? 'warn' : 'info', 'guard.tool.response.pii.checked', 'Tool response PII scan completed', {
|
|
156
|
+
toolName,
|
|
157
|
+
piiDetected: responseMatches.length > 0,
|
|
158
|
+
piiMatchCount: responseMatches.length,
|
|
159
|
+
piiTypes: uniqueTypes(responseMatches),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
102
162
|
return res;
|
|
103
163
|
},
|
|
104
164
|
};
|
|
@@ -115,36 +175,149 @@ function getInputText(req) {
|
|
|
115
175
|
if (typeof firstContent === 'string') {
|
|
116
176
|
return firstContent;
|
|
117
177
|
}
|
|
118
|
-
return '';
|
|
178
|
+
return collectStrings(lastMessage).join('\n');
|
|
119
179
|
}
|
|
120
|
-
function
|
|
180
|
+
function collectModelRequestText(req) {
|
|
181
|
+
return [
|
|
182
|
+
...collectStrings(req?.prompt),
|
|
183
|
+
...collectStrings(req?.messages),
|
|
184
|
+
...collectStrings(req?.docs),
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
async function maskModelRequest(req, tokenizer, matches) {
|
|
121
188
|
if (typeof req.prompt === 'string') {
|
|
122
|
-
req.prompt =
|
|
189
|
+
req.prompt = (await tokenizer.mask(req.prompt, matches)).maskedText;
|
|
190
|
+
}
|
|
191
|
+
if (req.messages) {
|
|
192
|
+
req.messages = await transformStrings(req.messages, async (value) => (await tokenizer.mask(value, matches)).maskedText);
|
|
193
|
+
}
|
|
194
|
+
if (req.docs) {
|
|
195
|
+
req.docs = await transformStrings(req.docs, async (value) => (await tokenizer.mask(value, matches)).maskedText);
|
|
123
196
|
}
|
|
124
|
-
req.messages = [
|
|
125
|
-
{
|
|
126
|
-
role: 'user',
|
|
127
|
-
content: [{ text }],
|
|
128
|
-
},
|
|
129
|
-
];
|
|
130
197
|
}
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
198
|
+
async function unmaskObject(obj, tokenizers) {
|
|
199
|
+
return transformStrings(obj, async (value) => {
|
|
200
|
+
let result = value;
|
|
201
|
+
for (const tokenizer of tokenizers) {
|
|
202
|
+
result = await tokenizer.unmask(result);
|
|
135
203
|
}
|
|
136
|
-
|
|
137
|
-
|
|
204
|
+
return result;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function transformStrings(obj, transform) {
|
|
208
|
+
if (typeof obj === 'string') {
|
|
209
|
+
return await transform(obj);
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(obj)) {
|
|
212
|
+
for (let i = 0; i < obj.length; i++) {
|
|
213
|
+
obj[i] = await transformStrings(obj[i], transform);
|
|
138
214
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
215
|
+
return obj;
|
|
216
|
+
}
|
|
217
|
+
if (obj !== null && typeof obj === 'object') {
|
|
218
|
+
for (const key of Object.keys(obj)) {
|
|
219
|
+
obj[key] = await transformStrings(obj[key], transform);
|
|
144
220
|
}
|
|
145
221
|
return obj;
|
|
222
|
+
}
|
|
223
|
+
return obj;
|
|
224
|
+
}
|
|
225
|
+
function collectStrings(obj) {
|
|
226
|
+
if (typeof obj === 'string') {
|
|
227
|
+
return [obj];
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(obj)) {
|
|
230
|
+
return obj.flatMap(collectStrings);
|
|
231
|
+
}
|
|
232
|
+
if (obj !== null && typeof obj === 'object') {
|
|
233
|
+
return Object.values(obj).flatMap(collectStrings);
|
|
234
|
+
}
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
async function scanPII(text, config) {
|
|
238
|
+
if (!text.trim()) {
|
|
239
|
+
return {
|
|
240
|
+
matches: [],
|
|
241
|
+
classifier: undefined,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return detectPII(text, {
|
|
245
|
+
model: config?.pii?.model,
|
|
246
|
+
mode: config?.pii?.mode,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function getGuardState(ctx = {}) {
|
|
250
|
+
ctx.context = ctx.context || {};
|
|
251
|
+
ctx.context[GUARD_CONTEXT_KEY] = ctx.context[GUARD_CONTEXT_KEY] || { tokenizers: [] };
|
|
252
|
+
return ctx.context[GUARD_CONTEXT_KEY];
|
|
253
|
+
}
|
|
254
|
+
function pushTokenizer(ctx, tokenizer) {
|
|
255
|
+
const state = getGuardState(ctx);
|
|
256
|
+
state.tokenizers.push(tokenizer);
|
|
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
|
+
}
|
|
268
|
+
function uniqueTypes(matches) {
|
|
269
|
+
return Array.from(new Set(matches.map((match) => match.type.toLowerCase())));
|
|
270
|
+
}
|
|
271
|
+
function roundScore(score) {
|
|
272
|
+
return Math.round(score * 10000) / 10000;
|
|
273
|
+
}
|
|
274
|
+
function createLogger(config) {
|
|
275
|
+
const enabled = config?.logging?.enabled ?? true;
|
|
276
|
+
const minimumLevel = config?.logging?.level ?? 'info';
|
|
277
|
+
const serviceName = config?.logging?.serviceName ?? '@intflows/genkit-guard';
|
|
278
|
+
const levelRank = {
|
|
279
|
+
debug: 10,
|
|
280
|
+
info: 20,
|
|
281
|
+
warn: 30,
|
|
282
|
+
error: 40,
|
|
283
|
+
};
|
|
284
|
+
const severityNumber = {
|
|
285
|
+
debug: 5,
|
|
286
|
+
info: 9,
|
|
287
|
+
warn: 13,
|
|
288
|
+
error: 17,
|
|
289
|
+
};
|
|
290
|
+
return (severity, eventName, body, attributes = {}) => {
|
|
291
|
+
if (!enabled || levelRank[severity] < levelRank[minimumLevel]) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const record = {
|
|
295
|
+
timestamp: new Date().toISOString(),
|
|
296
|
+
severityText: severity.toUpperCase(),
|
|
297
|
+
severityNumber: severityNumber[severity],
|
|
298
|
+
body,
|
|
299
|
+
resource: {
|
|
300
|
+
attributes: {
|
|
301
|
+
'service.name': serviceName,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
attributes: {
|
|
305
|
+
'event.name': eventName,
|
|
306
|
+
'code.namespace': 'genkit-guard',
|
|
307
|
+
...attributes,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
const line = JSON.stringify(record);
|
|
311
|
+
if (severity === 'error') {
|
|
312
|
+
console.error(line);
|
|
313
|
+
}
|
|
314
|
+
else if (severity === 'warn') {
|
|
315
|
+
console.warn(line);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
console.log(line);
|
|
319
|
+
}
|
|
146
320
|
};
|
|
147
|
-
transform(res);
|
|
148
321
|
}
|
|
149
322
|
function block(message, metadata) {
|
|
150
323
|
return {
|
|
@@ -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,19 +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
|
|
12
|
+
private valueToToken;
|
|
8
13
|
private counter;
|
|
9
14
|
private piiTypes;
|
|
15
|
+
private scopeId;
|
|
16
|
+
private tokenNamespace;
|
|
17
|
+
private storage;
|
|
18
|
+
constructor(options?: PiiTokenizerOptions);
|
|
10
19
|
private createToken;
|
|
11
20
|
mask(text: string, matches: {
|
|
12
21
|
type: string;
|
|
13
22
|
value: string;
|
|
14
|
-
}[]): PiiResult
|
|
15
|
-
unmask(text: string): string
|
|
16
|
-
|
|
23
|
+
}[]): Promise<PiiResult>;
|
|
24
|
+
unmask(text: string): Promise<string>;
|
|
25
|
+
importTokens(text: string): Promise<void>;
|
|
26
|
+
getVault(): Promise<{
|
|
17
27
|
[k: string]: string;
|
|
18
|
-
}
|
|
28
|
+
}>;
|
|
19
29
|
}
|
package/dist/pii/tokenizer.js
CHANGED
|
@@ -1,32 +1,69 @@
|
|
|
1
|
+
import { defaultPiiVaultStorage } from './storage.js';
|
|
1
2
|
export class PiiTokenizer {
|
|
2
|
-
|
|
3
|
+
valueToToken = new Map();
|
|
3
4
|
counter = 0;
|
|
4
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
|
+
}
|
|
5
14
|
createToken(type) {
|
|
6
|
-
return `[[${type}_${this.counter++}]]`;
|
|
15
|
+
return `[[${type}_${this.tokenNamespace}_${this.counter++}]]`;
|
|
7
16
|
}
|
|
8
|
-
mask(text, matches) {
|
|
17
|
+
async mask(text, matches) {
|
|
9
18
|
let masked = text;
|
|
10
19
|
for (const match of matches) {
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
if (!masked.includes(match.value)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const key = `${match.type}:${match.value}`;
|
|
24
|
+
let token = this.valueToToken.get(key);
|
|
25
|
+
if (!token) {
|
|
26
|
+
token = this.createToken(match.type);
|
|
27
|
+
this.valueToToken.set(key, token);
|
|
28
|
+
await this.storage.set(this.scopeId, token, match.value);
|
|
29
|
+
}
|
|
13
30
|
this.piiTypes.add(match.type.toLowerCase());
|
|
14
31
|
masked = masked.split(match.value).join(token);
|
|
15
32
|
}
|
|
16
33
|
return {
|
|
17
34
|
maskedText: masked,
|
|
18
|
-
pii:
|
|
35
|
+
pii: await this.getVault(),
|
|
19
36
|
piiTypes: Array.from(this.piiTypes)
|
|
20
37
|
};
|
|
21
38
|
}
|
|
22
|
-
unmask(text) {
|
|
39
|
+
async unmask(text) {
|
|
23
40
|
let result = text;
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
const entries = await this.storage.entries(this.scopeId);
|
|
42
|
+
for (const { token, value } of entries) {
|
|
43
|
+
result = result.split(token).join(value);
|
|
44
|
+
}
|
|
27
45
|
return result;
|
|
28
46
|
}
|
|
29
|
-
|
|
30
|
-
|
|
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, '');
|
|
31
67
|
}
|
|
68
|
+
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
32
69
|
}
|
package/dist/util/singleton.js
CHANGED
|
@@ -21,7 +21,22 @@ export class ModelSingleton {
|
|
|
21
21
|
env.localModelPath = modelPath;
|
|
22
22
|
// Allow remote download if missing
|
|
23
23
|
env.allowRemoteModels = true;
|
|
24
|
-
console.log(
|
|
24
|
+
console.log(JSON.stringify({
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
severityText: 'INFO',
|
|
27
|
+
severityNumber: 9,
|
|
28
|
+
body: 'Using guard model directory',
|
|
29
|
+
resource: {
|
|
30
|
+
attributes: {
|
|
31
|
+
'service.name': '@intflows/genkit-guard',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
attributes: {
|
|
35
|
+
'event.name': 'guard.models.directory',
|
|
36
|
+
'code.namespace': 'genkit-guard',
|
|
37
|
+
modelPath,
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
25
40
|
}
|
|
26
41
|
static async getExtractor(modelName = 'Xenova/all-MiniLM-L6-v2') {
|
|
27
42
|
if (!this.extractors.has(modelName)) {
|
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,21 +32,22 @@
|
|
|
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"
|
|
43
44
|
},
|
|
44
45
|
"peerDependencies": {
|
|
45
|
-
"genkit": "
|
|
46
|
+
"genkit": "1.39.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^25.8.0",
|
|
49
|
-
"genkit": "^1.
|
|
50
|
+
"genkit": "^1.39.0",
|
|
50
51
|
"typescript": "^6.0.3"
|
|
51
52
|
}
|
|
52
53
|
}
|
|
@@ -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.`);
|