Files
anonymizer/CHANGELOG.md
Oussama Knouz 7fb76e89f6 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>
2026-07-02 18:00:06 +01:00

7.1 KiB

Changelog

All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[0.5.0] - Unreleased

Added

  • Strict anti-leak mode (on by default). New strict option on Anonymizer. When enabled, the anonymized output is checked after detection to ensure no mapped value still appears as a whole token (ignoring placeholders — a placeholder's context field may legitimately echo a value, e.g. B+ in [PER_1.SANG:B+]). This catches a model that redacts one mention of a value but leaves another in clear — a case the previous bidirectional validation (placeholder ⇄ mapping-key) did not detect. On a suspected leak it throws AnonymizationError naming only the non-secret placeholder key. Fail-closed: it runs on the final result and is not swallowed into the regex fallback (which can't fix a name leak). Defaults to true — set strict: false to restore the previous behaviour (or if a false positive rejects an otherwise-fine result).
  • prefilter option — decouples the cheap PII pre-filter (skip the LLM when no PII is heuristically detected) from the presence of a regex fallback. Default true; set false to always consult the LLM while still keeping the fallback for LLM failures (maximum recall with graceful degradation).

Changed

  • Boundary-aware value substitution. Known-value reuse (applyKnown, used by anonymizeChunks and anonymizeTurn) now matches values only as whole tokens (Unicode letter/digit boundaries) instead of raw substrings, so a short value like "Ann" is no longer replaced inside "Anna", and "jean@exemple.ch" no longer matches inside "jean@exemple.church". Accented and non-Latin names are preserved. The strict leak check uses the same boundary logic, so detection and substitution agree.
  • deanonymize now restores longest placeholder keys first (defensive against prefix overlaps).

[0.4.0] - Unreleased

Added

  • Multi-turn conversation support. New Anonymizer.anonymizeTurn(text, session?) → { anon, mapping, legend, session } keeps one stable id per entity across a whole chat: it seeds each turn with a running, serializable AnonymizerSession ({ mapping, legend, history }), reuses known values via applyKnown, tells the model which ids are taken, and de-collides new ones. Persist the returned session and pass it back next turn.
  • Anonymizer.conversation(initial?) — a stateful in-memory wrapper (anonymize, deanonymize, session()) over anonymizeTurn.
  • Optional LlmProvider.anonymizeInConversation(text, ctx) — providers can use prior context (anonymized history, legend, usedIds, and optionally mapping) for better cross-turn coreference/attribution. openAICompatibleProvider implements it; providers that don't fall back to the batch path automatically.
  • openAICompatibleProvider options: includeMappingInContext (default false — only send real values to a trusted anonymizer endpoint) and historyMaxTurns via AnonymizerConfig (default 10).
  • Exported the AnonymizerSession type.

[0.3.1] - Unreleased

Changed

  • Default prompt hardening for lossless round-trips: (1) two different values of the same type never share a placeholder — they must be distinguished by context (e.g. [PER_1.IBAN:Ancien] vs [PER_1.IBAN:Nouveau]), preventing a within-message collision (old/new IBAN); (2) the model must not absorb adjacent punctuation/separators (commas, spaces, parentheses) into a placeholder. Together these took the live Gemma-4 e-learning demo from 4/5 to 5/5 exact round-trips.

[0.3.0] - Unreleased

Changed

  • openAICompatibleProvider now works with Infomaniak (and other open-model endpoints) out of the box. response_format is omitted by default instead of hard-coding { type: 'json_object' }, which current Infomaniak rejects (HTTP 422). Pass the new responseFormat option (e.g. { type: 'json_object' } or a json_schema object) for endpoints that support/require it. Breaking for endpoints that relied on the previous forced json_object.
  • JSON responses are now parsed leniently — a fenced JSON code block or surrounding prose is tolerated (the outermost { … } is extracted), so models without an enforced response_format don't cause spurious parse failures.
  • The default prompt gained a COHÉRENCE block (exact placeholder↔mapping-key identity, values are the original data never another placeholder, strict [TYPE_N…] format, mask the value not the adjacent label) to improve reliability across models.

[0.2.0] - Unreleased

Added

  • legend on every result — abbreviation → French meaning (PERPersonne, MMasculin), safe to forward to a downstream LLM so it understands the placeholder tokens. Backed by a built-in DEFAULT_LEGEND so coverage is guaranteed even if the model omits entries.
  • The default prompt now lets the model coin new uppercase abbreviations for entities/attributes/ context it discovers and return their meanings in legende.
  • PatternDef.meaning — optional human label for a tag, surfaced in the legend. presets.swiss/ presets.generic ship French meanings.
  • AnonymizationError (exported) — thrown when anonymization can't complete and no fallback exists; carries the originating error in .cause.

Changed (breaking)

  • Regex fallback is now opt-in. patterns no longer defaults to presets.swiss. With no fallback, an LLM failure throws AnonymizationError (fail-closed) and the pre-filter is bypassed. At least one of llm or patterns is required, or the constructor throws.
  • AnonymizationResult gained a required legend field; LlmProvider.anonymizeBatch returns legend.
  • anonymizeChunks(chunks, seed)seed is now { mapping, legend? } (was the bare mapping) and the return includes legend.

[0.1.0] - Unreleased

Added

  • Initial public release.
  • Anonymizer — pre-filter → LLM → regex fallback, bidirectional validation, deterministic coreference, and de-collision across question and retrieved chunks (anonymize, anonymizeChunks, deanonymize).
  • makeStreamDeanonymizer — streaming-safe de-anonymization that never leaks a split placeholder.
  • openAICompatibleProvider — pluggable LLM detection over any OpenAI-compatible Chat Completions API.
  • presets.swiss and presets.generic regex pattern sets; fully configurable custom patterns.
  • Regex-only mode (no LLM provider required).
  • PatternDef.validate — optional second-stage predicate to cut false positives; presets.generic uses it for a Luhn check on credit-card candidates, and de-overlaps its phone/date/IP patterns.
  • openAICompatibleProvider retries transient failures (network/timeout/429/5xx) via retries and retryDelayMs options; non-transient 4xx and malformed responses are not retried.
  • Hardened the nameHint heuristic: g/y flags are stripped internally so .test() is stateless.