@intflows/genkit-guard 0.0.13 → 0.0.14
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 +174 -202
- package/dist/core/decision.d.ts +33 -0
- package/dist/core/decision.js +9 -0
- package/dist/guard.config.d.ts +10 -0
- package/dist/guard.config.js +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +8 -10
- package/dist/intent/intentAnalyzer.d.ts +1 -1
- package/dist/intent/intentAnalyzer.js +2 -2
- package/dist/middleware/middleware.d.ts +87 -7
- package/dist/middleware/middleware.js +85 -4
- package/dist/pii/detector.d.ts +3 -1
- package/dist/pii/detector.js +23 -4
- package/package.json +6 -3
- package/scripts/publish-wiki.js +59 -0
- package/scripts/test-config.js +45 -0
- package/scripts/test-publish-wiki.js +30 -0
- package/scripts/test-release1.js +83 -0
- package/scripts/test-types.ts +30 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { type GuardDecision, type ToolGuardConfig } from '../core/decision.js';
|
|
2
|
+
import { type PiiLabelMappings } from '../guard.config.js';
|
|
1
3
|
import { z } from 'genkit';
|
|
2
4
|
import { type PiiVaultStorage } from '../pii/storage.js';
|
|
3
5
|
export type GuardConfig = {
|
|
6
|
+
policyVersion?: string;
|
|
7
|
+
tools?: ToolGuardConfig;
|
|
4
8
|
intent?: {
|
|
5
9
|
mode?: string;
|
|
6
10
|
allowedIntent?: string;
|
|
@@ -13,6 +17,7 @@ export type GuardConfig = {
|
|
|
13
17
|
reversible?: boolean;
|
|
14
18
|
model?: string;
|
|
15
19
|
mode?: 'ner' | 'classifier';
|
|
20
|
+
labelMappings?: PiiLabelMappings;
|
|
16
21
|
vault?: {
|
|
17
22
|
storage?: PiiVaultStorage;
|
|
18
23
|
scopeId?: string | ((req: any, ctx: any) => string | undefined);
|
|
@@ -22,6 +27,8 @@ export type GuardConfig = {
|
|
|
22
27
|
enabled?: boolean;
|
|
23
28
|
level?: LogSeverity;
|
|
24
29
|
serviceName?: string;
|
|
30
|
+
/** Awaited audit callback, independent of console logging level/enabled. Failure stops execution. */
|
|
31
|
+
onDecision?: (decision: GuardDecision) => void | Promise<void>;
|
|
25
32
|
};
|
|
26
33
|
models?: {
|
|
27
34
|
extractor?: string;
|
|
@@ -30,6 +37,20 @@ export type GuardConfig = {
|
|
|
30
37
|
};
|
|
31
38
|
type LogSeverity = 'debug' | 'info' | 'warn' | 'error';
|
|
32
39
|
export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodObject<{
|
|
40
|
+
policyVersion: z.ZodOptional<z.ZodString>;
|
|
41
|
+
tools: z.ZodOptional<z.ZodObject<{
|
|
42
|
+
defaultAction: z.ZodOptional<z.ZodEnum<["allow", "block", "redact", "approval-required"]>>;
|
|
43
|
+
rules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<["allow", "block", "redact", "approval-required"]>>>;
|
|
44
|
+
approve: z.ZodOptional<z.ZodAny>;
|
|
45
|
+
}, "strip", z.ZodTypeAny, {
|
|
46
|
+
defaultAction?: "allow" | "block" | "redact" | "approval-required" | undefined;
|
|
47
|
+
rules?: Record<string, "allow" | "block" | "redact" | "approval-required"> | undefined;
|
|
48
|
+
approve?: any;
|
|
49
|
+
}, {
|
|
50
|
+
defaultAction?: "allow" | "block" | "redact" | "approval-required" | undefined;
|
|
51
|
+
rules?: Record<string, "allow" | "block" | "redact" | "approval-required"> | undefined;
|
|
52
|
+
approve?: any;
|
|
53
|
+
}>>;
|
|
33
54
|
intent: z.ZodOptional<z.ZodObject<{
|
|
34
55
|
mode: z.ZodOptional<z.ZodString>;
|
|
35
56
|
allowedIntent: z.ZodOptional<z.ZodString>;
|
|
@@ -62,6 +83,7 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
62
83
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
63
84
|
model: z.ZodOptional<z.ZodString>;
|
|
64
85
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
86
|
+
labelMappings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>;
|
|
65
87
|
vault: z.ZodOptional<z.ZodObject<{
|
|
66
88
|
storage: z.ZodOptional<z.ZodAny>;
|
|
67
89
|
scopeId: z.ZodOptional<z.ZodAny>;
|
|
@@ -73,17 +95,19 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
73
95
|
scopeId?: any;
|
|
74
96
|
}>>;
|
|
75
97
|
}, "strip", z.ZodTypeAny, {
|
|
98
|
+
model?: string | undefined;
|
|
99
|
+
labelMappings?: Record<string, string | null> | undefined;
|
|
76
100
|
mode?: "ner" | "classifier" | undefined;
|
|
77
101
|
reversible?: boolean | undefined;
|
|
78
|
-
model?: string | undefined;
|
|
79
102
|
vault?: {
|
|
80
103
|
storage?: any;
|
|
81
104
|
scopeId?: any;
|
|
82
105
|
} | undefined;
|
|
83
106
|
}, {
|
|
107
|
+
model?: string | undefined;
|
|
108
|
+
labelMappings?: Record<string, string | null> | undefined;
|
|
84
109
|
mode?: "ner" | "classifier" | undefined;
|
|
85
110
|
reversible?: boolean | undefined;
|
|
86
|
-
model?: string | undefined;
|
|
87
111
|
vault?: {
|
|
88
112
|
storage?: any;
|
|
89
113
|
scopeId?: any;
|
|
@@ -93,14 +117,17 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
93
117
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
94
118
|
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
95
119
|
serviceName: z.ZodOptional<z.ZodString>;
|
|
120
|
+
onDecision: z.ZodOptional<z.ZodAny>;
|
|
96
121
|
}, "strip", z.ZodTypeAny, {
|
|
97
122
|
enabled?: boolean | undefined;
|
|
98
123
|
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
99
124
|
serviceName?: string | undefined;
|
|
125
|
+
onDecision?: any;
|
|
100
126
|
}, {
|
|
101
127
|
enabled?: boolean | undefined;
|
|
102
128
|
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
103
129
|
serviceName?: string | undefined;
|
|
130
|
+
onDecision?: any;
|
|
104
131
|
}>>;
|
|
105
132
|
models: z.ZodOptional<z.ZodObject<{
|
|
106
133
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -110,6 +137,20 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
110
137
|
extractor?: string | undefined;
|
|
111
138
|
}>>;
|
|
112
139
|
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
140
|
+
policyVersion: z.ZodOptional<z.ZodString>;
|
|
141
|
+
tools: z.ZodOptional<z.ZodObject<{
|
|
142
|
+
defaultAction: z.ZodOptional<z.ZodEnum<["allow", "block", "redact", "approval-required"]>>;
|
|
143
|
+
rules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<["allow", "block", "redact", "approval-required"]>>>;
|
|
144
|
+
approve: z.ZodOptional<z.ZodAny>;
|
|
145
|
+
}, "strip", z.ZodTypeAny, {
|
|
146
|
+
defaultAction?: "allow" | "block" | "redact" | "approval-required" | undefined;
|
|
147
|
+
rules?: Record<string, "allow" | "block" | "redact" | "approval-required"> | undefined;
|
|
148
|
+
approve?: any;
|
|
149
|
+
}, {
|
|
150
|
+
defaultAction?: "allow" | "block" | "redact" | "approval-required" | undefined;
|
|
151
|
+
rules?: Record<string, "allow" | "block" | "redact" | "approval-required"> | undefined;
|
|
152
|
+
approve?: any;
|
|
153
|
+
}>>;
|
|
113
154
|
intent: z.ZodOptional<z.ZodObject<{
|
|
114
155
|
mode: z.ZodOptional<z.ZodString>;
|
|
115
156
|
allowedIntent: z.ZodOptional<z.ZodString>;
|
|
@@ -142,6 +183,7 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
142
183
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
143
184
|
model: z.ZodOptional<z.ZodString>;
|
|
144
185
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
186
|
+
labelMappings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>;
|
|
145
187
|
vault: z.ZodOptional<z.ZodObject<{
|
|
146
188
|
storage: z.ZodOptional<z.ZodAny>;
|
|
147
189
|
scopeId: z.ZodOptional<z.ZodAny>;
|
|
@@ -153,17 +195,19 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
153
195
|
scopeId?: any;
|
|
154
196
|
}>>;
|
|
155
197
|
}, "strip", z.ZodTypeAny, {
|
|
198
|
+
model?: string | undefined;
|
|
199
|
+
labelMappings?: Record<string, string | null> | undefined;
|
|
156
200
|
mode?: "ner" | "classifier" | undefined;
|
|
157
201
|
reversible?: boolean | undefined;
|
|
158
|
-
model?: string | undefined;
|
|
159
202
|
vault?: {
|
|
160
203
|
storage?: any;
|
|
161
204
|
scopeId?: any;
|
|
162
205
|
} | undefined;
|
|
163
206
|
}, {
|
|
207
|
+
model?: string | undefined;
|
|
208
|
+
labelMappings?: Record<string, string | null> | undefined;
|
|
164
209
|
mode?: "ner" | "classifier" | undefined;
|
|
165
210
|
reversible?: boolean | undefined;
|
|
166
|
-
model?: string | undefined;
|
|
167
211
|
vault?: {
|
|
168
212
|
storage?: any;
|
|
169
213
|
scopeId?: any;
|
|
@@ -173,14 +217,17 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
173
217
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
174
218
|
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
175
219
|
serviceName: z.ZodOptional<z.ZodString>;
|
|
220
|
+
onDecision: z.ZodOptional<z.ZodAny>;
|
|
176
221
|
}, "strip", z.ZodTypeAny, {
|
|
177
222
|
enabled?: boolean | undefined;
|
|
178
223
|
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
179
224
|
serviceName?: string | undefined;
|
|
225
|
+
onDecision?: any;
|
|
180
226
|
}, {
|
|
181
227
|
enabled?: boolean | undefined;
|
|
182
228
|
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
183
229
|
serviceName?: string | undefined;
|
|
230
|
+
onDecision?: any;
|
|
184
231
|
}>>;
|
|
185
232
|
models: z.ZodOptional<z.ZodObject<{
|
|
186
233
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -190,6 +237,20 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
190
237
|
extractor?: string | undefined;
|
|
191
238
|
}>>;
|
|
192
239
|
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
240
|
+
policyVersion: z.ZodOptional<z.ZodString>;
|
|
241
|
+
tools: z.ZodOptional<z.ZodObject<{
|
|
242
|
+
defaultAction: z.ZodOptional<z.ZodEnum<["allow", "block", "redact", "approval-required"]>>;
|
|
243
|
+
rules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<["allow", "block", "redact", "approval-required"]>>>;
|
|
244
|
+
approve: z.ZodOptional<z.ZodAny>;
|
|
245
|
+
}, "strip", z.ZodTypeAny, {
|
|
246
|
+
defaultAction?: "allow" | "block" | "redact" | "approval-required" | undefined;
|
|
247
|
+
rules?: Record<string, "allow" | "block" | "redact" | "approval-required"> | undefined;
|
|
248
|
+
approve?: any;
|
|
249
|
+
}, {
|
|
250
|
+
defaultAction?: "allow" | "block" | "redact" | "approval-required" | undefined;
|
|
251
|
+
rules?: Record<string, "allow" | "block" | "redact" | "approval-required"> | undefined;
|
|
252
|
+
approve?: any;
|
|
253
|
+
}>>;
|
|
193
254
|
intent: z.ZodOptional<z.ZodObject<{
|
|
194
255
|
mode: z.ZodOptional<z.ZodString>;
|
|
195
256
|
allowedIntent: z.ZodOptional<z.ZodString>;
|
|
@@ -222,6 +283,7 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
222
283
|
reversible: z.ZodOptional<z.ZodBoolean>;
|
|
223
284
|
model: z.ZodOptional<z.ZodString>;
|
|
224
285
|
mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
|
|
286
|
+
labelMappings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodString>>>;
|
|
225
287
|
vault: z.ZodOptional<z.ZodObject<{
|
|
226
288
|
storage: z.ZodOptional<z.ZodAny>;
|
|
227
289
|
scopeId: z.ZodOptional<z.ZodAny>;
|
|
@@ -233,17 +295,19 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
233
295
|
scopeId?: any;
|
|
234
296
|
}>>;
|
|
235
297
|
}, "strip", z.ZodTypeAny, {
|
|
298
|
+
model?: string | undefined;
|
|
299
|
+
labelMappings?: Record<string, string | null> | undefined;
|
|
236
300
|
mode?: "ner" | "classifier" | undefined;
|
|
237
301
|
reversible?: boolean | undefined;
|
|
238
|
-
model?: string | undefined;
|
|
239
302
|
vault?: {
|
|
240
303
|
storage?: any;
|
|
241
304
|
scopeId?: any;
|
|
242
305
|
} | undefined;
|
|
243
306
|
}, {
|
|
307
|
+
model?: string | undefined;
|
|
308
|
+
labelMappings?: Record<string, string | null> | undefined;
|
|
244
309
|
mode?: "ner" | "classifier" | undefined;
|
|
245
310
|
reversible?: boolean | undefined;
|
|
246
|
-
model?: string | undefined;
|
|
247
311
|
vault?: {
|
|
248
312
|
storage?: any;
|
|
249
313
|
scopeId?: any;
|
|
@@ -253,14 +317,17 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
253
317
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
254
318
|
level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
|
|
255
319
|
serviceName: z.ZodOptional<z.ZodString>;
|
|
320
|
+
onDecision: z.ZodOptional<z.ZodAny>;
|
|
256
321
|
}, "strip", z.ZodTypeAny, {
|
|
257
322
|
enabled?: boolean | undefined;
|
|
258
323
|
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
259
324
|
serviceName?: string | undefined;
|
|
325
|
+
onDecision?: any;
|
|
260
326
|
}, {
|
|
261
327
|
enabled?: boolean | undefined;
|
|
262
328
|
level?: "debug" | "info" | "warn" | "error" | undefined;
|
|
263
329
|
serviceName?: string | undefined;
|
|
330
|
+
onDecision?: any;
|
|
264
331
|
}>>;
|
|
265
332
|
models: z.ZodOptional<z.ZodObject<{
|
|
266
333
|
extractor: z.ZodOptional<z.ZodString>;
|
|
@@ -271,6 +338,19 @@ export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodO
|
|
|
271
338
|
}>>;
|
|
272
339
|
}, z.ZodTypeAny, "passthrough">>, void>;
|
|
273
340
|
export declare const guardPlugin: (pluginOptions: void) => import("@genkit-ai/ai").GenkitPluginV2;
|
|
274
|
-
|
|
341
|
+
type GuardHooks = ReturnType<typeof createGuardHooks>;
|
|
342
|
+
type NativeGuard = ReturnType<typeof guardMiddleware> & GuardHooks;
|
|
343
|
+
type LegacyGuard = ((req: any, ctxOrNext: any, maybeNext?: any) => Promise<any>) & GuardHooks;
|
|
344
|
+
export declare function guard(config: GuardConfig & {
|
|
345
|
+
tools: ToolGuardConfig;
|
|
346
|
+
}): NativeGuard;
|
|
347
|
+
export declare function guard(config?: GuardConfig & {
|
|
348
|
+
tools?: undefined;
|
|
349
|
+
}): LegacyGuard;
|
|
350
|
+
export declare function guard(config: GuardConfig): NativeGuard | LegacyGuard;
|
|
275
351
|
export declare const guardAction: typeof guard;
|
|
352
|
+
declare function createGuardHooks(config?: GuardConfig): {
|
|
353
|
+
model: (req: any, ctx: any, next: any) => Promise<any>;
|
|
354
|
+
tool: (req: any, ctx: any, next: any) => Promise<any>;
|
|
355
|
+
};
|
|
276
356
|
export {};
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { GuardToolError } from '../core/decision.js';
|
|
3
|
+
import { resolveGuardModels } from '../guard.config.js';
|
|
1
4
|
import { generateMiddleware, z } from 'genkit';
|
|
2
5
|
import { analyzeIntentStructured, detectInjection } from '../intent/intentAnalyzer.js';
|
|
3
6
|
import { detectPII } from '../pii/detector.js';
|
|
4
7
|
import { PiiTokenizer } from '../pii/tokenizer.js';
|
|
5
8
|
import { defaultPiiVaultStorage } from '../pii/storage.js';
|
|
6
9
|
const GUARD_CONTEXT_KEY = '__genkitGuard';
|
|
10
|
+
const toolActionSchema = z.enum(['allow', 'block', 'redact', 'approval-required']);
|
|
7
11
|
const guardConfigSchema = z.object({
|
|
12
|
+
policyVersion: z.string().optional(),
|
|
13
|
+
tools: z.object({
|
|
14
|
+
defaultAction: toolActionSchema.optional(),
|
|
15
|
+
rules: z.record(z.string(), toolActionSchema).optional(),
|
|
16
|
+
approve: z.any().optional(),
|
|
17
|
+
}).optional(),
|
|
8
18
|
intent: z.object({
|
|
9
19
|
mode: z.string().optional(),
|
|
10
20
|
allowedIntent: z.string().optional(),
|
|
@@ -17,6 +27,7 @@ const guardConfigSchema = z.object({
|
|
|
17
27
|
reversible: z.boolean().optional(),
|
|
18
28
|
model: z.string().optional(),
|
|
19
29
|
mode: z.enum(['ner', 'classifier']).optional(),
|
|
30
|
+
labelMappings: z.record(z.string(), z.string().regex(/^[A-Z_]+$/).nullable()).optional(),
|
|
20
31
|
vault: z.object({
|
|
21
32
|
storage: z.any().optional(),
|
|
22
33
|
scopeId: z.any().optional(),
|
|
@@ -26,6 +37,7 @@ const guardConfigSchema = z.object({
|
|
|
26
37
|
enabled: z.boolean().optional(),
|
|
27
38
|
level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
|
28
39
|
serviceName: z.string().optional(),
|
|
40
|
+
onDecision: z.any().optional(),
|
|
29
41
|
}).optional(),
|
|
30
42
|
models: z.object({
|
|
31
43
|
extractor: z.string().optional(),
|
|
@@ -40,6 +52,10 @@ export const guardPlugin = guardMiddleware.plugin;
|
|
|
40
52
|
export function guard(config) {
|
|
41
53
|
const hooks = createGuardHooks(config);
|
|
42
54
|
const baseMiddleware = guardMiddleware(config);
|
|
55
|
+
// Genkit treats every function as legacy model-only middleware, ignoring tool hooks.
|
|
56
|
+
// New tool policies must use a native reference; legacy configurations stay callable.
|
|
57
|
+
if (config?.tools)
|
|
58
|
+
return Object.assign(baseMiddleware, hooks);
|
|
43
59
|
const fnRunner = async (req, ctxOrNext, maybeNext) => {
|
|
44
60
|
if (typeof maybeNext === 'function') {
|
|
45
61
|
return hooks.model(req, ctxOrNext, maybeNext);
|
|
@@ -62,8 +78,20 @@ export function guard(config) {
|
|
|
62
78
|
export const guardAction = guard;
|
|
63
79
|
function createGuardHooks(config) {
|
|
64
80
|
const logger = createLogger(config);
|
|
81
|
+
const models = resolveGuardModels(config);
|
|
82
|
+
const decide = async (start, fields) => {
|
|
83
|
+
const decision = Object.freeze({
|
|
84
|
+
schemaVersion: '1', decisionId: randomUUID(), timestamp: new Date().toISOString(),
|
|
85
|
+
policyVersion: config?.policyVersion ?? 'unversioned',
|
|
86
|
+
latencyMs: Math.max(0, performance.now() - start), ...fields,
|
|
87
|
+
});
|
|
88
|
+
logger(decision.action === 'block' || decision.action === 'approval-required' ? 'warn' : 'info', 'guard.decision', 'Guard policy decision', { decision });
|
|
89
|
+
await config?.logging?.onDecision?.(decision);
|
|
90
|
+
return decision;
|
|
91
|
+
};
|
|
65
92
|
return {
|
|
66
93
|
model: async (req, ctx, next) => {
|
|
94
|
+
const started = performance.now();
|
|
67
95
|
const input = getInputText(req);
|
|
68
96
|
logger('info', 'guard.model.start', 'Starting guard checks for model request');
|
|
69
97
|
const isInjection = await detectInjection(input);
|
|
@@ -71,17 +99,25 @@ function createGuardHooks(config) {
|
|
|
71
99
|
logger('warn', 'guard.intent.blocked', 'Prompt injection pattern detected', {
|
|
72
100
|
reason: 'pattern_match',
|
|
73
101
|
});
|
|
102
|
+
await decide(started, { guard: 'injection', action: 'block', reasonCode: 'INJECTION_PATTERN' });
|
|
74
103
|
return block('Prompt injection detected', {
|
|
75
104
|
reason: 'pattern_match',
|
|
76
105
|
});
|
|
77
106
|
}
|
|
107
|
+
await decide(started, { guard: 'injection', action: 'allow', reasonCode: 'INJECTION_CLEAR' });
|
|
108
|
+
const intentStarted = performance.now();
|
|
78
109
|
logger('info', 'guard.intent.analysis.start', 'Analyzing request intent');
|
|
79
|
-
const intentResult = await analyzeIntentStructured(input, config?.intent?.semantic?.intents ?? {}, config?.intent?.semantic?.threshold ?? 0.7);
|
|
110
|
+
const intentResult = await analyzeIntentStructured(input, config?.intent?.semantic?.intents ?? {}, config?.intent?.semantic?.threshold ?? 0.7, models.extractor);
|
|
80
111
|
logger('info', 'guard.intent.analysis.complete', 'Intent analysis completed', {
|
|
81
112
|
intent: intentResult.intent,
|
|
82
113
|
score: roundScore(intentResult.score),
|
|
83
114
|
allowed: intentResult.allowed,
|
|
84
115
|
});
|
|
116
|
+
await decide(intentStarted, {
|
|
117
|
+
guard: 'intent', action: intentResult.allowed ? 'allow' : 'block',
|
|
118
|
+
reasonCode: intentResult.allowed ? 'INTENT_ALLOWED' : 'INTENT_REJECTED',
|
|
119
|
+
confidence: intentResult.score,
|
|
120
|
+
});
|
|
85
121
|
if (!intentResult.allowed) {
|
|
86
122
|
logger('warn', 'guard.intent.blocked', 'Intent not allowed', {
|
|
87
123
|
intent: intentResult.intent,
|
|
@@ -92,6 +128,7 @@ function createGuardHooks(config) {
|
|
|
92
128
|
score: intentResult.score,
|
|
93
129
|
});
|
|
94
130
|
}
|
|
131
|
+
const piiStarted = performance.now();
|
|
95
132
|
const textForPii = collectModelRequestText(req);
|
|
96
133
|
const piiResponse = await scanPII(textForPii, config);
|
|
97
134
|
const piiMatches = piiResponse?.matches || [];
|
|
@@ -100,6 +137,8 @@ function createGuardHooks(config) {
|
|
|
100
137
|
await tokenizer.importTokens(textForPii);
|
|
101
138
|
await maskModelRequest(req, tokenizer, piiMatches);
|
|
102
139
|
pushTokenizer(ctx, tokenizer);
|
|
140
|
+
await decide(piiStarted, { guard: 'pii', action: piiMatches.length ? 'redact' : 'allow',
|
|
141
|
+
reasonCode: piiMatches.length ? 'PII_DETECTED' : 'PII_CLEAR' });
|
|
103
142
|
logger(piiMatches.length > 0 ? 'warn' : 'info', 'guard.model.pii.masked', 'PII scan completed for model request', {
|
|
104
143
|
piiDetected: piiMatches.length > 0,
|
|
105
144
|
piiMatchCount: piiMatches.length,
|
|
@@ -126,8 +165,23 @@ function createGuardHooks(config) {
|
|
|
126
165
|
return unmaskedResponse;
|
|
127
166
|
},
|
|
128
167
|
tool: async (req, ctx, next) => {
|
|
129
|
-
const
|
|
168
|
+
const started = performance.now();
|
|
130
169
|
const toolName = req?.toolRequest?.name;
|
|
170
|
+
const stop = async (action, reasonCode) => {
|
|
171
|
+
throw new GuardToolError(await decide(started, { guard: 'tool', action, reasonCode }));
|
|
172
|
+
};
|
|
173
|
+
const rules = config?.tools?.rules;
|
|
174
|
+
const action = rules && Object.hasOwn(rules, toolName)
|
|
175
|
+
? rules[toolName] : config?.tools?.defaultAction ?? 'allow';
|
|
176
|
+
if (!['allow', 'block', 'redact', 'approval-required'].includes(action)) {
|
|
177
|
+
return stop('block', 'TOOL_POLICY_ERROR');
|
|
178
|
+
}
|
|
179
|
+
if (action === 'block')
|
|
180
|
+
return stop('block', 'TOOL_BLOCKED');
|
|
181
|
+
if (action === 'approval-required' && !config?.tools?.approve) {
|
|
182
|
+
return stop('approval-required', 'TOOL_APPROVAL_REQUIRED');
|
|
183
|
+
}
|
|
184
|
+
const state = getGuardState(ctx);
|
|
131
185
|
// Genkit may provide a fresh middleware context for a tool turn. Create a recovery
|
|
132
186
|
// tokenizer that uses the configured vault so opaque tokens can be rehydrated safely.
|
|
133
187
|
if (state.tokenizers.length === 0) {
|
|
@@ -136,6 +190,19 @@ function createGuardHooks(config) {
|
|
|
136
190
|
if (req?.toolRequest && 'input' in req.toolRequest) {
|
|
137
191
|
req.toolRequest.input = await unmaskObject(req.toolRequest.input, state.tokenizers);
|
|
138
192
|
}
|
|
193
|
+
if (action === 'approval-required') {
|
|
194
|
+
let approved;
|
|
195
|
+
try {
|
|
196
|
+
approved = await config.tools.approve({
|
|
197
|
+
toolName, input: structuredClone(req?.toolRequest?.input), context: ctx?.context,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return stop('block', 'TOOL_POLICY_ERROR');
|
|
202
|
+
}
|
|
203
|
+
if (approved !== true)
|
|
204
|
+
return stop('block', 'TOOL_APPROVAL_DENIED');
|
|
205
|
+
}
|
|
139
206
|
const toolInputText = collectStrings(req?.toolRequest?.input).join('\n');
|
|
140
207
|
const piiResponse = await scanPII(toolInputText, config);
|
|
141
208
|
const piiMatches = piiResponse?.matches || [];
|
|
@@ -152,6 +219,18 @@ function createGuardHooks(config) {
|
|
|
152
219
|
piiMatchCount: piiMatches.length,
|
|
153
220
|
piiTypes,
|
|
154
221
|
});
|
|
222
|
+
if (action === 'redact' && req?.toolRequest) {
|
|
223
|
+
req.toolRequest.input = await transformStrings(req.toolRequest.input, (value) => {
|
|
224
|
+
// Irreversible redaction: do not send recoverable vault tokens to this tool.
|
|
225
|
+
for (const match of [...piiMatches].sort((a, b) => b.value.length - a.value.length)) {
|
|
226
|
+
if (match.value)
|
|
227
|
+
value = value.split(match.value).join('[REDACTED]');
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
await decide(started, { guard: 'tool', action: action === 'redact' ? 'redact' : 'allow',
|
|
233
|
+
reasonCode: action === 'redact' ? 'TOOL_REDACTED' : action === 'approval-required' ? 'TOOL_APPROVED' : 'TOOL_ALLOWED' });
|
|
155
234
|
const res = await next(req, ctx);
|
|
156
235
|
const toolResponseText = collectStrings(res).join('\n');
|
|
157
236
|
if (toolResponseText) {
|
|
@@ -249,9 +328,11 @@ async function scanPII(text, config) {
|
|
|
249
328
|
classifier: undefined,
|
|
250
329
|
};
|
|
251
330
|
}
|
|
331
|
+
const models = resolveGuardModels(config);
|
|
252
332
|
return detectPII(text, {
|
|
253
|
-
model:
|
|
254
|
-
mode:
|
|
333
|
+
model: models.pii,
|
|
334
|
+
mode: models.mode,
|
|
335
|
+
labelMappings: config?.pii?.labelMappings,
|
|
255
336
|
});
|
|
256
337
|
}
|
|
257
338
|
function getGuardState(ctx = {}) {
|
package/dist/pii/detector.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PiiLabelMappings } from '../guard.config.js';
|
|
1
2
|
export type PiiMatch = {
|
|
2
3
|
type: string;
|
|
3
4
|
value: string;
|
|
@@ -13,8 +14,9 @@ export type PrivacyFilterSpan = {
|
|
|
13
14
|
export declare function detectPII(text: string, opts?: {
|
|
14
15
|
model?: string;
|
|
15
16
|
mode?: 'ner' | 'classifier';
|
|
17
|
+
labelMappings?: PiiLabelMappings;
|
|
16
18
|
}): Promise<{
|
|
17
19
|
matches: PiiMatch[];
|
|
18
20
|
classifier: any;
|
|
19
21
|
}>;
|
|
20
|
-
export declare function privacyFilterOutputToMatches(text: string, output: unknown): PiiMatch[];
|
|
22
|
+
export declare function privacyFilterOutputToMatches(text: string, output: unknown, labelMappings?: PiiLabelMappings): PiiMatch[];
|
package/dist/pii/detector.js
CHANGED
|
@@ -40,7 +40,12 @@ export async function detectPII(text, opts) {
|
|
|
40
40
|
const ner = await ModelSingleton.getNER(model);
|
|
41
41
|
const entities = await ner(text);
|
|
42
42
|
for (const e of entities) {
|
|
43
|
-
|
|
43
|
+
const mappedType = mappedLabel(e.entity_group ?? e.entity, opts?.labelMappings);
|
|
44
|
+
if (mappedType !== undefined) {
|
|
45
|
+
if (mappedType)
|
|
46
|
+
results.push(...privacyFilterOutputToMatches(text, [e], opts?.labelMappings));
|
|
47
|
+
}
|
|
48
|
+
else if (e.entity && e.entity.includes('PER')) {
|
|
44
49
|
results.push({ type: 'NAME', value: (e.word || '').replace(/##/g, '') });
|
|
45
50
|
}
|
|
46
51
|
}
|
|
@@ -50,7 +55,7 @@ export async function detectPII(text, opts) {
|
|
|
50
55
|
// rather than individual BIOES-labelled tokens.
|
|
51
56
|
const cls = await ModelSingleton.getPIIClassifier(model);
|
|
52
57
|
classifierOutput = await cls(text, { aggregation_strategy: 'simple' });
|
|
53
|
-
for (const match of privacyFilterOutputToMatches(text, classifierOutput)) {
|
|
58
|
+
for (const match of privacyFilterOutputToMatches(text, classifierOutput, opts?.labelMappings)) {
|
|
54
59
|
if (!results.some((existing) => existing.value === match.value)) {
|
|
55
60
|
results.push(match);
|
|
56
61
|
}
|
|
@@ -61,7 +66,7 @@ export async function detectPII(text, opts) {
|
|
|
61
66
|
classifier: classifierOutput
|
|
62
67
|
};
|
|
63
68
|
}
|
|
64
|
-
export function privacyFilterOutputToMatches(text, output) {
|
|
69
|
+
export function privacyFilterOutputToMatches(text, output, labelMappings) {
|
|
65
70
|
if (!Array.isArray(output))
|
|
66
71
|
return [];
|
|
67
72
|
const matches = [];
|
|
@@ -73,7 +78,8 @@ export function privacyFilterOutputToMatches(text, output) {
|
|
|
73
78
|
if (typeof rawLabel !== 'string')
|
|
74
79
|
continue;
|
|
75
80
|
const label = rawLabel.replace(/^[BIES]-/, '').toLowerCase();
|
|
76
|
-
const
|
|
81
|
+
const customType = mappedLabel(rawLabel, labelMappings);
|
|
82
|
+
const type = customType !== undefined ? customType : (Object.hasOwn(PRIVACY_FILTER_TYPE_MAP, label) ? PRIVACY_FILTER_TYPE_MAP[label] : undefined);
|
|
77
83
|
if (!type)
|
|
78
84
|
continue;
|
|
79
85
|
let value;
|
|
@@ -95,3 +101,16 @@ export function privacyFilterOutputToMatches(text, output) {
|
|
|
95
101
|
}
|
|
96
102
|
return matches;
|
|
97
103
|
}
|
|
104
|
+
function mappedLabel(label, mappings) {
|
|
105
|
+
if (typeof label !== 'string' || !mappings)
|
|
106
|
+
return undefined;
|
|
107
|
+
const normalize = (value) => value.replace(/^[BIES]-/i, '').toLowerCase();
|
|
108
|
+
const entry = Object.entries(mappings).find(([key]) => normalize(key) === normalize(label));
|
|
109
|
+
if (!entry)
|
|
110
|
+
return undefined;
|
|
111
|
+
const type = entry[1];
|
|
112
|
+
if (type !== null && !/^[A-Z_]+$/.test(type)) {
|
|
113
|
+
throw new Error('PII label mapping types must contain only uppercase letters and underscores');
|
|
114
|
+
}
|
|
115
|
+
return type;
|
|
116
|
+
}
|
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.14",
|
|
26
26
|
"type": "module",
|
|
27
27
|
"exports": "./dist/index.js",
|
|
28
28
|
"types": "./dist/index.d.ts",
|
|
@@ -36,13 +36,16 @@
|
|
|
36
36
|
"prepare-models": "node scripts/download-model.js",
|
|
37
37
|
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
38
38
|
"build": "npm run clean && tsc",
|
|
39
|
-
"test": "npm run test:types && npm run test:privacy-filter && npm run test:storage && npm run test:concurrency",
|
|
39
|
+
"test": "npm run test:types && npm run test:privacy-filter && npm run test:storage && npm run test:concurrency && npm run test:config && npm run test:release1",
|
|
40
40
|
"test:types": "tsc --noEmit --ignoreConfig --module NodeNext --moduleResolution NodeNext --target ESNext --strict --skipLibCheck scripts/test-types.ts",
|
|
41
41
|
"test:privacy-filter": "npm run build && node scripts/test-privacy-filter.js",
|
|
42
42
|
"test:storage": "npm run build && node scripts/test-storage.js",
|
|
43
43
|
"test:concurrency": "npm run build && node scripts/test-concurrent-vault.js",
|
|
44
44
|
"test:redis": "npm run build && node scripts/test-redis-vault.js",
|
|
45
|
-
"prepublishOnly": "npm run build"
|
|
45
|
+
"prepublishOnly": "npm run build",
|
|
46
|
+
"test:config": "npm run build && node scripts/test-config.js",
|
|
47
|
+
"wiki:publish": "node scripts/publish-wiki.js",
|
|
48
|
+
"test:release1": "npm run build && node --test scripts/test-release1.js scripts/test-publish-wiki.js"
|
|
46
49
|
},
|
|
47
50
|
"dependencies": {
|
|
48
51
|
"@huggingface/transformers": "^4.2.0",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, readdirSync, copyFileSync, rmSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
let source = join(root, 'docs/wiki-v0.0.14');
|
|
11
|
+
let repository = 'https://github.com/IntFlows/genkit-guard.wiki.git';
|
|
12
|
+
let publish = false;
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
if (args[i] === '--publish') publish = true;
|
|
15
|
+
else if (['--source', '--repo'].includes(args[i])) {
|
|
16
|
+
const flag = args[i];
|
|
17
|
+
const value = args[++i];
|
|
18
|
+
if (!value || value.startsWith('-')) throw new Error(`Missing value for ${flag}`);
|
|
19
|
+
if (flag === '--source') source = resolve(value);
|
|
20
|
+
else repository = value;
|
|
21
|
+
} else if (args[i] === '--help') {
|
|
22
|
+
console.log('node scripts/publish-wiki.js [--source DIRECTORY] [--repo URL] [--publish]');
|
|
23
|
+
console.log('Default: clone wiki and preview diff. --publish commits and pushes changed numbered pages.');
|
|
24
|
+
process.exit(0);
|
|
25
|
+
} else throw new Error(`Unknown option: ${args[i]}`);
|
|
26
|
+
}
|
|
27
|
+
source = realpathSync(source);
|
|
28
|
+
const pages = readdirSync(source, { withFileTypes: true })
|
|
29
|
+
.filter(entry => entry.isFile() && /^\d+\..+\.md$/.test(entry.name)).map(entry => entry.name).sort();
|
|
30
|
+
if (!pages.length) throw new Error('No numbered Markdown wiki pages found');
|
|
31
|
+
const work = mkdtempSync(join(tmpdir(), 'genkit-guard-wiki-'));
|
|
32
|
+
const checkout = join(work, 'wiki');
|
|
33
|
+
function git(args, cwd = work) {
|
|
34
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', shell: false });
|
|
35
|
+
if (result.error) throw result.error;
|
|
36
|
+
if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'Git command failed');
|
|
37
|
+
return result.stdout;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
git(['clone', '--', repository, checkout]);
|
|
41
|
+
for (const page of pages) copyFileSync(join(source, page), join(checkout, page));
|
|
42
|
+
git(['add', '--', ...pages], checkout);
|
|
43
|
+
const summary = git(['diff', '--cached', '--stat'], checkout);
|
|
44
|
+
if (!summary.trim()) console.log('Wiki already matches these pages; nothing to publish.');
|
|
45
|
+
else {
|
|
46
|
+
console.log(summary);
|
|
47
|
+
console.log(git(['diff', '--cached', '--'], checkout));
|
|
48
|
+
if (!publish) console.log('Preview only. Run again with --publish to commit and push.');
|
|
49
|
+
else {
|
|
50
|
+
git(['commit', '-m', 'Update Genkit Guard wiki documentation'], checkout);
|
|
51
|
+
// Normal push: concurrent remote updates reject safely; never force-push.
|
|
52
|
+
console.log(git(['push', 'origin', 'HEAD'], checkout));
|
|
53
|
+
console.log('Wiki changes published.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
// Only remove the temporary directory created by this process.
|
|
58
|
+
rmSync(work, { recursive: true, force: true });
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { guard, initGuard, defineGuardConfig } from '../dist/index.js';
|
|
3
|
+
import { ModelSingleton } from '../dist/util/singleton.js';
|
|
4
|
+
import { detectPII, privacyFilterOutputToMatches } from '../dist/pii/detector.js';
|
|
5
|
+
const calls = [];
|
|
6
|
+
const originals = {};
|
|
7
|
+
for (const method of ['getExtractor', 'getNER', 'getPIIClassifier']) {
|
|
8
|
+
originals[method] = ModelSingleton[method];
|
|
9
|
+
ModelSingleton[method] = async (name) => {
|
|
10
|
+
calls.push([method, name]);
|
|
11
|
+
return async () => method === 'getExtractor' ? { tolist: () => [[1, 0], [1, 0]] } : [{ entity: 'B-LABEL_1', word: 'Alice' }];
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
await initGuard();
|
|
16
|
+
assert.deepEqual(calls.splice(0), [['getExtractor', 'Xenova/all-MiniLM-L6-v2'], ['getNER', 'Xenova/bert-base-NER']]);
|
|
17
|
+
for (const mode of ['ner', 'classifier']) {
|
|
18
|
+
const config = defineGuardConfig({
|
|
19
|
+
models: { extractor: 'custom/intent' },
|
|
20
|
+
intent: { semantic: { intents: { support: 'Customer support' } } },
|
|
21
|
+
pii: { mode, model: 'custom/pii', labelMappings: { LABEL_1: 'NAME' } },
|
|
22
|
+
logging: { enabled: false },
|
|
23
|
+
});
|
|
24
|
+
await initGuard(config);
|
|
25
|
+
const result = await guard(config).model({ prompt: 'Help Alice' }, {}, async (req) => {
|
|
26
|
+
assert.match(req.prompt, /\[\[NAME_/);
|
|
27
|
+
assert.doesNotMatch(req.prompt, /Alice/);
|
|
28
|
+
return { text: req.prompt };
|
|
29
|
+
});
|
|
30
|
+
assert.equal(result.text, 'Help Alice');
|
|
31
|
+
const expected = [['getExtractor', 'custom/intent'], [mode === 'ner' ? 'getNER' : 'getPIIClassifier', 'custom/pii']];
|
|
32
|
+
assert.deepEqual(calls.splice(0), [...expected, ...expected]);
|
|
33
|
+
}
|
|
34
|
+
await initGuard({ pii: { mode: 'classifier' } });
|
|
35
|
+
assert.equal(calls.splice(0)[1][1], 'openai/privacy-filter');
|
|
36
|
+
assert.deepEqual(privacyFilterOutputToMatches('Alice', [{ entity: 'S-private_person', word: 'Alice' }], { private_person: null }), []);
|
|
37
|
+
assert.deepEqual(privacyFilterOutputToMatches('Alice', [{ entity: 'B-label_1', word: 'Alice' }], { LABEL_1: 'CUSTOM_NAME' }), [{ type: 'CUSTOM_NAME', value: 'Alice' }]);
|
|
38
|
+
assert.deepEqual(privacyFilterOutputToMatches('Alice', [{ entity: 'toString', word: 'Alice' }]), []);
|
|
39
|
+
assert.throws(() => privacyFilterOutputToMatches('Alice', [{ entity: 'LABEL_1', word: 'Alice' }], { LABEL_1: 'bad-token' }), /uppercase/);
|
|
40
|
+
const regex = await detectPII('alice@example.com', { mode: 'classifier', labelMappings: { LABEL_1: null } });
|
|
41
|
+
assert.equal(regex.matches[0].type, 'EMAIL');
|
|
42
|
+
} finally {
|
|
43
|
+
Object.assign(ModelSingleton, originals);
|
|
44
|
+
}
|
|
45
|
+
console.log('Shared model configuration and compatibility tests passed.');
|