Repository navigation
Redaction Engine
How values are found, how a finding becomes a decision, and how a decision becomes a removal.
Canonical source: docs/ai-engine.md · code: lib/redaction/*, lib/ai/*.
Two ideas run through this whole layer:
- The model proposes; it never applies. Nothing a model returns changes a document. It points at text, a person decides, and deterministic code does the removing.
- Ask the model only what only a model can answer. A regular expression can tell that a string is an email address. It cannot tell whether Apple is a fruit, a company or a customer. Spending tokens on the first question is waste; spending them on the second is the point.
flowchart TD
N["Normalized document<br/>(pages streamed, text only)"] --> D1["1 · DETERMINISTIC DETECTORS<br/>email, phone, Luhn-checked cards, valid SSNs, IBANs,<br/>credentials, label-gated dates and accounts<br/>free · reproducible · no tokens"]
D1 -- "candidates + an already-found list" --> D2["2 · CLASSIFY<br/>type, language, likely density<br/>one ~2 KB sample"]
D2 --> D3["3 · CONTEXTUAL DETECTION<br/>names, prose addresses, roles, health, confidential<br/>~6 KB chunks through the throttle"]
D3 --> D4["4 · VERIFY<br/>is this labelled number really an account?<br/>one batched call for shaky candidates"]
D4 --> D5["5 · EXPAND<br/>occurrences 2..n of every judged value<br/>local string search · no tokens"]
D5 --> P["deduplicate → preset filter → persist as SUGGESTIONS"]
P --> H{"a person accepts or rejects<br/>(or a rule decides, or Hush proposes and a person approves)"}
H -- accepted --> R["the exporter removes it"]
H -- "rejected, or never decided" --> K["it stays in the document"]
Steps 1 and 5 cost nothing. Steps 2 to 4 are the only ones that spend tokens, and step 3 is told not to repeat what pattern matching already found. Images and scanned pages also get a vision pass, and spreadsheets a column pass (§4).
For a document mentioning john@example.com seventeen times:
| Approach | Model calls |
|---|---|
| Naive (ask per occurrence) | 17 |
| Ask once per chunk | ~1 per chunk containing it |
| This pipeline | 0: the regex matched, and step 5 found the rest |
For a name the regex cannot recognise, the model answers once, and the other sixteen occurrences are found by local search. Occurrence 2..n costs a scan, not a request, and cannot come back with a different answer than occurrence 1.
Whether this claim holds is now measurable: pnpm bench:models runs a model with and without the deterministic pass on a labelled corpus. See Testing and Benchmarks.
lib/redaction/detectors.ts. Each detector is a pattern plus, where it matters, a validator. The validators keep the list from being noise:
| Category | Guard |
|---|---|
financial |
Luhn check: an order number is not a payment card |
government-id |
structural validity: area ≠ 000/666, group ≠ 00
|
bank-account |
a bare 11-digit number is an account only if labelled as one; IBANs by shape |
date-of-birth |
a date is a birth date only after DOB / born / etc. |
customer-id |
ACME-88231 is a part number unless labelled Customer ID
|
url |
only links carrying a token or a private path; docs links are noise |
api-key |
provider credential formats and token shapes |
phone |
at least nine digits, so Room 12 at 9am does not match |
email |
by shape |
Overlaps resolve in favour of the higher-confidence detector, so a card number is reported once as a card and not also as a phone number. The patterns are module-level with the g flag, so lastIndex is reset before each use. A stateful regex silently returning different results on its second call is a classic, very quiet bug, and a test covers it.
The deterministic baseline on the labelled corpus's test split is 20.1% covered recall at 94.1% precision. It finds no names, which are the model's job. See Testing and Benchmarks.
lib/ai/providers/*, lib/ai/gateway.ts, lib/ai/prompts/*, lib/ai/schemas/*
-
Any of 25 providers. Calls go to whichever provider and model the operator configured and verified: the AI Gateway (the backward-compatible default), an official SDK adapter, a cloud platform, a local server (Ollama, LM Studio, llama.cpp), an OpenAI-compatible URL, or a ChatGPT sign-in. See AI Providers.
-
Structured output only. Every call passes a Zod schema, and output that does not validate is discarded. No prose is parsed, and there is no "find the JSON in the response" path.
-
Located, not trusted. The model returns the text it saw, never an offset, and the application finds that text itself:
const located = locateInPage(chunk.text, detection.text) if (!located) continue // the model named text that is not there: drop it
A model that paraphrases, corrects a typo or invents a plausible value cannot inject it into the document. Whitespace may differ (extracted text is unreliable about it); the characters may not. CI checks this rule with a scripted provider (
tests/analysis-orchestration.test.ts). -
Capabilities are checked before the call. Setup verifies structured output and image input and saves a declaration (
AI_MODEL_CAPABILITIES) bound to the provider, model and destination. An unverified or unsupported model returnsunsupportedinstead of being called. -
Failure is contained, and its reason is kept. A provider error returns a result carrying
skippedand a reason, never an exception. A run that fell short writesdocument.ai.degraded, and the usage panel says so. A document reviewed on pattern matching alone must never look identical to one the model genuinely found nothing in. -
Paced, then retried.
lib/services/throttle.tssits in front of every call.ANONIFY_AI_CONCURRENCYbounds calls in flight across every document (it used to be per document, so six documents meant twenty-four concurrent calls).429,5xxand timeouts retry with jitter and honourRetry-After;401,402and403never retry. -
A budget.
ANONIFY_AI_DAILY_SPEND_USD, estimated fromAiUsagerows and per-model prices: at 80% calls drop to one at a time; at 100% the contextual pass is skipped for the rest of the UTC day and said to be skipped. -
Prompts are never logged. They contain the document. Logs carry model, task, duration, token counts and an error category.
With no model at all, the product still works: deterministic detection, manual redaction, search, rules and export all run.
| Task | Sees | Returns |
|---|---|---|
classify |
~2 KB opening sample | type, language, density |
detect |
one ~6 KB chunk + values already found | verbatim spans, category, confidence, global
|
verify |
low-confidence candidates + 80 chars of context each | one verdict per candidate, batched |
columns |
headers + 5 sample values per column | which columns are sensitive, and why |
image |
the pixels + OCR text for context | regions as integers on a 0–1000 grid |
assistant |
whatever Hush's tools return, after consent | tool calls and replies (Hush Assistant) |
- Spreadsheets are judged by column, not by cell. Headers plus a handful of samples is enough to judge a column, and "this column is sensitive" is far cheaper and closer to the decision a reviewer actually makes than ten thousand answers. It becomes one column-level suggestion.
- Faces are asked for as the whole head, with generous bounds. Covering only the eyes does not reliably anonymise anyone, and slightly too large is recoverable where slightly too small is a leak.
- The 0–1000 grid (since 1.6.2) is the convention vision models such as Qwen3-VL and Gemini are trained on, so they answer in it unprompted. Requiring integers means a model that answers in 0–1 fractions fails validation instead of having its box read as a speck in the corner. The application scales once. An over-long region explanation is shortened rather than discarding the region.
-
Preset filtering applies to model proposals (
categoryAllowed) as well as to the detectors. A preset that turned off account numbers and then accepted the model's account-number suggestions would narrow nothing. See Redaction Presets.
Detection ─▶ Redaction { status: "suggested", source: "ai" }
│
reviewer ───┼──▶ accepted ──▶ counts at export (with a method)
└──▶ rejected ──▶ stays in the file, forever
Rule ──────────▶ Redaction { status: "accepted", source: "rule", ruleId }
Manual ────────▶ Redaction { status: "accepted", source: "user" }
Hush ──(Approve)▶ a rule, or accepted/rejected groups, or manual redactions
The exporter reads only accepted. Nothing else can reach the file.
-
Rules ("redact every
EMP-\d{5}") search the normalized document on the server, write one accepted redaction per match, and never consult a model. Deleting a rule removes exactly what it created. Rules can be literal or RE2 RegEx, at document, batch or global scope. See Search and Rules. -
Occurrence groups. The inspector groups suggestions by
category|normalizedText, so seventeen "John Smith"s are one decision. - Accept all operates on every filtered suggestion but leaves ignored ones alone (fixed in 1.5.0).
-
Undo/redo keeps up to 50 snapshots of the redaction map and saves only the redactions it changed (fixed in 1.5.0). The snapshot materialises Immer's draft first (
current()), because spreading the draft stores references that the next mutation edits. That was a real bug, caught by a test.
lib/redaction/apply.ts turns accepted redactions into format-specific instructions. The interesting part is mapping offsets onto structure. A span's id is its source address (word/header1.xml#p3r1, ppt/slides/slide2.xml#p0r1, text#1042, part:0.1/body#248), so the exporter parses the address instead of searching for the value.
- DOCX / PPTX: page offsets map onto the runs that produced them. A value straddling runs (Word splits at every formatting change) gives each run exactly the slice it contributed.
- PDF: offsets map onto glyph boxes measured from the font's own character widths, reaching below the baseline for descenders, padded 1.5 pt. The redacted page is rasterised at 2× with the boxes burned in. (The width-based placement is the 1.5.0 fix for GHSA-h3hv-rpqw-7p84; earlier analyses are covered a whole run at a time.)
- XLSX / CSV / TSV: addresses, not offsets: cell, row or column. Formulas referencing a redacted address are dropped.
- Images: geometry directly, or OCR span boxes for a text redaction.
- TXT / RTF / EML: offsets into the source through the atom map; EML also reaches headers and filenames.
Alongside the precise edits, every export carries a value sweep: the accepted strings are removed wherever else they appear in the container. That catches a name in a DOCX header the editor never displayed, or an email in a hidden worksheet.
What replaces the value is the redaction's method (mask, pseudonymize, tokenize, encrypt), gated by category and by whether the target can carry text. One method applies per value, strongest wins. See Export and Anonymisation.
lib/redaction/validation.ts. The export is re-opened and read the way an adversary would:
| Format | Read as |
|---|---|
extracted text via pdf.js; a raw byte scan with whitespace, parens and escapes stripped; no /Annots or /Redact; redacted pages re-extract with zero spans |
|
| DOCX / PPTX | every .xml and .rels part, raw; the package still opens |
| XLSX | every cell of every sheet, hidden included, plus every part and the shared-string table |
| CSV / TSV | re-parsed as a grid, every cell |
| TXT / RTF | the exact text; RTF re-parsed (it must still open) |
| EML | re-parsed by two parsers, ours and postal-mime; attachment substitutions checked by checksum |
| Image | pixel sampling in the test suites, and metadata |
| Rebuilt MBOX | split again by the upload's scanner: count, bytes per message, separator lines |
Any accepted value still present throws ExportVerificationError. The artifact is not saved, no link is issued, and the client is told the export was refused. The operator's log records how many values leaked; the client is told nothing that would help locate them.
Values shorter than four characters are not asserted on, because they collide by coincidence and a check that fails at random is a check people learn to ignore. Strings the pipeline itself authored (surrogates, ciphertexts) are excised from the haystack first, because a long random ciphertext can contain a short accepted value by chance, and none of them is derived from the value in a way that could reproduce it.
A second artifact, downloadable beside the file: counts removed by category, the style and method each was applied with, what the reviewer rejected or never decided, the preset (and a note if it narrowed the search), attachment dispositions, and both checksums. It carries counts, never content, and that is checked (assertReportOmitsValues) before the report is stored. See Security Model §6.
- It does not find everything. The reviewer is the control, and accepting is a deliberate act.
- Confidence is a hint, not a probability. It is shown as a band and a percentage for triage, never as a verdict.
- Verification proves the absence of the accepted strings, not of the information. "The CEO of the company on Elm Street" still identifies someone after the name is gone. That judgement belongs to the reviewer.
- Detection is English-shaped (#43).
Anonify: AI proposes, the application applies, and only what a person accepted is removed.
Repository · Report a bug · Request a feature · Security policy · Apache-2.0
This wiki describes v1.10.0 and summarises and links the in-repo documentation. Where the two disagree, the files under docs/, CHANGELOG.md and SECURITY.md are canonical.
Getting going
Using it
- Supported Formats
- Review Workspace
- Search and Rules
- Hush Assistant
- Redaction Presets
- Export and Anonymisation
- Batches and Mailboxes
How it works
- Architecture
- Processing Workflow
- Redaction Engine
- Storage and Encryption
- Streaming IO
- Data Model
- API Reference
- Security Model
When it breaks
Contributing
Repository · Issues · Releases · Changelog