@studio-foundation/anonymizer 0.3.0-beta.1

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 ADDED
@@ -0,0 +1,90 @@
1
+ # @studio-foundation/anonymizer
2
+
3
+ PII detection and anonymization library. Replaces sensitive data with consistent tokens before sending to LLMs, with a keymap to restore the original values afterward.
4
+
5
+ ## Role
6
+
7
+ anonymizer sits at the bottom of the stack — a pure utility with zero `@studio/*` dependencies. The runner wraps it in `AnonymizationMiddleware` and injects it transparently into the agent execution loop.
8
+
9
+ ```
10
+ user data → anonymize() → [PERSON_1], [EMAIL_1] → LLM → deanonymize() → original values
11
+ ```
12
+
13
+ ## Key exports
14
+
15
+ ```typescript
16
+ import { anonymize, deanonymize, Tokenizer } from '@studio-foundation/anonymizer';
17
+ import type { PIICategory, PIIDetectionResult, AnonymizerOptions } from '@studio-foundation/anonymizer';
18
+
19
+ // Anonymize a string
20
+ const { text, keymap } = anonymize('Hi Marie, call me at 555-867-5309');
21
+ // text → "Hi [PERSON_1], call me at [PHONE_1]"
22
+ // keymap → { "PERSON_1": "Marie", "PHONE_1": "555-867-5309" }
23
+
24
+ // Restore originals
25
+ const original = deanonymize(text, keymap);
26
+ // → "Hi Marie, call me at 555-867-5309"
27
+
28
+ // Cross-stage consistency — pass the keymap from a previous call
29
+ const { text: text2, keymap: keymap2 } = anonymize(nextChunk, { seedKeymap: keymap });
30
+ // PERSON_1 still maps to "Marie" across calls
31
+ ```
32
+
33
+ ## PII categories
34
+
35
+ | Category | Token format | What it detects |
36
+ |----------|-------------|-----------------|
37
+ | `person` | `PERSON_N` | Names after salutations (Dear, Hi, Mr., Dr., etc.) — best effort |
38
+ | `email` | `EMAIL_N` | Email addresses |
39
+ | `phone` | `PHONE_N` | US phone numbers (10 digits, various formats) |
40
+ | `ssn` | `SSN_N` | Social security numbers (ddd-dd-dddd) |
41
+ | `credit_card` | `CREDIT_CARD_N` | 16-digit card numbers |
42
+ | `address` | `ADDRESS_N` | Reserved (detection not yet implemented) |
43
+
44
+ ## Detection strategy
45
+
46
+ Two-phase detection on each call:
47
+
48
+ 1. **Regex (high precision)** — email, phone, SSN, credit card. Structural patterns anchored to avoid false positives (e.g. SSN regex uses strict hyphen format to avoid matching phone fragments).
49
+
50
+ 2. **Person names (best effort)** — salutation-gated pattern (`Dear X`, `Hi X`, `Mr. X`, etc.). Only catches explicitly addressed names — not bare occurrences.
51
+
52
+ Spans are non-overlapping. If two patterns match the same range, the first one wins and the position is marked occupied.
53
+
54
+ ## Token consistency
55
+
56
+ Same value → same token, within and across calls:
57
+
58
+ ```typescript
59
+ // Within one call: two mentions of the same email → same token
60
+ anonymize('Contact foo@bar.com or reach foo@bar.com')
61
+ // → "Contact [EMAIL_1] or reach [EMAIL_1]"
62
+
63
+ // Across calls: seed the next call with the previous keymap
64
+ const { text: t1, keymap: km1 } = anonymize(stage1Output);
65
+ const { text: t2, keymap: km2 } = anonymize(stage2Output, { seedKeymap: km1 });
66
+ // EMAIL_1 is the same person in t1 and t2
67
+ ```
68
+
69
+ ## Filter by category
70
+
71
+ ```typescript
72
+ // Only anonymize emails — leave names and phones unchanged
73
+ const { text } = anonymize(rawText, { categories: ['email'] });
74
+ ```
75
+
76
+ ## How it's used in Studio
77
+
78
+ The runner wraps this in `AnonymizationMiddleware` (in `runner/src/middleware/anonymization.ts`). When `anonymize: true` is set on an agent or a run:
79
+
80
+ 1. Task description is anonymized before being sent to the LLM
81
+ 2. Tool results are anonymized before being injected back into context
82
+ 3. The accumulated keymap is written to `.studio/runs/anonymization/<run-id>.keymap.json` for post-run inspection
83
+
84
+ The middleware is wired by the engine and passed to `runAgent()` — user code doesn't call `anonymize()` directly.
85
+
86
+ ## Rules
87
+
88
+ - **Zero `@studio/*` dependencies.** This package must stay a pure utility.
89
+ - `anonymize()` is stateless — the `Tokenizer` is created fresh each call (or seeded via `seedKeymap`).
90
+ - Person detection is always best-effort and non-fatal — failures are silently skipped.
@@ -0,0 +1,7 @@
1
+ import type { PIISpan } from './types.js';
2
+ /**
3
+ * Detect PII spans in text.
4
+ * Returns non-overlapping spans sorted by position.
5
+ */
6
+ export declare function detectPII(text: string): PIISpan[];
7
+ //# sourceMappingURL=detector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,OAAO,EAAE,MAAM,YAAY,CAAC;AA0BvD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,EAAE,CAyBjD"}
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectPII = detectPII;
4
+ // Regex patterns for structural PII (high precision)
5
+ const PATTERNS = [
6
+ {
7
+ category: 'email',
8
+ regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g,
9
+ },
10
+ {
11
+ category: 'phone',
12
+ // US phone: must be at least 10 digits. Require optional country code + area code.
13
+ // Anchored to avoid matching partial SSN-like strings.
14
+ regex: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g,
15
+ },
16
+ {
17
+ category: 'ssn',
18
+ // SSN: exactly ddd-dd-dddd with hyphens (strict format, not dots or spaces
19
+ // to avoid collision with phone numbers already consumed)
20
+ regex: /\b\d{3}-\d{2}-\d{4}\b/g,
21
+ },
22
+ {
23
+ category: 'credit_card',
24
+ regex: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
25
+ },
26
+ ];
27
+ /**
28
+ * Detect PII spans in text.
29
+ * Returns non-overlapping spans sorted by position.
30
+ */
31
+ function detectPII(text) {
32
+ const spans = [];
33
+ const occupied = new Set();
34
+ // Phase 1: Structural PII via regex
35
+ for (const { category, regex } of PATTERNS) {
36
+ regex.lastIndex = 0;
37
+ let match;
38
+ while ((match = regex.exec(text)) !== null) {
39
+ const start = match.index;
40
+ const end = start + match[0].length;
41
+ if (isOccupied(occupied, start, end))
42
+ continue;
43
+ markOccupied(occupied, start, end);
44
+ spans.push({ start, end, category, value: match[0] });
45
+ }
46
+ }
47
+ // Phase 2: Person names — best-effort via @redactpii/node
48
+ try {
49
+ detectPersons(text, occupied, spans);
50
+ }
51
+ catch {
52
+ // Person detection is best-effort; silently skip on any error
53
+ }
54
+ return spans.sort((a, b) => a.start - b.start);
55
+ }
56
+ function isOccupied(occupied, start, end) {
57
+ for (let i = start; i < end; i++) {
58
+ if (occupied.has(i))
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+ function markOccupied(occupied, start, end) {
64
+ for (let i = start; i < end; i++) {
65
+ occupied.add(i);
66
+ }
67
+ }
68
+ function detectPersons(text, occupied, spans) {
69
+ // The @redactpii/node NAME pattern only catches names after salutations
70
+ // (Dear, Hi, Hello, Hey, etc.) — not bare names. We replicate that pattern
71
+ // here and use it directly, avoiding the ESM/CJS import complexity.
72
+ // This gives best-effort detection for explicitly addressed names.
73
+ const salutationPattern = /(?: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;
74
+ let match;
75
+ salutationPattern.lastIndex = 0;
76
+ while ((match = salutationPattern.exec(text)) !== null) {
77
+ // match[1] is the captured name group
78
+ const nameValue = match[1];
79
+ if (!nameValue)
80
+ continue;
81
+ // Find the actual start position of the captured name within the full match
82
+ const fullMatchStart = match.index;
83
+ const nameOffset = match[0].indexOf(nameValue);
84
+ const start = fullMatchStart + nameOffset;
85
+ const end = start + nameValue.length;
86
+ if (isOccupied(occupied, start, end))
87
+ continue;
88
+ markOccupied(occupied, start, end);
89
+ spans.push({ start, end, category: 'person', value: nameValue });
90
+ }
91
+ }
92
+ //# sourceMappingURL=detector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detector.js","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { PIIDetectionResult, AnonymizerOptions } from './types.js';
2
+ export type { PIICategory, PIIDetectionResult, AnonymizerOptions } from './types.js';
3
+ export { Tokenizer } from './tokenizer.js';
4
+ /**
5
+ * Anonymize PII in text. Returns anonymized text + keymap (token → original).
6
+ * Same PII value always gets the same token within a call.
7
+ * Pass seedKeymap to maintain consistency across multiple calls.
8
+ */
9
+ export declare function anonymize(text: string, options?: AnonymizerOptions): PIIDetectionResult;
10
+ /**
11
+ * Restore original PII values from keymap.
12
+ * Tokens not in the keymap are left unchanged.
13
+ */
14
+ export declare function deanonymize(text: string, keymap: Record<string, string>): string;
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAExE,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,kBAAkB,CAyBvF;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAQhF"}
package/dist/index.js ADDED
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Tokenizer = void 0;
4
+ exports.anonymize = anonymize;
5
+ exports.deanonymize = deanonymize;
6
+ const detector_js_1 = require("./detector.js");
7
+ const tokenizer_js_1 = require("./tokenizer.js");
8
+ var tokenizer_js_2 = require("./tokenizer.js");
9
+ Object.defineProperty(exports, "Tokenizer", { enumerable: true, get: function () { return tokenizer_js_2.Tokenizer; } });
10
+ /**
11
+ * Anonymize PII in text. Returns anonymized text + keymap (token → original).
12
+ * Same PII value always gets the same token within a call.
13
+ * Pass seedKeymap to maintain consistency across multiple calls.
14
+ */
15
+ function anonymize(text, options) {
16
+ const spans = (0, detector_js_1.detectPII)(text);
17
+ const filtered = options?.categories
18
+ ? spans.filter(s => options.categories.includes(s.category))
19
+ : spans;
20
+ const tokenizer = new tokenizer_js_1.Tokenizer();
21
+ // Seed from existing keymap for cross-call consistency
22
+ if (options?.seedKeymap && Object.keys(options.seedKeymap).length > 0) {
23
+ tokenizer.loadKeymap(options.seedKeymap);
24
+ }
25
+ if (filtered.length === 0) {
26
+ return { text, keymap: tokenizer.getKeymap() };
27
+ }
28
+ // Replace spans from right to left to preserve character positions
29
+ const sortedDesc = [...filtered].sort((a, b) => b.start - a.start);
30
+ let result = text;
31
+ for (const span of sortedDesc) {
32
+ const token = tokenizer.tokenize(span.value, span.category);
33
+ result = result.slice(0, span.start) + token + result.slice(span.end);
34
+ }
35
+ return { text: result, keymap: tokenizer.getKeymap() };
36
+ }
37
+ /**
38
+ * Restore original PII values from keymap.
39
+ * Tokens not in the keymap are left unchanged.
40
+ */
41
+ function deanonymize(text, keymap) {
42
+ let result = text;
43
+ // Sort by token length descending so EMAIL_10 is replaced before EMAIL_1
44
+ const sorted = Object.entries(keymap).sort((a, b) => b[0].length - a[0].length);
45
+ for (const [token, original] of sorted) {
46
+ result = result.replaceAll(token, original);
47
+ }
48
+ return result;
49
+ }
50
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAYA,8BAyBC;AAMD,kCAQC;AAnDD,+CAA0C;AAC1C,iDAA2C;AAI3C,+CAA2C;AAAlC,yGAAA,SAAS,OAAA;AAElB;;;;GAIG;AACH,SAAgB,SAAS,CAAC,IAAY,EAAE,OAA2B;IACjE,MAAM,KAAK,GAAG,IAAA,uBAAS,EAAC,IAAI,CAAC,CAAC;IAC9B,MAAM,QAAQ,GAAG,OAAO,EAAE,UAAU;QAClC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,UAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7D,CAAC,CAAC,KAAK,CAAC;IAEV,MAAM,SAAS,GAAG,IAAI,wBAAS,EAAE,CAAC;IAClC,uDAAuD;IACvD,IAAI,OAAO,EAAE,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtE,SAAS,CAAC,UAAU,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IAC3C,CAAC;IAED,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,SAAS,EAAE,EAAE,CAAC;IACjD,CAAC;IAED,mEAAmE;IACnE,MAAM,UAAU,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACnE,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,MAAM,KAAK,GAAG,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC5D,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACxE,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,SAAS,EAAE,EAAE,CAAC;AACzD,CAAC;AAED;;;GAGG;AACH,SAAgB,WAAW,CAAC,IAAY,EAAE,MAA8B;IACtE,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,yEAAyE;IACzE,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAChF,KAAK,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QACvC,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { PIICategory } from './types.js';
2
+ export declare class Tokenizer {
3
+ private inverse;
4
+ private keymap;
5
+ private counters;
6
+ /**
7
+ * Get or create a consistent sequential token for a PII value.
8
+ */
9
+ tokenize(value: string, category: PIICategory): string;
10
+ getKeymap(): Record<string, string>;
11
+ /**
12
+ * Load an existing keymap (for cross-stage continuity).
13
+ * Populates inverse map so existing tokens are reused.
14
+ */
15
+ loadKeymap(keymap: Record<string, string>): void;
16
+ }
17
+ //# sourceMappingURL=tokenizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokenizer.d.ts","sourceRoot":"","sources":["../src/tokenizer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAW9C,qBAAa,SAAS;IAEpB,OAAO,CAAC,OAAO,CAA6B;IAE5C,OAAO,CAAC,MAAM,CAA6B;IAE3C,OAAO,CAAC,QAAQ,CAAkC;IAElD;;OAEG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,GAAG,MAAM;IAetD,SAAS,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAInC;;;OAGG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI;CAiBjD"}
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Tokenizer = void 0;
4
+ const CATEGORY_PREFIX = {
5
+ person: 'PERSON',
6
+ email: 'EMAIL',
7
+ phone: 'PHONE',
8
+ address: 'ADDRESS',
9
+ ssn: 'SSN',
10
+ credit_card: 'CREDIT_CARD',
11
+ };
12
+ class Tokenizer {
13
+ // original value → token
14
+ inverse = new Map();
15
+ // token → original value
16
+ keymap = new Map();
17
+ // category → counter
18
+ counters = new Map();
19
+ /**
20
+ * Get or create a consistent sequential token for a PII value.
21
+ */
22
+ tokenize(value, category) {
23
+ const existing = this.inverse.get(value);
24
+ if (existing)
25
+ return existing;
26
+ const counter = (this.counters.get(category) ?? 0) + 1;
27
+ this.counters.set(category, counter);
28
+ const prefix = CATEGORY_PREFIX[category];
29
+ const token = `${prefix}_${counter}`;
30
+ this.inverse.set(value, token);
31
+ this.keymap.set(token, value);
32
+ return token;
33
+ }
34
+ getKeymap() {
35
+ return Object.fromEntries(this.keymap);
36
+ }
37
+ /**
38
+ * Load an existing keymap (for cross-stage continuity).
39
+ * Populates inverse map so existing tokens are reused.
40
+ */
41
+ loadKeymap(keymap) {
42
+ for (const [token, value] of Object.entries(keymap)) {
43
+ this.keymap.set(token, value);
44
+ this.inverse.set(value, token);
45
+ // Restore counter state from token name (e.g. PERSON_3 → counter = 3)
46
+ const match = token.match(/^([A-Z_]+)_(\d+)$/);
47
+ if (match) {
48
+ const cat = Object.entries(CATEGORY_PREFIX).find(([, p]) => p === match[1])?.[0];
49
+ if (cat) {
50
+ const n = parseInt(match[2], 10);
51
+ if ((this.counters.get(cat) ?? 0) < n) {
52
+ this.counters.set(cat, n);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ exports.Tokenizer = Tokenizer;
60
+ //# sourceMappingURL=tokenizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokenizer.js","sourceRoot":"","sources":["../src/tokenizer.ts"],"names":[],"mappings":";;;AAEA,MAAM,eAAe,GAAgC;IACnD,MAAM,EAAE,QAAQ;IAChB,KAAK,EAAE,OAAO;IACd,KAAK,EAAE,OAAO;IACd,OAAO,EAAE,SAAS;IAClB,GAAG,EAAE,KAAK;IACV,WAAW,EAAE,aAAa;CAC3B,CAAC;AAEF,MAAa,SAAS;IACpB,yBAAyB;IACjB,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,yBAAyB;IACjB,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3C,qBAAqB;IACb,QAAQ,GAAG,IAAI,GAAG,EAAuB,CAAC;IAElD;;OAEG;IACH,QAAQ,CAAC,KAAa,EAAE,QAAqB;QAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACzC,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAE9B,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC;QACvD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QAErC,MAAM,MAAM,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;QACzC,MAAM,KAAK,GAAG,GAAG,MAAM,IAAI,OAAO,EAAE,CAAC;QAErC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC/B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC9B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,SAAS;QACP,OAAO,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACzC,CAAC;IAED;;;OAGG;IACH,UAAU,CAAC,MAA8B;QACvC,KAAK,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACpD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC9B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC/B,sEAAsE;YACtE,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC;YAC/C,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAA4B,CAAC;gBAC5G,IAAI,GAAG,EAAE,CAAC;oBACR,MAAM,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;oBACjC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;wBACtC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;oBAC5B,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;CACF;AAnDD,8BAmDC"}
@@ -0,0 +1,16 @@
1
+ export type PIICategory = 'person' | 'email' | 'phone' | 'address' | 'ssn' | 'credit_card';
2
+ export interface PIISpan {
3
+ start: number;
4
+ end: number;
5
+ category: PIICategory;
6
+ value: string;
7
+ }
8
+ export interface PIIDetectionResult {
9
+ text: string;
10
+ keymap: Record<string, string>;
11
+ }
12
+ export interface AnonymizerOptions {
13
+ categories?: PIICategory[];
14
+ seedKeymap?: Record<string, string>;
15
+ }
16
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GACnB,QAAQ,GACR,OAAO,GACP,OAAO,GACP,SAAS,GACT,KAAK,GACL,aAAa,CAAC;AAElB,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,WAAW,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,WAAW,EAAE,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@studio-foundation/anonymizer",
3
+ "version": "0.3.0-beta.1",
4
+ "description": "PII detection and anonymization with consistent token mapping",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "keywords": [
8
+ "pii",
9
+ "anonymization",
10
+ "privacy"
11
+ ],
12
+ "author": "Ariane Guay",
13
+ "license": "AGPL-3.0-only",
14
+ "dependencies": {
15
+ "@redactpii/node": "^1.0.16"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.2",
19
+ "@types/node": "^25.2.3",
20
+ "vitest": "^4.0.18"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "clean": "rm -rf dist"
27
+ }
28
+ }
@@ -0,0 +1,97 @@
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 ADDED
@@ -0,0 +1,52 @@
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
+ }
@@ -0,0 +1,63 @@
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 ADDED
@@ -0,0 +1,24 @@
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
+ }