@intflows/genkit-guard 0.0.13 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +337 -191
- package/dist/core/audit.d.ts +5 -0
- package/dist/core/audit.js +23 -0
- package/dist/core/decision-storage.d.ts +50 -0
- package/dist/core/decision-storage.js +84 -0
- 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 +8 -1
- package/dist/index.js +11 -10
- package/dist/intent/intentAnalyzer.d.ts +1 -1
- package/dist/intent/intentAnalyzer.js +2 -2
- package/dist/middleware/middleware.d.ts +183 -7
- package/dist/middleware/middleware.js +96 -6
- package/dist/pii/detector.d.ts +7 -5
- package/dist/pii/detector.js +47 -17
- package/dist/util/fallback.d.ts +8 -0
- package/dist/util/fallback.js +32 -0
- package/package.json +7 -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-release2.js +208 -0
- package/scripts/test-types.ts +41 -0
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { publishDecision } from '../core/audit.js';
|
|
2
|
+
import { runGuardModel } from '../util/fallback.js';
|
|
3
|
+
import { GuardToolError } from '../core/decision.js';
|
|
4
|
+
import { resolveGuardModels } from '../guard.config.js';
|
|
1
5
|
import { generateMiddleware, z } from 'genkit';
|
|
2
6
|
import { analyzeIntentStructured, detectInjection } from '../intent/intentAnalyzer.js';
|
|
3
7
|
import { detectPII } from '../pii/detector.js';
|
|
4
8
|
import { PiiTokenizer } from '../pii/tokenizer.js';
|
|
5
9
|
import { defaultPiiVaultStorage } from '../pii/storage.js';
|
|
6
10
|
const GUARD_CONTEXT_KEY = '__genkitGuard';
|
|
11
|
+
const toolActionSchema = z.enum(['allow', 'block', 'redact', 'approval-required']);
|
|
7
12
|
const guardConfigSchema = z.object({
|
|
13
|
+
policyVersion: z.string().optional(),
|
|
14
|
+
tools: z.object({
|
|
15
|
+
defaultAction: toolActionSchema.optional(),
|
|
16
|
+
rules: z.record(z.string(), toolActionSchema).optional(),
|
|
17
|
+
approve: z.any().optional(),
|
|
18
|
+
}).optional(),
|
|
8
19
|
intent: z.object({
|
|
9
20
|
mode: z.string().optional(),
|
|
10
21
|
allowedIntent: z.string().optional(),
|
|
@@ -17,6 +28,11 @@ const guardConfigSchema = z.object({
|
|
|
17
28
|
reversible: z.boolean().optional(),
|
|
18
29
|
model: z.string().optional(),
|
|
19
30
|
mode: z.enum(['ner', 'classifier']).optional(),
|
|
31
|
+
fallback: z.object({
|
|
32
|
+
model: z.string(), mode: z.enum(['ner', 'classifier']).optional(),
|
|
33
|
+
labelMappings: z.record(z.string(), z.string().regex(/^[A-Z_]+$/).nullable()).optional(),
|
|
34
|
+
}).optional(),
|
|
35
|
+
labelMappings: z.record(z.string(), z.string().regex(/^[A-Z_]+$/).nullable()).optional(),
|
|
20
36
|
vault: z.object({
|
|
21
37
|
storage: z.any().optional(),
|
|
22
38
|
scopeId: z.any().optional(),
|
|
@@ -26,9 +42,12 @@ const guardConfigSchema = z.object({
|
|
|
26
42
|
enabled: z.boolean().optional(),
|
|
27
43
|
level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
|
28
44
|
serviceName: z.string().optional(),
|
|
45
|
+
onDecision: z.any().optional(),
|
|
46
|
+
store: z.any().optional(),
|
|
29
47
|
}).optional(),
|
|
30
48
|
models: z.object({
|
|
31
49
|
extractor: z.string().optional(),
|
|
50
|
+
extractorFallback: z.string().optional(),
|
|
32
51
|
}).optional(),
|
|
33
52
|
}).passthrough();
|
|
34
53
|
export const guardMiddleware = generateMiddleware({
|
|
@@ -40,6 +59,10 @@ export const guardPlugin = guardMiddleware.plugin;
|
|
|
40
59
|
export function guard(config) {
|
|
41
60
|
const hooks = createGuardHooks(config);
|
|
42
61
|
const baseMiddleware = guardMiddleware(config);
|
|
62
|
+
// Genkit treats every function as legacy model-only middleware, ignoring tool hooks.
|
|
63
|
+
// New tool policies must use a native reference; legacy configurations stay callable.
|
|
64
|
+
if (config?.tools)
|
|
65
|
+
return Object.assign(baseMiddleware, hooks);
|
|
43
66
|
const fnRunner = async (req, ctxOrNext, maybeNext) => {
|
|
44
67
|
if (typeof maybeNext === 'function') {
|
|
45
68
|
return hooks.model(req, ctxOrNext, maybeNext);
|
|
@@ -62,8 +85,11 @@ export function guard(config) {
|
|
|
62
85
|
export const guardAction = guard;
|
|
63
86
|
function createGuardHooks(config) {
|
|
64
87
|
const logger = createLogger(config);
|
|
88
|
+
const models = resolveGuardModels(config);
|
|
89
|
+
const decide = (start, fields) => publishDecision(config, start, fields);
|
|
65
90
|
return {
|
|
66
91
|
model: async (req, ctx, next) => {
|
|
92
|
+
const started = performance.now();
|
|
67
93
|
const input = getInputText(req);
|
|
68
94
|
logger('info', 'guard.model.start', 'Starting guard checks for model request');
|
|
69
95
|
const isInjection = await detectInjection(input);
|
|
@@ -71,17 +97,26 @@ function createGuardHooks(config) {
|
|
|
71
97
|
logger('warn', 'guard.intent.blocked', 'Prompt injection pattern detected', {
|
|
72
98
|
reason: 'pattern_match',
|
|
73
99
|
});
|
|
100
|
+
await decide(started, { guard: 'injection', action: 'block', reasonCode: 'INJECTION_PATTERN' });
|
|
74
101
|
return block('Prompt injection detected', {
|
|
75
102
|
reason: 'pattern_match',
|
|
76
103
|
});
|
|
77
104
|
}
|
|
105
|
+
await decide(started, { guard: 'injection', action: 'allow', reasonCode: 'INJECTION_CLEAR' });
|
|
106
|
+
const intentStarted = performance.now();
|
|
78
107
|
logger('info', 'guard.intent.analysis.start', 'Analyzing request intent');
|
|
79
|
-
const
|
|
108
|
+
const analyze = (model) => analyzeIntentStructured(input, config?.intent?.semantic?.intents ?? {}, config?.intent?.semantic?.threshold ?? 0.7, model);
|
|
109
|
+
const intentResult = await runGuardModel(config, 'intent', () => analyze(models.extractor), config?.models?.extractorFallback ? () => analyze(config.models.extractorFallback) : undefined);
|
|
80
110
|
logger('info', 'guard.intent.analysis.complete', 'Intent analysis completed', {
|
|
81
111
|
intent: intentResult.intent,
|
|
82
112
|
score: roundScore(intentResult.score),
|
|
83
113
|
allowed: intentResult.allowed,
|
|
84
114
|
});
|
|
115
|
+
await decide(intentStarted, {
|
|
116
|
+
guard: 'intent', action: intentResult.allowed ? 'allow' : 'block',
|
|
117
|
+
reasonCode: intentResult.allowed ? 'INTENT_ALLOWED' : 'INTENT_REJECTED',
|
|
118
|
+
confidence: intentResult.score,
|
|
119
|
+
});
|
|
85
120
|
if (!intentResult.allowed) {
|
|
86
121
|
logger('warn', 'guard.intent.blocked', 'Intent not allowed', {
|
|
87
122
|
intent: intentResult.intent,
|
|
@@ -92,6 +127,7 @@ function createGuardHooks(config) {
|
|
|
92
127
|
score: intentResult.score,
|
|
93
128
|
});
|
|
94
129
|
}
|
|
130
|
+
const piiStarted = performance.now();
|
|
95
131
|
const textForPii = collectModelRequestText(req);
|
|
96
132
|
const piiResponse = await scanPII(textForPii, config);
|
|
97
133
|
const piiMatches = piiResponse?.matches || [];
|
|
@@ -100,11 +136,13 @@ function createGuardHooks(config) {
|
|
|
100
136
|
await tokenizer.importTokens(textForPii);
|
|
101
137
|
await maskModelRequest(req, tokenizer, piiMatches);
|
|
102
138
|
pushTokenizer(ctx, tokenizer);
|
|
139
|
+
await decide(piiStarted, { guard: 'pii', action: piiMatches.length ? 'redact' : 'allow',
|
|
140
|
+
reasonCode: piiMatches.length ? 'PII_DETECTED' : 'PII_CLEAR' });
|
|
103
141
|
logger(piiMatches.length > 0 ? 'warn' : 'info', 'guard.model.pii.masked', 'PII scan completed for model request', {
|
|
104
142
|
piiDetected: piiMatches.length > 0,
|
|
105
143
|
piiMatchCount: piiMatches.length,
|
|
106
144
|
piiTypes,
|
|
107
|
-
piiMode:
|
|
145
|
+
piiMode: piiResponse.effectiveMode ?? models.mode,
|
|
108
146
|
classifierOutputPresent: Boolean(piiResponse.classifier),
|
|
109
147
|
});
|
|
110
148
|
req.metadata = {
|
|
@@ -117,6 +155,9 @@ function createGuardHooks(config) {
|
|
|
117
155
|
piiModel: config?.pii?.model,
|
|
118
156
|
piiMode: config?.pii?.mode,
|
|
119
157
|
piiClassifierOutput: piiResponse.classifier,
|
|
158
|
+
piiEffectiveModel: piiResponse.effectiveModel,
|
|
159
|
+
piiEffectiveMode: piiResponse.effectiveMode,
|
|
160
|
+
piiUsedFallback: piiResponse.usedFallback,
|
|
120
161
|
};
|
|
121
162
|
const res = await next(req, ctx);
|
|
122
163
|
const unmaskedResponse = await unmaskObject(res, getGuardState(ctx).tokenizers);
|
|
@@ -126,8 +167,23 @@ function createGuardHooks(config) {
|
|
|
126
167
|
return unmaskedResponse;
|
|
127
168
|
},
|
|
128
169
|
tool: async (req, ctx, next) => {
|
|
129
|
-
const
|
|
170
|
+
const started = performance.now();
|
|
130
171
|
const toolName = req?.toolRequest?.name;
|
|
172
|
+
const stop = async (action, reasonCode) => {
|
|
173
|
+
throw new GuardToolError(await decide(started, { guard: 'tool', action, reasonCode }));
|
|
174
|
+
};
|
|
175
|
+
const rules = config?.tools?.rules;
|
|
176
|
+
const action = rules && Object.hasOwn(rules, toolName)
|
|
177
|
+
? rules[toolName] : config?.tools?.defaultAction ?? 'allow';
|
|
178
|
+
if (!['allow', 'block', 'redact', 'approval-required'].includes(action)) {
|
|
179
|
+
return stop('block', 'TOOL_POLICY_ERROR');
|
|
180
|
+
}
|
|
181
|
+
if (action === 'block')
|
|
182
|
+
return stop('block', 'TOOL_BLOCKED');
|
|
183
|
+
if (action === 'approval-required' && !config?.tools?.approve) {
|
|
184
|
+
return stop('approval-required', 'TOOL_APPROVAL_REQUIRED');
|
|
185
|
+
}
|
|
186
|
+
const state = getGuardState(ctx);
|
|
131
187
|
// Genkit may provide a fresh middleware context for a tool turn. Create a recovery
|
|
132
188
|
// tokenizer that uses the configured vault so opaque tokens can be rehydrated safely.
|
|
133
189
|
if (state.tokenizers.length === 0) {
|
|
@@ -136,6 +192,19 @@ function createGuardHooks(config) {
|
|
|
136
192
|
if (req?.toolRequest && 'input' in req.toolRequest) {
|
|
137
193
|
req.toolRequest.input = await unmaskObject(req.toolRequest.input, state.tokenizers);
|
|
138
194
|
}
|
|
195
|
+
if (action === 'approval-required') {
|
|
196
|
+
let approved;
|
|
197
|
+
try {
|
|
198
|
+
approved = await config.tools.approve({
|
|
199
|
+
toolName, input: structuredClone(req?.toolRequest?.input), context: ctx?.context,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return stop('block', 'TOOL_POLICY_ERROR');
|
|
204
|
+
}
|
|
205
|
+
if (approved !== true)
|
|
206
|
+
return stop('block', 'TOOL_APPROVAL_DENIED');
|
|
207
|
+
}
|
|
139
208
|
const toolInputText = collectStrings(req?.toolRequest?.input).join('\n');
|
|
140
209
|
const piiResponse = await scanPII(toolInputText, config);
|
|
141
210
|
const piiMatches = piiResponse?.matches || [];
|
|
@@ -145,6 +214,9 @@ function createGuardHooks(config) {
|
|
|
145
214
|
piiDetected: piiMatches.length > 0,
|
|
146
215
|
piiTypes,
|
|
147
216
|
piiMatchCount: piiMatches.length,
|
|
217
|
+
piiEffectiveModel: piiResponse.effectiveModel,
|
|
218
|
+
piiEffectiveMode: piiResponse.effectiveMode,
|
|
219
|
+
piiUsedFallback: piiResponse.usedFallback,
|
|
148
220
|
};
|
|
149
221
|
logger(piiMatches.length > 0 ? 'warn' : 'info', 'guard.tool.pii.checked', 'Tool request PII scan completed', {
|
|
150
222
|
toolName,
|
|
@@ -152,6 +224,18 @@ function createGuardHooks(config) {
|
|
|
152
224
|
piiMatchCount: piiMatches.length,
|
|
153
225
|
piiTypes,
|
|
154
226
|
});
|
|
227
|
+
if (action === 'redact' && req?.toolRequest) {
|
|
228
|
+
req.toolRequest.input = await transformStrings(req.toolRequest.input, (value) => {
|
|
229
|
+
// Irreversible redaction: do not send recoverable vault tokens to this tool.
|
|
230
|
+
for (const match of [...piiMatches].sort((a, b) => b.value.length - a.value.length)) {
|
|
231
|
+
if (match.value)
|
|
232
|
+
value = value.split(match.value).join('[REDACTED]');
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
await decide(started, { guard: 'tool', action: action === 'redact' ? 'redact' : 'allow',
|
|
238
|
+
reasonCode: action === 'redact' ? 'TOOL_REDACTED' : action === 'approval-required' ? 'TOOL_APPROVED' : 'TOOL_ALLOWED' });
|
|
155
239
|
const res = await next(req, ctx);
|
|
156
240
|
const toolResponseText = collectStrings(res).join('\n');
|
|
157
241
|
if (toolResponseText) {
|
|
@@ -247,12 +331,18 @@ async function scanPII(text, config) {
|
|
|
247
331
|
return {
|
|
248
332
|
matches: [],
|
|
249
333
|
classifier: undefined,
|
|
334
|
+
effectiveModel: undefined,
|
|
335
|
+
effectiveMode: undefined,
|
|
336
|
+
usedFallback: false,
|
|
250
337
|
};
|
|
251
338
|
}
|
|
339
|
+
const models = resolveGuardModels(config);
|
|
252
340
|
return detectPII(text, {
|
|
253
|
-
model:
|
|
254
|
-
mode:
|
|
255
|
-
|
|
341
|
+
model: models.pii,
|
|
342
|
+
mode: models.mode,
|
|
343
|
+
labelMappings: config?.pii?.labelMappings,
|
|
344
|
+
fallback: config?.pii?.fallback,
|
|
345
|
+
}, config);
|
|
256
346
|
}
|
|
257
347
|
function getGuardState(ctx = {}) {
|
|
258
348
|
ctx.context = ctx.context || {};
|
package/dist/pii/detector.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { GuardConfig } from '../middleware/middleware.js';
|
|
2
|
+
import type { PiiLabelMappings } from '../guard.config.js';
|
|
1
3
|
export type PiiMatch = {
|
|
2
4
|
type: string;
|
|
3
5
|
value: string;
|
|
@@ -10,11 +12,11 @@ export type PrivacyFilterSpan = {
|
|
|
10
12
|
end?: number;
|
|
11
13
|
score?: number;
|
|
12
14
|
};
|
|
13
|
-
export declare function detectPII(text: string, opts?: {
|
|
14
|
-
model?: string;
|
|
15
|
-
mode?: 'ner' | 'classifier';
|
|
16
|
-
}): Promise<{
|
|
15
|
+
export declare function detectPII(text: string, opts?: GuardConfig['pii'], config?: GuardConfig): Promise<{
|
|
17
16
|
matches: PiiMatch[];
|
|
18
17
|
classifier: any;
|
|
18
|
+
effectiveModel: string;
|
|
19
|
+
effectiveMode: "ner" | "classifier";
|
|
20
|
+
usedFallback: boolean;
|
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runGuardModel } from '../util/fallback.js';
|
|
1
2
|
import { ModelSingleton } from '../util/singleton.js';
|
|
2
3
|
const PRIVACY_FILTER_TYPE_MAP = {
|
|
3
4
|
account_number: 'ACCOUNT_NUMBER',
|
|
@@ -25,7 +26,7 @@ const REGEX_RULES = [
|
|
|
25
26
|
// CREDIT CARD (keep your existing one if needed)
|
|
26
27
|
{ type: 'CREDIT_CARD', pattern: /\b(?:\d[ -]*?){13,16}\b/g }
|
|
27
28
|
];
|
|
28
|
-
export async function detectPII(text, opts) {
|
|
29
|
+
export async function detectPII(text, opts, config) {
|
|
29
30
|
const mode = opts?.mode ?? 'ner';
|
|
30
31
|
const model = opts?.model;
|
|
31
32
|
const results = [];
|
|
@@ -34,34 +35,49 @@ export async function detectPII(text, opts) {
|
|
|
34
35
|
const matches = text.match(rule.pattern) || [];
|
|
35
36
|
matches.forEach(m => results.push({ type: rule.type, value: m }));
|
|
36
37
|
}
|
|
37
|
-
//
|
|
38
|
+
// Only model loading/inference is retried. Label mapping errors are not model failures.
|
|
39
|
+
const infer = async (selected, usedFallback = false) => {
|
|
40
|
+
const selectedMode = selected.mode ?? mode;
|
|
41
|
+
const pipeline = selectedMode === 'ner'
|
|
42
|
+
? await ModelSingleton.getNER(selected.model)
|
|
43
|
+
: await ModelSingleton.getPIIClassifier(selected.model);
|
|
44
|
+
const output = selectedMode === 'ner' ? await pipeline(text)
|
|
45
|
+
: await pipeline(text, { aggregation_strategy: 'simple' });
|
|
46
|
+
if (!Array.isArray(output))
|
|
47
|
+
throw new Error('Invalid PII model output');
|
|
48
|
+
return { output, mode: selectedMode, labelMappings: selected.labelMappings, usedFallback,
|
|
49
|
+
model: selected.model ?? (selectedMode === 'ner' ? 'Xenova/bert-base-NER' : 'openai/privacy-filter') };
|
|
50
|
+
};
|
|
51
|
+
const selected = await runGuardModel(config, 'pii', () => infer({ model, mode, labelMappings: opts?.labelMappings }), opts?.fallback ? () => infer(opts.fallback, true) : undefined);
|
|
38
52
|
let classifierOutput = undefined;
|
|
39
|
-
if (mode === 'ner') {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
if (selected.mode === 'ner') {
|
|
54
|
+
for (const e of selected.output) {
|
|
55
|
+
const mappedType = mappedLabel(e.entity_group ?? e.entity, selected.labelMappings);
|
|
56
|
+
if (mappedType !== undefined) {
|
|
57
|
+
if (mappedType)
|
|
58
|
+
results.push(...privacyFilterOutputToMatches(text, [e], selected.labelMappings));
|
|
59
|
+
}
|
|
60
|
+
else if (e.entity && e.entity.includes('PER')) {
|
|
44
61
|
results.push({ type: 'NAME', value: (e.word || '').replace(/##/g, '') });
|
|
45
62
|
}
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
else {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
classifierOutput = await cls(text, { aggregation_strategy: 'simple' });
|
|
53
|
-
for (const match of privacyFilterOutputToMatches(text, classifierOutput)) {
|
|
54
|
-
if (!results.some((existing) => existing.value === match.value)) {
|
|
66
|
+
classifierOutput = selected.output;
|
|
67
|
+
for (const match of privacyFilterOutputToMatches(text, classifierOutput, selected.labelMappings)) {
|
|
68
|
+
if (!results.some((existing) => existing.value === match.value))
|
|
55
69
|
results.push(match);
|
|
56
|
-
}
|
|
57
70
|
}
|
|
58
71
|
}
|
|
59
72
|
return {
|
|
60
73
|
matches: results,
|
|
61
|
-
classifier: classifierOutput
|
|
74
|
+
classifier: classifierOutput,
|
|
75
|
+
effectiveModel: selected.model,
|
|
76
|
+
effectiveMode: selected.mode,
|
|
77
|
+
usedFallback: selected.usedFallback,
|
|
62
78
|
};
|
|
63
79
|
}
|
|
64
|
-
export function privacyFilterOutputToMatches(text, output) {
|
|
80
|
+
export function privacyFilterOutputToMatches(text, output, labelMappings) {
|
|
65
81
|
if (!Array.isArray(output))
|
|
66
82
|
return [];
|
|
67
83
|
const matches = [];
|
|
@@ -73,7 +89,8 @@ export function privacyFilterOutputToMatches(text, output) {
|
|
|
73
89
|
if (typeof rawLabel !== 'string')
|
|
74
90
|
continue;
|
|
75
91
|
const label = rawLabel.replace(/^[BIES]-/, '').toLowerCase();
|
|
76
|
-
const
|
|
92
|
+
const customType = mappedLabel(rawLabel, labelMappings);
|
|
93
|
+
const type = customType !== undefined ? customType : (Object.hasOwn(PRIVACY_FILTER_TYPE_MAP, label) ? PRIVACY_FILTER_TYPE_MAP[label] : undefined);
|
|
77
94
|
if (!type)
|
|
78
95
|
continue;
|
|
79
96
|
let value;
|
|
@@ -95,3 +112,16 @@ export function privacyFilterOutputToMatches(text, output) {
|
|
|
95
112
|
}
|
|
96
113
|
return matches;
|
|
97
114
|
}
|
|
115
|
+
function mappedLabel(label, mappings) {
|
|
116
|
+
if (typeof label !== 'string' || !mappings)
|
|
117
|
+
return undefined;
|
|
118
|
+
const normalize = (value) => value.replace(/^[BIES]-/i, '').toLowerCase();
|
|
119
|
+
const entry = Object.entries(mappings).find(([key]) => normalize(key) === normalize(label));
|
|
120
|
+
if (!entry)
|
|
121
|
+
return undefined;
|
|
122
|
+
const type = entry[1];
|
|
123
|
+
if (type !== null && !/^[A-Z_]+$/.test(type)) {
|
|
124
|
+
throw new Error('PII label mapping types must contain only uppercase letters and underscores');
|
|
125
|
+
}
|
|
126
|
+
return type;
|
|
127
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GuardConfig } from '../middleware/middleware.js';
|
|
2
|
+
export declare class GuardModelError extends Error {
|
|
3
|
+
readonly guard: 'intent' | 'pii';
|
|
4
|
+
readonly code = "MODEL_UNAVAILABLE";
|
|
5
|
+
constructor(guard: 'intent' | 'pii');
|
|
6
|
+
}
|
|
7
|
+
/** One fallback attempt per operation, only after a model operation throws. */
|
|
8
|
+
export declare function runGuardModel<T>(config: GuardConfig | undefined, guard: 'intent' | 'pii', primary: () => Promise<T>, fallback?: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { publishDecision } from '../core/audit.js';
|
|
2
|
+
export class GuardModelError extends Error {
|
|
3
|
+
guard;
|
|
4
|
+
code = 'MODEL_UNAVAILABLE';
|
|
5
|
+
constructor(guard) {
|
|
6
|
+
// Do not attach underlying errors: inference errors can contain request content.
|
|
7
|
+
super(`Primary and fallback ${guard} models failed`);
|
|
8
|
+
this.guard = guard;
|
|
9
|
+
this.name = 'GuardModelError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** One fallback attempt per operation, only after a model operation throws. */
|
|
13
|
+
export async function runGuardModel(config, guard, primary, fallback) {
|
|
14
|
+
const start = performance.now();
|
|
15
|
+
try {
|
|
16
|
+
return await primary();
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!fallback)
|
|
20
|
+
throw error; // Preserve existing failure behavior without opt-in.
|
|
21
|
+
}
|
|
22
|
+
let result;
|
|
23
|
+
try {
|
|
24
|
+
result = await fallback();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
await publishDecision(config, start, { guard, action: 'block', reasonCode: 'MODEL_UNAVAILABLE' });
|
|
28
|
+
throw new GuardModelError(guard);
|
|
29
|
+
}
|
|
30
|
+
await publishDecision(config, start, { guard, action: 'allow', reasonCode: 'MODEL_FALLBACK_USED' });
|
|
31
|
+
return result;
|
|
32
|
+
}
|
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.1.0",
|
|
26
26
|
"type": "module",
|
|
27
27
|
"exports": "./dist/index.js",
|
|
28
28
|
"types": "./dist/index.d.ts",
|
|
@@ -36,13 +36,17 @@
|
|
|
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 && npm run test:release2",
|
|
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",
|
|
49
|
+
"test:release2": "npm run build && node --test scripts/test-release2.js"
|
|
46
50
|
},
|
|
47
51
|
"dependencies": {
|
|
48
52
|
"@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.1.0');
|
|
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.');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
test('wiki preview never pushes; publish updates pages and preserves unrelated files', () => {
|
|
9
|
+
const work = mkdtempSync(join(tmpdir(), 'wiki-test-'));
|
|
10
|
+
const run = (cmd, args, cwd = work) => {
|
|
11
|
+
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com' } });
|
|
12
|
+
assert.equal(result.status, 0, result.stderr); return result.stdout;
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
const remote = join(work, 'remote.git'); const seed = join(work, 'seed'); const source = join(work, 'pages');
|
|
16
|
+
run('git', ['init', '--bare', remote]); run('git', ['clone', remote, seed]);
|
|
17
|
+
writeFileSync(join(seed, 'Home.md'), 'Preserve me');
|
|
18
|
+
run('git', ['add', '.'], seed); run('git', ['commit', '-m', 'initial'], seed); run('git', ['push', 'origin', 'HEAD'], seed);
|
|
19
|
+
mkdirSync(source); writeFileSync(join(source, '1.-Home.md'), '# New home'); writeFileSync(join(source, 'README.md'), 'Do not publish');
|
|
20
|
+
const args = [resolve('scripts/publish-wiki.js'), '--repo', remote, '--source', source];
|
|
21
|
+
const before = run('git', ['rev-parse', 'HEAD'], remote);
|
|
22
|
+
assert.match(run(process.execPath, args), /Preview only/);
|
|
23
|
+
assert.equal(run('git', ['rev-parse', 'HEAD'], remote), before);
|
|
24
|
+
assert.match(run(process.execPath, [...args, '--publish']), /published/);
|
|
25
|
+
assert.equal(run('git', ['show', 'HEAD:1.-Home.md'], remote), '# New home');
|
|
26
|
+
assert.equal(run('git', ['show', 'HEAD:Home.md'], remote), 'Preserve me');
|
|
27
|
+
assert.doesNotMatch(run('git', ['ls-tree', '--name-only', 'HEAD'], remote), /README/);
|
|
28
|
+
assert.match(run(process.execPath, [...args, '--publish']), /nothing to publish/);
|
|
29
|
+
} finally { rmSync(work, { recursive: true, force: true }); }
|
|
30
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { genkit, z } from 'genkit';
|
|
4
|
+
import { guard, guardMiddleware, GuardToolError } from '../dist/index.js';
|
|
5
|
+
import { ModelSingleton } from '../dist/util/singleton.js';
|
|
6
|
+
ModelSingleton.getExtractor = async () => async () => ({ tolist: () => [[1, 0], [1, 0]] });
|
|
7
|
+
ModelSingleton.getNER = async () => async () => [];
|
|
8
|
+
const base = { intent: { semantic: { intents: { support: 'Support' } } }, logging: { enabled: false } };
|
|
9
|
+
const request = () => ({ toolRequest: { name: 'sendEmail', input: { email: 'alice@example.com', nested: ['alice@example.com'] } } });
|
|
10
|
+
|
|
11
|
+
test('tool policies enforce before execution and redact nested arguments', async () => {
|
|
12
|
+
for (const action of ['allow', 'block', 'redact', 'approval-required']) {
|
|
13
|
+
const decisions = [];
|
|
14
|
+
const middleware = guard({ ...base, tools: { rules: { sendEmail: action } }, logging: { enabled: false, onDecision: d => decisions.push(d) } });
|
|
15
|
+
let executed = 0;
|
|
16
|
+
const run = middleware.tool(request(), {}, async req => {
|
|
17
|
+
executed++;
|
|
18
|
+
if (action === 'redact') assert.deepEqual(req.toolRequest.input, { email: '[REDACTED]', nested: ['[REDACTED]'] });
|
|
19
|
+
else assert.equal(req.toolRequest.input.email, 'alice@example.com');
|
|
20
|
+
return { toolResponse: { name: 'sendEmail', output: 'ok' } };
|
|
21
|
+
});
|
|
22
|
+
if (action === 'block' || action === 'approval-required') await assert.rejects(run, GuardToolError);
|
|
23
|
+
else await run;
|
|
24
|
+
assert.equal(executed, action === 'allow' || action === 'redact' ? 1 : 0);
|
|
25
|
+
assert.equal(decisions[0].action, action);
|
|
26
|
+
assert.equal(decisions[0].schemaVersion, '1');
|
|
27
|
+
assert.doesNotMatch(JSON.stringify(decisions), /alice|example.com|nested/);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('approval is explicit, per-call and cannot be spoofed by request flags', async () => {
|
|
32
|
+
for (const approve of [undefined, () => false, () => 'true', () => { throw new Error('secret'); }, async () => true]) {
|
|
33
|
+
let ran = false;
|
|
34
|
+
const middleware = guard({ ...base, tools: { defaultAction: 'approval-required', approve } });
|
|
35
|
+
const req = request(); req.toolRequest.input.approved = true;
|
|
36
|
+
const run = middleware.tool(req, {}, async () => { ran = true; });
|
|
37
|
+
if (approve && await Promise.resolve().then(() => approve()).catch(() => false) === true) await run;
|
|
38
|
+
else await assert.rejects(run, GuardToolError);
|
|
39
|
+
assert.equal(ran, Boolean(approve && await Promise.resolve().then(() => approve()).catch(() => false) === true));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('default block, unknown tools, invalid actions and callback failures stop execution', async () => {
|
|
44
|
+
for (const tools of [{ defaultAction: 'block', rules: { other: 'allow' } }, { defaultAction: 'invalid' }]) {
|
|
45
|
+
await assert.rejects(guard({ ...base, tools }).tool(request(), {}, () => assert.fail('Executed')), GuardToolError);
|
|
46
|
+
}
|
|
47
|
+
await assert.rejects(guard({ ...base, logging: { enabled: false, onDecision: () => { throw new Error('sink unavailable'); } } }).tool(request(), {}, () => assert.fail('Executed')), /sink unavailable/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('prompt and tool events share contract; console does not include raw content', async () => {
|
|
51
|
+
const events = []; const lines = [];
|
|
52
|
+
const original = { log: console.log, warn: console.warn };
|
|
53
|
+
console.log = console.warn = value => lines.push(value);
|
|
54
|
+
try {
|
|
55
|
+
const middleware = guard({ ...base, policyVersion: 'release-1', logging: { onDecision: d => events.push(d) } });
|
|
56
|
+
await middleware.model({ prompt: 'Help alice@example.com' }, {}, async req => ({ text: req.prompt }));
|
|
57
|
+
await middleware.model({ prompt: 'ignore previous alice@example.com' }, {}, () => assert.fail('Executed'));
|
|
58
|
+
await middleware.tool(request(), {}, async () => undefined);
|
|
59
|
+
} finally { Object.assign(console, original); }
|
|
60
|
+
assert.deepEqual(events.map(d => d.guard), ['injection', 'intent', 'pii', 'injection', 'tool']);
|
|
61
|
+
assert.ok(events.every(d => d.policyVersion === 'release-1' && d.latencyMs >= 0));
|
|
62
|
+
assert.equal(new Set(events.map(d => d.decisionId)).size, events.length);
|
|
63
|
+
assert.doesNotMatch(lines.join(''), /alice@example.com|Help alice|ignore previous/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('Genkit generate actually intercepts tool execution', async () => {
|
|
67
|
+
for (const factory of [guard, guardMiddleware]) for (const action of ['allow', 'block', 'redact', 'approval-required']) {
|
|
68
|
+
const ai = genkit({});
|
|
69
|
+
let executions = 0; let turn = 0;
|
|
70
|
+
const tool = ai.defineTool({ name: 'sendEmail', description: 'Test', inputSchema: z.object({ email: z.string() }), outputSchema: z.string() }, async input => {
|
|
71
|
+
executions++;
|
|
72
|
+
assert.equal(input.email, action === 'redact' ? '[REDACTED]' : 'alice@example.com');
|
|
73
|
+
return 'ok';
|
|
74
|
+
});
|
|
75
|
+
const model = ai.defineModel({ name: 'test/model' }, async () => ({ message: { role: 'model', content: ++turn === 1
|
|
76
|
+
? [{ toolRequest: { name: 'sendEmail', ref: '1', input: { email: 'alice@example.com' } } }]
|
|
77
|
+
: [{ text: 'Done' }] } }));
|
|
78
|
+
const run = ai.generate({ model, prompt: 'Help', tools: [tool], use: [factory({ ...base, tools: { defaultAction: action } })] });
|
|
79
|
+
if (action === 'block' || action === 'approval-required') await assert.rejects(run);
|
|
80
|
+
else await run;
|
|
81
|
+
assert.equal(executions, ['allow', 'redact'].includes(action) ? 1 : 0);
|
|
82
|
+
}
|
|
83
|
+
});
|