@intflows/genkit-guard 0.0.12 → 0.0.13
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 +21 -5
- package/dist/middleware/middleware.js +8 -0
- package/dist/pii/detector.d.ts +14 -4
- package/dist/pii/detector.js +52 -2
- package/dist/util/singleton.d.ts +2 -1
- package/dist/util/singleton.js +8 -5
- package/package.json +5 -3
- package/scripts/test-privacy-filter.js +116 -0
- package/scripts/test-types.ts +21 -21
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ npm install @intflows/genkit-guard
|
|
|
63
63
|
# Download Local Models (Only needed once)
|
|
64
64
|
node node_modules/@intflows/genkit-guard/scripts/download-model.js
|
|
65
65
|
```
|
|
66
|
-
|
|
66
|
+
_This downloads the models to `./models`; the total size is approximately 1.5 GB._
|
|
67
67
|
|
|
68
68
|
### 2. Update genkit
|
|
69
69
|
|
|
@@ -193,10 +193,26 @@ intent: {
|
|
|
193
193
|
### **PII Guard**
|
|
194
194
|
|
|
195
195
|
```ts
|
|
196
|
-
pii: {
|
|
197
|
-
reversible: true
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
pii: {
|
|
197
|
+
reversible: true,
|
|
198
|
+
mode: "classifier"
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`classifier` mode uses `openai/privacy-filter` as a token-classification model with aggregated
|
|
203
|
+
spans. Model-detected names, addresses, emails, phone numbers, URLs, dates, account numbers and
|
|
204
|
+
secrets are converted into reversible masking tokens. Regex rules continue to run as an additional
|
|
205
|
+
layer, and duplicate spans are masked only once.
|
|
206
|
+
|
|
207
|
+
During multi-turn tool execution, opaque tokens returned through a different Genkit middleware
|
|
208
|
+
context are rehydrated from the configured vault before tool execution and before the final
|
|
209
|
+
response is returned to the application.
|
|
210
|
+
|
|
211
|
+
Preload the same mode during application startup:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
await initGuard({ pii: { mode: "classifier" } });
|
|
215
|
+
```
|
|
200
216
|
|
|
201
217
|
### **PII Vault Isolation and External Storage**
|
|
202
218
|
|
|
@@ -128,6 +128,11 @@ function createGuardHooks(config) {
|
|
|
128
128
|
tool: async (req, ctx, next) => {
|
|
129
129
|
const state = getGuardState(ctx);
|
|
130
130
|
const toolName = req?.toolRequest?.name;
|
|
131
|
+
// Genkit may provide a fresh middleware context for a tool turn. Create a recovery
|
|
132
|
+
// tokenizer that uses the configured vault so opaque tokens can be rehydrated safely.
|
|
133
|
+
if (state.tokenizers.length === 0) {
|
|
134
|
+
state.tokenizers.push(createTokenizer(config, req, ctx));
|
|
135
|
+
}
|
|
131
136
|
if (req?.toolRequest && 'input' in req.toolRequest) {
|
|
132
137
|
req.toolRequest.input = await unmaskObject(req.toolRequest.input, state.tokenizers);
|
|
133
138
|
}
|
|
@@ -199,6 +204,9 @@ async function unmaskObject(obj, tokenizers) {
|
|
|
199
204
|
return transformStrings(obj, async (value) => {
|
|
200
205
|
let result = value;
|
|
201
206
|
for (const tokenizer of tokenizers) {
|
|
207
|
+
// A token may have been produced by another model/tool turn with a different Genkit
|
|
208
|
+
// context or vault scope. Import only opaque tokens actually present in this value.
|
|
209
|
+
await tokenizer.importTokens(result);
|
|
202
210
|
result = await tokenizer.unmask(result);
|
|
203
211
|
}
|
|
204
212
|
return result;
|
package/dist/pii/detector.d.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
export type PiiMatch = {
|
|
2
|
+
type: string;
|
|
3
|
+
value: string;
|
|
4
|
+
};
|
|
5
|
+
export type PrivacyFilterSpan = {
|
|
6
|
+
entity_group?: string;
|
|
7
|
+
entity?: string;
|
|
8
|
+
word?: string;
|
|
9
|
+
start?: number;
|
|
10
|
+
end?: number;
|
|
11
|
+
score?: number;
|
|
12
|
+
};
|
|
1
13
|
export declare function detectPII(text: string, opts?: {
|
|
2
14
|
model?: string;
|
|
3
15
|
mode?: 'ner' | 'classifier';
|
|
4
16
|
}): Promise<{
|
|
5
|
-
matches:
|
|
6
|
-
type: string;
|
|
7
|
-
value: string;
|
|
8
|
-
}[];
|
|
17
|
+
matches: PiiMatch[];
|
|
9
18
|
classifier: any;
|
|
10
19
|
}>;
|
|
20
|
+
export declare function privacyFilterOutputToMatches(text: string, output: unknown): PiiMatch[];
|
package/dist/pii/detector.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { ModelSingleton } from '../util/singleton.js';
|
|
2
|
+
const PRIVACY_FILTER_TYPE_MAP = {
|
|
3
|
+
account_number: 'ACCOUNT_NUMBER',
|
|
4
|
+
private_address: 'ADDRESS',
|
|
5
|
+
private_email: 'EMAIL',
|
|
6
|
+
private_person: 'NAME',
|
|
7
|
+
private_phone: 'PHONE',
|
|
8
|
+
private_url: 'URL',
|
|
9
|
+
private_date: 'DATE',
|
|
10
|
+
secret: 'SECRET',
|
|
11
|
+
};
|
|
2
12
|
const REGEX_RULES = [
|
|
3
13
|
// EMAIL (keep your existing one)
|
|
4
14
|
{ type: 'EMAIL', pattern: /\b[\w\.-]+@[\w\.-]+\.\w{2,}\b/gi },
|
|
@@ -36,12 +46,52 @@ export async function detectPII(text, opts) {
|
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
else {
|
|
39
|
-
//
|
|
49
|
+
// Privacy Filter is a token-classification model. Aggregation produces complete spans
|
|
50
|
+
// rather than individual BIOES-labelled tokens.
|
|
40
51
|
const cls = await ModelSingleton.getPIIClassifier(model);
|
|
41
|
-
classifierOutput = await cls(text);
|
|
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)) {
|
|
55
|
+
results.push(match);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
42
58
|
}
|
|
43
59
|
return {
|
|
44
60
|
matches: results,
|
|
45
61
|
classifier: classifierOutput
|
|
46
62
|
};
|
|
47
63
|
}
|
|
64
|
+
export function privacyFilterOutputToMatches(text, output) {
|
|
65
|
+
if (!Array.isArray(output))
|
|
66
|
+
return [];
|
|
67
|
+
const matches = [];
|
|
68
|
+
for (const candidate of output) {
|
|
69
|
+
if (!candidate || typeof candidate !== 'object')
|
|
70
|
+
continue;
|
|
71
|
+
const span = candidate;
|
|
72
|
+
const rawLabel = span.entity_group ?? span.entity;
|
|
73
|
+
if (typeof rawLabel !== 'string')
|
|
74
|
+
continue;
|
|
75
|
+
const label = rawLabel.replace(/^[BIES]-/, '').toLowerCase();
|
|
76
|
+
const type = PRIVACY_FILTER_TYPE_MAP[label];
|
|
77
|
+
if (!type)
|
|
78
|
+
continue;
|
|
79
|
+
let value;
|
|
80
|
+
if (Number.isInteger(span.start) &&
|
|
81
|
+
Number.isInteger(span.end) &&
|
|
82
|
+
span.start >= 0 &&
|
|
83
|
+
span.end > span.start &&
|
|
84
|
+
span.end <= text.length) {
|
|
85
|
+
value = text.slice(span.start, span.end);
|
|
86
|
+
}
|
|
87
|
+
else if (typeof span.word === 'string') {
|
|
88
|
+
value = span.word.trim();
|
|
89
|
+
}
|
|
90
|
+
if (!value || !text.includes(value))
|
|
91
|
+
continue;
|
|
92
|
+
if (!matches.some((existing) => existing.value === value)) {
|
|
93
|
+
matches.push({ type, value });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return matches;
|
|
97
|
+
}
|
package/dist/util/singleton.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
export declare const PRIVACY_FILTER_PIPELINE_TASK: "token-classification";
|
|
1
2
|
export declare class ModelSingleton {
|
|
2
3
|
private static extractors;
|
|
3
4
|
private static nerClassifiers;
|
|
4
|
-
private static
|
|
5
|
+
private static privacyFilters;
|
|
5
6
|
static init(): void;
|
|
6
7
|
static getExtractor(modelName?: string): Promise<any>;
|
|
7
8
|
static getNER(modelName?: string): Promise<any>;
|
package/dist/util/singleton.js
CHANGED
|
@@ -3,12 +3,13 @@ import path from 'path';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
export const PRIVACY_FILTER_PIPELINE_TASK = 'token-classification';
|
|
6
7
|
env.allowRemoteModels = false;
|
|
7
8
|
env.localModelPath = path.join(__dirname, '../../models');
|
|
8
9
|
export class ModelSingleton {
|
|
9
10
|
static extractors = new Map();
|
|
10
11
|
static nerClassifiers = new Map();
|
|
11
|
-
static
|
|
12
|
+
static privacyFilters = new Map();
|
|
12
13
|
static init() {
|
|
13
14
|
// Always resolve model path relative to the client app, not the library
|
|
14
15
|
const projectRoot = process.cwd();
|
|
@@ -55,12 +56,14 @@ export class ModelSingleton {
|
|
|
55
56
|
return this.nerClassifiers.get(modelName);
|
|
56
57
|
}
|
|
57
58
|
static async getPIIClassifier(modelName = 'openai/privacy-filter') {
|
|
58
|
-
if (!this.
|
|
59
|
+
if (!this.privacyFilters.has(modelName)) {
|
|
59
60
|
this.init();
|
|
60
|
-
const inst = await pipeline(
|
|
61
|
-
|
|
61
|
+
const inst = await pipeline(PRIVACY_FILTER_PIPELINE_TASK, modelName, {
|
|
62
|
+
dtype: 'q4',
|
|
63
|
+
});
|
|
64
|
+
this.privacyFilters.set(modelName, inst);
|
|
62
65
|
}
|
|
63
|
-
return this.
|
|
66
|
+
return this.privacyFilters.get(modelName);
|
|
64
67
|
}
|
|
65
68
|
static async preload(models) {
|
|
66
69
|
const tasks = [];
|
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.0.13",
|
|
26
26
|
"type": "module",
|
|
27
27
|
"exports": "./dist/index.js",
|
|
28
28
|
"types": "./dist/index.d.ts",
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
36
|
"prepare-models": "node scripts/download-model.js",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
37
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
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
40
|
"test:types": "tsc --noEmit --ignoreConfig --module NodeNext --moduleResolution NodeNext --target ESNext --strict --skipLibCheck scripts/test-types.ts",
|
|
41
|
+
"test:privacy-filter": "npm run build && node scripts/test-privacy-filter.js",
|
|
40
42
|
"test:storage": "npm run build && node scripts/test-storage.js",
|
|
41
43
|
"test:concurrency": "npm run build && node scripts/test-concurrent-vault.js",
|
|
42
44
|
"test:redis": "npm run build && node scripts/test-redis-vault.js",
|
|
@@ -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.');
|
package/scripts/test-types.ts
CHANGED
|
@@ -1,21 +1,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
|
-
});
|
|
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
|
+
});
|