← 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 };