diff --git a/.changeset/mcp-payload-personal-data.md b/.changeset/mcp-payload-personal-data.md new file mode 100644 index 0000000000..9bd846f921 --- /dev/null +++ b/.changeset/mcp-payload-personal-data.md @@ -0,0 +1,6 @@ +--- +'@posthog/mcp': minor +'@posthog/core': minor +--- + +Tool arguments, tool results, error messages, exception values, and resource read URIs now have personal data redacted by default, as the intent already did. Emails (an accented local part such as `josé@example.com` included), Luhn-valid card numbers (13 to 19 digits, ungrouped or grouped the way cards are printed: 4-4-4-4, 4-4-4-4-3, 4-6-5, or 4-6-4, by single spaces or dashes), US social security numbers, IPv6 and public IPv4 addresses, and phone numbers written with an international prefix (`+`, `00`, `011`) or a parenthesized area code become `[redacted]`, in string values and object keys at any depth. In a text that parses as JSON, a card number written as a JSON number, `4111111111111111.0` and `4111111111111111e0` included, becomes the string `"[redacted]"`; negative numbers and fractions with digits after the point are kept. In any other text, a log line or a URL included, a card becomes `[redacted]`, unquoted. A JSON or JavaScript string escape (`\n`, `\"`, `\u00e9`) no longer hides an identifier or a PostHog token written right after it, an identifier written with `\uXXXX` escapes (`ada\u0040example.com`, `4111\u00201111\u00201111\u00201111`) is read as the characters they encode, and a JSON text copy of a result is redacted in place, so it keeps its formatting and stays valid JSON. Private, loopback, and link-local addresses, versions written after a letter (`v1.2.3.4`), and `415-555-0142`-style numbers are kept. Some identifiers still read as personal data and are redacted: `ddd-dd-dddd` references (read as social security numbers), a four-part version at the start of a string or after a space, `:`, or `@` (`22.11.0.3`, `node:22.11.0.3`), zero-padded ids starting `00` (`0012345678`, read as an international prefix), `+`-signed numbers and ISO 6709 coordinates (`+1500000000`, `+40.6894-074.0447/`, read as phone numbers), a URL with a user and password (redacted whole), and about 1 in 10 numeric ids of 13 to 19 digits (they pass the card checksum). Argument names these rules would change are no longer listed in `$mcp_input_keys`. If your tools return personal data on purpose, set `redactPayloadPersonalData: false` on `instrument()` or `PostHogMCP`. `beforeSend` can drop a value but cannot restore one. With the option off, the intent and feedback text keep their redaction, and credentials are redacted everywhere. A string longer than four times the captured string limit is redacted on its first part only, the part truncation can keep, so a multi-megabyte tool result costs about what a captured one does. A URL that runs past that part, such as a `data:` image in an HTML page or an address whose user or token sits after it, is now replaced whole by `[redacted]` and nothing after it is captured, with the option on or off. It was read in full before. Object keys in arguments and results are cut to 256 characters, with the option on or off. diff --git a/packages/core/src/privacy.ts b/packages/core/src/privacy.ts index 4e4ec322fd..461e2714ed 100644 --- a/packages/core/src/privacy.ts +++ b/packages/core/src/privacy.ts @@ -6,6 +6,13 @@ export interface TextSanitizationOptions { maxStringLength: number truncationSuffix: string + /** Applies {@link redactPayloadPii} to captured strings and object keys, and redacts numbers that pass as card numbers. */ + redactPayloadPersonalData?: boolean +} + +export interface CapturedValueOptions extends TextSanitizationOptions { + /** Object keys are cut to this many characters after redaction, so a match the scan window split never ships. */ + maxKeyLength: number } const REDACTED_VALUE = '[redacted]' @@ -103,14 +110,14 @@ const IPV6_PATTERN = // A separator (space, dot, or dash) is required between the 3-2-4 groups so bare // 9-digit IDs are never mistaken for an SSN. const US_SSN_PATTERN = /\b\d{3}[ .-]\d{2}[ .-]\d{4}\b/g -// A run of >=13 digits optionally grouped by a single space, dot, dash, or -// slash. This only marks the numeric region; `redactCardInMatch` then looks for -// the actual card as a run of whole separator-delimited groups that passes Luhn, -// so an adjacent field such as an expiry (`4111 1111 1111 1111 12/30`) is not -// absorbed into a failing check that would leak the card. -const CREDIT_CARD_CANDIDATE_PATTERN = /\b\d(?:[ ./-]?\d){12,}\b/g -// Matches each separator-delimited digit group inside a card candidate. -const DIGIT_GROUP_PATTERN = /\d+/g +// How cards are printed when grouped, shortest first so a field after a card is +// left for its own pattern. An ungrouped card is one run of 13–19 digits. +const CARD_GROUPINGS = [ + [4, 4, 4, 4], + [4, 4, 4, 4, 3], + [4, 6, 4], + [4, 6, 5], +] // Phone matching is structural rather than "any 10–15 digits", so dates // (`2024-01-15 12:30`) and dotted versions are not mistaken for numbers. Two // forms: a North-American 3-3-4 grouping, and an international number that must @@ -121,11 +128,41 @@ const DIGIT_GROUP_PATTERN = /\d+/g const PHONE_NANP_PATTERN = /(^|[^\w+])((?:\+?1[ ./-]?)?(?:\(\d{3}\)[ ./-]?|\d{3}[ ./-])\d{3}[ ./-]\d{4})(?![\w])/g const PHONE_INTL_PATTERN = /(^|[^\w])(\+\d{1,3}(?:[ ./()-]{0,2}\d){7,13})(?![\w])/g -type JsonRecord = Record - -function isRecord(value: unknown): value is JsonRecord { - return !!value && typeof value === 'object' && !Array.isArray(value) -} +// The payload pass is narrower because tool payloads carry identifiers that only +// look like personal data: versions glued to a letter, digit, or `.`, internal +// addresses, and `415-555-0142`-shaped order numbers. +const PAYLOAD_IPV4_PATTERN = + /(^|[^A-Za-z0-9.])((?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))(?!\d|\.\d)/g +const NON_PUBLIC_IPV4_RANGES = [ + '127.0.0.0/8', + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '169.254.0.0/16', + '0.0.0.0/32', +] +const PHONE_PARENTHESIZED_PATTERN = /(^|[^\w+])((?:\+?1[ ./-]?)?\(\d{3}\)[ ./-]?\d{3}[ ./-]\d{4})(?![\w])/g +// Not after a `.`, where `00` or `011` starts a fraction's digits, nor after a +// `-`, where it is the last group of a dashed number (`874-426-0063`). +const PAYLOAD_PHONE_INTL_PATTERN = /(^|[^\w.-])((?:\+|00|011)[ .-]?[1-9]\d{0,2}(?:[ ./()-]{0,2}\d){7,13})(?![\w])/g +// A number in JSON value position: after `:`, `,` or `[`, and before `,`, `}` or `]`. +const JSON_NUMBER_PATTERN = /([:,[]\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?=\s*[,}\]])/g +const LEADING_BOM_PATTERN = /^\uFEFF/ +const JSON_NUMBER_PARTS_PATTERN = /^-?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ +const WORD_CHARACTER_PATTERN = /\w/ +// A JSON or JavaScript string escape. Read as text, its letter or hex digits glue +// to what follows: `"x\nada@example.com"` holds the address `nada@example.com`, +// and `"x\n4111…"` has no boundary before the card. +const ESCAPE_PATTERN = /\\(?:["\\/bfnrt]|u[0-9A-Fa-f]{4})/g +// What the identifier patterns read in place of an escaped control character, +// quote, or backslash: neither a word character nor any pattern's separator, so +// it ends an identifier without joining the digit groups on either side of it. +const ESCAPE_MASK = '\u0000' +// What they read in place of a non-ASCII character other than a space, escaped or +// not: legal in an address's local part, so `josé@example.com` is redacted whole, +// but not a word character, so a card written right before one still ends there. +const NON_ASCII_STAND_IN = '%' +const NON_ASCII_PATTERN = /[\u0080-\uffff]/g function shouldRedactKey(key: string): boolean { return SENSITIVE_KEY_PATTERN.test(key) @@ -513,7 +550,7 @@ function isBinaryBlob(value: string): boolean { /** Replaces PostHog API keys wherever they appear in a string. */ function redactCredentials(value: string): string { - return value.replace(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE) + return redactIdentifiers(value, [[matching(POSTHOG_TOKEN_PATTERN), redactWhole]]) } /** Replaces every URL-shaped match, credentials and all. */ @@ -521,44 +558,93 @@ function redactUrls(value: string): string { return sanitizeUrlsInString(value, { allowNestedUrls: true, stripPunctuation: true }) } +function scanWindowLength(options: TextSanitizationOptions): number { + return 4 * options.maxStringLength +} + /** - * Runs `redact` over only the head of `value` that truncation can keep, so a - * multi-megabyte tool result costs about the same as the part captured. The head - * ends where no URL crosses it, because a URL is redacted or kept on the whole - * address. Redaction shrinks text, so a head that ends up too short to cover - * truncation's cut falls back to the whole string. Either way the kept part - * reads exactly as if the whole string were redacted. + * Runs `redact` over only the window of `value` that truncation can keep, so a + * multi-megabyte tool result costs about the same as the part captured. + * Redaction shrinks text, so a window that ends up too short to cover + * truncation's cut doubles, up to four times its first length. The kept part + * reads as the start of what redacting the whole string would give. */ function redactCapturedHead( value: string, redact: (value: string) => string, + options: TextSanitizationOptions, + windowLength = scanWindowLength(options) +): string { + return value.length > windowLength + ? keepHead( + redact(value.slice(0, windowLength)), + windowLength, + () => redactCapturedHead(value, redact, options, 2 * windowLength), + options + ) + : redact(value) +} + +/** + * {@link redactCapturedHead} with the URL pass after `redact`. A URL is redacted + * or kept on the whole address, and the part that decides it, such as the `@` + * closing a user, can sit past the window, so a URL that runs past the window is + * replaced whole and nothing after it is read. + */ +function redactCapturedText( + value: string, + redact: (value: string) => string, + options: TextSanitizationOptions, + windowLength = scanWindowLength(options) +): string { + if (value.length <= windowLength) { + return redactUrls(redact(value)) + } + const head = redact(value.slice(0, windowLength)) + const urlStart = URL_END_PATTERN.test(value[windowLength]) ? -1 : trailingUrlStart(head) + return urlStart === -1 + ? keepHead( + redactUrls(head), + windowLength, + () => redactCapturedText(value, redact, options, 2 * windowLength), + options + ) + : redactUrls(head.slice(0, urlStart)) + REDACTED_VALUE + options.truncationSuffix +} + +function keepHead( + head: string, + windowLength: number, + redactWiderWindow: () => string, options: TextSanitizationOptions ): string { - const scannedHeadLength = 4 * options.maxStringLength - if (value.length > scannedHeadLength) { - const head = redact(value.slice(0, headLength(value, scannedHeadLength))) - // Twice what truncation keeps, so a match split where the head was cut sits - // past the part truncation keeps. - if (head.length >= 2 * options.maxStringLength) { - return head + options.truncationSuffix - } + // Twice what truncation keeps, so a match split where the head was cut sits + // past the part truncation keeps. + if (head.length >= 2 * options.maxStringLength) { + return head + options.truncationSuffix } - return redact(value) + // At the widest window, drop the stretch a split match could sit in instead. + return windowLength < 4 * scanWindowLength(options) + ? redactWiderWindow() + : head.slice(0, -options.maxStringLength) + options.truncationSuffix } -/** The scanned head, shortened to end just after the last character that ends a URL. */ -function headLength(value: string, scannedHeadLength: number): number { - let end = scannedHeadLength - while (end > 0 && !URL_END_PATTERN.test(value[end - 1])) { - end-- +/** Where the URL that runs to the end of `text` starts, or -1. */ +function trailingUrlStart(text: string): number { + let lastUrlEnd = text.length + while (lastUrlEnd > 0 && !URL_END_PATTERN.test(text[lastUrlEnd - 1])) { + lastUrlEnd-- } - return end + const url = URL_PATTERN_ONCE.exec(text.slice(lastUrlEnd)) + return url ? lastUrlEnd + url.index : -1 } function sanitizeString(value: string, options: TextSanitizationOptions): string { - return isBinaryBlob(value) - ? BINARY_REDACTED_VALUE - : redactCapturedHead(value, (head) => redactUrls(redactCredentials(head)), options) + if (isBinaryBlob(value)) { + return BINARY_REDACTED_VALUE + } + const redactPersonalData = options.redactPayloadPersonalData ? redactPayloadPii : (head: string) => head + return redactCapturedText(value, (head) => redactPersonalData(redactCredentials(head)), options) } /** @@ -577,7 +663,7 @@ export function sanitizeFreeText(value: string, options: TextSanitizationOptions if (isBinaryBlob(value)) { return BINARY_REDACTED_VALUE } - return redactCapturedHead(value, (head) => redactUrls(redactPii(redactCredentials(head))), options) + return redactCapturedText(value, (head) => redactPii(redactCredentials(head)), options) } function passesLuhn(digits: string): boolean { @@ -600,43 +686,160 @@ function passesLuhn(digits: string): boolean { return sum % 10 === 0 } -// A card candidate can span more than one card plus adjacent fields (e.g. an -// expiry, or a second card). This redacts every card in it: scanning -// left-to-right over the whole separator-delimited digit groups, at each start it -// takes the longest run whose joined digits are 13–19 long and pass Luhn, redacts -// just that span, and resumes after it; a group that begins no valid run is kept -// and skipped. Checking group-aligned runs rather than arbitrary digit windows -// keeps the false-positive rate at Luhn's own ~1-in-10, instead of letting a -// chance-valid sub-window of an ordinary long ID trigger redaction. -function redactCardInMatch(match: string): string { - const groups: { digits: string; start: number; end: number }[] = [] - for (let m = DIGIT_GROUP_PATTERN.exec(match); m !== null; m = DIGIT_GROUP_PATTERN.exec(match)) { - groups.push({ digits: m[0], start: m.index, end: m.index + m[0].length }) +/** Where the card that starts at digit group `first` of a run ends, or -1. */ +type CardEnd = (run: string, first: number) => number + +/** How a pass reads card numbers: what joins digit groups, where a run of groups may start and end, and which groups make a card. */ +interface CardReading { + separators: string + startsAt: (text: string, index: number) => boolean + endsAt: (text: string, index: number) => boolean + cardEnd: CardEnd +} + +function isDigitAt(text: string, index: number): boolean { + const code = text.charCodeAt(index) + return code >= 48 && code <= 57 +} + +function isWordAt(text: string, index: number): boolean { + return index >= 0 && index < text.length && WORD_CHARACTER_PATTERN.test(text[index]) +} + +function digitsEnd(text: string, index: number): number { + let end = index + while (isDigitAt(text, end)) { + end++ } - let output = '' - let cursor = 0 - let first = 0 - while (first < groups.length) { + return end +} + +/** + * The intent reads any run of whole groups joined by one space, dot, dash, or + * slash: at each group, the shortest run whose 13–19 digits pass Luhn. The + * shortest leaves a field after the card, such as the SSN in + * `4111111111111111 078-05-1120`, for its own pattern. + */ +const INTENT_CARDS: CardReading = { + separators: ' ./-', + startsAt: (text, index) => !isWordAt(text, index - 1), + endsAt: (text, index) => !isWordAt(text, index), + cardEnd: (run, first) => { let digits = '' - let matchedLast = -1 - for (let last = first; last < groups.length; last++) { - digits += groups[last].digits + for (let groupStart = first; groupStart < run.length;) { + const groupEnd = digitsEnd(run, groupStart) + digits += run.slice(groupStart, groupEnd) if (digits.length > 19) { - break + return -1 } if (digits.length >= 13 && passesLuhn(digits)) { - matchedLast = last + return groupEnd } + groupStart = groupEnd + 1 } - if (matchedLast >= 0) { - output += match.slice(cursor, groups[first].start) + REDACTED_VALUE - cursor = groups[matchedLast].end - first = matchedLast + 1 + return -1 + }, +} + +/** + * The payload reads only digits grouped by single spaces or dashes, the way + * cards are printed: one group of 13–19 digits or one of {@link CARD_GROUPINGS}. + * Any other grouping is a date, a phone number, or a list of ids. A `.` makes a + * fraction or a version, so a leading `-` or `.` belongs to a negative number or + * a fraction unless a letter comes before it (`card-4111…`, `No.4111…`), and a + * trailing `.` and digit or a letter to a number the run is only part of. + */ +const PAYLOAD_CARDS: CardReading = { + separators: ' -', + startsAt: (text, index) => { + const before = text[index - 1] + return ( + index === 0 || + (before === '.' || before === '-' ? /[A-Za-z_]/.test(text[index - 2] ?? '') : !isWordAt(text, index - 1)) + ) + }, + endsAt: (text, index) => !isWordAt(text, index) && !(text[index] === '.' && isDigitAt(text, index + 1)), + cardEnd: (run, first) => { + const lengths: number[] = [] + for (let groupStart = first; groupStart < run.length && lengths.length < 5;) { + const groupEnd = digitsEnd(run, groupStart) + lengths.push(groupEnd - groupStart) + groupStart = groupEnd + 1 + } + if (lengths[0] >= 13) { + return isCardDigits(run.slice(first, first + lengths[0])) ? first + lengths[0] : -1 + } + for (const grouping of CARD_GROUPINGS) { + const end = first + grouping.reduce((sum, length) => sum + length + 1, -1) + if ( + grouping.every((length, index) => lengths[index] === length) && + passesLuhn(run.slice(first, end).replace(/\D/g, '')) + ) { + return end + } + } + return -1 + }, +} + +/** + * Finds the next run of 13 or more digits in whole groups that `reading` allows. + * A loop rather than a pattern: a backtracking pattern over a megabyte-long run + * of digit groups overflows the stack. + */ +function findCardRun(reading: CardReading): Finder { + return (text, from) => { + for (let start = from; start < text.length; start++) { + if (!isDigitAt(text, start) || isDigitAt(text, start - 1) || !reading.startsAt(text, start)) { + continue + } + let digits = 0 + let end = -1 + let groupStart = start + for (;;) { + const groupEnd = digitsEnd(text, groupStart) + digits += groupEnd - groupStart + if (digits >= 13 && reading.endsAt(text, groupEnd)) { + end = groupEnd + } + groupStart = groupEnd + 1 + if (!reading.separators.includes(text[groupEnd]) || !isDigitAt(text, groupStart)) { + break + } + } + if (end >= 0) { + return [start, end] + } + start = groupStart - 1 + } + return null + } +} + +/** + * Redacts every card in a run of digit groups. A run can hold more than one card + * plus adjacent fields, such as an expiry or a second card, so the groups are + * scanned left to right: a card is redacted and the scan resumes after it, and a + * group that starts no card is kept. + */ +function redactCardsInRun(run: string, cardEnd: CardEnd): string { + let output = '' + let cursor = 0 + for (let first = 0; first < run.length;) { + const end = cardEnd(run, first) + if (end >= 0) { + output += run.slice(cursor, first) + REDACTED_VALUE + cursor = end + first = end + 1 } else { - first++ + first = digitsEnd(run, first) + 1 } } - return output + match.slice(cursor) + return output + run.slice(cursor) +} + +function cardRule(reading: CardReading): Rule { + return [findCardRun(reading), (run) => redactCardsInRun(run, reading.cardEnd)] } /** @@ -649,19 +852,260 @@ function redactCardInMatch(match: string): string { * nothing matches. */ export function redactPii(value: string): string { - let result = value.replace(UNICODE_SPACE_PATTERN, ' ') - result = result.replace(EMAIL_PATTERN, REDACTED_VALUE) - result = result.replace(IPV4_PATTERN, REDACTED_VALUE) - result = result.replace(IPV6_PATTERN, `$1${REDACTED_VALUE}`) - result = result.replace(CREDIT_CARD_CANDIDATE_PATTERN, redactCardInMatch) - result = result.replace(US_SSN_PATTERN, REDACTED_VALUE) - result = result.replace(PHONE_NANP_PATTERN, `$1${REDACTED_VALUE}`) - result = result.replace(PHONE_INTL_PATTERN, `$1${REDACTED_VALUE}`) - return result + return redactIdentifiers(value, [ + [matching(EMAIL_PATTERN), redactWhole], + [matching(IPV4_PATTERN), redactWhole], + [matching(IPV6_PATTERN), redactWhole], + jsonNumberRule(value, (token) => isIntegerCard(token) || holdsIntentCard(token)), + cardRule(INTENT_CARDS), + [matching(US_SSN_PATTERN), redactWhole], + [matching(PHONE_NANP_PATTERN), redactWhole], + [matching(PHONE_INTL_PATTERN), redactWhole], + ]) +} + +function holdsIntentCard(text: string): boolean { + return redactIdentifiers(text, [cardRule(INTENT_CARDS)]) !== text +} + +/** What one identifier is replaced with, given where it starts in the text the patterns read. */ +type Redaction = (identifier: string, start: number, read: string) => string + +/** Where the next identifier at or after `from` starts and ends in the text the patterns read, if any. */ +type Finder = (read: string, from: number) => [start: number, end: number] | null + +type Rule = [find: Finder, redact: Redaction] + +const redactWhole: Redaction = () => REDACTED_VALUE + +/** + * A pattern that matches its identifier whole, or captures the boundary before + * it in group 1 and the identifier in group 2. + */ +function matching(pattern: RegExp): Finder { + return (read, from) => { + pattern.lastIndex = from + const match = pattern.exec(read) + if (match === null) { + return null + } + const end = match.index + match[0].length + return [end - (match[2] ?? match[0]).length, end] + } } -export function sanitizeCapturedValue(value: unknown, options: TextSanitizationOptions): unknown { - return sanitizeValueWith(value, (stringValue) => sanitizeString(stringValue, options)) +/** A text as the identifier patterns read it, and where each of its characters came from. */ +interface EscapeReading { + read: string + /** The offset in the text of the character the patterns read at `index`; `read.length` maps to the text's end. */ + offset: (index: number) => number +} + +/** Horizontal Unicode spaces read as an ASCII space, and other non-ASCII characters as {@link NON_ASCII_STAND_IN}. */ +function readCharacters(text: string): string { + return text.replace(UNICODE_SPACE_PATTERN, ' ').replace(NON_ASCII_PATTERN, NON_ASCII_STAND_IN) +} + +/** + * Reads `text` as {@link readCharacters} does, and each escape in it as one + * character: an escaped control character, quote, or backslash as + * {@link ESCAPE_MASK}, and any other `\uXXXX` as the character it encodes would + * read, so `ada\u0040example.com` and `4111\u00201111…` read as what they are. + */ +function readEscapes(text: string): EscapeReading { + const spaced = readCharacters(text) + if (!text.includes('\\')) { + return { read: spaced, offset: (index) => index } + } + const offsets = new Int32Array(text.length + 1) + let read = '' + let cursor = 0 + const copyUpTo = (end: number): void => { + for (let index = cursor; index < end; index++) { + offsets[read.length + index - cursor] = index + } + read += spaced.slice(cursor, end) + } + for (const escape of spaced.matchAll(ESCAPE_PATTERN)) { + copyUpTo(escape.index) + offsets[read.length] = escape.index + read += escapeStandIn(escape[0]) + cursor = escape.index + escape[0].length + } + copyUpTo(text.length) + offsets[read.length] = text.length + return { read, offset: (index) => offsets[index] } +} + +function escapeStandIn(escape: string): string { + if (escape[1] !== 'u') { + return ESCAPE_MASK + } + const code = parseInt(escape.slice(2), 16) + if ( + code < 0x20 || + (code >= 0x7f && code <= 0x9f) || + code === 0x2028 || + code === 0x2029 || + code === 0x22 || + code === 0x5c + ) { + return ESCAPE_MASK + } + return readCharacters(String.fromCharCode(code)) +} + +/** + * Applies each rule in turn to `value` as {@link readEscapes} reads it, and + * splices each replacement over the characters the identifier was read from. + * An identifier that covers part of an escape covers all of it, so the text + * stays valid JSON, and text outside an identifier comes back byte for byte. + */ +function redactIdentifiers(value: string, rules: Rule[]): string { + let text = value + let reading = readEscapes(text) + for (const [find, redact] of rules) { + const { read, offset } = reading + let redacted = '' + let cursor = 0 + for (let span = find(read, 0); span !== null; span = find(read, span[1])) { + const identifier = read.slice(span[0], span[1]) + const replacement = redact(identifier, span[0], read) + if (replacement !== identifier) { + redacted += text.slice(cursor, offset(span[0])) + replacement + cursor = offset(span[1]) + } + } + if (cursor > 0) { + text = redacted + text.slice(cursor) + reading = readEscapes(text) + } + } + return text +} + +function ipv4ToNumber(address: string): number { + return address.split('.').reduce((value, octet) => value * 256 + Number(octet), 0) +} + +function isPublicIpv4(address: string): boolean { + const value = ipv4ToNumber(address) + return !NON_PUBLIC_IPV4_RANGES.some((range) => { + const [network, prefixLength] = range.split('/') + const size = 2 ** (32 - Number(prefixLength)) + return Math.floor(value / size) === ipv4ToNumber(network) / size + }) +} + +/** + * Writes a JSON number for which `isCard` holds as the string `"[redacted]"`, + * whole, so JSON text stays valid JSON. Only when `text` is JSON: in other text + * the quote would end a URL match early and hide the credentials after it, so the + * card is left for the card pass. A number stands outside every JSON string when + * an even number of quotes precede it; escaped quotes are masked, so they are not + * counted. The count carries over from one number to the next, so a text full of + * numbers is read once, not once per number, and `text` is parsed at most once. + */ +function jsonNumberRule(text: string, isCard: (token: string) => boolean): Rule { + let quotes = 0 + let counted = 0 + let json: boolean | undefined + return [ + matching(JSON_NUMBER_PATTERN), + (token, start, read) => { + for (; counted < start; counted++) { + quotes += read[counted] === '"' ? 1 : 0 + } + if (quotes % 2 !== 0 || !isCard(token)) { + return token + } + json ??= isJsonText(text) + return json ? `"${REDACTED_VALUE}"` : token + }, + ] +} + +function isJsonText(text: string): boolean { + try { + JSON.parse(text.replace(LEADING_BOM_PATTERN, '')) + return true + } catch { + return false + } +} + +function isCardDigits(digits: string): boolean { + return /^\d{13,19}$/.test(digits) && passesLuhn(digits) +} + +function isCardNumber(value: number): boolean { + return isCardDigits(String(value)) +} + +/** + * Whether a JSON number token equals a card number, read from its digits rather + * than parsed, so a 19-digit card past 2^53 is judged exactly: + * `4111111111111111.0` and `41111111111111110e-1` are both the card. + */ +function isIntegerCard(token: string): boolean { + const [, whole, fraction = '', exponent = '0'] = JSON_NUMBER_PARTS_PATTERN.exec(token) ?? [] + if (whole === undefined) { + return false + } + const digits = whole + fraction + const significant = digits.replace(/^0+/, '') + const integerLength = whole.length + Number(exponent) - (digits.length - significant.length) + return ( + integerLength >= 13 && + integerLength <= 19 && + !/[1-9]/.test(significant.slice(integerLength)) && + isCardDigits(significant.slice(0, integerLength).padEnd(integerLength, '0')) + ) +} + +/** + * {@link redactPii} narrowed for tool arguments, results, and errors: an IPv4 + * address only when public and not glued to a version, and a phone number only + * with an international prefix or a parenthesized area code. A string with + * nothing to redact comes back as received, Unicode spaces included. + */ +export function redactPayloadPii(value: string): string { + return redactIdentifiers(value, [ + [matching(EMAIL_PATTERN), redactWhole], + [matching(PAYLOAD_IPV4_PATTERN), (address) => (isPublicIpv4(address) ? REDACTED_VALUE : address)], + [matching(IPV6_PATTERN), redactWhole], + jsonNumberRule(value, (token) => token[0] !== '-' && isIntegerCard(token)), + cardRule(PAYLOAD_CARDS), + [matching(US_SSN_PATTERN), redactWhole], + [matching(PHONE_PARENTHESIZED_PATTERN), redactWhole], + [matching(PAYLOAD_PHONE_INTL_PATTERN), redactWhole], + ]) +} + +export function sanitizeCapturedValue(value: unknown, options: CapturedValueOptions): unknown { + return sanitizeValueWith( + value, + options.redactPayloadPersonalData + ? payloadPasses(options) + : { string: (stringValue) => sanitizeString(stringValue, options), key: (key) => cutKey(key, options) } + ) +} + +function cutKey(key: string, options: CapturedValueOptions): string { + return key.length > options.maxKeyLength ? key.slice(0, options.maxKeyLength) + options.truncationSuffix : key +} + +function redactCardNumber(value: number): unknown { + return isCardNumber(value) ? REDACTED_VALUE : value +} + +/** What the payload personal data pass rewrites in a structure. */ +function payloadPasses(options: CapturedValueOptions): ValuePasses { + return { + string: (value) => sanitizeString(value, options), + key: (key) => cutKey(redactCapturedHead(key, redactPayloadPii, options), options), + number: redactCardNumber, + } } /** @@ -670,20 +1114,44 @@ export function sanitizeCapturedValue(value: unknown, options: TextSanitizationO * values get structured-PII redaction in the load-bearing order too. */ export function sanitizeFreeTextValue(value: unknown, options: TextSanitizationOptions): unknown { - return sanitizeValueWith(value, (stringValue) => sanitizeFreeText(stringValue, options)) + return sanitizeValueWith(value, { string: (stringValue) => sanitizeFreeText(stringValue, options) }) } -function sanitizeValueWith(value: unknown, sanitizeStringFn: (value: string) => string): unknown { +/** What a walk rewrites. Object keys and numbers are kept as received unless a pass is given for them. */ +interface ValuePasses { + string: (value: string) => string + key?: (key: string) => string + number?: (value: number) => unknown +} + +/** Orders by Unicode code point, which `<` does not for characters outside the Basic Multilingual Plane. */ +function compareCodePoints(left: string, right: string): number { + for (let index = 0; index < left.length && index < right.length;) { + const leftPoint = left.codePointAt(index)! + const rightPoint = right.codePointAt(index)! + if (leftPoint !== rightPoint) { + return leftPoint - rightPoint + } + index += leftPoint > 0xffff ? 2 : 1 + } + return left.length - right.length +} + +function sanitizeValueWith(value: unknown, passes: ValuePasses): unknown { if (value == null) { return value } if (typeof value === 'string') { - return sanitizeStringFn(value) + return passes.string(value) + } + + if (typeof value === 'number' && passes.number) { + return passes.number(value) } if (Array.isArray(value)) { - return value.map((item) => sanitizeValueWith(item, sanitizeStringFn)) + return value.map((item) => sanitizeValueWith(item, passes)) } if (value instanceof Date) { @@ -694,9 +1162,17 @@ function sanitizeValueWith(value: unknown, sanitizeStringFn: (value: string) => return value } - const result: JsonRecord = {} + const result = new Map() + const receivedKeys = new Map() for (const [key, nestedValue] of Object.entries(value)) { - result[key] = shouldRedactKey(key) ? REDACTED_VALUE : sanitizeValueWith(nestedValue, sanitizeStringFn) + const sanitizedKey = passes.key ? passes.key(key) : key + const keptKey = receivedKeys.get(sanitizedKey) + if (keptKey !== undefined && compareCodePoints(keptKey, key) <= 0) { + continue + } + receivedKeys.set(sanitizedKey, key) + result.set(sanitizedKey, shouldRedactKey(key) ? REDACTED_VALUE : sanitizeValueWith(nestedValue, passes)) } - return result + // Defined rather than assigned, so a parsed `__proto__` member stays a member instead of setting the prototype. + return Object.fromEntries(result) } diff --git a/packages/mcp/src/__tests__/e2e-sanitization.test.ts b/packages/mcp/src/__tests__/e2e-sanitization.test.ts index d8f58b71ca..0eacc7b676 100644 --- a/packages/mcp/src/__tests__/e2e-sanitization.test.ts +++ b/packages/mcp/src/__tests__/e2e-sanitization.test.ts @@ -268,3 +268,107 @@ describe('E2E Sanitization - real MCP tool calls', () => { } }) }) + +describe('E2E payload personal data', () => { + const accounts = '{"accounts":{"ada@example.com":{"plan":"pro"}},"card":4111111111111111,"count":3}' + + type Properties = Record + const text = (value: unknown) => JSON.stringify(value) + const firstText = (properties: Properties) => + (properties.$mcp_response as { content: { text: string }[] }).content[0].text + + it.each([ + { + scenario: 'personal data in arguments and results is redacted by default', + options: {}, + args: { email: 'ada@example.com', limit: 5 }, + result: async () => ({ + content: [{ type: 'text' as const, text: 'Customer ada@example.com last seen from 203.0.113.7' }], + }), + check: (call: Properties) => { + expect(text(call.$mcp_parameters)).not.toContain('ada@example.com') + expect((call.$mcp_parameters as any).request.params.arguments).toEqual({ email: '[redacted]', limit: 5 }) + expect(text(call.$mcp_response)).not.toContain('ada@example.com') + expect(text(call.$mcp_response)).not.toContain('203.0.113.7') + }, + }, + { + scenario: 'a personal key and a card number in a result are redacted and its JSON stays valid', + options: {}, + args: {}, + result: async () => ({ + content: [{ type: 'text' as const, text: accounts }], + structuredContent: JSON.parse(accounts), + }), + check: (call: Properties) => { + expect(text(call.$mcp_response)).not.toContain('ada@example.com') + expect(text(call.$mcp_response)).not.toContain('4111111111111111') + expect(firstText(call)).toBe('{"accounts":{"[redacted]":{"plan":"pro"}},"card":"[redacted]","count":3}') + }, + }, + { + scenario: 'identifiers that only look like personal data are kept in payloads', + options: {}, + args: {}, + result: async () => ({ + content: [ + { + type: 'text' as const, + text: 'Order 415-555-0142 built with v22.11.0.3, db at 127.0.0.1:5432, client ip:203.0.113.7, support +1 415 555 0142', + }, + ], + }), + check: (call: Properties) => { + expect(firstText(call)).toBe( + 'Order 415-555-0142 built with v22.11.0.3, db at 127.0.0.1:5432, client ip:[redacted], support [redacted]' + ) + }, + }, + { + scenario: 'turning the option off keeps personal data in payloads but not in intent', + options: { redactPayloadPersonalData: false }, + args: { email: 'ada@example.com', context: 'look up ada@example.com' }, + result: async () => ({ content: [{ type: 'text' as const, text: 'Customer ada@example.com' }] }), + check: (call: Properties) => { + expect(text(call.$mcp_parameters)).toContain('ada@example.com') + expect(text(call.$mcp_response)).toContain('ada@example.com') + expect(call.$mcp_intent).toBe('look up [redacted]') + }, + }, + { + scenario: 'personal data in error text and exceptions is redacted by default', + options: {}, + args: {}, + result: async (): Promise => { + throw new Error('No account for ada@example.com') + }, + check: (call: Properties, everything: unknown) => { + expect(call.$mcp_error_message).toBe('No account for [redacted]') + expect(text(everything)).not.toContain('ada@example.com') + }, + }, + ])('$scenario', async ({ options, args, result, check }) => { + const { server, client, cleanup } = await setupTestServerAndClient() + const eventCapture = new EventCapture() + await eventCapture.start() + + try { + const { instrument } = await import('../index') + await instrument(server, fakePostHog(), { enableConversationId: false, ...options }) + server.registerTool( + 'lookup', + { inputSchema: { email: z.string().optional(), limit: z.number().optional() } }, + result as never + ) + + await client.request({ method: 'tools/call', params: { name: 'lookup', arguments: args } }, CallToolResultSchema) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const [call] = eventCapture.findCapturesByEvent('$mcp_tool_call') + check(call.properties, eventCapture.getCaptures()) + } finally { + await eventCapture.stop() + await cleanup() + } + }) +}) diff --git a/packages/mcp/src/__tests__/feedback.test.ts b/packages/mcp/src/__tests__/feedback.test.ts index 1203caff7a..72dede50ab 100644 --- a/packages/mcp/src/__tests__/feedback.test.ts +++ b/packages/mcp/src/__tests__/feedback.test.ts @@ -508,18 +508,44 @@ describe('collectFeedback (send_feedback virtual tool)', () => { feedback_type: 'issue', summary: 'A tool failed.', reporter: 'jane@example.com', - meta: { contact: 'john@example.com' }, + meta: { contact: 'john@example.com', 'ada@example.com': 'vip' }, }) await new Promise((r) => setTimeout(r, 50)) const p = capture.findCapturesByEvent(PostHogMCPAnalyticsEvent.Feedback)[0].properties expect(p.$mcp_feedback_reporter).toBe('[redacted]') expect(p.$mcp_feedback_meta).not.toContain('john@example.com') + expect(p.$mcp_feedback_meta).not.toContain('ada@example.com') expect(p.$mcp_feedback_meta).toContain('[redacted]') await capture.stop() }) + it.each([ + ['a card', { 'card\n4111111111111111': 1 }, '4111111111111111'], + ['an SSN', { 'ssn\n078-05-1120': 1 }, '078-05-1120'], + ['a public IPv4', { 'ip\n203.0.113.77': 1 }, '203.0.113.77'], + ['a phone', { 'tel\n+1 415 555 0177': 1 }, '555 0177'], + ['an IPv6', { nested: { 'x\t2001:db8::7334': 1 } }, '2001:db8::7334'], + ['a card-number integer', { n: 4111111111111111 }, '4111111111111111'], + ])( + 'redacts %s in a stringified extra after a JSON escape, keeping valid JSON', + async (_label, meta, identifier) => { + const capture = new EventCapture() + await capture.start() + instrument(server, fakePostHog(), { collectFeedback: { extraProperties: { meta: { type: 'object' } } } }) + + await callTool(client, SEND_FEEDBACK, { feedback_type: 'issue', summary: 'A tool failed.', meta }) + + await new Promise((r) => setTimeout(r, 50)) + const captured = capture.findCapturesByEvent(PostHogMCPAnalyticsEvent.Feedback)[0].properties.$mcp_feedback_meta + expect(captured).not.toContain(identifier) + expect(() => JSON.parse(captured as string)).not.toThrow() + + await capture.stop() + } + ) + it('does not log the agent-supplied summary', async () => { const logger = vi.fn() instrument(server, fakePostHog(), { collectFeedback: true, logger }) diff --git a/packages/mcp/src/__tests__/mcp-payloads.test.ts b/packages/mcp/src/__tests__/mcp-payloads.test.ts index c156e4a6fd..6da0572545 100644 --- a/packages/mcp/src/__tests__/mcp-payloads.test.ts +++ b/packages/mcp/src/__tests__/mcp-payloads.test.ts @@ -1,13 +1,20 @@ import { buildCapturedMcpParameters, + redactPayloadPii, redactPii, sanitizeCapturedValue, + sanitizeFreeText, sanitizeFreeTextValue, } from '../extensions/mcp-payloads' +import { sanitizeEvent } from '../extensions/sanitization' +import type { McpEvent } from '../types' describe('buildCapturedMcpParameters', () => { + const captured = (request: unknown): unknown => + sanitizeEvent({ parameters: buildCapturedMcpParameters(request) } as McpEvent).parameters + it('captures useful tool-call inputs without transport internals or duplicated intent', () => { - const parameters = buildCapturedMcpParameters({ + const parameters = captured({ id: 102, jsonrpc: '2.0', method: 'tools/call', @@ -45,8 +52,63 @@ describe('buildCapturedMcpParameters', () => { }) }) - it('redacts PostHog tokens from captured string values', () => { + it('keeps a parsed __proto__ argument', () => { + const request = JSON.parse('{"method":"tools/call","params":{"name":"t","arguments":{"__proto__":{"x":1}}}}') + + expect(JSON.stringify(buildCapturedMcpParameters(request))).toBe( + '{"request":{"method":"tools/call","params":{"name":"t","arguments":{"__proto__":{"x":1}}}}}' + ) + }) + + it.each([100, 100_000])('reads no more than the entry limit of %i arguments', (count) => { + let reads = 0 + const args: Record = {} + for (let index = 0; index < count; index++) { + Object.defineProperty(args, `k${index}`, { + enumerable: true, + get: () => (reads++, `customer ${index} order ORD-${index}`), + }) + } + + buildCapturedMcpParameters({ method: 'tools/call', params: { name: 't', arguments: args } }) + + expect(reads).toBeLessThanOrEqual(200) + }) + + const realArguments = (count: number): [string, number][] => + Array.from({ length: count }, (_, index) => [`k${index}`, index]) + + it.each([ + ['first', 100], + ['first', 101], + ['in the middle', 100], + ['last', 100], + ['last', 101], + ['absent', 100], + ['absent', 101], + ])('keeps the first 100 real arguments with context %s among %i', (position, count) => { + const real = realArguments(count) + const context: [string, string][] = position === 'absent' ? [] : [['context', 'why']] + const entries = + position === 'first' + ? [...context, ...real] + : position === 'last' + ? [...real, ...context] + : [...real.slice(0, 50), ...context, ...real.slice(50)] + const parameters = buildCapturedMcpParameters({ + method: 'tools/call', + params: { name: 't', arguments: Object.fromEntries(entries) }, + }) as { request: { params: { arguments: Record } } } + + expect(parameters.request.params.arguments).toEqual({ + ...Object.fromEntries(real.slice(0, 100)), + ...(count > 100 ? { '...': '[MaxProperties ~]' } : {}), + }) + }) + + it('redacts PostHog tokens from captured string values', () => { + const parameters = captured({ method: 'tools/call', params: { name: 'projects-get', @@ -413,7 +475,7 @@ describe('URL credential redaction', () => { const html = `

Hi

logo` const authoritySearch = vi.spyOn(String.prototype, 'matchAll') try { - expect(sanitizeCapturedValue(html)).toBe(html) + expect(sanitizeCapturedValue(html)).toBe('

Hi

{ + expect(redactPayloadPii(input)).toBe(expected) + }) + + it.each([ + ['a US phone without a prefix or parentheses', 'Order 415-555-0142'], + ['a version after a letter', 'built with v22.11.0.3'], + ['a five-part version', 'release 1.2.3.4.5'], + ['an address followed by a digit-led field', 'release 203.0.113.7.4'], + ['a public IPv4 directly after a letter', 'IP203.0.113.7'], + ['a loopback address with a port', 'db at 127.0.0.1:5432'], + ['a 10/8 address', 'host 10.20.30.40'], + ['a 172.16/12 address', 'host 172.31.255.1'], + ['a 192.168/16 address', 'host 192.168.1.10'], + ['a link-local address', 'metadata 169.254.169.254'], + ['the unspecified address', 'bind 0.0.0.0'], + ['a Luhn-invalid long digit run', 'order 1234567890123456'], + ['a date and time', 'at 2024-01-15 12:30 UTC'], + ['a float whose fraction passes the card checksum', '{"v":710.4032039642334}'], + ['a fraction of 16 digits', '[0.4111111111111111]'], + ['a negative card-shaped number', '[-4111111111111111]'], + ['a card-shaped number with a fraction', '[4111111111111111.5]'], + ['a card grouped with dots', 'ref 4111.1111.1111.1111'], + ['a negative card-shaped JSON member', '{"n":-4111111111111111}'], + ['a fraction starting 011', '[620.0119256973267]'], + ['a fraction starting 00', '[0.0012345678901]'], + ['two dates', '2024-08-12 2022-03-03'], + ['two dates whose tail passes the card checksum', '2027-03-14 2020-08-20'], + ['two US phone-shaped numbers', '385-353-6556 200-426-7992'], + ['two US phone-shaped numbers, the first passing the checksum', '813-545-4304 607-403-3631'], + ['a US phone-shaped number ending in a zero-padded group', '874-426-0063 247-900-7184'], + ['six-digit ids', '769677 908755 886773'], + ['ZIP+4 codes', '98432-1669 62609-6852'], + ])('keeps %s', (_label, input) => { + expect(redactPayloadPii(input)).toBe(input) + }) + + describe('ordinary number lists', () => { + let seed = 99 + const random = (low: number, high: number): number => + low + Math.floor(((seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31) * (high - low + 1)) + const pad = (value: number, length: number): string => String(value).padStart(length, '0') + const date = (): string => `${random(2015, 2027)}-${pad(random(1, 12), 2)}-${pad(random(1, 28), 2)}` + const phone = (): string => `${random(200, 999)}-${random(200, 999)}-${pad(random(0, 9999), 4)}` + + it.each([ + ['date pairs', () => `${date()} ${date()}`], + ['US phone-shaped pairs', () => `${phone()} ${phone()}`], + ['six-digit id lists', () => `${random(100000, 999999)} ${random(100000, 999999)} ${random(100000, 999999)}`], + [ + 'ZIP+4 lists', + () => `${random(10000, 99999)}-${random(1000, 9999)} ${random(10000, 99999)}-${random(1000, 9999)}`, + ], + ])('keeps every one of 2000 %s', (_label, generate) => { + const lists = Array.from({ length: 2000 }, generate) + + expect(lists.filter((list) => redactPayloadPii(list) !== list)).toEqual([]) + }) + }) + + it.each([ + ['a US phone without a prefix', 'Order 415-555-0142'], + ['a loopback address', 'db at 127.0.0.1'], + ])('leaves the intent pass redacting %s', (_label, input) => { + expect(redactPii(input)).not.toBe(input) + }) +}) + +describe('card numbers', () => { + it.each([ + ['grouped 4-4-4-3', 'amex 3782 8224 6310 005', 'amex [redacted]', 'amex 3782 8224 6310 005'], + ['grouped 4-4-5', 'visa 4222 2222 22222', 'visa [redacted]', 'visa 4222 2222 22222'], + ['grouped 4-4-4-1', 'visa 4222 2222 2222 2', 'visa [redacted]', 'visa 4222 2222 2222 2'], + ['grouped 5-5-5-1', 'pan 41111 11111 11111 1', 'pan [redacted]', 'pan 41111 11111 11111 1'], + ['grouped in pairs', 'pan 41 11 11 11 11 11 11 11', 'pan [redacted]', 'pan 41 11 11 11 11 11 11 11'], + ['grouped 4-4-4-4-3', 'pan 6212 3456 7890 1234 569', 'pan [redacted]', 'pan [redacted]'], + ['grouped 4-6-5', 'amex 3782 822463 10005', 'amex [redacted]', 'amex [redacted]'], + ['grouped 4-6-4', 'diners 3056 930902 5904', 'diners [redacted]', 'diners [redacted]'], + [ + 'grouped 4-4-4-4 after a leading group', + 'ref 1234 4111 1111 1111 1111', + 'ref 1234 [redacted]', + 'ref 1234 [redacted]', + ], + [ + 'grouped 4-4-4-4 and followed by an SSN', + 'card 4111 1111 1111 1111 078-05-1120', + 'card [redacted] [redacted]', + 'card [redacted] [redacted]', + ], + ['glued to a word by a dash', 'card-4111111111111111', 'card-[redacted]', 'card-[redacted]'], + ['glued to a word by a dot', 'No.4111111111111111', 'No.[redacted]', 'No.[redacted]'], + ['grouped by dashes and glued by one', 'ref-4111-1111-1111-1111', 'ref-[redacted]', 'ref-[redacted]'], + [ + 'followed by an SSN', + 'card 4111111111111111 078-05-1120', + 'card [redacted] [redacted]', + 'card [redacted] [redacted]', + ], + [ + 'grouped by mixed separators with a dot', + 'card 4111-1111 1111.1111 ok', + 'card [redacted] ok', + 'card 4111-1111 1111.1111 ok', + ], + ['grouped by slashes', 'card 4111/1111/1111/1111', 'card [redacted]', 'card 4111/1111/1111/1111'], + ['with a fraction', 'cards 4111111111111111.1 x', 'cards [redacted].1 x', 'cards 4111111111111111.1 x'], + ])('%s: %s', (_label, input, intent, payload) => { + expect(redactPii(input)).toBe(intent) + expect(redactPayloadPii(input)).toBe(payload) + }) + + it.each([ + ['escaped line breaks', '{"pan":"4111\\n1111\\n1111\\n1111"}'], + ['line breaks', '4111\n1111\n1111\n1111'], + ['an escaped line break inside a phone number', '{"phone":"+44\\n20 7946 0958"}'], + ])('does not join digit groups across %s', (_label, input) => { + expect(redactPii(input)).toBe(input) + expect(redactPayloadPii(input)).toBe(input) + }) +}) + +describe('JSON text', () => { + const passes = [ + ['payload', (value: unknown): unknown => sanitizeCapturedValue(value, { redactPayloadPersonalData: true })], + ['intent', sanitizeFreeTextValue], + ] as const + const identifiers = [ + ['an email', 'ada@example.com'], + ['a card', '4111111111111111'], + ['an SSN', '078-05-1120'], + ['a public IPv4', '203.0.113.7'], + ['an IPv6', '2001:db8::7334'], + ['a phone', '+1 415 555 0142'], + ['a PostHog token', 'phc_123456789012345678901234567890'], + ] as const + const escapes = ['\\n', '\\t', '\\r', '\\b', '\\f', '\\/', '\\\\', '\\"', '\\u00e9'] + const containers = [ + ['plain', (json: string) => json], + ['embedded in prose', (json: string) => `Result: ${json}`], + ['NDJSON', (json: string) => `{"a":1}\n${json}`], + ['after a BOM', (json: string) => `\uFEFF${json}`], + ] as const + const member = (escape: string, value: string): string => `{"note":"x${escape}${value}","x${escape}${value}":1}` + + describe.each(passes)('through the %s pass', (_pass, sanitize) => { + it.each( + identifiers.flatMap(([label, identifier]) => + escapes + // An escaped letter reads as part of the address after it; see the escaped characters below. + .filter((escape) => !(label === 'an email' && escape === '\\u00e9')) + .flatMap((escape) => containers.map(([name, wrap]) => [label, escape, name, identifier, wrap] as const)) + ) + )('redacts %s after %s, %s, in place', (_label, escape, _container, identifier, wrap) => { + expect(sanitize(wrap(member(escape, identifier)))).toBe(wrap(member(escape, '[redacted]'))) + }) + + it.each([ + ['an address with an escaped @', '{"e":"ada\\u0040example.com"}', '{"e":"[redacted]"}'], + ['an address with an escaped dot', '{"e":"ada@example\\u002ecom"}', '{"e":"[redacted]"}'], + ['an address with an escaped last letter', '{"e":"ada@example.co\\u006d"}', '{"e":"[redacted]"}'], + ['an address with an escaped accented letter', '{"e":"jos\\u00e9@example.com"}', '{"e":"[redacted]"}'], + ['an address after an escaped letter, with it', '{"e":"x\\u00e9ada@example.com"}', '{"e":"[redacted]"}'], + ['an address after an escaped line break', '{"e":"x\\u000aada@example.com"}', '{"e":"x\\u000a[redacted]"}'], + ['a card grouped by escaped spaces', '{"c":"4111\\u00201111\\u00201111\\u00201111"}', '{"c":"[redacted]"}'], + ['a card grouped by escaped NNBSP', '{"c":"4111\\u202f1111\\u202f1111\\u202f1111"}', '{"c":"[redacted]"}'], + ['a card grouped by escaped dashes', '{"c":"4111\\u002d1111\\u002d1111\\u002d1111"}', '{"c":"[redacted]"}'], + ['a card before an escaped letter', '{"c":"4111111111111111\\u00e9"}', '{"c":"[redacted]\\u00e9"}'], + ['an SSN grouped by escaped dashes', '{"s":"078\\u002d05\\u002d1120"}', '{"s":"[redacted]"}'], + ['a phone grouped by escaped spaces', '{"p":"+1\\u0020415\\u0020555\\u00200142"}', '{"p":"[redacted]"}'], + ['a phone grouped by an escaped NBSP', '{"p":"+44\\u00a020 7946 0958"}', '{"p":"[redacted]"}'], + ['a public IPv4 with an escaped dot', '{"i":"203.0.113\\u002e7"}', '{"i":"[redacted]"}'], + ['a PostHog token with an escaped letter', '{"t":"phc_\\u0061bcdefghijklmnopqrstuvwxyz"}', '{"t":"[redacted]"}'], + ['a card after an escaped quote', '{"a":"\\u0022","n":4111111111111111}', '{"a":"\\u0022","n":"[redacted]"}'], + ])('redacts %s, keeping the JSON valid', (_label, text, expected) => { + const sanitized = sanitize(text) as string + + expect(sanitized).toBe(expected) + expect(() => JSON.parse(sanitized)).not.toThrow() + }) + + it.each([ + ['a card with a zero fraction', '{"n":4111111111111111.0}'], + ['a card with an exponent', '[4111111111111111e0]'], + ['a card written with a fraction and an exponent', '[41111111111111110e-1]'], + ['a 19-digit card with a zero fraction', '[6212345678901234569.00]'], + ])('replaces %s written as a JSON number whole', (_label, text) => { + const sanitized = sanitize(text) as string + const parsed: unknown = JSON.parse(sanitized) + + expect(JSON.stringify(parsed)).not.toMatch(/\d{13}/) + expect(sanitized).toContain('"[redacted]"') + }) + + it.each([2, 4, 10])('quotes a card nested 7 levels deep in JSON indented by %i', (indent) => { + let value: unknown = [4111111111111111] + for (let level = 0; level < 6; level++) { + value = { level: value } + } + + expect(JSON.parse(sanitize(JSON.stringify(value, null, indent)) as string)).toEqual( + JSON.parse(JSON.stringify(value).replace('4111111111111111', '"[redacted]"')) + ) + }) + + it('reads a text that is only a card number as prose, leaving it unquoted', () => { + expect(sanitize('4111111111111111')).toBe('[redacted]') + }) + + it.each(escapes.flatMap((escape) => containers.map(([name, wrap]) => [escape, name, wrap] as const)))( + 'keeps text with %s and nothing to redact byte for byte, %s', + (escape, _container, wrap) => { + const text = wrap(`{"note":"x${escape}y 12.5${escape}4111","x${escape}":[-1.5e3]}`) + + expect(sanitize(text)).toBe(text) + } + ) + + it.each([ + ['duplicate keys', '{"email":"ada@example.com","email":null}', '{"email":"[redacted]","email":null}'], + ['a 17-digit card integer', '{"pan":62123456789012347}', '{"pan":"[redacted]"}'], + ['a 19-digit card integer', '[6212345678901234569, 1]', '["[redacted]", 1]'], + ['a card after a BOM', '\uFEFF{"n":4111111111111111}', '\uFEFF{"n":"[redacted]"}'], + [ + 'a __proto__ member', + '{"__proto__":{"email":"ada@example.com"},"y":2}', + '{"__proto__":{"email":"[redacted]"},"y":2}', + ], + ['a card between commas inside a string', '{"note":"a, 4111111111111111, b"}', '{"note":"a, [redacted], b"}'], + [ + 'pretty-printed JSON, keeping its formatting', + JSON.stringify({ card: 4111111111111111, note: 'x\nada@example.com', count: 3 }, null, 2), + JSON.stringify({ card: '[redacted]', note: 'x\n[redacted]', count: 3 }, null, 2), + ], + [ + 'JSON nested deeper than any walk', + `${'['.repeat(50_000)}"ada@example.com"${']'.repeat(50_000)}`, + `${'['.repeat(50_000)}"[redacted]"${']'.repeat(50_000)}`, + ], + ])('redacts %s in place', (_label, text, expected) => { + expect(sanitize(text)).toBe(expected) + }) + + it.each([ + ['a __proto__ member', '{"__proto__":{"x":1},"y":2}'], + ['an integer above 2^53', '{"id":9007199254740993,"n":-0}'], + ['pretty-printed JSON with nothing to redact', JSON.stringify({ id: 'ord_1', total: 12.5 }, null, 2)], + ])('keeps %s byte for byte', (_label, text) => { + expect(sanitize(text)).toBe(text) + }) + }) + + it.each([ + ['a float whose fraction passes the card checksum', '{"v":710.4032039642334}'], + ['a fraction of 16 digits', '[0.4111111111111111]'], + ['a negative card-shaped number', '[-4111111111111111]'], + ['a negative card-shaped member', '{"n":-4111111111111111}'], + ['a card-shaped number with a fraction', '[4111111111111111.5]'], + ['a card-shaped number scaled to a fraction', '[4111111111111111e-3]'], + ])('keeps %s byte for byte through the payload pass', (_label, text) => { + expect(sanitizeCapturedValue(text, { redactPayloadPersonalData: true })).toBe(text) + }) + + it('replaces a negative card written as a JSON number whole through the intent pass', () => { + expect(sanitizeFreeTextValue('{"a":-4111111111111111}')).toBe('{"a":"[redacted]"}') + }) + + it.each(escapes)('redacts a PostHog token after %s with the payload option off', (escape) => { + const text = member(escape, 'phc_123456789012345678901234567890') + + expect(sanitizeCapturedValue(text)).toBe(member(escape, '[redacted]')) + }) + + describe('random floats', () => { + const sanitize = (value: unknown): unknown => sanitizeCapturedValue(value, { redactPayloadPersonalData: true }) + let seed = 42 + const random = (): number => (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31 + const floats = Array.from({ length: 10_000 }, () => random() * 1000) + + it('keeps them in short JSON text copies', () => { + const texts = floats.slice(0, 1000).map((v) => JSON.stringify({ v })) + + expect(texts.filter((text) => sanitize(text) !== text)).toEqual([]) + }) + + it('keeps them in a JSON text copy longer than the scanned window', () => { + const text = JSON.stringify(floats) + + expect(sanitize(text)).toBe(`${text.slice(0, 131_072)}...`) + }) + }) +}) + +const presignedUrlLine = `https://bucket.s3.amazonaws.com/a.pdf?X-Amz-Security-Token=${'Z'.repeat(883)}&X-Amz-Signature=${'a'.repeat(64)}\n` +const redactedPresignedUrlLine = + 'https://bucket.s3.amazonaws.com/a.pdf?X-Amz-Security-Token=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D\n' + +describe('sanitizing a multi-megabyte string', () => { + const medianMilliseconds = (run: () => unknown): number => { + const times = Array.from({ length: 5 }, () => { + const start = performance.now() + run() + return performance.now() - start + }) + return times.sort((a, b) => a - b)[2] + } + const sanitizers = [ + ['payload', (value: string) => sanitizeCapturedValue(value, { redactPayloadPersonalData: true })], + ['payload option off', (value: string) => sanitizeCapturedValue(value)], + ['intent', (value: string) => sanitizeFreeText(value)], + ] as const + const values = [ + ['digits joined by - and .', (length: number) => '1-1.'.repeat(length / 4)], + ['comma-separated ids', (length: number) => '1000000,'.repeat(length / 8)], + [ + 'a URL whose userinfo and token sit past the window', + (length: number) => `https:secretuser${'a'.repeat(length)}@localhost/?api_key=phc_123456789012345678901234567890`, + ], + [ + 'pre-signed URL lines that redaction shrinks tenfold', + (length: number) => presignedUrlLine.repeat(Math.ceil(length / presignedUrlLine.length)), + ], + ] as const + + it.each( + sanitizers.flatMap(([name, sanitize]) => values.map(([label, value]) => [name, label, value, sanitize] as const)) + )('costs the %s pass on 8MB of %s about what it costs on 1MB', (_name, _label, value, sanitize) => { + const small = value(1_000_000) + const large = value(8_000_000) + sanitize(small) + + expect(medianMilliseconds(() => sanitize(large)) / medianMilliseconds(() => sanitize(small))).toBeLessThan(3) + }) + + it.each([ + ['redactPii', redactPii], + ['redactPayloadPii', redactPayloadPii], + ['the intent pass', sanitizeFreeText], + ] as const)('%s does not overflow the stack on 12MB of digits joined by - and .', (_name, redact) => { + expect(() => redact('1-1.'.repeat(3_000_000))).not.toThrow() + }) +}) + describe('sanitizing strings longer than truncation keeps', () => { const WINDOW = 131_072 const token = 'phc_123456789012345678901234567890' const filler = (length: number): string => 'lorem ipsum '.repeat(Math.ceil(length / 12)).slice(0, length) it.each([ - ['plain text', filler(5_000_000), `${filler(WINDOW - 2)}...`], - ['a token cut at the window edge', filler(WINDOW - 32) + token, `${filler(WINDOW - 32)}...`], + ['plain text', filler(5_000_000), `${filler(WINDOW)}...`], + ['a token cut at the window edge', filler(WINDOW - 32) + token, `${filler(WINDOW - 32)}[redacted]...`], [ 'a credential URL cut at the window edge', `${filler(WINDOW - 44)}https://example.com/?api_key=secretsecretsecretsecret more`, - `${filler(WINDOW - 44)}...`, + `${filler(WINDOW - 44)}[redacted]...`, ], [ 'an attachment', @@ -552,28 +1015,54 @@ describe('sanitizing strings longer than truncation keeps', () => { `${filler(12)}a. ${`${token} `.repeat(4_000)}and more`, `${filler(12)}a. ${'[redacted] '.repeat(4_000)}and more`, ], - // A URL running past the head is judged whole: its credentials and field count can sit beyond the cut. + // A URL running past the window is replaced whole: its credentials and field count can sit beyond the cut. [ - 'a URL whose credentials sit past the head', + 'a URL whose credentials sit past the window', `${filler(996)}https:secretuser${'a'.repeat(WINDOW)}@localhost/path`, - `${filler(996)}https://%5Bredacted%5D@localhost/path`, + `${filler(996)}[redacted]...`, + ], + [ + 'a URL opening the value whose userinfo and token sit past the window', + `https:secretuser${'a'.repeat(WINDOW)}@localhost/path?api_key=${token}`, + '[redacted]...', ], [ - 'a URI whose field count passes the limit past the head', + 'a URI whose field count passes the limit past the window', `${filler(996)}resource:private/${'p'.repeat(WINDOW)}?${'a=1&'.repeat(200)}`, - `${filler(996)}[redacted]`, + `${filler(996)}[redacted]...`, ], [ 'pre-signed URLs', `https://bucket.s3.amazonaws.com/a.pdf?X-Amz-Security-Token=${'Z'.repeat(1_200)}&X-Amz-Signature=${'a'.repeat(64)}\n`.repeat( 200 ), - 'https://bucket.s3.amazonaws.com/a.pdf?X-Amz-Security-Token=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D\n'.repeat( - 200 - ), + `${'https://bucket.s3.amazonaws.com/a.pdf?X-Amz-Security-Token=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D\n'.repeat( + 97 + )}[redacted]...`, + ], + // Redacting shrinks these tenfold, so even the largest window keeps less than twice what truncation keeps. + [ + 'pre-signed URL lines past the largest window', + presignedUrlLine.repeat(1_000), + `${redactedPresignedUrlLine.repeat(4 * 128).slice(0, -32_768)}...`, ], ])('scans only the window truncation can keep: %s', (_case, value, expected) => { expect(sanitizeCapturedValue(value)).toBe(expected) + expect(sanitizeCapturedValue(value, { redactPayloadPersonalData: true })).toBe(expected) expect(sanitizeFreeTextValue(value)).toBe(expected) }) + + it.each([ + ['payload', (value: string) => sanitizeCapturedValue(value, { redactPayloadPersonalData: true })], + ['intent', (value: string) => sanitizeFreeTextValue(value)], + ] as const)( + 'drops a match cut at the widest window edge when redaction shrinks the head below what truncation keeps: %s', + (_name, sanitize) => { + const tokenCount = Math.floor((4 * WINDOW - 12) / 305) + const padding = ' '.repeat(4 * WINDOW - 12 - 305 * tokenCount) + const value = `${`phc_${'a'.repeat(300)} `.repeat(tokenCount)}${padding}john.doe@example.com and more` + + expect(sanitize(value)).toBe('...') + } + ) }) diff --git a/packages/mcp/src/__tests__/posthog-mcp.test.ts b/packages/mcp/src/__tests__/posthog-mcp.test.ts index e1d3759b80..e4650fdbdb 100644 --- a/packages/mcp/src/__tests__/posthog-mcp.test.ts +++ b/packages/mcp/src/__tests__/posthog-mcp.test.ts @@ -253,6 +253,21 @@ describe('PostHogMCP', () => { } }) + it.each([ + [{}, '[redacted]'], + [{ redactPayloadPersonalData: false }, 'ada@example.com'], + ])('applies the payload personal data pass to manual captures with options %j', async (options, email) => { + const client = newClient(options) + try { + client.captureToolCall({ toolName: 'lookup', distinctId: 'user-123', parameters: { email: 'ada@example.com' } }) + await tick() + + expect(onlyCapture(PostHogMCPAnalyticsEvent.ToolCall).properties.$mcp_parameters).toEqual({ email }) + } finally { + await client.shutdown() + } + }) + it.each([ { name: 'trims a direct model value', diff --git a/packages/mcp/src/__tests__/sanitization.test.ts b/packages/mcp/src/__tests__/sanitization.test.ts index e0586ea22c..ba6d79b803 100644 --- a/packages/mcp/src/__tests__/sanitization.test.ts +++ b/packages/mcp/src/__tests__/sanitization.test.ts @@ -492,14 +492,17 @@ describe('sanitizeEvent - exception values', () => { describe('sanitizeEvent - resource name', () => { // `$identify` names its request the same way a read does, so gating redaction // on the read event type used to publish the raw address alongside the person. - it.each(['mcp:resources/read', 'identify', '$exception'])( - 'redacts credentials from a %s resource name', - (eventType) => { - const event = makeEvent({ eventType, resourceName: 'https://fakeuser:fakepass@example.com/guide' }) + // On a read the personal data pass runs first and takes `fakepass@example.com` + // for an email, so the URL no longer parses and is redacted whole. + it.each([ + ['mcp:resources/read', '[redacted]'], + ['identify', 'https://%5Bredacted%5D@example.com/guide'], + ['$exception', 'https://%5Bredacted%5D@example.com/guide'], + ])('redacts credentials from a %s resource name', (eventType, expected) => { + const event = makeEvent({ eventType, resourceName: 'https://fakeuser:fakepass@example.com/guide' }) - expect(sanitizeEvent(event).resourceName).toBe('https://%5Bredacted%5D@example.com/guide') - } - ) + expect(sanitizeEvent(event).resourceName).toBe(expected) + }) }) describe('sanitizeEvent - intent PII redaction', () => { @@ -558,22 +561,25 @@ describe('sanitizeEvent - intent PII redaction', () => { ) }) - it('does not redact the same PII shapes from structured parameters or responses', () => { - const event = makeEvent({ - userIntent: 'Enriching the profile for dave@example.com from the CRM.', - parameters: { email: 'dave@example.com', ip: '203.0.113.42' }, - response: { content: [{ type: 'text', text: 'Matched dave@example.com at 203.0.113.42.' }] }, - }) - - const result = sanitizeEvent(event) - - expect(result.userIntent).toBe('Enriching the profile for [redacted] from the CRM.') - // Structured tool data keeps the same shapes: they are often legitimate here. - expect(result.parameters).toEqual({ email: 'dave@example.com', ip: '203.0.113.42' }) - expect((result.response as { content: { text: string }[] }).content[0].text).toBe( - 'Matched dave@example.com at 203.0.113.42.' - ) - }) + it.each([ + [true, '[redacted]', 'Matched [redacted] at [redacted].'], + [false, 'dave@example.com', 'Matched dave@example.com at 203.0.113.42.'], + ])( + 'redacts the intent whatever the payload option, and payloads only when it is on (%s)', + (redactPayloadPersonalData, email, text) => { + const event = makeEvent({ + userIntent: 'Enriching the profile for dave@example.com from the CRM.', + parameters: { email: 'dave@example.com', limit: 5 }, + response: { content: [{ type: 'text', text: 'Matched dave@example.com at 203.0.113.42.' }] }, + }) + + const result = sanitizeEvent(event, { redactPayloadPersonalData }) + + expect(result.userIntent).toBe('Enriching the profile for [redacted] from the CRM.') + expect(result.parameters).toEqual({ email, limit: 5 }) + expect((result.response as { content: { text: string }[] }).content[0].text).toBe(text) + } + ) it('does not mutate the original intent string', () => { const original = 'Paging on-call about ticket from user@example.com right now.' @@ -585,6 +591,75 @@ describe('sanitizeEvent - intent PII redaction', () => { }) }) +describe('sanitizeEvent - payload personal data', () => { + const accounts = '{"accounts":{"ada@example.com":{"plan":"pro"}},"card":4111111111111111,"count":3}' + + it('redacts a personal key and a card number and keeps the JSON text copy valid', () => { + const event = makeEvent({ + response: { content: [{ type: 'text', text: accounts }], structuredContent: JSON.parse(accounts) }, + }) + + const response = sanitizeEvent(event).response as { content: { text: string }[]; structuredContent: unknown } + + const redacted = { accounts: { '[redacted]': { plan: 'pro' } }, card: '[redacted]', count: 3 } + expect(response.structuredContent).toEqual(redacted) + expect(JSON.parse(response.content[0].text)).toEqual(redacted) + }) + + it.each([ + [{ 'bob@example.com': 'second', 'ada@example.com': 'first' }], + [{ 'ada@example.com': 'first', 'bob@example.com': 'second' }], + ])('keeps the entry whose received key sorts first when redacted keys collide (%j)', (parameters) => { + expect(sanitizeEvent(makeEvent({ parameters })).parameters).toEqual({ '[redacted]': 'first' }) + }) + + it.each([ + ['a non-card number', 1234567890123456], + ['a small number', 42], + ['a boolean', true], + ])('keeps %s', (_label, value) => { + expect(sanitizeEvent(makeEvent({ parameters: { value } })).parameters).toEqual({ value }) + }) + + it.each([ + [true, 'No account for [redacted]', 'file:///users/[redacted]/notes.md'], + [false, 'No account for ada@example.com', 'file:///users/ada@example.com/notes.md'], + ])( + 'applies to exception values and a read resource URI only when on (%s)', + (redactPayloadPersonalData, value, uri) => { + const event = makeEvent({ + eventType: 'mcp:resources/read', + resourceName: 'file:///users/ada@example.com/notes.md', + isError: true, + error: { + $exception_list: [ + { type: 'Error', value: 'No account for ada@example.com', mechanism: { type: 'generic', handled: true } }, + ], + $exception_level: 'error', + }, + }) + + const result = sanitizeEvent(event, { redactPayloadPersonalData }) + + expect(result.error?.$exception_list[0].value).toBe(value) + expect(result.resourceName).toBe(uri) + } + ) + + it('redacts an address split by the exception value limit whole', () => { + const value = `${'x'.repeat(2_040)} ada@example.com ${'y'.repeat(5_000)}` + const event = makeEvent({ + isError: true, + error: { + $exception_list: [{ type: 'Error', value, mechanism: { type: 'generic', handled: true } }], + $exception_level: 'error', + }, + }) + + expect(sanitizeEvent(event).error?.$exception_list[0].value).not.toContain('ada@') + }) +}) + describe('sanitizeEvent - integration', () => { it('should sanitize both parameters and response in a full event', () => { const largeBase64 = makeLargeBase64() diff --git a/packages/mcp/src/__tests__/sink.test.ts b/packages/mcp/src/__tests__/sink.test.ts index 89645b0518..63ff5225a2 100644 --- a/packages/mcp/src/__tests__/sink.test.ts +++ b/packages/mcp/src/__tests__/sink.test.ts @@ -1,6 +1,7 @@ import type { EventMessage, PostHog } from 'posthog-node' -import { McpEventSink } from '../extensions/sink' +import { buildCapturedMcpParameters } from '../extensions/mcp-payloads' +import { McpEventSink, processMcpEvent } from '../extensions/sink' import { MCPAnalyticsEventType } from '../extensions/event-types' import type { McpEvent } from '../types' @@ -63,3 +64,126 @@ describe('McpEventSink groups forwarding', () => { expect('groups' in calls[0]).toBe(false) }) }) + +describe('processMcpEvent cost', () => { + const rowsEvent = (rows: number): McpEvent => ({ + eventType: MCPAnalyticsEventType.mcpToolsCall, + sessionId: 'session-1', + timestamp: new Date(), + parameters: {}, + response: { + content: [{ type: 'text', text: 'ok' }], + structuredContent: { + rows: Array.from( + { length: rows }, + (_, i) => `customer ${i} order ORD-${i} status shipped 2026-11-06T12:00:00Z` + ), + }, + }, + }) + + async function medianMs(event: McpEvent): Promise { + const options = { enableExceptionAutocapture: true, redactPayloadPersonalData: true } + await processMcpEvent(event, options, () => {}) + const runs: number[] = [] + for (let run = 0; run < 5; run++) { + const start = performance.now() + await processMcpEvent(event, options, () => {}) + runs.push(performance.now() - start) + } + return runs.sort((left, right) => left - right)[2] + } + + it('applies the entry limit before sanitizing, so entries past it cost nothing', async () => { + const atLimit = await medianMs(rowsEvent(100)) + const farPastLimit = await medianMs(rowsEvent(100_000)) + + expect(farPastLimit).toBeLessThan(10 * atLimit + 5) + }) +}) + +describe('processMcpEvent __proto__ members', () => { + it.each([ + [ + 'with personal data', + '{"__proto__":{"email":"ada@example.com"},"y":2}', + '{"__proto__":{"email":"[redacted]"},"y":2}', + ], + ['with nothing to redact', '{"__proto__":{"x":1},"y":2}', '{"__proto__":{"x":1},"y":2}'], + ])('keeps a parsed one %s in parameters, structuredContent, and its text copy', async (_label, json, expected) => { + const event: McpEvent = { + eventType: MCPAnalyticsEventType.mcpToolsCall, + sessionId: 'session-1', + timestamp: new Date(), + parameters: JSON.parse(json), + response: { content: [{ type: 'text', text: json }], structuredContent: JSON.parse(json) }, + } + + const processed = await processMcpEvent(event, { + enableExceptionAutocapture: false, + redactPayloadPersonalData: true, + }) + const response = processed!.event.response as { content: { text: string }[]; structuredContent: unknown } + + expect(JSON.stringify(processed!.event.parameters)).toBe(expected) + expect(JSON.stringify(response.structuredContent)).toBe(expected) + expect(response.content[0].text).toBe(expected) + }) +}) + +describe('processMcpEvent identifiers at the scan window edge', () => { + const window = 131_072 + const filler = (length: number): string => { + const unit = `${'x'.repeat(50)}@ex.com ` + return unit.repeat(Math.ceil(length / unit.length)).slice(0, length - 1) + ' ' + } + const process = (event: Partial, redactPayloadPersonalData = true): ReturnType => + processMcpEvent( + { eventType: MCPAnalyticsEventType.mcpToolsCall, sessionId: 'session-1', timestamp: new Date(), ...event }, + { enableExceptionAutocapture: false, redactPayloadPersonalData } + ) + const around = (text: string, needle: string): string | null => { + const index = text.indexOf(needle) + return index < 0 ? null : text.slice(Math.max(0, index - 20), index + needle.length + 20) + } + + it.each([ + ['ada.lovelace@example.com', 12], + ['078-05-1120', 8], + ['4111 1111 1111 1111', 14], + ['+44 20 7946 0958', 8], + ['203.0.113.77', 8], + ])('redacts %s in an argument that the window cuts after %i characters', async (identifier, kept) => { + const argument = `${filler(window - kept)}${identifier} tail` + const parameters = buildCapturedMcpParameters({ + method: 'tools/call', + params: { name: 'echo', arguments: { text: argument } }, + }) + + const processed = await process({ parameters }) + + expect(around(JSON.stringify(processed!.event.parameters), identifier.slice(0, kept))).toBeNull() + }) + + it.each([ + ['response', true], + ['parameters', true], + ['response', false], + ['parameters', false], + ] as const)( + 'cuts an object key in the %s to the key limit, past an address the window splits, with personal data %s', + async (field, redactPayloadPersonalData) => { + const key = `${'k'.repeat(window - 13)} ada.lovelace@example.com` + const payload = { content: [{ type: 'text', text: 'ok' }], [key]: 1 } + + const processed = await process({ [field]: payload }, redactPayloadPersonalData) + + expect( + around(JSON.stringify(processed!.captures.map((capture) => capture.properties)), 'ada.lovelace') + ).toBeNull() + expect( + Math.max(...Object.keys(processed!.event[field] as object).map((name) => name.length)) + ).toBeLessThanOrEqual(256 + 3) + } + ) +}) diff --git a/packages/mcp/src/__tests__/string-method-registration.test.ts b/packages/mcp/src/__tests__/string-method-registration.test.ts index a36a4ab460..1886e5c57a 100644 --- a/packages/mcp/src/__tests__/string-method-registration.test.ts +++ b/packages/mcp/src/__tests__/string-method-registration.test.ts @@ -178,8 +178,9 @@ describe('setRequestHandler with string method names (MCP SDK v2)', () => { it.each([ ['file:///guide.md', 'file:///guide.md', false], - ['https://fakeuser:fakepass@example.com/guide', 'https://%5Bredacted%5D@example.com/guide', false], - ['https://fakeuser:fakepass@example.com/guide', 'https://%5Bredacted%5D@example.com/guide', true], + // The personal data pass reads `fakepass@example.com` as an email before the URL pass runs. + ['https://fakeuser:fakepass@example.com/guide', '[redacted]', false], + ['https://fakeuser:fakepass@example.com/guide', '[redacted]', true], [ 'https://example.com/guide?token=fakesecret&chapter=intro', 'https://example.com/guide?token=%5Bredacted%5D&chapter=intro', diff --git a/packages/mcp/src/__tests__/tool-input.test.ts b/packages/mcp/src/__tests__/tool-input.test.ts index 051070a189..3d884b6bec 100644 --- a/packages/mcp/src/__tests__/tool-input.test.ts +++ b/packages/mcp/src/__tests__/tool-input.test.ts @@ -81,6 +81,22 @@ describe('getToolInputProperties', () => { }) }) + it.each([ + ['personal data', 'ada@example.com'], + ['a PostHog token', 'phc_AbCdEfGhIjKlMnOpQrStUvWx'], + ['URL credentials', 'https://svc:hunter2@internal.test/doc'], + ])('does not record a declared name carrying %s', (_label, name) => { + expect( + getToolInputProperties( + { id: 1, [name]: 1 }, + { properties: { id: {}, [name]: {} } }, + { + shouldRecordInputKey: () => true, + } + ) + ).toEqual({ $mcp_input_keys: ['id', '[redacted]'] }) + }) + describe('inputAliases', () => { const schema = { properties: { id: {}, key: {}, name: {} } } const inputAliases = { id: ['experimentId', 'experiment_id'], key: ['flagKey'], name: ['title'] } diff --git a/packages/mcp/src/extensions/capture.ts b/packages/mcp/src/extensions/capture.ts index 446631a8be..870d742581 100644 --- a/packages/mcp/src/extensions/capture.ts +++ b/packages/mcp/src/extensions/capture.ts @@ -101,6 +101,7 @@ export function captureEvent( return sink.capture(fullEvent, { enableExceptionAutocapture: data.options.enableExceptionAutocapture ?? true, + redactPayloadPersonalData: data.options.redactPayloadPersonalData ?? true, beforeSend: data.options.beforeSend, }) } diff --git a/packages/mcp/src/extensions/feedback.ts b/packages/mcp/src/extensions/feedback.ts index 779e398907..c21cab4e3e 100644 --- a/packages/mcp/src/extensions/feedback.ts +++ b/packages/mcp/src/extensions/feedback.ts @@ -255,7 +255,7 @@ function captureExtraValue(value: unknown): unknown { return sanitized } try { - return truncateFeedbackText(JSON.stringify(sanitized), MAX_FEEDBACK_TEXT_LENGTH) + return truncateFeedbackText(sanitizeFreeText(JSON.stringify(sanitized)), MAX_FEEDBACK_TEXT_LENGTH) } catch { return undefined } diff --git a/packages/mcp/src/extensions/mcp-payloads.ts b/packages/mcp/src/extensions/mcp-payloads.ts index 6c6898119f..fb111d5f79 100644 --- a/packages/mcp/src/extensions/mcp-payloads.ts +++ b/packages/mcp/src/extensions/mcp-payloads.ts @@ -4,17 +4,20 @@ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE import { + redactPayloadPii, redactPii, sanitizeCapturedValue as sanitizeCoreCapturedValue, sanitizeFreeText as sanitizeCoreFreeText, sanitizeFreeTextValue as sanitizeCoreFreeTextValue, - type TextSanitizationOptions, + type CapturedValueOptions, } from '@posthog/core' -import { MAX_STRING_LENGTH, TRUNCATION_SUFFIX } from './truncation' +import { limitEntries, MAX_BREADTH, MAX_KEY_LENGTH, MAX_STRING_LENGTH, TRUNCATION_SUFFIX } from './truncation' const CONTEXT_ARGUMENT_NAME = 'context' -const SANITIZATION_OPTIONS: TextSanitizationOptions = { +const CAPTURED_REQUEST_FIELDS = ['id', 'jsonrpc', 'method', 'params'] +const SANITIZATION_OPTIONS: CapturedValueOptions = { maxStringLength: MAX_STRING_LENGTH, + maxKeyLength: MAX_KEY_LENGTH, truncationSuffix: TRUNCATION_SUFFIX, } @@ -24,62 +27,58 @@ function isRecord(value: unknown): value is JsonRecord { return !!value && typeof value === 'object' && !Array.isArray(value) } -export { redactPii } +export { redactPayloadPii, redactPii } export function sanitizeFreeText(value: string): string { return sanitizeCoreFreeText(value, SANITIZATION_OPTIONS) } -export function sanitizeCapturedValue(value: unknown): unknown { - return sanitizeCoreCapturedValue(value, SANITIZATION_OPTIONS) +export function sanitizeCapturedValue(value: unknown, options?: Partial): unknown { + return sanitizeCoreCapturedValue(value, { ...SANITIZATION_OPTIONS, ...options }) } export function sanitizeFreeTextValue(value: unknown): unknown { return sanitizeCoreFreeTextValue(value, SANITIZATION_OPTIONS) } -function buildCapturedMcpArguments(argumentsValue: unknown): unknown { - if (!isRecord(argumentsValue)) { - return sanitizeCapturedValue(argumentsValue) - } - - const capturedArguments: JsonRecord = {} - for (const [key, value] of Object.entries(argumentsValue)) { - if (key === CONTEXT_ARGUMENT_NAME) { - continue - } - capturedArguments[key] = sanitizeCapturedValue(value) - } - return capturedArguments +/** + * The request as captured: its id, JSON-RPC version, method, and params, without + * the injected `context` argument, which `$mcp_intent` carries. The entry and + * nesting limits apply here, so a call with a huge argument object costs what a + * call at those limits costs. It is not sanitized here: `sanitizeEvent` does that + * once, with the payload personal data option. + */ +export function buildCapturedMcpParameters(request: unknown): JsonRecord { + return limitEntries({ request: pickCapturedFields(request) }) as JsonRecord } -function buildCapturedMcpParams(params: unknown): unknown { - if (!isRecord(params)) { - return sanitizeCapturedValue(params) +function pickCapturedFields(request: unknown): unknown { + if (!isRecord(request)) { + return request } - - const capturedParams: JsonRecord = {} - for (const [key, value] of Object.entries(params)) { - capturedParams[key] = key === 'arguments' ? buildCapturedMcpArguments(value) : sanitizeCapturedValue(value) + const fields = Object.fromEntries( + CAPTURED_REQUEST_FIELDS.filter((key) => key in request).map((key) => [key, request[key]]) + ) + const { params } = fields + if (isRecord(params) && isRecord(params.arguments) && Object.hasOwn(params.arguments, CONTEXT_ARGUMENT_NAME)) { + fields.params = { ...params, arguments: argumentsWithoutContext(params.arguments) } } - return capturedParams + return fields } -export function buildCapturedMcpParameters(request: unknown): JsonRecord { - if (!isRecord(request)) { - return { request: sanitizeCapturedValue(request) } - } - - const capturedRequest: JsonRecord = {} - for (const key of ['id', 'jsonrpc', 'method'] as const) { - if (key in request) { - capturedRequest[key] = sanitizeCapturedValue(request[key]) +/** + * The arguments without `context`, so it does not count toward the entry limit. + * Copies one more than the limit, enough for the limit to see that more remain. + */ +function argumentsWithoutContext(args: JsonRecord): JsonRecord { + const kept: [string, unknown][] = [] + for (const key of Object.keys(args)) { + if (kept.length > MAX_BREADTH) { + break + } + if (key !== CONTEXT_ARGUMENT_NAME) { + kept.push([key, args[key]]) } } - - if ('params' in request) { - capturedRequest.params = buildCapturedMcpParams(request.params) - } - - return { request: capturedRequest } + return Object.fromEntries(kept) } diff --git a/packages/mcp/src/extensions/posthog-mcp.ts b/packages/mcp/src/extensions/posthog-mcp.ts index 7434c6278b..c2dd2e1fcb 100644 --- a/packages/mcp/src/extensions/posthog-mcp.ts +++ b/packages/mcp/src/extensions/posthog-mcp.ts @@ -93,6 +93,8 @@ export interface PostHogMCPOptions extends PostHogOptions { * `llm_model` argument as fallback. On by default; `false` disables it. */ captureModel?: MCPAnalyticsOptions['captureModel'] + /** See {@link MCPAnalyticsOptions.redactPayloadPersonalData}. */ + redactPayloadPersonalData?: boolean /** * Correlate calls with an agent-carried `conversation_id` and a derived * PostHog session id. On by default; `false` leaves schemas, arguments, tool @@ -160,6 +162,7 @@ export class PostHogMCP extends PostHog { readonly #feedbackOptions: CollectFeedbackOptions | undefined readonly #captureModel: MCPAnalyticsOptions['captureModel'] readonly #enableConversationId: boolean + readonly #redactPayloadPersonalData: boolean readonly #serverBuild: string | undefined readonly #analyticsParameterOwnership = new Map() @@ -178,6 +181,7 @@ export class PostHogMCP extends PostHog { } this.#captureModel = options.captureModel this.#enableConversationId = options.enableConversationId ?? true + this.#redactPayloadPersonalData = options.redactPayloadPersonalData ?? true this.#serverBuild = serverBuild applyMcpLibIdentity(this) } @@ -523,7 +527,10 @@ export class PostHogMCP extends PostHog { #emit(event: McpEvent): void { event.serverBuild = this.#serverBuild void this.#sink - .capture(event, { enableExceptionAutocapture: this.options.enableExceptionAutocapture ?? true }) + .capture(event, { + enableExceptionAutocapture: this.options.enableExceptionAutocapture ?? true, + redactPayloadPersonalData: this.#redactPayloadPersonalData, + }) .catch((error) => log(`Warning: PostHogMCP failed to capture event - ${error}`)) } diff --git a/packages/mcp/src/extensions/sanitization.ts b/packages/mcp/src/extensions/sanitization.ts index 51a0572e2e..091c15082a 100644 --- a/packages/mcp/src/extensions/sanitization.ts +++ b/packages/mcp/src/extensions/sanitization.ts @@ -6,8 +6,14 @@ import type { ErrorProperties, Event, McpEvent } from '../types' import { MCPAnalyticsEventType } from './event-types' import { sanitizeCapturedValue, sanitizeFreeText } from './mcp-payloads' +import { limitPayloadEntries, MAX_ERROR_MESSAGE_LENGTH } from './truncation' type SanitizedRecord = Record +type Sanitize = (value: unknown) => unknown + +export interface PayloadSanitizationOptions { + redactPayloadPersonalData: boolean +} function isRecord(value: unknown): value is SanitizedRecord { return !!value && typeof value === 'object' && !Array.isArray(value) @@ -22,15 +28,19 @@ function isRecord(value: unknown): value is SanitizedRecord { * This is a synchronous operation that returns a new object without mutating the original. * It should run after customer redaction in the event pipeline. */ -export function sanitizeEvent(event: T): T { - const result = { ...event } +export function sanitizeEvent( + event: T, + { redactPayloadPersonalData }: PayloadSanitizationOptions = { redactPayloadPersonalData: true } +): T { + const result = limitPayloadEntries(event) + const sanitizePayload: Sanitize = (value) => sanitizeCapturedValue(value, { redactPayloadPersonalData }) if (result.response != null) { - result.response = sanitizeResponse(result.response) + result.response = sanitizeResponse(result.response, sanitizePayload) } if (result.parameters != null) { - result.parameters = sanitizeParameters(result.parameters) + result.parameters = sanitizeParameters(result.parameters, sanitizePayload) } // Every event type, not just `resources/read`: the `$exception` sibling @@ -41,7 +51,10 @@ export function sanitizeEvent(event: T): T { result.resourceName = result.eventType === MCPAnalyticsEventType.mcpUnknownTool ? sanitizeFreeText(result.resourceName) - : (sanitizeCapturedValue(result.resourceName) as string) + : (sanitizeCapturedValue(result.resourceName, { + redactPayloadPersonalData: + redactPayloadPersonalData && result.eventType === MCPAnalyticsEventType.mcpResourcesRead, + }) as string) } // The intent comes straight from an agent-narrated `context` string, so it can @@ -60,7 +73,7 @@ export function sanitizeEvent(event: T): T { } if (result.error != null) { - result.error = sanitizeExceptionValues(result.error) + result.error = sanitizeExceptionValues(result.error, redactPayloadPersonalData) } return result @@ -70,7 +83,7 @@ export function sanitizeEvent(event: T): T { * Sanitizes exception messages before they fan out to both the primary MCP * event's error-message property and the `$exception` sibling. */ -function sanitizeExceptionValues(error: ErrorProperties): ErrorProperties { +function sanitizeExceptionValues(error: ErrorProperties, redactPayloadPersonalData: boolean): ErrorProperties { if (!Array.isArray(error.$exception_list)) { return error } @@ -79,7 +92,10 @@ function sanitizeExceptionValues(error: ErrorProperties): ErrorProperties { ...error, $exception_list: error.$exception_list.map((exception) => ({ ...exception, - value: sanitizeCapturedValue(exception.value) as string, + value: sanitizeCapturedValue(exception.value, { + redactPayloadPersonalData, + maxStringLength: MAX_ERROR_MESSAGE_LENGTH, + }) as string, })), } } @@ -88,28 +104,28 @@ function sanitizeExceptionValues(error: ErrorProperties): ErrorProperties { * Sanitizes response content blocks by replacing non-text content types * with informative redaction messages. */ -function sanitizeResponse(response: unknown): unknown { +function sanitizeResponse(response: unknown, sanitize: Sanitize): unknown { // Sanitize each kept block on its own, so the pass never scans the data of replaced blocks. if (isRecord(response) && Array.isArray(response.content)) { - const result = sanitizeCapturedValue({ ...response, content: [] }) as SanitizedRecord - result.content = response.content.map(sanitizeContentBlock) + const result = sanitize({ ...response, content: [] }) as SanitizedRecord + result.content = response.content.map((block) => sanitizeContentBlock(block, sanitize)) return result } - return sanitizeCapturedValue(response) + return sanitize(response) } /** * Sanitizes a single content block based on its type discriminator. */ -function sanitizeContentBlock(block: unknown): unknown { +function sanitizeContentBlock(block: unknown, sanitize: Sanitize): unknown { if (!isRecord(block)) { - return sanitizeCapturedValue(block) + return sanitize(block) } switch (block.type) { case 'text': case 'resource_link': - return sanitizeCapturedValue(block) + return sanitize(block) case 'image': return { @@ -124,12 +140,12 @@ function sanitizeContentBlock(block: unknown): unknown { } case 'resource': - return sanitizeResourceBlock(block) + return sanitizeResourceBlock(block, sanitize) default: return { type: 'text', - text: `[unsupported content type "${sanitizeCapturedValue(block.type)}" redacted - not supported by PostHog MCP analytics]`, + text: `[unsupported content type "${sanitize(block.type)}" redacted - not supported by PostHog MCP analytics]`, } } } @@ -139,20 +155,20 @@ function sanitizeContentBlock(block: unknown): unknown { * BlobResourceContents (has `blob` field) are redacted. * TextResourceContents (has `text` field) pass through. */ -function sanitizeResourceBlock(block: SanitizedRecord): unknown { +function sanitizeResourceBlock(block: SanitizedRecord, sanitize: Sanitize): unknown { if (isRecord(block.resource) && 'blob' in block.resource) { return { type: 'text', text: '[binary resource content redacted - not supported by PostHog MCP analytics]', } } - return sanitizeCapturedValue(block) + return sanitize(block) } /** * Recursively scans parameters for large base64-encoded strings and replaces them. * Uses a size gate (10KB) to avoid regex testing on small strings. */ -function sanitizeParameters(obj: unknown): unknown { - return sanitizeCapturedValue(obj) +function sanitizeParameters(obj: unknown, sanitize: Sanitize): unknown { + return sanitize(obj) } diff --git a/packages/mcp/src/extensions/sink.ts b/packages/mcp/src/extensions/sink.ts index fe33c0c67b..99fc3ca1eb 100644 --- a/packages/mcp/src/extensions/sink.ts +++ b/packages/mcp/src/extensions/sink.ts @@ -10,11 +10,11 @@ import type { BeforeSendFn, Event, McpEvent } from '../types' import { log, type LoggerFn } from './logger' import { type PostHogCaptureEvent, buildPostHogCaptureEvents } from './posthog-events' import { newPrefixedId } from './ids' -import { sanitizeEvent } from './sanitization' +import { type PayloadSanitizationOptions, sanitizeEvent } from './sanitization' import { truncateEvent } from './truncation' /** Per-event toggles consulted by the sink when fanning out an event. */ -export interface McpCaptureOptions { +export interface McpCaptureOptions extends PayloadSanitizationOptions { enableExceptionAutocapture: boolean /** Inspect/modify/drop hook applied to each fanned-out payload before capture. */ beforeSend?: BeforeSendFn @@ -39,7 +39,7 @@ export async function processMcpEvent( let processed: McpEvent = event try { - processed = sanitizeEvent(processed) + processed = sanitizeEvent(processed, options) } catch (err) { logger(`Failed to sanitize event: ${err}`) return null diff --git a/packages/mcp/src/extensions/tool-input.ts b/packages/mcp/src/extensions/tool-input.ts index c485c761d1..353b3a85c1 100644 --- a/packages/mcp/src/extensions/tool-input.ts +++ b/packages/mcp/src/extensions/tool-input.ts @@ -1,5 +1,6 @@ import type { InputAliasMap, JsonRecord, ShouldRecordInputKeyFn, ToolInputOptions } from '../types' import { PostHogMCPAnalyticsProperty } from './constants' +import { sanitizeCapturedValue } from './mcp-payloads' import { getObjectShape, isZodRawShapeCompat, unwrapInputSchema } from './mcp-sdk-compat' const MAX_INPUT_KEYS = 20 @@ -71,7 +72,11 @@ export function getToolInputProperties(input: unknown, inputSchema?: unknown, op let hasRedacted = false for (const key of keys) { const isDeclared = known.has(key) - if (key.length <= MAX_KEY_LENGTH && shouldRecord(record, key, isDeclared)) { + if ( + key.length <= MAX_KEY_LENGTH && + sanitizeCapturedValue(key, { redactPayloadPersonalData: true }) === key && + shouldRecord(record, key, isDeclared) + ) { ;(isDeclared ? declared : undeclared).push(key) } else { hasRedacted = true diff --git a/packages/mcp/src/extensions/truncation.ts b/packages/mcp/src/extensions/truncation.ts index b9b09f44ba..ac89c853a9 100644 --- a/packages/mcp/src/extensions/truncation.ts +++ b/packages/mcp/src/extensions/truncation.ts @@ -9,11 +9,12 @@ import type { ErrorProperties, Event, StackFrame, McpEvent } from '../types' export const MAX_DEPTH = 10 export const MAX_BREADTH = 100 export const MAX_STRING_LENGTH = 32_768 // 32KB +export const MAX_KEY_LENGTH = 256 export const MAX_EVENT_BYTES = 102_400 // 100KB // --- Field-level limit constants --- const MAX_USER_INTENT_LENGTH = 2048 -const MAX_ERROR_MESSAGE_LENGTH = 2048 +export const MAX_ERROR_MESSAGE_LENGTH = 2048 const MAX_RESOURCE_NAME_LENGTH = 256 const MAX_METADATA_LENGTH = 256 const MAX_STACK_FRAMES = 50 @@ -161,24 +162,25 @@ function visitObject( maxStringLength: number, memo: WeakSet ): Record { - const result: Record = {} + const entries: [string, unknown][] = [] const keys = Object.keys(obj) let count = 0 for (const key of keys) { if (count >= maxBreadth) { - result['...'] = '[MaxProperties ~]' + entries.push(['...', '[MaxProperties ~]']) break } // Skip undefined values — matches JSON.stringify behavior (omits undefined properties) if (obj[key] === undefined) { continue } - result[key] = visit(obj[key], remainingDepth, maxBreadth, maxStringLength, memo) + entries.push([key, visit(obj[key], remainingDepth, maxBreadth, maxStringLength, memo)]) count++ } - return result + // Defined rather than assigned, so a parsed `__proto__` member stays a member instead of setting the prototype. + return Object.fromEntries(entries) } // --- Field-level truncation helpers --- @@ -425,6 +427,26 @@ function normalizeReducibleFields(event: MutableEvent, depth: number): MutableEv return reduced } +/** + * Applies the entry and nesting limits to the payloads the sanitizer reads, so + * its work is bounded by those limits rather than by the payload. Strings are + * cut later, by {@link truncateEvent}, after the sanitizer has read past the cut. + */ +export function limitPayloadEntries(event: T): T { + const result: MutableEvent = { ...event } + for (const field of ['parameters', 'response'] as const) { + if (result[field] != null) { + result[field] = limitEntries(result[field]) + } + } + return result as T +} + +/** {@link limitPayloadEntries} for one payload, such as the parameters as they are captured. */ +export function limitEntries(payload: unknown): unknown { + return normalize(payload, MAX_DEPTH, MAX_BREADTH, Infinity) +} + /** * Applies layered truncation to an event: * 1. Field-level string limits (userIntent, resourceName, metadata fields, error.message) diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 10b2d21f70..cb48899e74 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -150,6 +150,17 @@ export interface MCPAnalyticsOptions { * fanning out into PostHog error tracking. */ enableExceptionAutocapture?: boolean + /** + * Redact personal data from tool arguments, results, error messages, exception + * values, and resource read URIs. Defaults to `true`. Emails, Luhn-valid card + * numbers, US social security numbers, IPv6 and public IPv4 addresses, and + * phone numbers written with an international prefix or a parenthesized area + * code become `[redacted]`. Set to `false` when your tools return personal + * data on purpose: `beforeSend` can drop a value but cannot restore one. The + * intent and feedback text keep their redaction, and credentials are redacted + * everywhere either way. + */ + redactPayloadPersonalData?: boolean /** Inject a required `context` parameter on every tool to capture user intent. */ context?: boolean | MCPAnalyticsContextOptions /**