feat(anonymizer): strict anti-leak mode (default on), prefilter decoupling, boundary-aware substitution

Harden the core privacy guarantee:

- Add `strict` mode (default true): after detection, verify no mapped value
  survives as a whole token in the output (ignoring placeholders, whose context
  may legitimately echo a value like `B+`). Catches a model that redacts one
  mention of a value but leaves another in clear — which the placeholder/mapping
  bijection check missed. Fail-closed: throws AnonymizationError naming only the
  non-secret placeholder key, and runs on the final result so it is not swallowed
  into the regex fallback (which can't fix a name leak). Set strict:false to opt out.
- Add `prefilter` option (default true): decouple the PII pre-filter from the
  presence of a regex fallback. Set false to always consult the LLM while keeping
  the fallback for LLM failures (max recall + graceful degradation).
- Boundary-aware value substitution: applyKnown and the leak check now match
  values only as whole tokens (Unicode letter/digit boundaries, regex-escaped),
  so "Ann" no longer replaces inside "Anna" and "jean@x.ch" no longer matches
  inside "jean@x.church"; accented/non-Latin names preserved.
- deanonymize restores longest placeholder keys first (prefix-overlap defense).

Restructures anonymize/anonymizeChunks/anonymizeTurn to a single exit so the
leak check runs once on the final result. Behavior is unchanged for callers that
were already leak-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oussama Knouz
2026-07-02 18:00:06 +01:00
parent 9941a21dd6
commit 7fb76e89f6
5 changed files with 268 additions and 27 deletions

View File

@@ -186,6 +186,107 @@ describe('Anonymizer (Swiss preset)', () => {
});
});
describe('strict anti-leak mode', () => {
// A model that redacts the FIRST mention of a value but leaves a second in clear.
const leakyLlm = () =>
failingLlm({
anonymize: vi.fn().mockResolvedValue({
anon: 'Dossier de [PER_1.NOM:M] ; contactez Alain Jaccard directement.',
mapping: { '[PER_1.NOM:M]': 'Alain Jaccard' },
legend: {},
}),
});
it('throws AnonymizationError when a mapped value still appears in clear', async () => {
const s = new Anonymizer({ llm: leakyLlm(), strict: true });
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toBeInstanceOf(AnonymizationError);
});
it('the leak error names the placeholder key, never the secret value', async () => {
const s = new Anonymizer({ llm: leakyLlm(), strict: true });
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toMatchObject({
message: expect.stringContaining('[PER_1.NOM:M]'),
});
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.not.toMatchObject({
message: expect.stringContaining('Alain Jaccard'),
});
});
it('fails closed even with a fallback configured (does not silently degrade to regex)', async () => {
const s = new Anonymizer({ llm: leakyLlm(), patterns: presets.swiss, strict: true });
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toBeInstanceOf(AnonymizationError);
});
it('with strict:false the leaky result is returned as-is', async () => {
const s = new Anonymizer({ llm: leakyLlm(), strict: false });
const r = await s.anonymize('Dossier de Alain Jaccard');
expect(r.anon).toContain('Alain Jaccard'); // leak check disabled → passes through
});
it('is enabled by default (no strict option → still throws on a leak)', async () => {
const s = new Anonymizer({ llm: leakyLlm() });
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toBeInstanceOf(AnonymizationError);
});
it('does NOT flag a value that only appears inside a placeholder context (e.g. B+)', async () => {
const anonymize = vi.fn().mockResolvedValue({
anon: 'Groupe sanguin [PER_1.SANG:B+] confirmé.',
mapping: { '[PER_1.SANG:B+]': 'B+' },
legend: {},
});
const s = new Anonymizer({ llm: failingLlm({ anonymize }), strict: true });
const r = await s.anonymize('Groupe sanguin B+ confirmé.');
expect(r.anon).toBe('Groupe sanguin [PER_1.SANG:B+] confirmé.');
});
it('does NOT flag a value that is only a substring of a larger token', async () => {
// "Ann" is redacted; "Anna" (a different token) is left in clear → not a leak.
const anonymize = vi.fn().mockResolvedValue({
anon: '[PER_1.PRENOM:F] connaît Anna.',
mapping: { '[PER_1.PRENOM:F]': 'Ann' },
legend: {},
});
const s = new Anonymizer({ llm: failingLlm({ anonymize }), strict: true });
const r = await s.anonymize('Ann connaît Anna.');
expect(r.anon).toBe('[PER_1.PRENOM:F] connaît Anna.');
});
it('regex-only output never trips the check (every occurrence is replaced)', async () => {
const s = new Anonymizer({ patterns: presets.swiss, strict: true });
const r = await s.anonymize('Écris à jean@exemple.ch puis à jean@exemple.ch');
expect(r.anon).not.toContain('jean@exemple.ch');
});
});
describe('prefilter decoupling', () => {
it('prefilter:false consults the LLM even for PII-free text when a fallback exists', async () => {
const anonymize = vi.fn().mockResolvedValue({ anon: 'INNER JOIN', mapping: {}, legend: {} });
const s = new Anonymizer({ llm: failingLlm({ anonymize }), patterns: presets.swiss, prefilter: false });
await s.anonymize('Explique INNER JOIN'); // no structured PII, no name hint
expect(anonymize).toHaveBeenCalledTimes(1);
});
it('prefilter default (true) still skips the LLM for PII-free text', async () => {
const anonymize = vi.fn();
const s = new Anonymizer({ llm: failingLlm({ anonymize }), patterns: presets.swiss });
await s.anonymize('Explique INNER JOIN');
expect(anonymize).not.toHaveBeenCalled();
});
});
describe('boundary-aware applyKnown', () => {
it('reuses a known value only as a whole token, not inside a larger word', async () => {
const s = new Anonymizer({ llm: failingLlm(), patterns: presets.swiss });
const seed = { '[PER_1.PRENOM:F]': 'Ann' };
// "Anna" must stay intact; the old substring replace would mangle it.
const kept = await s.anonymizeChunks(['Anna arrive demain.'], { mapping: seed });
expect(kept.anon[0]).toBe('Anna arrive demain.');
// but a standalone "Ann" is reused deterministically (no LLM call).
const reused = await s.anonymizeChunks(['Ann arrive demain.'], { mapping: seed });
expect(reused.anon[0]).toBe('[PER_1.PRENOM:F] arrive demain.');
});
});
describe('multi-turn conversation (anonymizeTurn)', () => {
it('keeps stable ids across turns and accumulates the session', async () => {
const anonymizeInConversation = vi