@intflows/genkit-guard 0.0.11 → 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 +30 -6
- package/dist/middleware/middleware.js +8 -0
- package/dist/pii/detector.d.ts +14 -4
- package/dist/pii/detector.js +52 -2
- package/dist/pii/storage.d.ts +6 -0
- package/dist/pii/storage.js +67 -10
- 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-storage.js +137 -78
- 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
|
|
|
@@ -208,6 +224,7 @@ By default, PII is stored in an in-memory vault scoped to a single tokenizer ins
|
|
|
208
224
|
|
|
209
225
|
That generated namespace prevents two concurrent calls from sharing the same visible placeholder names. Vault lookups are isolated by the configured storage scope, so User A and User B can safely produce their own email tokens without cross-resolving each other's PII.
|
|
210
226
|
|
|
227
|
+
For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend. Redis clients can be passed through the built-in helper:
|
|
211
228
|
For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend. Redis clients can be passed through the built-in helper:
|
|
212
229
|
|
|
213
230
|
```ts
|
|
@@ -223,7 +240,8 @@ guard({
|
|
|
223
240
|
vault: {
|
|
224
241
|
storage: createRedisPiiVaultStorage(redis, {
|
|
225
242
|
keyPrefix: "my-app:pii",
|
|
226
|
-
ttlSeconds: 3600
|
|
243
|
+
ttlSeconds: 3600,
|
|
244
|
+
fallbackToMemory: true
|
|
227
245
|
}),
|
|
228
246
|
scopeId: (req, ctx) => ctx?.auth?.sessionId ?? req?.metadata?.requestId
|
|
229
247
|
}
|
|
@@ -231,6 +249,12 @@ guard({
|
|
|
231
249
|
});
|
|
232
250
|
```
|
|
233
251
|
|
|
252
|
+
`ttlSeconds` applies the configured expiry to both the scoped vault and token index. When
|
|
253
|
+
`fallbackToMemory` is enabled, successful writes are also mirrored in process memory and Redis
|
|
254
|
+
operation failures fall back to that mirror. The fallback is disabled by default, is local to one
|
|
255
|
+
process, and is not a replacement for Redis persistence or multi-worker availability. Its in-memory
|
|
256
|
+
entries observe the same TTL. Redis errors continue to propagate when fallback is disabled.
|
|
257
|
+
|
|
234
258
|
For another backend, use `createPiiVaultStorage({ get, set, entries, getByToken })` with your database, cache, or secret store.
|
|
235
259
|
|
|
236
260
|
Choose a `scopeId` that matches your isolation boundary, such as request ID, session ID, tenant/user ID, or a combination like `tenantId:userId:requestId`. A shared external backend should never ignore `scopeId`, because placeholders are only safe when resolved against the correct vault scope. The placeholder sent to the model uses an opaque generated namespace rather than exposing your `scopeId`.
|
|
@@ -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/pii/storage.d.ts
CHANGED
|
@@ -27,7 +27,13 @@ export type RedisPiiVaultClient = {
|
|
|
27
27
|
export type RedisPiiVaultStorageOptions = {
|
|
28
28
|
keyPrefix?: string;
|
|
29
29
|
tokenIndexKey?: string;
|
|
30
|
+
/** Expire Redis vault keys after this many seconds. Omit to keep them indefinitely. */
|
|
30
31
|
ttlSeconds?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Keep a process-local mirror and use it when a Redis operation fails.
|
|
34
|
+
* Disabled by default so Redis failures remain visible to callers.
|
|
35
|
+
*/
|
|
36
|
+
fallbackToMemory?: boolean;
|
|
31
37
|
};
|
|
32
38
|
export declare function createRedisPiiVaultStorage(redis: RedisPiiVaultClient, options?: RedisPiiVaultStorageOptions): PiiVaultStorage;
|
|
33
39
|
export declare class InMemoryPiiVaultStorage implements PiiVaultStorage {
|
package/dist/pii/storage.js
CHANGED
|
@@ -4,6 +4,13 @@ export function createPiiVaultStorage(adapter) {
|
|
|
4
4
|
export function createRedisPiiVaultStorage(redis, options = {}) {
|
|
5
5
|
const keyPrefix = options.keyPrefix ?? 'genkit-guard:pii';
|
|
6
6
|
const tokenIndexKey = options.tokenIndexKey ?? `${keyPrefix}:tokens`;
|
|
7
|
+
const ttlSeconds = options.ttlSeconds;
|
|
8
|
+
if (ttlSeconds !== undefined && (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
|
|
9
|
+
throw new Error('Redis PII vault ttlSeconds must be a positive integer.');
|
|
10
|
+
}
|
|
11
|
+
if (ttlSeconds !== undefined && !redis.expire) {
|
|
12
|
+
throw new Error('Redis PII vault ttlSeconds requires an expire method on the Redis client.');
|
|
13
|
+
}
|
|
7
14
|
const hGet = redis.hGet?.bind(redis) ?? redis.hget?.bind(redis);
|
|
8
15
|
const hSet = redis.hSet?.bind(redis) ?? redis.hset?.bind(redis);
|
|
9
16
|
const hGetAll = redis.hGetAll?.bind(redis) ?? redis.hgetall?.bind(redis);
|
|
@@ -11,31 +18,81 @@ export function createRedisPiiVaultStorage(redis, options = {}) {
|
|
|
11
18
|
throw new Error('Redis PII vault storage requires hGet/hSet/hGetAll or hget/hset/hgetall methods.');
|
|
12
19
|
}
|
|
13
20
|
const scopeKey = (scopeId) => `${keyPrefix}:scope:${scopeId}`;
|
|
21
|
+
const fallback = options.fallbackToMemory ? new ExpiringInMemoryPiiVaultStorage(ttlSeconds) : undefined;
|
|
14
22
|
async function maybeExpire(key) {
|
|
15
|
-
if (
|
|
16
|
-
await redis.expire(key,
|
|
23
|
+
if (ttlSeconds !== undefined) {
|
|
24
|
+
await redis.expire(key, ttlSeconds);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function withFallback(redisOperation, memoryOperation) {
|
|
28
|
+
try {
|
|
29
|
+
return await redisOperation();
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (!fallback)
|
|
33
|
+
throw error;
|
|
34
|
+
return memoryOperation();
|
|
17
35
|
}
|
|
18
36
|
}
|
|
19
37
|
return createPiiVaultStorage({
|
|
20
38
|
async get(scopeId, token) {
|
|
21
|
-
return (await hGet(scopeKey(scopeId), token)) ?? undefined;
|
|
39
|
+
return withFallback(async () => (await hGet(scopeKey(scopeId), token)) ?? undefined, () => fallback.get(scopeId, token));
|
|
22
40
|
},
|
|
23
41
|
async getByToken(token) {
|
|
24
|
-
return (await hGet(tokenIndexKey, token)) ?? undefined;
|
|
42
|
+
return withFallback(async () => (await hGet(tokenIndexKey, token)) ?? undefined, () => fallback.getByToken(token));
|
|
25
43
|
},
|
|
26
44
|
async set(scopeId, token, value) {
|
|
27
45
|
const scopedKey = scopeKey(scopeId);
|
|
28
|
-
|
|
29
|
-
await
|
|
30
|
-
await
|
|
31
|
-
|
|
46
|
+
// Warm the opt-in fallback on every write so data written before an outage is available.
|
|
47
|
+
await fallback?.set(scopeId, token, value);
|
|
48
|
+
await withFallback(async () => {
|
|
49
|
+
await hSet(scopedKey, token, value);
|
|
50
|
+
await hSet(tokenIndexKey, token, value);
|
|
51
|
+
await maybeExpire(scopedKey);
|
|
52
|
+
await maybeExpire(tokenIndexKey);
|
|
53
|
+
}, () => undefined);
|
|
32
54
|
},
|
|
33
55
|
async entries(scopeId) {
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
return withFallback(async () => {
|
|
57
|
+
const values = await hGetAll(scopeKey(scopeId));
|
|
58
|
+
return Object.entries(values).map(([token, value]) => ({ token, value }));
|
|
59
|
+
}, () => fallback.entries(scopeId));
|
|
36
60
|
},
|
|
37
61
|
});
|
|
38
62
|
}
|
|
63
|
+
class ExpiringInMemoryPiiVaultStorage {
|
|
64
|
+
ttlSeconds;
|
|
65
|
+
storage = new InMemoryPiiVaultStorage();
|
|
66
|
+
scopeExpiresAt = new Map();
|
|
67
|
+
tokenIndexExpiresAt;
|
|
68
|
+
constructor(ttlSeconds) {
|
|
69
|
+
this.ttlSeconds = ttlSeconds;
|
|
70
|
+
}
|
|
71
|
+
get(scopeId, token) {
|
|
72
|
+
return this.isScopeExpired(scopeId) ? undefined : this.storage.get(scopeId, token);
|
|
73
|
+
}
|
|
74
|
+
getByToken(token) {
|
|
75
|
+
return this.isTokenIndexExpired() ? undefined : this.storage.getByToken(token);
|
|
76
|
+
}
|
|
77
|
+
set(scopeId, token, value) {
|
|
78
|
+
this.storage.set(scopeId, token, value);
|
|
79
|
+
if (this.ttlSeconds !== undefined) {
|
|
80
|
+
const expiry = Date.now() + this.ttlSeconds * 1_000;
|
|
81
|
+
this.scopeExpiresAt.set(scopeId, expiry);
|
|
82
|
+
this.tokenIndexExpiresAt = expiry;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
entries(scopeId) {
|
|
86
|
+
return this.isScopeExpired(scopeId) ? [] : this.storage.entries(scopeId);
|
|
87
|
+
}
|
|
88
|
+
isScopeExpired(scopeId) {
|
|
89
|
+
const expiry = this.scopeExpiresAt.get(scopeId);
|
|
90
|
+
return expiry !== undefined && expiry <= Date.now();
|
|
91
|
+
}
|
|
92
|
+
isTokenIndexExpired() {
|
|
93
|
+
return this.tokenIndexExpiresAt !== undefined && this.tokenIndexExpiresAt <= Date.now();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
39
96
|
export class InMemoryPiiVaultStorage {
|
|
40
97
|
scopes = new Map();
|
|
41
98
|
tokenIndex = new Map();
|
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-storage.js
CHANGED
|
@@ -1,78 +1,137 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import {
|
|
3
|
-
createPiiVaultStorage,
|
|
4
|
-
createRedisPiiVaultStorage,
|
|
5
|
-
InMemoryPiiVaultStorage,
|
|
6
|
-
} from '../dist/index.js';
|
|
7
|
-
import { PiiTokenizer } from '../dist/pii/tokenizer.js';
|
|
8
|
-
|
|
9
|
-
class FakeRedis {
|
|
10
|
-
hashes = new Map();
|
|
11
|
-
expirations = new Map();
|
|
12
|
-
|
|
13
|
-
async hGet(key, field) {
|
|
14
|
-
return this.hashes.get(key)?.[field] ?? null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async hSet(key, field, value) {
|
|
18
|
-
const hash = this.hashes.get(key) ?? {};
|
|
19
|
-
hash[field] = value;
|
|
20
|
-
this.hashes.set(key, hash);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async hGetAll(key) {
|
|
24
|
-
return this.hashes.get(key) ?? {};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async expire(key, seconds) {
|
|
28
|
-
this.expirations.set(key, seconds);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const memory = new InMemoryPiiVaultStorage();
|
|
33
|
-
await memory.set('scope-a', '[[EMAIL_token_0]]', 'a@example.com');
|
|
34
|
-
assert.equal(await memory.get('scope-a', '[[EMAIL_token_0]]'), 'a@example.com');
|
|
35
|
-
assert.equal(await memory.get('scope-b', '[[EMAIL_token_0]]'), undefined);
|
|
36
|
-
assert.equal(await memory.getByToken('[[EMAIL_token_0]]'), 'a@example.com');
|
|
37
|
-
|
|
38
|
-
const custom = createPiiVaultStorage({
|
|
39
|
-
async get(scopeId, token) {
|
|
40
|
-
return scopeId === 'custom' && token === '[[EMAIL_custom_0]]' ? 'custom@example.com' : undefined;
|
|
41
|
-
},
|
|
42
|
-
async set() {},
|
|
43
|
-
async entries(scopeId) {
|
|
44
|
-
return scopeId === 'custom'
|
|
45
|
-
? [{ token: '[[EMAIL_custom_0]]', value: 'custom@example.com' }]
|
|
46
|
-
: [];
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const customTokenizer = new PiiTokenizer({ scopeId: 'custom', storage: custom });
|
|
51
|
-
assert.equal(await customTokenizer.unmask('Hi [[EMAIL_custom_0]]'), 'Hi custom@example.com');
|
|
52
|
-
|
|
53
|
-
const redis = new FakeRedis();
|
|
54
|
-
const redisStorage = createRedisPiiVaultStorage(redis, {
|
|
55
|
-
keyPrefix: 'test:pii',
|
|
56
|
-
ttlSeconds: 60,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const userATokenizer = new PiiTokenizer({ scopeId: 'tenant:userA', storage: redisStorage });
|
|
60
|
-
const userBTokenizer = new PiiTokenizer({ scopeId: 'tenant:userB', storage: redisStorage });
|
|
61
|
-
|
|
62
|
-
const userAMasked = await userATokenizer.mask('Email alice@example.com', [
|
|
63
|
-
{ type: 'EMAIL', value: 'alice@example.com' },
|
|
64
|
-
]);
|
|
65
|
-
const userBMasked = await userBTokenizer.mask('Email bob@example.com', [
|
|
66
|
-
{ type: 'EMAIL', value: 'bob@example.com' },
|
|
67
|
-
]);
|
|
68
|
-
|
|
69
|
-
assert.notEqual(userAMasked.maskedText, userBMasked.maskedText);
|
|
70
|
-
assert.equal(await userATokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
71
|
-
assert.equal(await userBTokenizer.unmask(userBMasked.maskedText), 'Email bob@example.com');
|
|
72
|
-
assert.equal(await userATokenizer.unmask(userBMasked.maskedText), userBMasked.maskedText);
|
|
73
|
-
|
|
74
|
-
const laterPassTokenizer = new PiiTokenizer({ scopeId: 'tenant:userA:later', storage: redisStorage });
|
|
75
|
-
await laterPassTokenizer.importTokens(userAMasked.maskedText);
|
|
76
|
-
assert.equal(await laterPassTokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
77
|
-
|
|
78
|
-
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
createPiiVaultStorage,
|
|
4
|
+
createRedisPiiVaultStorage,
|
|
5
|
+
InMemoryPiiVaultStorage,
|
|
6
|
+
} from '../dist/index.js';
|
|
7
|
+
import { PiiTokenizer } from '../dist/pii/tokenizer.js';
|
|
8
|
+
|
|
9
|
+
class FakeRedis {
|
|
10
|
+
hashes = new Map();
|
|
11
|
+
expirations = new Map();
|
|
12
|
+
|
|
13
|
+
async hGet(key, field) {
|
|
14
|
+
return this.hashes.get(key)?.[field] ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async hSet(key, field, value) {
|
|
18
|
+
const hash = this.hashes.get(key) ?? {};
|
|
19
|
+
hash[field] = value;
|
|
20
|
+
this.hashes.set(key, hash);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async hGetAll(key) {
|
|
24
|
+
return this.hashes.get(key) ?? {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async expire(key, seconds) {
|
|
28
|
+
this.expirations.set(key, seconds);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const memory = new InMemoryPiiVaultStorage();
|
|
33
|
+
await memory.set('scope-a', '[[EMAIL_token_0]]', 'a@example.com');
|
|
34
|
+
assert.equal(await memory.get('scope-a', '[[EMAIL_token_0]]'), 'a@example.com');
|
|
35
|
+
assert.equal(await memory.get('scope-b', '[[EMAIL_token_0]]'), undefined);
|
|
36
|
+
assert.equal(await memory.getByToken('[[EMAIL_token_0]]'), 'a@example.com');
|
|
37
|
+
|
|
38
|
+
const custom = createPiiVaultStorage({
|
|
39
|
+
async get(scopeId, token) {
|
|
40
|
+
return scopeId === 'custom' && token === '[[EMAIL_custom_0]]' ? 'custom@example.com' : undefined;
|
|
41
|
+
},
|
|
42
|
+
async set() {},
|
|
43
|
+
async entries(scopeId) {
|
|
44
|
+
return scopeId === 'custom'
|
|
45
|
+
? [{ token: '[[EMAIL_custom_0]]', value: 'custom@example.com' }]
|
|
46
|
+
: [];
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const customTokenizer = new PiiTokenizer({ scopeId: 'custom', storage: custom });
|
|
51
|
+
assert.equal(await customTokenizer.unmask('Hi [[EMAIL_custom_0]]'), 'Hi custom@example.com');
|
|
52
|
+
|
|
53
|
+
const redis = new FakeRedis();
|
|
54
|
+
const redisStorage = createRedisPiiVaultStorage(redis, {
|
|
55
|
+
keyPrefix: 'test:pii',
|
|
56
|
+
ttlSeconds: 60,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const userATokenizer = new PiiTokenizer({ scopeId: 'tenant:userA', storage: redisStorage });
|
|
60
|
+
const userBTokenizer = new PiiTokenizer({ scopeId: 'tenant:userB', storage: redisStorage });
|
|
61
|
+
|
|
62
|
+
const userAMasked = await userATokenizer.mask('Email alice@example.com', [
|
|
63
|
+
{ type: 'EMAIL', value: 'alice@example.com' },
|
|
64
|
+
]);
|
|
65
|
+
const userBMasked = await userBTokenizer.mask('Email bob@example.com', [
|
|
66
|
+
{ type: 'EMAIL', value: 'bob@example.com' },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
assert.notEqual(userAMasked.maskedText, userBMasked.maskedText);
|
|
70
|
+
assert.equal(await userATokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
71
|
+
assert.equal(await userBTokenizer.unmask(userBMasked.maskedText), 'Email bob@example.com');
|
|
72
|
+
assert.equal(await userATokenizer.unmask(userBMasked.maskedText), userBMasked.maskedText);
|
|
73
|
+
|
|
74
|
+
const laterPassTokenizer = new PiiTokenizer({ scopeId: 'tenant:userA:later', storage: redisStorage });
|
|
75
|
+
await laterPassTokenizer.importTokens(userAMasked.maskedText);
|
|
76
|
+
assert.equal(await laterPassTokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
77
|
+
|
|
78
|
+
assert.equal(redis.expirations.get('test:pii:scope:tenant:userA'), 60);
|
|
79
|
+
assert.equal(redis.expirations.get('test:pii:tokens'), 60);
|
|
80
|
+
|
|
81
|
+
class FailingRedis extends FakeRedis {
|
|
82
|
+
failed = false;
|
|
83
|
+
|
|
84
|
+
fail() {
|
|
85
|
+
this.failed = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async hGet(key, field) {
|
|
89
|
+
if (this.failed) throw new Error('Redis unavailable');
|
|
90
|
+
return super.hGet(key, field);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async hSet(key, field, value) {
|
|
94
|
+
if (this.failed) throw new Error('Redis unavailable');
|
|
95
|
+
return super.hSet(key, field, value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async hGetAll(key) {
|
|
99
|
+
if (this.failed) throw new Error('Redis unavailable');
|
|
100
|
+
return super.hGetAll(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const failingRedis = new FailingRedis();
|
|
105
|
+
const resilientStorage = createRedisPiiVaultStorage(failingRedis, { fallbackToMemory: true });
|
|
106
|
+
await resilientStorage.set('resilient-scope', '[[EMAIL_resilient_0]]', 'safe@example.com');
|
|
107
|
+
failingRedis.fail();
|
|
108
|
+
assert.equal(
|
|
109
|
+
await resilientStorage.get('resilient-scope', '[[EMAIL_resilient_0]]'),
|
|
110
|
+
'safe@example.com'
|
|
111
|
+
);
|
|
112
|
+
assert.equal(await resilientStorage.getByToken('[[EMAIL_resilient_0]]'), 'safe@example.com');
|
|
113
|
+
assert.deepEqual(await resilientStorage.entries('resilient-scope'), [
|
|
114
|
+
{ token: '[[EMAIL_resilient_0]]', value: 'safe@example.com' },
|
|
115
|
+
]);
|
|
116
|
+
await resilientStorage.set('resilient-scope', '[[PHONE_resilient_1]]', '0400000000');
|
|
117
|
+
assert.equal(await resilientStorage.getByToken('[[PHONE_resilient_1]]'), '0400000000');
|
|
118
|
+
|
|
119
|
+
const strictRedis = new FailingRedis();
|
|
120
|
+
const strictStorage = createRedisPiiVaultStorage(strictRedis);
|
|
121
|
+
strictRedis.fail();
|
|
122
|
+
await assert.rejects(() => strictStorage.get('scope', 'token'), /Redis unavailable/);
|
|
123
|
+
|
|
124
|
+
assert.throws(
|
|
125
|
+
() => createRedisPiiVaultStorage(new FakeRedis(), { ttlSeconds: 0 }),
|
|
126
|
+
/positive integer/
|
|
127
|
+
);
|
|
128
|
+
assert.throws(
|
|
129
|
+
() =>
|
|
130
|
+
createRedisPiiVaultStorage(
|
|
131
|
+
{ hGet: async () => null, hSet: async () => undefined, hGetAll: async () => ({}) },
|
|
132
|
+
{ ttlSeconds: 60 }
|
|
133
|
+
),
|
|
134
|
+
/requires an expire method/
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
console.log('PII vault storage 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
|
+
});
|