Add whitelist config for masking exclusions (#53)
authorStefan Gasser <redacted>
Tue, 20 Jan 2026 20:52:02 +0000 (21:52 +0100)
committerGitHub <redacted>
Tue, 20 Jan 2026 20:52:02 +0000 (21:52 +0100)
Adds masking.whitelist config option to exclude specific text patterns
from PII masking. Useful for preventing false positives on known text
like company names or product identifiers.

- Add whitelist property to MaskingSchema (default: empty array)
- Add filterWhitelistedEntities function to filter detected PII
- Patterns match if detected text is contained in whitelist entry
  or whitelist entry is contained in detected text

config.example.yaml
src/config.ts
src/pii/detect.test.ts
src/pii/detect.ts
src/pii/mask.test.ts
src/providers/openai/stream-transformer.test.ts

index f7c5faa97a68a362e825b00658861480100f9925..319d0596e28e070f7d14b4e219ab3091c920ad27 100644 (file)
@@ -42,6 +42,10 @@ masking:
   show_markers: false
   marker_text: "[protected]"
 
+  # Text patterns that are never masked (protects against false positives)
+  # whitelist:
+  #   - "Company Name Inc."
+
 # PII Detection settings (Microsoft Presidio)
 pii_detection:
   presidio_url: ${PRESIDIO_URL:-http://localhost:5002}
index f22f6982b39fa92c2b2e3396e2dc98c7f54bf74a..f26ac32a697faba12a48bb51884ec703a1ff38b6 100644 (file)
@@ -22,6 +22,7 @@ const OpenAIProviderSchema = z.object({
 const MaskingSchema = z.object({
   show_markers: z.boolean().default(false),
   marker_text: z.string().default("[protected]"),
+  whitelist: z.array(z.string()).default([]),
 });
 
 const LanguageEnum = z.enum(SUPPORTED_LANGUAGES);
index 9b2169b619f150d469bb967f240c1cf2933d9181..0e6003040d8fd969b5add6cc377b8e5a737ee513 100644 (file)
@@ -1,7 +1,7 @@
 import { afterEach, describe, expect, mock, test } from "bun:test";
 import { openaiExtractor } from "../masking/extractors/openai";
 import type { OpenAIMessage, OpenAIRequest } from "../providers/openai/types";
-import { PIIDetector } from "./detect";
+import { filterWhitelistedEntities, PIIDetector } from "./detect";
 
 const originalFetch = globalThis.fetch;
 
@@ -210,4 +210,51 @@ describe("PIIDetector", () => {
       expect(healthy).toBe(false);
     });
   });
+
+  describe("filterWhitelistedEntities", () => {
+    test("filters entities matching whitelist pattern", () => {
+      const text = "You are Claude Code, Anthropic's official CLI for Claude.";
+      const entities = [{ entity_type: "PERSON", start: 8, end: 14, score: 0.9 }];
+      const whitelist = ["You are Claude Code, Anthropic's official CLI for Claude."];
+
+      const result = filterWhitelistedEntities(text, entities, whitelist);
+
+      expect(result).toHaveLength(0);
+    });
+
+    test("keeps entities not in whitelist", () => {
+      const text = "Contact John Doe at john@example.com";
+      const entities = [
+        { entity_type: "PERSON", start: 8, end: 16, score: 0.9 },
+        { entity_type: "EMAIL_ADDRESS", start: 20, end: 36, score: 0.95 },
+      ];
+      const whitelist = ["Claude"];
+
+      const result = filterWhitelistedEntities(text, entities, whitelist);
+
+      expect(result).toHaveLength(2);
+    });
+
+    test("filters when entity text is contained in whitelist pattern", () => {
+      const text = "Hello Claude, how are you?";
+      const entities = [{ entity_type: "PERSON", start: 6, end: 12, score: 0.85 }];
+      const whitelist = ["You are Claude Code"];
+
+      const result = filterWhitelistedEntities(text, entities, whitelist);
+
+      expect(result).toHaveLength(0);
+    });
+
+    test("returns all entities when whitelist is empty", () => {
+      const text = "Contact Claude at claude@example.com";
+      const entities = [
+        { entity_type: "PERSON", start: 8, end: 14, score: 0.9 },
+        { entity_type: "EMAIL_ADDRESS", start: 18, end: 36, score: 0.95 },
+      ];
+
+      const result = filterWhitelistedEntities(text, entities, []);
+
+      expect(result).toHaveLength(2);
+    });
+  });
 });
index eb07a448ebd1628e8d50207f0a99dff0f752db1e..13e3c29838f8a46710fe53cff075f6fc3ea4cf75 100644 (file)
@@ -10,6 +10,21 @@ export interface PIIEntity {
   score: number;
 }
 
+export function filterWhitelistedEntities(
+  text: string,
+  entities: PIIEntity[],
+  whitelist: string[],
+): PIIEntity[] {
+  if (whitelist.length === 0) return entities;
+
+  return entities.filter((entity) => {
+    const detectedText = text.slice(entity.start, entity.end);
+    return !whitelist.some(
+      (pattern) => pattern.includes(detectedText) || detectedText.includes(pattern),
+    );
+  });
+}
+
 interface AnalyzeRequest {
   text: string;
   language: string;
@@ -103,6 +118,7 @@ export class PIIDetector {
     const scanRoles = config.pii_detection.scan_roles
       ? new Set(config.pii_detection.scan_roles)
       : null;
+    const whitelist = config.masking.whitelist;
 
     const spanEntities: PIIEntity[][] = await Promise.all(
       spans.map(async (span) => {
@@ -110,7 +126,8 @@ export class PIIDetector {
           return [];
         }
         if (!span.text) return [];
-        return this.detectPII(span.text, langResult.language);
+        const entities = await this.detectPII(span.text, langResult.language);
+        return filterWhitelistedEntities(span.text, entities, whitelist);
       }),
     );
 
index 39ba8b8f52d6cdcc5b743b93bfdfb3d208d6ae74..ff54a2afd16e330419e1de9b37114d6a8b76c6cf 100644 (file)
@@ -17,11 +17,13 @@ import {
 const defaultConfig: MaskingConfig = {
   show_markers: false,
   marker_text: "[protected]",
+  whitelist: [],
 };
 
 const configWithMarkers: MaskingConfig = {
   show_markers: true,
   marker_text: "[protected]",
+  whitelist: [],
 };
 
 /** Helper to create a minimal request from messages */
index df9134c53182b8c3332ae663071ef6dd5849a8f2..797e738db004c096d9e6f9e2d71f9caea7666712 100644 (file)
@@ -6,6 +6,7 @@ import { createUnmaskingStream } from "./stream-transformer";
 const defaultConfig: MaskingConfig = {
   show_markers: false,
   marker_text: "[protected]",
+  whitelist: [],
 };
 
 /**
git clone https://git.99rst.org/PROJECT