@intflows/genkit-guard 0.0.12 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -186
- package/dist/core/decision.d.ts +33 -0
- package/dist/core/decision.js +9 -0
- package/dist/guard.config.d.ts +10 -0
- package/dist/guard.config.js +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +8 -10
- package/dist/intent/intentAnalyzer.d.ts +1 -1
- package/dist/intent/intentAnalyzer.js +2 -2
- package/dist/middleware/middleware.d.ts +87 -7
- package/dist/middleware/middleware.js +93 -4
- package/dist/pii/detector.d.ts +16 -4
- package/dist/pii/detector.js +72 -3
- package/dist/util/singleton.d.ts +2 -1
- package/dist/util/singleton.js +8 -5
- package/package.json +9 -4
- package/scripts/publish-wiki.js +59 -0
- package/scripts/test-config.js +45 -0
- package/scripts/test-privacy-filter.js +116 -0
- package/scripts/test-publish-wiki.js +30 -0
- package/scripts/test-release1.js +83 -0
- package/scripts/test-types.ts +51 -21
|
@@ -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.');
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { detectPII, privacyFilterOutputToMatches } from '../dist/pii/detector.js';
|
|
3
|
+
import { ModelSingleton, PRIVACY_FILTER_PIPELINE_TASK } from '../dist/util/singleton.js';
|
|
4
|
+
import { InMemoryPiiVaultStorage } from '../dist/pii/storage.js';
|
|
5
|
+
import { PiiTokenizer } from '../dist/pii/tokenizer.js';
|
|
6
|
+
import { guard } from '../dist/index.js';
|
|
7
|
+
|
|
8
|
+
assert.equal(PRIVACY_FILTER_PIPELINE_TASK, 'token-classification');
|
|
9
|
+
|
|
10
|
+
const input = 'Contact Alice Smith at alice@example.com. Her key is sk-live-secret.';
|
|
11
|
+
const expectedSpans = [
|
|
12
|
+
{ entity_group: 'private_person', word: ' Alice Smith' },
|
|
13
|
+
{ entity_group: 'private_email', word: ' alice@example.com' },
|
|
14
|
+
{ entity_group: 'secret', word: ' sk-live-secret' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
assert.deepEqual(privacyFilterOutputToMatches(input, expectedSpans), [
|
|
18
|
+
{ type: 'NAME', value: 'Alice Smith' },
|
|
19
|
+
{ type: 'EMAIL', value: 'alice@example.com' },
|
|
20
|
+
{ type: 'SECRET', value: 'sk-live-secret' },
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
privacyFilterOutputToMatches('Home: 10 Green Street', [
|
|
25
|
+
{ entity_group: 'private_address', start: 6, end: 21, word: 'wrong fallback' },
|
|
26
|
+
{ entity_group: 'O', word: 'Home' },
|
|
27
|
+
{ entity_group: 'private_date', word: 'not present' },
|
|
28
|
+
null,
|
|
29
|
+
]),
|
|
30
|
+
[{ type: 'ADDRESS', value: '10 Green Street' }]
|
|
31
|
+
);
|
|
32
|
+
assert.deepEqual(privacyFilterOutputToMatches(input, { invalid: true }), []);
|
|
33
|
+
|
|
34
|
+
const originalGetPIIClassifier = ModelSingleton.getPIIClassifier;
|
|
35
|
+
const originalGetExtractor = ModelSingleton.getExtractor;
|
|
36
|
+
let receivedOptions;
|
|
37
|
+
ModelSingleton.getPIIClassifier = async () => async (_text, options) => {
|
|
38
|
+
receivedOptions = options;
|
|
39
|
+
return expectedSpans;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const detection = await detectPII(input, { mode: 'classifier' });
|
|
44
|
+
assert.deepEqual(receivedOptions, { aggregation_strategy: 'simple' });
|
|
45
|
+
|
|
46
|
+
// The regex email and model email are deduplicated; model-only name and secret become matches.
|
|
47
|
+
assert.deepEqual(detection.matches, [
|
|
48
|
+
{ type: 'EMAIL', value: 'alice@example.com' },
|
|
49
|
+
{ type: 'NAME', value: 'Alice Smith' },
|
|
50
|
+
{ type: 'SECRET', value: 'sk-live-secret' },
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const storage = new InMemoryPiiVaultStorage();
|
|
54
|
+
const tokenizer = new PiiTokenizer({ scopeId: 'privacy-filter-test', storage });
|
|
55
|
+
const masked = await tokenizer.mask(input, detection.matches);
|
|
56
|
+
|
|
57
|
+
assert.doesNotMatch(masked.maskedText, /Alice Smith|alice@example\.com|sk-live-secret/);
|
|
58
|
+
assert.match(masked.maskedText, /\[\[NAME_/);
|
|
59
|
+
assert.match(masked.maskedText, /\[\[EMAIL_/);
|
|
60
|
+
assert.match(masked.maskedText, /\[\[SECRET_/);
|
|
61
|
+
assert.equal(await tokenizer.unmask(masked.maskedText), input);
|
|
62
|
+
} finally {
|
|
63
|
+
ModelSingleton.getPIIClassifier = originalGetPIIClassifier;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Reproduce a Genkit multi-turn response: an inner turn creates the token in one scope, while
|
|
67
|
+
// the final response passes through middleware attached to another context and scope.
|
|
68
|
+
const crossTurnStorage = new InMemoryPiiVaultStorage();
|
|
69
|
+
const innerTurn = new PiiTokenizer({ scopeId: 'inner-turn', storage: crossTurnStorage });
|
|
70
|
+
const crossTurnMasked = await innerTurn.mask('owner@example.com', [
|
|
71
|
+
{ type: 'EMAIL', value: 'owner@example.com' },
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
ModelSingleton.getExtractor = async () => async () => ({ tolist: () => [] });
|
|
75
|
+
ModelSingleton.getPIIClassifier = async () => async () => [];
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const middleware = guard({
|
|
79
|
+
intent: { semantic: { threshold: 0, intents: {} } },
|
|
80
|
+
pii: {
|
|
81
|
+
mode: 'classifier',
|
|
82
|
+
vault: { storage: crossTurnStorage, scopeId: 'outer-turn' },
|
|
83
|
+
},
|
|
84
|
+
logging: { enabled: false },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const response = await middleware.model(
|
|
88
|
+
{ prompt: 'Fetch blob metadata' },
|
|
89
|
+
{},
|
|
90
|
+
async () => ({ answer: `File owner: ${crossTurnMasked.maskedText}` })
|
|
91
|
+
);
|
|
92
|
+
assert.deepEqual(response, { answer: 'File owner: owner@example.com' });
|
|
93
|
+
|
|
94
|
+
const unknownTokenResponse = await middleware.model(
|
|
95
|
+
{ prompt: 'Fetch blob metadata' },
|
|
96
|
+
{},
|
|
97
|
+
async () => ({ answer: '[[EMAIL_unknownnamespace_99]]' })
|
|
98
|
+
);
|
|
99
|
+
assert.deepEqual(unknownTokenResponse, { answer: '[[EMAIL_unknownnamespace_99]]' });
|
|
100
|
+
|
|
101
|
+
let toolInput;
|
|
102
|
+
await middleware.tool(
|
|
103
|
+
{ toolRequest: { name: 'sendEmail', input: { recipient: crossTurnMasked.maskedText } } },
|
|
104
|
+
{},
|
|
105
|
+
async (request) => {
|
|
106
|
+
toolInput = request.toolRequest.input;
|
|
107
|
+
return { sent: true };
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
assert.deepEqual(toolInput, { recipient: 'owner@example.com' });
|
|
111
|
+
} finally {
|
|
112
|
+
ModelSingleton.getExtractor = originalGetExtractor;
|
|
113
|
+
ModelSingleton.getPIIClassifier = originalGetPIIClassifier;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log('Privacy Filter pipeline, masking and cross-turn unmasking 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
|
+
});
|
package/scripts/test-types.ts
CHANGED
|
@@ -1,21 +1,51 @@
|
|
|
1
|
-
import { guard, createRedisPiiVaultStorage, type RedisPiiVaultClient } from '../src/index.js';
|
|
2
|
-
|
|
3
|
-
const redis: RedisPiiVaultClient = {
|
|
4
|
-
async hGet() {
|
|
5
|
-
return undefined;
|
|
6
|
-
},
|
|
7
|
-
async hSet() {},
|
|
8
|
-
async hGetAll() {
|
|
9
|
-
return {};
|
|
10
|
-
},
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
guard({
|
|
14
|
-
pii: {
|
|
15
|
-
reversible: true,
|
|
16
|
-
vault: {
|
|
17
|
-
storage: createRedisPiiVaultStorage(redis),
|
|
18
|
-
scopeId: (req: any, ctx: any) => ctx?.auth?.sessionId ?? req?.metadata?.requestId,
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
});
|
|
1
|
+
import { guard, createRedisPiiVaultStorage, type RedisPiiVaultClient } from '../src/index.js';
|
|
2
|
+
|
|
3
|
+
const redis: RedisPiiVaultClient = {
|
|
4
|
+
async hGet() {
|
|
5
|
+
return undefined;
|
|
6
|
+
},
|
|
7
|
+
async hSet() {},
|
|
8
|
+
async hGetAll() {
|
|
9
|
+
return {};
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
guard({
|
|
14
|
+
pii: {
|
|
15
|
+
reversible: true,
|
|
16
|
+
vault: {
|
|
17
|
+
storage: createRedisPiiVaultStorage(redis),
|
|
18
|
+
scopeId: (req: any, ctx: any) => ctx?.auth?.sessionId ?? req?.metadata?.requestId,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
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);
|