From f26dff44a2d2713859f33e31decbd95300f60752 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:45:29 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Apply=20PII=20fi?= =?UTF-8?q?lter=20to=20OpenAI-compat=20+=20Responses=20agents=20+=20decoup?= =?UTF-8?q?le=20cross-replica=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged that messagePiiFilter only ran inside the LibreChat chat AgentClient path; the OpenAI-compatible and Responses agent controllers (api/server/controllers/agents/openai.js, api/server/controllers/agents/responses.js) both call createRun with the same appConfig but never invoked the filter, so API clients sent raw credentials to the model and to Langfuse even with onMatch set. Added applyMessagePiiRedactionToMessages in packages/api that walks an OpenAI-style messages array (both string and content-part shapes) and mutates user message text in place. Both controllers call it at their handler entry point, immediately after the input messages are constructed and before the formatting, persistence, and createRun calls. Block mode returns HTTP 400 in the endpoint's native error shape; warn and silent log the redaction and continue. Codex also flagged that the cross-replica pii_matches replay added in 6c7fe770e was nested under the jobData.userMessage guard. Because the request controller persists piiMatches before the agent run starts (so before onStart fires and writes userMessage via updateMetadata), a subscriber landing on another replica between those two writes would find piiMatches in metadata but no userMessage, and the toast would be dropped. Split the cross-replica fallback into two independent reconstructions so pii_matches replays whenever it is persisted, even if userMessage has not been written yet. Also added a PiiMatchesEvent variant to the ServerSentEvent union so onChunk callers are typed correctly. --- api/server/controllers/agents/openai.js | 25 ++++++++ api/server/controllers/agents/responses.js | 25 ++++++++ packages/api/src/agents/messagePiiFilter.ts | 53 +++++++++++++++++ .../api/src/stream/GenerationJobManager.ts | 57 ++++++++++--------- packages/api/src/types/events.ts | 16 +++++- 5 files changed, 147 insertions(+), 29 deletions(-) diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 89422b2eaa..d12f365d92 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -33,6 +33,7 @@ const { resolveAgentScopedSkillIds, createOpenAIContentAggregator, isChatCompletionValidationFailure, + applyMessagePiiRedactionToMessages, } = require('@librechat/api'); const { buildSummarizationHandlers, @@ -177,6 +178,30 @@ const OpenAIChatCompletionController = async (req, res) => { ); } + // Apply messagePiiFilter to user messages before any downstream + // work (formatting, run creation, LLM dispatch, Langfuse trace). The + // chat endpoint pre-redacts in api/server/controllers/agents/request.js; + // this is the same guarantee for OpenAI-compatible API clients. + const piiConfig = appConfig?.messagePiiFilter; + if (piiConfig != null && typeof piiConfig.onMatch === 'string') { + const { matches } = applyMessagePiiRedactionToMessages(request.messages, piiConfig); + if (matches.length > 0) { + const labels = matches.map((m) => m.patternLabel).join(', '); + if (piiConfig.onMatch === 'block') { + return sendErrorResponse( + res, + 400, + `Message blocked by PII filter: ${labels}. Edit and retry.`, + 'invalid_request_error', + 'message_pii_filter_block', + ); + } + logger.info( + `[messagePiiFilter] redacted ${matches.length} match(es) on OpenAI API (mode=${piiConfig.onMatch}, patterns=${matches.map((m) => m.patternId).join(',')})`, + ); + } + } + const responseId = `chatcmpl-${nanoid()}`; const created = Math.floor(Date.now() / 1000); diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 9022c1e6f0..851b4b1013 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -41,6 +41,7 @@ const { sendResponsesErrorResponse, createResponsesEventHandlers, createAggregatorEventHandlers, + applyMessagePiiRedactionToMessages, } = require('@librechat/api'); const { createResponsesToolEndCallback, @@ -575,6 +576,30 @@ const createResponse = async (req, res) => { typeof request.input === 'string' ? request.input : request.input, ); + // Apply messagePiiFilter to user input before saving, formatting, + // or dispatching to the model. The chat endpoint pre-redacts in + // api/server/controllers/agents/request.js; this is the same + // guarantee for Responses API clients. + const piiConfig = appConfig?.messagePiiFilter; + if (piiConfig != null && typeof piiConfig.onMatch === 'string') { + const { matches } = applyMessagePiiRedactionToMessages(inputMessages, piiConfig); + if (matches.length > 0) { + const labels = matches.map((m) => m.patternLabel).join(', '); + if (piiConfig.onMatch === 'block') { + return sendResponsesErrorResponse( + res, + 400, + `Message blocked by PII filter: ${labels}. Edit and retry.`, + 'invalid_request', + 'message_pii_filter_block', + ); + } + logger.info( + `[messagePiiFilter] redacted ${matches.length} match(es) on Responses API (mode=${piiConfig.onMatch}, patterns=${matches.map((m) => m.patternId).join(',')})`, + ); + } + } + // Merge previous messages with new input const allMessages = [...previousMessages, ...inputMessages]; diff --git a/packages/api/src/agents/messagePiiFilter.ts b/packages/api/src/agents/messagePiiFilter.ts index aaad1b0557..1638820110 100644 --- a/packages/api/src/agents/messagePiiFilter.ts +++ b/packages/api/src/agents/messagePiiFilter.ts @@ -187,3 +187,56 @@ export function serializeMessagePiiFilterForClient( })), }; } + +/** + * Walk an OpenAI-style messages array and apply PII redaction to user + * message content in place. Used by the OpenAI-compatible and + * Responses agent controllers, which receive a messages array instead + * of the chat endpoint's single `req.body.text`. + * + * Handles both content shapes: plain strings and arrays of content + * parts (text, image_url, etc.). Only `text`-bearing parts are + * scrubbed. Mutates the array in place and returns the aggregated + * match set so the caller can choose the mode reaction (HTTP 400 for + * block, log for warn/silent). + */ +export function applyMessagePiiRedactionToMessages( + messages: Array<{ + role?: string; + content?: string | Array<{ type?: string; text?: string; [key: string]: unknown }>; + }>, + config: MessagePiiFilterConfig | undefined, +): { matches: PatternMatch[] } { + if (config == null || !Array.isArray(messages) || messages.length === 0) { + return { matches: [] }; + } + const aggregate = new Map(); + const accumulate = (text: string): string => { + const result = applyMessagePiiRedaction(text, config); + for (const m of result.matches) { + const prior = aggregate.get(m.patternId); + if (prior == null) { + aggregate.set(m.patternId, { ...m }); + } else { + prior.count += m.count; + } + } + return result.text; + }; + + for (const msg of messages) { + if (msg == null || msg.role !== 'user') { + continue; + } + if (typeof msg.content === 'string') { + msg.content = accumulate(msg.content); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part != null && typeof part.text === 'string') { + part.text = accumulate(part.text); + } + } + } + } + return { matches: Array.from(aggregate.values()) }; +} diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 300120c3e4..6d09b7fcf0 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -927,36 +927,37 @@ class GenerationJobManagerClass { } } runtime.earlyEventBuffer = []; - } else if (this._isRedis && !options?.skipBufferReplay && jobData?.userMessage) { + } else if (this._isRedis && !options?.skipBufferReplay) { /** - * Cross-replica fallback: the created event was buffered on the generating - * instance and published via Redis pub/sub before this subscriber was active. - * Reconstruct from persisted metadata. Only fields stored by trackUserMessage() - * are available (messageId, parentMessageId, conversationId, text); - * sender/isCreatedByUser are invariant for user messages and added back here. + * Cross-replica fallback. Events emitted on the generating + * instance were buffered there and published via Redis pub/sub + * before this subscriber was active, so reconstruct from + * persisted metadata. The two reconstructions are independent + * because they are persisted at different points in the + * request lifecycle: `pii_matches` is written by the + * pre-redaction step in the request controller (before the + * agent run starts), while `userMessage` is written by the + * agent's `onStart` callback after the user message is + * created. A subscriber that connects between those two + * moments must still receive the pii_matches reconstruction + * even though `jobData.userMessage` is not yet set. */ - logger.debug( - `[GenerationJobManager] Cross-replica subscribe: emitting created event from metadata for ${streamId}`, - ); - const createdEvent: t.CreatedEvent = { - created: true, - message: { - ...jobData.userMessage, - sender: 'User', - isCreatedByUser: true, - }, - streamId, - }; - onChunk(createdEvent); - - /** - * Replay pii_matches from persisted metadata so the warn-mode - * toast still fires for subscribers that connect on a - * different replica than the one that emitted the live event. - * The same-replica path is already covered by earlyEventBuffer - * replay above; this branch only runs when no buffer exists. - */ - if (jobData.piiMatches && jobData.piiMatches.length > 0) { + if (jobData?.userMessage) { + logger.debug( + `[GenerationJobManager] Cross-replica subscribe: emitting created event from metadata for ${streamId}`, + ); + const createdEvent: t.CreatedEvent = { + created: true, + message: { + ...jobData.userMessage, + sender: 'User', + isCreatedByUser: true, + }, + streamId, + }; + onChunk(createdEvent); + } + if (jobData?.piiMatches && jobData.piiMatches.length > 0) { logger.debug( `[GenerationJobManager] Cross-replica subscribe: emitting pii_matches from metadata for ${streamId}`, ); diff --git a/packages/api/src/types/events.ts b/packages/api/src/types/events.ts index d068888b17..8d466094bd 100644 --- a/packages/api/src/types/events.ts +++ b/packages/api/src/types/events.ts @@ -46,4 +46,18 @@ export type FinalEvent = { error?: { message: string }; }; -export type ServerSentEvent = StreamEvent | CreatedEvent | FinalEvent; +/** + * Surfaces PII / credential pattern matches detected by the + * messagePiiFilter (server-side pre-redaction or agents-side hook). + * Frontend renders the configured `warn`-mode toast off this event. + */ +export type PiiMatchesEvent = { + type: 'pii_matches'; + matches: Array<{ + patternId: string; + patternLabel: string; + count: number; + }>; +}; + +export type ServerSentEvent = StreamEvent | CreatedEvent | FinalEvent | PiiMatchesEvent;