@intflows/genkit-guard 0.0.14 → 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.
@@ -0,0 +1,208 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { genkit, z } from 'genkit';
7
+ import { guard, guardMiddleware, initGuard, GuardModelError, createJsonlDecisionStore, createGuardDecisionStore } from '../dist/index.js';
8
+ import { ModelSingleton } from '../dist/util/singleton.js';
9
+
10
+ const base = { models: { extractor: 'primary-intent', extractorFallback: 'backup-intent' },
11
+ intent: { semantic: { intents: { support: 'Support' }, threshold: 0.7 } },
12
+ pii: { mode: 'classifier', model: 'primary-pii', fallback: { model: 'backup-pii', mode: 'ner', labelMappings: { LABEL_1: 'NAME' } } },
13
+ logging: { enabled: false } };
14
+ const vectors = () => ({ tolist: () => [[1, 0], [1, 0]] });
15
+ const methods = ['getExtractor', 'getNER', 'getPIIClassifier'];
16
+ async function withModels(overrides, work) {
17
+ const originals = Object.fromEntries(methods.map(name => [name, ModelSingleton[name]]));
18
+ Object.assign(ModelSingleton, { getExtractor: async () => async () => vectors(), getNER: async () => async () => [], getPIIClassifier: async () => async () => [] }, overrides);
19
+ try { await work(); } finally { Object.assign(ModelSingleton, originals); }
20
+ }
21
+ const event = (id = 'one') => ({ schemaVersion: '1', decisionId: id, timestamp: new Date().toISOString(),
22
+ guard: 'tool', policyVersion: 'test-v1', action: 'allow', reasonCode: 'TOOL_ALLOWED', latencyMs: 1 });
23
+
24
+ test('JSONL persists across instances, strips unknown fields and serializes concurrent writers', async () => {
25
+ const dir = await mkdtemp(join(tmpdir(), 'guard-audit-'));
26
+ try {
27
+ const path = join(dir, 'nested', 'decisions.jsonl');
28
+ const first = createJsonlDecisionStore(path); const second = createJsonlDecisionStore(path);
29
+ assert.deepEqual(await first.read(), []);
30
+ await Promise.all(Array.from({ length: 60 }, (_, i) => (i % 2 ? first : second).append({ ...event(String(i)), rawPrompt: 'secret@example.com' })));
31
+ const saved = await createJsonlDecisionStore(path).read();
32
+ assert.equal(saved.length, 60); assert.equal(new Set(saved.map(d => d.decisionId)).size, 60);
33
+ assert.doesNotMatch(await readFile(path, 'utf8'), /secret@example.com|rawPrompt/);
34
+ assert.throws(() => first.append({ ...event(), schemaVersion: '999' }));
35
+ assert.throws(() => first.append({ ...event(), latencyMs: NaN }));
36
+ const snapshot = event('snapshot'); const pending = first.append(snapshot); snapshot.policyVersion = 'changed'; await pending;
37
+ assert.equal((await first.read()).at(-1).policyVersion, 'test-v1');
38
+ await writeFile(path, '{broken');
39
+ await assert.rejects(first.read(), /Incomplete/);
40
+ await assert.rejects(first.append(event()), /Incomplete/);
41
+ await writeFile(path, 'not-json\n');
42
+ await assert.rejects(first.read(), /line 1/);
43
+ await writeFile(path, ''); await first.append(event('recovered'));
44
+ assert.equal((await first.read())[0].decisionId, 'recovered');
45
+ } finally { await rm(dir, { recursive: true, force: true }); }
46
+ });
47
+
48
+ test('store delivery is awaited before callback and before tool; failure is closed', async () => {
49
+ await withModels({}, async () => {
50
+ const order = [];
51
+ const store = createGuardDecisionStore({ append: async () => { await new Promise(r => setTimeout(r, 5)); order.push('stored'); } });
52
+ await guard({ ...base, tools: { defaultAction: 'allow' }, logging: { enabled: false, store, onDecision: () => { order.push('callback'); } } })
53
+ .tool({ toolRequest: { name: 'lookup', input: {} } }, {}, async () => { order.push('executed'); });
54
+ assert.deepEqual(order, ['stored', 'callback', 'executed']);
55
+ const failing = guard({ ...base, tools: { defaultAction: 'allow' }, logging: { enabled: false, store: { append: () => { throw new Error('disk unavailable'); } } } });
56
+ await assert.rejects(failing.tool({ toolRequest: { name: 'lookup', input: {} } }, {}, () => assert.fail('executed')), /disk unavailable/);
57
+ await assert.rejects(failing.model({ prompt: 'Help' }, {}, () => assert.fail('executed')), /disk unavailable/);
58
+ });
59
+ });
60
+
61
+ test('startup fallbacks recover load failures for both guard models', async () => {
62
+ const calls = []; const decisions = [];
63
+ await withModels({
64
+ getExtractor: async name => { calls.push(name); if (name === 'primary-intent') throw new Error('load failed'); return async () => vectors(); },
65
+ getPIIClassifier: async name => { calls.push(name); throw new Error('load failed'); },
66
+ getNER: async name => { calls.push(name); return async () => []; },
67
+ }, async () => {
68
+ await initGuard({ ...base, logging: { enabled: false, onDecision: d => { decisions.push(d); } } });
69
+ assert.deepEqual(calls.sort(), ['backup-intent', 'backup-pii', 'primary-intent', 'primary-pii']);
70
+ assert.equal(decisions.length, 2);
71
+ assert.ok(decisions.every(d => d.reasonCode === 'MODEL_FALLBACK_USED'));
72
+ });
73
+ });
74
+
75
+ test('runtime inference fallback masks using its own mode and labels and keeps regex', async () => {
76
+ const calls = []; const decisions = [];
77
+ await withModels({
78
+ getExtractor: async name => async () => { calls.push(name); if (name === 'primary-intent') throw new Error('input secret'); return vectors(); },
79
+ getPIIClassifier: async () => async () => { throw new Error('alice@example.com'); },
80
+ getNER: async name => async () => { calls.push(name); return [{ entity: 'LABEL_1', word: 'Alice' }]; },
81
+ }, async () => {
82
+ const middleware = guard({ ...base, logging: { enabled: false, onDecision: d => { decisions.push(d); } } });
83
+ const result = await middleware.model({ prompt: 'Help Alice at alice@example.com' }, {}, async req => {
84
+ assert.doesNotMatch(req.prompt, /Alice|alice@example.com/);
85
+ assert.match(req.prompt, /NAME_/); assert.match(req.prompt, /EMAIL_/);
86
+ assert.equal(req.metadata.piiEffectiveModel, 'backup-pii');
87
+ assert.equal(req.metadata.piiEffectiveMode, 'ner');
88
+ assert.equal(req.metadata.piiUsedFallback, true);
89
+ return { text: req.prompt };
90
+ });
91
+ assert.equal(result.text, 'Help Alice at alice@example.com');
92
+ assert.deepEqual(calls, ['primary-intent', 'backup-intent', 'backup-pii']);
93
+ assert.equal(decisions.filter(d => d.reasonCode === 'MODEL_FALLBACK_USED').length, 2);
94
+ assert.doesNotMatch(JSON.stringify(decisions), /Alice|alice@example.com|input secret/);
95
+ });
96
+ });
97
+
98
+ test('fallback label mappings do not inherit primary mappings', async () => {
99
+ await withModels({ getPIIClassifier: async name => async () => {
100
+ if (name === 'primary-pii') throw new Error('failed');
101
+ return [{ entity_group: 'private_person', word: 'Alice' }];
102
+ } }, async () => {
103
+ const config = { ...base, pii: { mode: 'classifier', model: 'primary-pii', labelMappings: { private_person: null }, fallback: { model: 'backup-pii' } } };
104
+ await guard(config).model({ prompt: 'Help Alice' }, {}, async req => { assert.match(req.prompt, /NAME_/); return {}; });
105
+ });
106
+ });
107
+
108
+ test('policy rejection and successful primary never call a fallback', async () => {
109
+ const calls = [];
110
+ await withModels({ getExtractor: async name => { calls.push(name); return async () => ({ tolist: () => [[1, 0], [0, 1]] }); } }, async () => {
111
+ const result = await guard(base).model({ prompt: 'Help' }, {}, () => assert.fail('executed'));
112
+ assert.equal(result.finishReason, 'blocked'); assert.deepEqual(calls, ['primary-intent']);
113
+ });
114
+ await withModels({ getExtractor: async name => { assert.equal(name, 'primary-intent'); return async () => vectors(); },
115
+ getPIIClassifier: async name => { assert.equal(name, 'primary-pii'); return async () => []; },
116
+ getNER: async () => assert.fail('fallback loaded'),
117
+ }, async () => { await initGuard(base); await guard(base).model({ prompt: 'Help' }, {}, async () => ({})); });
118
+ });
119
+
120
+ test('both models failing stop startup and model/tool execution; no regex-only bypass', async () => {
121
+ for (const kind of ['intent', 'pii']) {
122
+ const decisions = [];
123
+ const overrides = kind === 'intent'
124
+ ? { getExtractor: async () => { throw new Error('private prompt'); } }
125
+ : { getPIIClassifier: async () => { throw new Error('private prompt'); }, getNER: async () => { throw new Error('private prompt'); } };
126
+ await withModels(overrides, async () => {
127
+ const config = { ...base, logging: { enabled: false, onDecision: d => { decisions.push(d); } } };
128
+ await assert.rejects(initGuard(config), GuardModelError);
129
+ await assert.rejects(guard(config).model({ prompt: 'Help alice@example.com' }, {}, () => assert.fail('executed')), GuardModelError);
130
+ if (kind === 'pii') await assert.rejects(guard(config).tool({ toolRequest: { name: 'lookup', input: 'alice@example.com' } }, {}, () => assert.fail('executed')), GuardModelError);
131
+ assert.ok(decisions.some(d => d.reasonCode === 'MODEL_UNAVAILABLE'));
132
+ assert.doesNotMatch(JSON.stringify(decisions), /private prompt|alice@example.com/);
133
+ });
134
+ }
135
+ });
136
+
137
+ test('audit callback failure after recovery never retries model work', async () => {
138
+ let attempts = 0;
139
+ await withModels({ getExtractor: async name => { attempts++; if (name === 'primary-intent') throw new Error('failed'); return async () => vectors(); } }, async () => {
140
+ await assert.rejects(initGuard({ ...base, logging: { enabled: false, onDecision: () => { throw new Error('audit unavailable'); } } }), /audit unavailable/);
141
+ assert.equal(attempts, 2);
142
+ });
143
+ });
144
+
145
+ test('no configured fallback preserves original error', async () => {
146
+ const original = new Error('original');
147
+ await withModels({ getExtractor: async () => { throw original; } }, async () => {
148
+ await assert.rejects(initGuard(), error => error === original);
149
+ });
150
+ });
151
+
152
+ test('Genkit tools use fallback detection and persistent decision storage', async () => {
153
+ const dir = await mkdtemp(join(tmpdir(), 'guard-genkit-audit-'));
154
+ try {
155
+ await withModels({ getPIIClassifier: async () => async () => { throw new Error('failed'); } }, async () => {
156
+ const store = createJsonlDecisionStore(join(dir, 'audit.jsonl'));
157
+ const ai = genkit({}); let turn = 0; let calls = 0;
158
+ const tool = ai.defineTool({ name: 'lookup', description: 'Test', inputSchema: z.object({ email: z.string() }), outputSchema: z.string() }, async input => {
159
+ assert.equal(input.email, '[REDACTED]'); calls++; return 'ok';
160
+ });
161
+ const model = ai.defineModel({ name: 'test/release2' }, async () => ({ message: { role: 'model', content: ++turn === 1
162
+ ? [{ toolRequest: { name: 'lookup', ref: '1', input: { email: 'alice@example.com' } } }]
163
+ : [{ text: 'Done' }] } }));
164
+ await ai.generate({ model, prompt: 'Help', tools: [tool], use: [guardMiddleware({ ...base, tools: { defaultAction: 'redact' }, logging: { enabled: false, store } })] });
165
+ assert.equal(calls, 1);
166
+ const saved = await store.read();
167
+ assert.ok(saved.some(d => d.reasonCode === 'MODEL_FALLBACK_USED'));
168
+ assert.ok(saved.some(d => d.reasonCode === 'TOOL_REDACTED'));
169
+ assert.doesNotMatch(JSON.stringify(saved), /alice@example.com/);
170
+ });
171
+ } finally { await rm(dir, { recursive: true, force: true }); }
172
+ });
173
+
174
+ test('audit store failure after model recovery never retries or executes', async () => {
175
+ let attempts = 0;
176
+ await withModels({ getPIIClassifier: async () => { attempts++; throw new Error('load failed'); },
177
+ getNER: async () => { attempts++; return async () => []; },
178
+ }, async () => {
179
+ const config = { ...base, logging: { enabled: false, store: { append: d => {
180
+ if (d.reasonCode === 'MODEL_FALLBACK_USED') throw new Error('store unavailable');
181
+ } } } };
182
+ await assert.rejects(guard(config).model({ prompt: 'Help' }, {}, () => assert.fail('executed')), /store unavailable/);
183
+ assert.equal(attempts, 2);
184
+ });
185
+ });
186
+
187
+ test('invalid label configuration and downstream failures do not trigger fallback', async () => {
188
+ let backups = 0;
189
+ await withModels({ getPIIClassifier: async () => async () => [{ entity_group: 'private_person', word: 'Alice' }],
190
+ getNER: async () => { backups++; return async () => []; },
191
+ }, async () => {
192
+ const config = { ...base, pii: { ...base.pii, labelMappings: { private_person: 'bad-type' } } };
193
+ await assert.rejects(guard(config).model({ prompt: 'Help Alice' }, {}, () => assert.fail('executed')), /uppercase/);
194
+ await assert.rejects(guard(base).model({ prompt: 'Help Alice' }, {}, () => { throw new Error('downstream failed'); }), /downstream failed/);
195
+ assert.equal(backups, 0);
196
+ });
197
+ });
198
+
199
+ test('both inference attempts failing propagate sanitized GuardModelError', async () => {
200
+ await withModels({ getPIIClassifier: async () => async () => { throw new Error('private input'); },
201
+ getNER: async () => async () => { throw new Error('private input'); },
202
+ }, async () => {
203
+ await assert.rejects(guard(base).model({ prompt: 'Help' }, {}, () => assert.fail('executed')), error => {
204
+ assert.ok(error instanceof GuardModelError); assert.equal(error.code, 'MODEL_UNAVAILABLE');
205
+ assert.doesNotMatch(String(error), /private input/); assert.equal(error.cause, undefined); return true;
206
+ });
207
+ });
208
+ });
@@ -49,3 +49,14 @@ guard(release1);
49
49
  // Legacy direct invocation remains typed for configurations without tool policies.
50
50
  void guard()({ prompt: 'test' }, async (request: unknown) => request);
51
51
  void guard({ pii: { mode: 'ner' } })({ prompt: 'test' }, async (request: unknown) => request);
52
+
53
+ import { createJsonlDecisionStore, createGuardDecisionStore, GuardModelError } from '../src/index.js';
54
+ const nextVersion = defineGuardConfig({
55
+ models: { extractorFallback: 'backup/intent' },
56
+ pii: { mode: 'classifier', fallback: { model: 'backup/pii', mode: 'ner', labelMappings: { PER: 'NAME' } } },
57
+ logging: { store: createJsonlDecisionStore('./logs/decisions.jsonl') },
58
+ });
59
+ guard(nextVersion);
60
+ void initGuard(nextVersion);
61
+ createGuardDecisionStore({ append: async decision => { const version: '1' = decision.schemaVersion; } });
62
+ const unavailable: string = new GuardModelError('pii').code;