@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 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
- _ This downloads the models to ./models folder, the total size is ~1.5 GB ( 1GB for Openai/privacy-filter + .5 GB for MiniLM-L6-v2)
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;
@@ -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[];
@@ -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
- // classifier mode: we call the classifier and return its output alongside regex matches.
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
+ }
@@ -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 textClassifiers;
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>;
@@ -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 textClassifiers = new Map();
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.textClassifiers.has(modelName)) {
59
+ if (!this.privacyFilters.has(modelName)) {
59
60
  this.init();
60
- const inst = await pipeline('text-classification', modelName);
61
- this.textClassifiers.set(modelName, inst);
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.textClassifiers.get(modelName);
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.12",
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
- "build": "tsc",
38
- "test": "npm run test:types && npm run test:storage && npm run test:concurrency",
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.');
@@ -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
+ });