/** * MCP-layer PII redaction. * * Masks sensitive financial identifiers in tool response strings before they * leave the server and reach the LLM agent or the user. * * WHAT IS MASKED: * - US bank routing numbers (9 digits, ABA) * - US bank account numbers (4–17 digits following routing or account keywords) * - Payment card numbers (13–19 digits, Luhn-matching encouraged; basic pattern here) * - US Social Security Numbers (NNN-NN-NNNN / NNN NN NNNN / NNNNNNNNN) * * WHAT IS LEFT INTACT: * - Vendor/company names * - Contact information (phone numbers, email addresses, street addresses) * - Dollar amounts and invoice/PO numbers * - Any other non-financial-identity data * * See docs/design.md §2.5 for the requirement context. * See src/redact.test.ts for the full contract. */ // --------------------------------------------------------------------------- // Mask helper // --------------------------------------------------------------------------- /** The string used to replace masked values. */ export const REDACTED = '[REDACTED]'; // --------------------------------------------------------------------------- // Individual pattern redactors // --------------------------------------------------------------------------- /** * Mask US Social Security Numbers. * * Patterns matched: * - NNN-NN-NNNN (canonical) * - NNN NN NNNN (spaced) * - NNNNNNNNN (bare 9 digits) — only when preceded by an SSN keyword * to avoid colliding with routing/account numbers handled below. */ function redactSSN(value: string): string { // Canonical and spaced formats (unambiguous) let result = value.replace(/\b(\d{3})[- ](\d{2})[- ](\d{4})\b/g, REDACTED); // Bare 9-digit SSN preceded by an SSN keyword result = result.replace( /\b(ssn|social\s+security(?:\s+number)?|tax\s+id)\s*[:#]?\s*(\d{9})\b/gi, (_match, keyword) => `${keyword} ${REDACTED}`, ); return result; } /** * Mask US ABA routing numbers. * * ABA routing numbers are exactly 9 digits. We match them when: * a) preceded by a routing-number keyword, OR * b) followed by a routing-number keyword * * We do NOT mask bare 9-digit strings without a keyword to avoid clobbering * zip+4 combos, phone fragments, etc. */ function redactRouting(value: string): string { // Keyword BEFORE the number: "routing number: 021000021" let result = value.replace( /\b(routing\s*(?:number|#|no\.?)?|aba\s*(?:number|#|no\.?)?)\s*[:#]?\s*(\d{9})\b/gi, (_match, keyword) => `${keyword.trim()} ${REDACTED}`, ); // Keyword AFTER the number: "021000021 (routing)" result = result.replace(/\b(\d{9})\s*\((routing|aba)\)/gi, `${REDACTED} ($2)`); return result; } /** * Mask US bank account numbers. * * Bank account numbers are 4–17 digits. We mask them only when a keyword * context makes them unambiguous, to avoid clobbering invoice/PO numbers, * phone numbers, etc. */ function redactAccountNumber(value: string): string { return value.replace( /\b(account\s*(?:number|#|no\.?)?|acct\.?\s*(?:#|no\.?)?)\s*[:#]?\s*(\d{4,17})\b/gi, (_match, keyword) => `${keyword.trim()} ${REDACTED}`, ); } /** * Mask payment card numbers (credit / debit). * * Matches 13–19 consecutive digits (with optional spaces or hyphens between * groups of 4) that look like a PAN. We apply a basic Luhn check so common * non-card numeric strings (invoice IDs, phone numbers) do not get masked. */ function passesLuhn(digits: string): boolean { let sum = 0; let alternate = false; for (let i = digits.length - 1; i >= 0; i--) { let n = parseInt(digits[i]!, 10); if (alternate) { n *= 2; if (n > 9) n -= 9; } sum += n; alternate = !alternate; } return sum % 10 === 0; } function redactCard(value: string): string { // Match 13–19 digits, optionally separated by spaces or hyphens in groups of 4. return value.replace(/\b(\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{1,7}|\d{13,19})\b/g, (match) => { const digits = match.replace(/[\s-]/g, ''); if (digits.length < 13 || digits.length > 19) return match; if (!passesLuhn(digits)) return match; return REDACTED; }); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Redact PII from a string that may appear in a tool response. * * Applies all pattern redactors in a safe order (SSN first to avoid the bare * 9-digit pattern conflicting with the routing-number check). * * The function is PURE and has no side effects. It never makes network calls. * * @param value The raw string (may be JSON, plain text, CSV, etc.) * @returns A copy of `value` with sensitive fields replaced by `[REDACTED]`. */ export function redact(value: string): string { let result = value; result = redactSSN(result); result = redactRouting(result); result = redactAccountNumber(result); result = redactCard(result); return result; } /** * Mask a sensitive field value at the field level. * * Use this when you have an already-isolated sensitive field value (e.g. a * bank account number stored in its own database column) and need to produce * a display-safe string. Unlike `redact()`, which is designed for inline * pattern-matching inside free-form text, `maskValue` blindly replaces the * entire value with masking characters. * * Finance-tier tools MUST still call `redact()` on the field as well — * `maskValue` is a complementary, not a replacement, operation. * * @param value The raw sensitive string (e.g. "123456789", "4111-1111-1111-1111"). * @returns A string of `●` characters the same length as `value` (max 16), * safe to include in tool output or logs. */ export function maskValue(value: string): string { // Show at most 16 mask characters so excessively long values don't bloat output. return '●'.repeat(Math.min(value.length, 16)); }