@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.
@@ -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
- export declare function guard(config?: GuardConfig): (req: any, ctxOrNext: any, maybeNext?: any) => Promise<any>;
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 state = getGuardState(ctx);
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: config?.pii?.model,
254
- mode: config?.pii?.mode,
333
+ model: models.pii,
334
+ mode: models.mode,
335
+ labelMappings: config?.pii?.labelMappings,
255
336
  });
256
337
  }
257
338
  function getGuardState(ctx = {}) {
@@ -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[];
@@ -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
- if (e.entity && e.entity.includes('PER')) {
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 type = PRIVACY_FILTER_TYPE_MAP[label];
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.13",
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.');