@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.
@@ -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
+ });
@@ -19,3 +19,33 @@ guard({
19
19
  },
20
20
  },
21
21
  });
22
+
23
+ import { defineGuardConfig, initGuard, type GuardConfig } from '../src/index.js';
24
+ const shared = defineGuardConfig({
25
+ models: { extractor: 'custom/intent' },
26
+ pii: { model: 'custom/pii', mode: 'classifier', labelMappings: { LABEL_0: null, LABEL_1: 'NAME' } },
27
+ });
28
+ guard(shared);
29
+ void initGuard(shared);
30
+ const legacy: GuardConfig = { models: { extractor: 'legacy/intent' }, pii: { model: 'legacy/pii' } };
31
+ guard(legacy);
32
+ void initGuard(legacy);
33
+
34
+ import type { GuardDecision } from '../src/index.js';
35
+ const release1 = defineGuardConfig({
36
+ policyVersion: 'release-1',
37
+ tools: {
38
+ defaultAction: 'block',
39
+ rules: { lookup: 'allow', sendEmail: 'approval-required', summarize: 'redact' },
40
+ approve: async ({ toolName, input, context }) => {
41
+ const call: unknown[] = [toolName, input, context];
42
+ return call.length === 0;
43
+ },
44
+ },
45
+ logging: { onDecision: async (decision: GuardDecision) => { console.log(decision.reasonCode); } },
46
+ });
47
+ guard(release1);
48
+
49
+ // Legacy direct invocation remains typed for configurations without tool policies.
50
+ void guard()({ prompt: 'test' }, async (request: unknown) => request);
51
+ void guard({ pii: { mode: 'ner' } })({ prompt: 'test' }, async (request: unknown) => request);