← back to AbramsOS
lib/chat-redact.js
177 lines
// The PII redaction boundary. Every prompt bound for an EXTERNAL model provider
// (Gemini, Claude-CLI) passes through redact() first. Local Ollama lanes bypass
// it (raw data never leaves the box). Per AGENTS.md: redact name, address,
// account numbers, MRN, NDC-as-PHI before any prompt to an external provider.
//
// Design: order matters — the most specific/structured patterns run first so a
// broad pattern can't swallow a token another rule should have labelled. Each
// match is replaced with a stable [TAG] placeholder. redact() is pure and
// synchronous; it returns { text, redactions } so the caller can audit HOW MANY
// spans were removed without ever logging the spans themselves.
// Structured-first. Every regex is global + case-insensitive where sensible.
// These run on WHITESPACE-NORMALIZED text (NBSP/thin-space collapsed to plain
// spaces) so an attacker can't slip PII past a pattern with an exotic separator.
const RULES = [
// Email addresses
{ tag: 'EMAIL', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
// Full street address WITH a city/ST/ZIP tail — run this BEFORE the bare
// street rule so the whole line (incl. "Apt 2B, Springfield, IL 62704") is
// taken as one ADDRESS span rather than leaving the city/state/zip behind.
{
tag: 'ADDRESS',
// The city/state/zip tail is only consumed when it is genuinely present:
// a comma-introduced city AND a state-or-ZIP, so a following capitalized
// word like "Prescription" is never mistaken for a "ST" token.
re: /\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){1,5}(?:street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|court|ct|way|place|pl|circle|cir|terrace|ter|parkway|pkwy|highway|hwy)\b\.?(?:\s*,?\s*(?:apt|apartment|unit|suite|ste|no|#|fl|floor|bldg|building|rm|room)\.?\s*[A-Za-z0-9-]+)?(?:\s*,\s*[A-Za-z.'-]+(?:\s+[A-Za-z.'-]+){0,3}\s*,\s*[A-Z]{2}(?:\s+\d{5}(?:-\d{4})?)?)?/gi,
},
// PO Box forms
{ tag: 'ADDRESS', re: /\bP\.?\s*O\.?\s*Box\s+\d+\b/gi },
// Bare street address: number + optional directional + street words + suffix.
// "123 W 5th", "12-14 Main St", "742 Evergreen Terrace" all match.
{
tag: 'ADDRESS',
re: /\b\d{1,6}(?:-\d{1,6})?\s+(?:(?:N|S|E|W|NE|NW|SE|SW)\.?\s+)?(?:[A-Za-z0-9.'-]+\s+){0,4}?(?:street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|court|ct|way|place|pl|circle|cir|terrace|ter|parkway|pkwy|highway|hwy|suite|ste|apt|unit|#|[0-9]+(?:st|nd|rd|th))\b\.?/gi,
},
// Credit-card-like 13-19 digit runs, allowing odd grouping like
// "4111-111111-1111" (groups need not be 4 digits). Runs BEFORE the phone
// rules so a 16-digit card isn't half-claimed as a phone; the min/max digit
// gate keeps 10-digit phone numbers from matching here.
{ tag: 'CARD', re: /\b\d{4}[ -]?\d{2,7}[ -]?\d{2,7}(?:[ -]?\d{1,7})?\b/g, min: 13, max: 19 },
// Phone numbers. Two forms:
// - International "+CC ..." (e.g. "+44 20 7946 0958") — a + then 8-15 digits
// with spaces/dashes/dots/parens allowed between.
{ tag: 'PHONE', re: /\+\d[\d\s().-]{7,16}\d\b/g },
// - US/NANP: optional +1, separators . - space or parens.
{ tag: 'PHONE', re: /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/g },
// SSN — hyphen OR space separated ("123-45-6789" / "123 45 6789").
{ tag: 'SSN', re: /\b\d{3}[\s-]\d{2}[\s-]\d{4}\b/g },
// SSN introduced by a label, incl. a plain 9-digit run in that context.
{
tag: 'SSN',
re: /\b(?:ssn|social\s+security(?:\s+(?:no|number|#))?)\s*[:#]?\s*(\d{3}[\s-]?\d{2}[\s-]?\d{4})\b/gi,
group: 1,
},
// NDC drug codes (National Drug Code) — PHI when tied to a person.
// 4-4-2, 5-4-2, 5-3-2, 5-4-1 hyphenated forms.
{ tag: 'NDC', re: /\b\d{4,5}-\d{3,4}-\d{1,2}\b/g },
// MRN / Rx / DEA / account / patient-id / subscriber / group / prior-auth
// numbers introduced by a label — keep the label, redact the value.
{
tag: 'ACCTNO',
re: /\b(?:MRN|medical\s+record(?:\s+number)?|account(?:\s+(?:no|number|#))?|acct|rx(?:\s+(?:no|number|#))?|dea|policy(?:\s+(?:no|number|#))?|member(?:\s+(?:id|no|number|#))?|claim(?:\s+(?:no|number|#))?|patient(?:\s+id)?|subscriber(?:\s+(?:id|no|number|#))?|group(?:\s+(?:no|number|#))?|prior\s*auth(?:orization)?(?:\s+(?:no|number|#))?|auth(?:orization)?(?:\s+(?:no|number|#))?)\s*[:#]?\s*([A-Z0-9][A-Z0-9-]{3,})\b/gi,
group: 1,
},
// Standalone unlabeled identifiers: a mixed alnum token (letters AND digits)
// of length >=6, e.g. an MRN "A1234567" or "PA9981234" in prose with no
// label. Pure-digit and pure-alpha tokens are left alone (so order numbers
// and words survive); an alnum mix is the identifier signature.
{ tag: 'ID', re: /\b(?=[A-Z0-9-]{6,}\b)(?=[A-Z-]*\d)(?=\d*[A-Z])[A-Z0-9-]{6,}\b/gi },
// ZIP (5 or ZIP+4) — only when preceded by a 2-letter state token to avoid
// nuking every 5-digit number (order numbers, prices).
{ tag: 'ZIP', re: /\b[A-Z]{2}\s+\d{5}(?:-\d{4})?\b/g },
];
// Person-name redaction is done on a supplied list (from the grounding layer,
// which already knows the real names in the record set) rather than by a fragile
// "any capitalized word" heuristic that would shred product/merchant names. The
// caller passes { names: ['Steve Abrams', 'Jane Doe', ...] }.
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Collapse exotic Unicode spacing (NBSP U+00A0, thin/hair/figure spaces,
// zero-width joiners) to plain ASCII spaces so a name/pattern can't be smuggled
// past a rule with a look-alike separator.
function normalizeWhitespace(s) {
return s
// Collapse NBSP + all Unicode space separators (thin/hair/figure/en/em/
// ideographic, etc.) to a plain ASCII space.
.replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " ")
// Strip zero-width space / joiner / non-joiner / BOM entirely.
.replace(/[\u200B-\u200D\uFEFF]/g, "");
}
// Count the actual digits in a candidate span (for CARD min/max gating).
function digitCount(s) {
let n = 0;
for (const ch of s) if (ch >= '0' && ch <= '9') n++;
return n;
}
// Build a whitespace/separator-tolerant, case-insensitive matcher for a person
// name so "Jane Doe", "Jane-Doe", "JANE, DOE", and NBSP-separated variants all
// hit. Each inter-token gap is allowed to be spaces, hyphens, or a comma.
function nameRegex(name) {
const tokens = String(name).trim().split(/[\s,]+/).filter(Boolean).map(escapeRe);
if (!tokens.length) return null;
const body = tokens.join('[\\s,-]+');
return new RegExp(`\\b${body}\\b`, 'gi');
}
function redact(input, { names = [] } = {}) {
let text = normalizeWhitespace(typeof input === 'string' ? input : String(input ?? ''));
let redactions = 0;
// Order names longest-first so a full name is claimed before its bare tokens.
const ordered = [...names]
.map((n) => String(n || '').trim())
.filter((n) => n.length >= 2)
.sort((a, b) => b.length - a.length);
// 1) Full-name matches first (most reliable single signal), separator- and
// case-tolerant. Bare single tokens are held for step 3 so a first/last
// token inside an email local-part (jane.doe@…) is claimed by EMAIL first.
for (const name of ordered) {
const re = nameRegex(name);
if (!re) continue;
text = text.replace(re, () => { redactions++; return '[NAME]'; });
}
// 2) Structured patterns, most-specific first.
for (const rule of RULES) {
text = text.replace(rule.re, (match, g1) => {
// CARD (and any rule with min/max) only fires when the digit count is in
// range — keeps benign 9-12 digit order numbers from being nuked.
if (rule.min != null || rule.max != null) {
const d = digitCount(match);
if ((rule.min != null && d < rule.min) || (rule.max != null && d > rule.max)) return match;
}
if (rule.group === 1 && g1 != null) {
// Preserve the label prefix, redact only the captured value.
const idx = match.lastIndexOf(g1);
redactions++;
return match.slice(0, idx) + `[${rule.tag}]`;
}
redactions++;
return `[${rule.tag}]`;
});
}
// 3) Loose name-token pass LAST — catches a bare first/last name in prose
// (e.g. "ask Jane about it") without shredding emails already redacted above.
for (const name of ordered) {
if (!/[\s,-]/.test(name)) continue; // single-token names handled in step 1
for (const part of name.split(/[\s,-]+/)) {
if (part.length < 3) continue;
const pre = new RegExp(`\\b${escapeRe(part)}\\b`, 'gi');
text = text.replace(pre, () => { redactions++; return '[NAME]'; });
}
}
return { text, redactions };
}
module.exports = { redact };