@studio-foundation/anonymizer 0.3.0-beta.1 → 0.3.0-beta.5

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/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@studio-foundation/anonymizer",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.5",
4
4
  "description": "PII detection and anonymization with consistent token mapping",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
7
10
  "keywords": [
8
11
  "pii",
9
12
  "anonymization",
package/ARCHITECTURE.md DELETED
@@ -1,40 +0,0 @@
1
- # @studio-foundation/anonymizer
2
-
3
- Bibliothèque de détection et anonymisation de PII. Remplace les données sensibles par des tokens avant envoi au LLM, avec keymap pour restaurer les valeurs originales.
4
-
5
- ## Concept
6
-
7
- `anonymize(text)` → `{ text: "Hi [PERSON_1]...", keymap: { "PERSON_1": "Marie" } }`
8
- `deanonymize(text, keymap)` → texte original restauré.
9
-
10
- Même valeur → même token, au sein d'un appel et entre appels si `seedKeymap` fourni.
11
-
12
- ## Règles
13
-
14
- - **ZERO dépendance `@studio/*`** — bibliothèque pure, réutilisable hors de Studio
15
- - Stateless : `Tokenizer` créé à chaque appel (ou seedé via `seedKeymap`)
16
- - Détection en 2 phases : regex haute précision (email, phone, SSN, credit card) + noms via patterns de salutation (best-effort)
17
- - La détection de personnes est non-fatale — silencieusement skippée sur erreur
18
- - Les spans sont non-overlapping : premier match gagne, position marquée occupée
19
-
20
- ## Fichiers clés
21
-
22
- - `index.ts` — `anonymize(text, options?)`, `deanonymize(text, keymap)`, exports publics
23
- - `detector.ts` — `detectPII(text)` : détection regex + noms par salutation
24
- - `tokenizer.ts` — `Tokenizer` : génération et résolution de tokens séquentiels
25
- - `types.ts` — `PIICategory`, `PIISpan`, `PIIDetectionResult`, `AnonymizerOptions`
26
-
27
- ## Catégories PII
28
-
29
- `person` → `PERSON_N`, `email` → `EMAIL_N`, `phone` → `PHONE_N`,
30
- `ssn` → `SSN_N`, `credit_card` → `CREDIT_CARD_N`, `address` → `ADDRESS_N` (réservé)
31
-
32
- ## Intégration dans Studio
33
-
34
- Utilisé par `runner/src/middleware/anonymization.ts` (`AnonymizationMiddleware`).
35
- Activé via `--anonymize` sur `studio run` ou `anonymize: true` dans l'agent YAML.
36
- Keymap persisté dans `.studio/runs/anonymization/<run-id>.keymap.json`.
37
-
38
- ## Dépendances
39
-
40
- Aucune `@studio/*`. Bibliothèque autonome.
package/src/detector.ts DELETED
@@ -1,97 +0,0 @@
1
- import type { PIICategory, PIISpan } from './types.js';
2
-
3
- // Regex patterns for structural PII (high precision)
4
- const PATTERNS: Array<{ category: PIICategory; regex: RegExp }> = [
5
- {
6
- category: 'email',
7
- regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g,
8
- },
9
- {
10
- category: 'phone',
11
- // US phone: must be at least 10 digits. Require optional country code + area code.
12
- // Anchored to avoid matching partial SSN-like strings.
13
- regex: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g,
14
- },
15
- {
16
- category: 'ssn',
17
- // SSN: exactly ddd-dd-dddd with hyphens (strict format, not dots or spaces
18
- // to avoid collision with phone numbers already consumed)
19
- regex: /\b\d{3}-\d{2}-\d{4}\b/g,
20
- },
21
- {
22
- category: 'credit_card',
23
- regex: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
24
- },
25
- ];
26
-
27
- /**
28
- * Detect PII spans in text.
29
- * Returns non-overlapping spans sorted by position.
30
- */
31
- export function detectPII(text: string): PIISpan[] {
32
- const spans: PIISpan[] = [];
33
- const occupied = new Set<number>();
34
-
35
- // Phase 1: Structural PII via regex
36
- for (const { category, regex } of PATTERNS) {
37
- regex.lastIndex = 0;
38
- let match: RegExpExecArray | null;
39
- while ((match = regex.exec(text)) !== null) {
40
- const start = match.index;
41
- const end = start + match[0].length;
42
- if (isOccupied(occupied, start, end)) continue;
43
- markOccupied(occupied, start, end);
44
- spans.push({ start, end, category, value: match[0] });
45
- }
46
- }
47
-
48
- // Phase 2: Person names — best-effort via @redactpii/node
49
- try {
50
- detectPersons(text, occupied, spans);
51
- } catch {
52
- // Person detection is best-effort; silently skip on any error
53
- }
54
-
55
- return spans.sort((a, b) => a.start - b.start);
56
- }
57
-
58
- function isOccupied(occupied: Set<number>, start: number, end: number): boolean {
59
- for (let i = start; i < end; i++) {
60
- if (occupied.has(i)) return true;
61
- }
62
- return false;
63
- }
64
-
65
- function markOccupied(occupied: Set<number>, start: number, end: number): void {
66
- for (let i = start; i < end; i++) {
67
- occupied.add(i);
68
- }
69
- }
70
-
71
- function detectPersons(text: string, occupied: Set<number>, spans: PIISpan[]): void {
72
- // The @redactpii/node NAME pattern only catches names after salutations
73
- // (Dear, Hi, Hello, Hey, etc.) — not bare names. We replicate that pattern
74
- // here and use it directly, avoiding the ESM/CJS import complexity.
75
- // This gives best-effort detection for explicitly addressed names.
76
- const salutationPattern =
77
- /(?:dear|hi|hello|greetings|hey(?:\s+there)?|mr\.?|mrs\.?|ms\.?|dr\.?|prof\.?)\s+([A-Z][a-zA-ZÀ-ÿ'\-]+(?:\s+[A-Z][a-zA-ZÀ-ÿ'\-]+)*)/gi;
78
-
79
- let match: RegExpExecArray | null;
80
- salutationPattern.lastIndex = 0;
81
-
82
- while ((match = salutationPattern.exec(text)) !== null) {
83
- // match[1] is the captured name group
84
- const nameValue = match[1];
85
- if (!nameValue) continue;
86
-
87
- // Find the actual start position of the captured name within the full match
88
- const fullMatchStart = match.index;
89
- const nameOffset = match[0].indexOf(nameValue);
90
- const start = fullMatchStart + nameOffset;
91
- const end = start + nameValue.length;
92
-
93
- if (isOccupied(occupied, start, end)) continue;
94
- markOccupied(occupied, start, end);
95
- spans.push({ start, end, category: 'person', value: nameValue });
96
- }
97
- }
package/src/index.ts DELETED
@@ -1,52 +0,0 @@
1
- import { detectPII } from './detector.js';
2
- import { Tokenizer } from './tokenizer.js';
3
- import type { PIIDetectionResult, AnonymizerOptions } from './types.js';
4
-
5
- export type { PIICategory, PIIDetectionResult, AnonymizerOptions } from './types.js';
6
- export { Tokenizer } from './tokenizer.js';
7
-
8
- /**
9
- * Anonymize PII in text. Returns anonymized text + keymap (token → original).
10
- * Same PII value always gets the same token within a call.
11
- * Pass seedKeymap to maintain consistency across multiple calls.
12
- */
13
- export function anonymize(text: string, options?: AnonymizerOptions): PIIDetectionResult {
14
- const spans = detectPII(text);
15
- const filtered = options?.categories
16
- ? spans.filter(s => options.categories!.includes(s.category))
17
- : spans;
18
-
19
- const tokenizer = new Tokenizer();
20
- // Seed from existing keymap for cross-call consistency
21
- if (options?.seedKeymap && Object.keys(options.seedKeymap).length > 0) {
22
- tokenizer.loadKeymap(options.seedKeymap);
23
- }
24
-
25
- if (filtered.length === 0) {
26
- return { text, keymap: tokenizer.getKeymap() };
27
- }
28
-
29
- // Replace spans from right to left to preserve character positions
30
- const sortedDesc = [...filtered].sort((a, b) => b.start - a.start);
31
- let result = text;
32
- for (const span of sortedDesc) {
33
- const token = tokenizer.tokenize(span.value, span.category);
34
- result = result.slice(0, span.start) + token + result.slice(span.end);
35
- }
36
-
37
- return { text: result, keymap: tokenizer.getKeymap() };
38
- }
39
-
40
- /**
41
- * Restore original PII values from keymap.
42
- * Tokens not in the keymap are left unchanged.
43
- */
44
- export function deanonymize(text: string, keymap: Record<string, string>): string {
45
- let result = text;
46
- // Sort by token length descending so EMAIL_10 is replaced before EMAIL_1
47
- const sorted = Object.entries(keymap).sort((a, b) => b[0].length - a[0].length);
48
- for (const [token, original] of sorted) {
49
- result = result.replaceAll(token, original);
50
- }
51
- return result;
52
- }
package/src/tokenizer.ts DELETED
@@ -1,63 +0,0 @@
1
- import type { PIICategory } from './types.js';
2
-
3
- const CATEGORY_PREFIX: Record<PIICategory, string> = {
4
- person: 'PERSON',
5
- email: 'EMAIL',
6
- phone: 'PHONE',
7
- address: 'ADDRESS',
8
- ssn: 'SSN',
9
- credit_card: 'CREDIT_CARD',
10
- };
11
-
12
- export class Tokenizer {
13
- // original value → token
14
- private inverse = new Map<string, string>();
15
- // token → original value
16
- private keymap = new Map<string, string>();
17
- // category → counter
18
- private counters = new Map<PIICategory, number>();
19
-
20
- /**
21
- * Get or create a consistent sequential token for a PII value.
22
- */
23
- tokenize(value: string, category: PIICategory): string {
24
- const existing = this.inverse.get(value);
25
- if (existing) return existing;
26
-
27
- const counter = (this.counters.get(category) ?? 0) + 1;
28
- this.counters.set(category, counter);
29
-
30
- const prefix = CATEGORY_PREFIX[category];
31
- const token = `${prefix}_${counter}`;
32
-
33
- this.inverse.set(value, token);
34
- this.keymap.set(token, value);
35
- return token;
36
- }
37
-
38
- getKeymap(): Record<string, string> {
39
- return Object.fromEntries(this.keymap);
40
- }
41
-
42
- /**
43
- * Load an existing keymap (for cross-stage continuity).
44
- * Populates inverse map so existing tokens are reused.
45
- */
46
- loadKeymap(keymap: Record<string, string>): void {
47
- for (const [token, value] of Object.entries(keymap)) {
48
- this.keymap.set(token, value);
49
- this.inverse.set(value, token);
50
- // Restore counter state from token name (e.g. PERSON_3 → counter = 3)
51
- const match = token.match(/^([A-Z_]+)_(\d+)$/);
52
- if (match) {
53
- const cat = Object.entries(CATEGORY_PREFIX).find(([, p]) => p === match[1])?.[0] as PIICategory | undefined;
54
- if (cat) {
55
- const n = parseInt(match[2], 10);
56
- if ((this.counters.get(cat) ?? 0) < n) {
57
- this.counters.set(cat, n);
58
- }
59
- }
60
- }
61
- }
62
- }
63
- }
package/src/types.ts DELETED
@@ -1,24 +0,0 @@
1
- export type PIICategory =
2
- | 'person'
3
- | 'email'
4
- | 'phone'
5
- | 'address'
6
- | 'ssn'
7
- | 'credit_card';
8
-
9
- export interface PIISpan {
10
- start: number;
11
- end: number;
12
- category: PIICategory;
13
- value: string;
14
- }
15
-
16
- export interface PIIDetectionResult {
17
- text: string;
18
- keymap: Record<string, string>; // "PERSON_1" → "Marie-Claire"
19
- }
20
-
21
- export interface AnonymizerOptions {
22
- categories?: PIICategory[];
23
- seedKeymap?: Record<string, string>;
24
- }
@@ -1,88 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { anonymize, deanonymize } from '../src/index.js';
3
-
4
- describe('anonymize', () => {
5
- it('replaces emails with tokens', () => {
6
- const result = anonymize('Send to mc@acme.com please');
7
- expect(result.text).not.toContain('mc@acme.com');
8
- expect(result.text).toContain('EMAIL_1');
9
- expect(result.keymap['EMAIL_1']).toBe('mc@acme.com');
10
- });
11
-
12
- it('replaces the same PII twice in one call with the same token', () => {
13
- const result = anonymize('Email mc@acme.com then mc@acme.com again');
14
- const emails = result.text.match(/EMAIL_\d+/g) ?? [];
15
- expect(emails.every(e => e === 'EMAIL_1')).toBe(true);
16
- expect(result.text.split('EMAIL_1').length - 1).toBe(2);
17
- });
18
-
19
- it('assigns different tokens to different PII of same category', () => {
20
- const result = anonymize('Email mc@acme.com and other@example.com');
21
- expect(result.keymap['EMAIL_1']).toBeDefined();
22
- expect(result.keymap['EMAIL_2']).toBeDefined();
23
- expect(result.keymap['EMAIL_1']).not.toBe(result.keymap['EMAIL_2']);
24
- });
25
-
26
- it('handles multiple categories', () => {
27
- const result = anonymize('Email: mc@acme.com, Phone: 514-555-1234');
28
- expect(Object.keys(result.keymap).length).toBeGreaterThanOrEqual(2);
29
- expect(result.text).not.toContain('mc@acme.com');
30
- expect(result.text).not.toContain('514-555-1234');
31
- });
32
-
33
- it('returns unchanged text when no PII found', () => {
34
- const result = anonymize('This text has no sensitive data');
35
- expect(result.text).toBe('This text has no sensitive data');
36
- expect(result.keymap).toEqual({});
37
- });
38
-
39
- it('seedKeymap ensures cross-call consistency', () => {
40
- // First call establishes EMAIL_1 for mc@acme.com
41
- const first = anonymize('mc@acme.com');
42
- // Second call with the keymap as seed
43
- const second = anonymize('other@example.com and mc@acme.com', {
44
- seedKeymap: first.keymap,
45
- });
46
- // mc@acme.com should still be EMAIL_1 (from seed)
47
- expect(second.keymap['EMAIL_1']).toBe('mc@acme.com');
48
- // other@example.com gets EMAIL_2
49
- expect(second.keymap['EMAIL_2']).toBe('other@example.com');
50
- });
51
- });
52
-
53
- describe('deanonymize', () => {
54
- it('restores original values from keymap', () => {
55
- const { text, keymap } = anonymize('Send to mc@acme.com please');
56
- const restored = deanonymize(text, keymap);
57
- expect(restored).toBe('Send to mc@acme.com please');
58
- });
59
-
60
- it('leaves unknown tokens unchanged', () => {
61
- const result = deanonymize('Hello PERSON_99', {});
62
- expect(result).toBe('Hello PERSON_99');
63
- });
64
-
65
- it('restores multiple tokens', () => {
66
- const keymap = { 'EMAIL_1': 'a@b.com', 'PERSON_1': 'Alice' };
67
- const restored = deanonymize('EMAIL_1 sent by PERSON_1', keymap);
68
- expect(restored).toBe('a@b.com sent by Alice');
69
- });
70
-
71
- it('round-trip: anonymize then deanonymize restores original', () => {
72
- const original = 'Email: mc@acme.com, Phone: 514-555-1234';
73
- const { text, keymap } = anonymize(original);
74
- const restored = deanonymize(text, keymap);
75
- expect(restored).toBe(original);
76
- });
77
-
78
- it('deanonymizes correctly when there are more than 9 PII values of same category', () => {
79
- // Build a keymap with EMAIL_1 through EMAIL_11
80
- const keymap: Record<string, string> = {};
81
- for (let i = 1; i <= 11; i++) {
82
- keymap[`EMAIL_${i}`] = `user${i}@example.com`;
83
- }
84
- const text = 'EMAIL_1 EMAIL_10 EMAIL_11 EMAIL_2';
85
- const restored = deanonymize(text, keymap);
86
- expect(restored).toBe('user1@example.com user10@example.com user11@example.com user2@example.com');
87
- });
88
- });
@@ -1,47 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { detectPII } from '../src/detector.js';
3
-
4
- describe('detectPII', () => {
5
- it('detects email addresses', () => {
6
- const spans = detectPII('Contact mc@acme.com for info');
7
- const emails = spans.filter(s => s.category === 'email');
8
- expect(emails.length).toBeGreaterThan(0);
9
- expect(emails[0].value).toBe('mc@acme.com');
10
- });
11
-
12
- it('detects phone numbers', () => {
13
- const spans = detectPII('Call 514-555-1234 now');
14
- const phones = spans.filter(s => s.category === 'phone');
15
- expect(phones.length).toBeGreaterThan(0);
16
- });
17
-
18
- it('detects credit card numbers', () => {
19
- const spans = detectPII('Card number: 4111111111111111');
20
- const cards = spans.filter(s => s.category === 'credit_card');
21
- expect(cards.length).toBeGreaterThan(0);
22
- });
23
-
24
- it('returns empty array for clean text', () => {
25
- const spans = detectPII('This is a normal sentence without PII');
26
- expect(spans).toEqual([]);
27
- });
28
-
29
- it('returns spans with correct position bounds', () => {
30
- const text = 'Email: mc@acme.com here';
31
- const spans = detectPII(text);
32
- const email = spans.find(s => s.category === 'email');
33
- expect(email).toBeDefined();
34
- expect(text.slice(email!.start, email!.end)).toBe(email!.value);
35
- });
36
-
37
- it('does not return overlapping spans', () => {
38
- const text = 'Email mc@acme.com and phone 514-555-1234';
39
- const spans = detectPII(text);
40
- // Verify no two spans overlap
41
- for (let i = 0; i < spans.length; i++) {
42
- for (let j = i + 1; j < spans.length; j++) {
43
- expect(spans[i].end <= spans[j].start || spans[j].end <= spans[i].start).toBe(true);
44
- }
45
- }
46
- });
47
- });
@@ -1,55 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { Tokenizer } from '../src/tokenizer.js';
3
-
4
- describe('Tokenizer', () => {
5
- let tokenizer: Tokenizer;
6
-
7
- beforeEach(() => {
8
- tokenizer = new Tokenizer();
9
- });
10
-
11
- it('assigns sequential tokens per category', () => {
12
- const t1 = tokenizer.tokenize('Marie-Claire', 'person');
13
- const t2 = tokenizer.tokenize('Jean-François', 'person');
14
- const t3 = tokenizer.tokenize('mc@acme.com', 'email');
15
- expect(t1).toBe('PERSON_1');
16
- expect(t2).toBe('PERSON_2');
17
- expect(t3).toBe('EMAIL_1');
18
- });
19
-
20
- it('returns the same token for the same value', () => {
21
- const t1 = tokenizer.tokenize('Marie-Claire', 'person');
22
- const t2 = tokenizer.tokenize('Marie-Claire', 'person');
23
- expect(t1).toBe(t2);
24
- expect(t1).toBe('PERSON_1');
25
- });
26
-
27
- it('builds keymap correctly', () => {
28
- tokenizer.tokenize('Marie-Claire', 'person');
29
- tokenizer.tokenize('mc@acme.com', 'email');
30
- const keymap = tokenizer.getKeymap();
31
- expect(keymap).toEqual({
32
- 'PERSON_1': 'Marie-Claire',
33
- 'EMAIL_1': 'mc@acme.com',
34
- });
35
- });
36
-
37
- it('handles all supported categories', () => {
38
- const categories = ['person', 'email', 'phone', 'address', 'ssn', 'credit_card'] as const;
39
- for (const cat of categories) {
40
- const token = tokenizer.tokenize('test-value', cat);
41
- expect(token).toMatch(/^[A-Z_]+_1$/);
42
- }
43
- });
44
-
45
- it('loadKeymap restores state for cross-call consistency', () => {
46
- // Simulate loading a pre-existing keymap
47
- tokenizer.loadKeymap({ 'PERSON_1': 'Alice', 'EMAIL_1': 'alice@example.com' });
48
- // New tokenization should continue from counter 1
49
- const t = tokenizer.tokenize('Bob', 'person');
50
- expect(t).toBe('PERSON_2');
51
- // Existing value should reuse its token
52
- const tAlice = tokenizer.tokenize('Alice', 'person');
53
- expect(tAlice).toBe('PERSON_1');
54
- });
55
- });
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "commonjs",
5
- "lib": ["ES2022"],
6
- "outDir": "./dist",
7
- "rootDir": "./src",
8
- "declaration": true,
9
- "declarationMap": true,
10
- "sourceMap": true,
11
- "strict": true,
12
- "esModuleInterop": true,
13
- "skipLibCheck": true,
14
- "forceConsistentCasingInFileNames": true,
15
- "resolveJsonModule": true,
16
- "moduleResolution": "node"
17
- },
18
- "include": ["src/**/*"],
19
- "exclude": ["node_modules", "dist", "tests"]
20
- }