From 5ee8e95cbbce72bf0e87325530d48b81fa119384 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:41:39 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20fix:=20PII=20filter=20end-to-end?= =?UTF-8?q?=20wiring=20for=20warn/silent/block=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end testing surfaced four bugs in the original PR that this commit fixes. AppConfig was dropping the messagePiiFilter yaml field. The AppConfig interface in packages/data-schemas curates a specific subset of TCustomConfig and messagePiiFilter was not on it, so the controller saw undefined and never instantiated the factory. Added a passthrough on both the interface and the AppService factory output. The pii_matches SSE event was being written to a dead response. Agents use a resumable-stream architecture where the POST response ends immediately and the live SSE bytes flow through a separate GET endpoint fed by GenerationJobManager. Swapped sendEvent(this.options.res, ...) for GenerationJobManager.emitChunk(streamId, ...) to match the pattern other agent events use. The persisted user message kept the original credential text. The hook only redacted the prompt going to the LLM; the userMessage row saved to MongoDB and the optimistic display still showed the original. Added applyMessagePiiRedaction helper and pre-redact req.body.text in the agents request controller before the user message is constructed, so the DB row and the created SSE event both carry redacted text from the start. Block mode also leaked the credential. It now pre-redacts (so the credential never reaches the DB or the created event) and throws from inside getReqData's second invocation, which fires after the user message save is in-flight; the throw routes through the existing emitError path for the assistant turn. Toast severity also bumped from info to warning to match a credential-redaction event. --- api/server/controllers/agents/client.js | 27 +++++++------ api/server/controllers/agents/request.js | 44 ++++++++++++++++++++- client/src/hooks/SSE/usePiiHandler.ts | 2 +- packages/api/src/agents/messagePiiFilter.ts | 35 +++++++++++++++- packages/data-schemas/src/app/service.ts | 2 + packages/data-schemas/src/types/app.ts | 2 + 6 files changed, 96 insertions(+), 16 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 3ba9556031..79eae61105 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -2,7 +2,6 @@ require('events').EventEmitter.defaultMaxListeners = 100; const { logger } = require('@librechat/data-schemas'); const { getBufferString, HumanMessage } = require('@librechat/agents/langchain/messages'); const { - sendEvent, createRun, createMessagePiiFilterHooks, isEnabled, @@ -1057,7 +1056,20 @@ class AgentClient extends BaseClient { ); } - const piiFilterResult = createMessagePiiFilterHooks(appConfig?.messagePiiFilter); + const piiFilterResult = createMessagePiiFilterHooks(appConfig?.messagePiiFilter, { + onMatches: (matches) => { + // Agents use the resumable-stream architecture: the POST + // response (this.options.res) ends immediately after the + // controller returns a streamId, and the long-lived SSE + // bytes flow through a separate GET endpoint fed by + // GenerationJobManager. Writing to this.options.res here + // is silently swallowed because it's already ended. + const streamId = this.options.req?._resumableStreamId; + if (streamId != null) { + GenerationJobManager.emitChunk(streamId, { type: 'pii_matches', matches }); + } + }, + }); run = await createRun({ agents, @@ -1120,17 +1132,6 @@ class AgentClient extends BaseClient { `Message blocked by PII filter${labels.length > 0 ? `: ${labels}` : ''}. Edit and retry.`, ); } - if ( - appConfig?.messagePiiFilter?.onMatch === 'warn' && - piiFilterResult.collector.matches.length > 0 && - this.options.res != null && - !this.options.res.writableEnded - ) { - sendEvent(this.options.res, { - type: 'pii_matches', - matches: piiFilterResult.collector.matches, - }); - } } }; diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 6c254f061a..519ea3fd25 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -10,6 +10,7 @@ const { decrementPendingRequest, sanitizeMessageForTransmit, checkAndIncrementPendingRequest, + applyMessagePiiRedaction, } = require('@librechat/api'); const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup'); const { handleAbortError } = require('~/server/middleware'); @@ -107,7 +108,6 @@ function getPreliminaryUserMessage({ messageId, parentMessageId, text }, convers */ const ResumableAgentController = async (req, res, next, initializeClient, addTitle) => { const { - text, isRegenerate, endpointOption, conversationId: reqConversationId, @@ -117,9 +117,34 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit overrideParentMessageId = null, responseMessageId: editedResponseMessageId = null, } = req.body; + let text = req.body.text; const userId = req.user.id; + // Pre-redact PII before user message is constructed/saved so the + // persisted message, the `created` SSE event, and the prompt sent + // to the LLM all carry the redacted text. The agents-side hook + // remains the redaction site for block mode (which denies rather + // than rewrites). + let piiPreRedactMatches = null; + let piiBlockReason = null; + const piiConfig = req.config?.messagePiiFilter; + if (piiConfig != null && typeof piiConfig.onMatch === 'string') { + const result = applyMessagePiiRedaction(text, piiConfig); + if (result.matches.length > 0) { + // All modes redact the persisted/displayed user message so the + // raw credential never reaches MongoDB or the `created` SSE event. + // Mode differs only in what happens after redaction. + text = result.text; + req.body.text = result.text; + if (piiConfig.onMatch === 'warn') { + piiPreRedactMatches = result.matches; + } else if (piiConfig.onMatch === 'block') { + piiBlockReason = result.matches.map((m) => m.patternLabel).join(', '); + } + } + } + /** When to generate the conversation title. `immediate` (default) fires title * generation in parallel with the response, from the user's first message; * `final` defers it until the full response completes (legacy behavior). @@ -154,6 +179,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const jobCreatedAt = job.createdAt; // Capture creation time to detect job replacement req._resumableStreamId = streamId; + if (piiPreRedactMatches != null) { + GenerationJobManager.emitChunk(streamId, { + type: 'pii_matches', + matches: piiPreRedactMatches, + }); + } + // Send JSON response IMMEDIATELY so client can connect to SSE stream // This is critical: tool loading (MCP OAuth) may emit events that the client needs to receive res.json({ streamId, conversationId, status: 'started' }); @@ -285,6 +317,16 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit userMessage = data.userMessage; } // conversationId is pre-generated, no need to update from callback + + // Block-mode abort. BaseClient calls getReqData twice: the first time + // before saveMessageToDatabase fires (data.userMessage is set), the + // second time after (data.userMessagePromise is set). Throw on the + // SECOND call so the already-redacted user message has been queued for + // persistence; then the throw propagates up through sendMessage's + // catch and routes through the existing emitError path. + if (piiBlockReason != null && data.userMessagePromise != null) { + throw new Error(`Message blocked by PII filter: ${piiBlockReason}. Edit and retry.`); + } }; // Start background generation - readyPromise resolves immediately now diff --git a/client/src/hooks/SSE/usePiiHandler.ts b/client/src/hooks/SSE/usePiiHandler.ts index f869f7aa43..0dfa8d204c 100644 --- a/client/src/hooks/SSE/usePiiHandler.ts +++ b/client/src/hooks/SSE/usePiiHandler.ts @@ -18,7 +18,7 @@ export default function usePiiHandler() { } showToast({ message: localize('com_ui_pii_redacted', { 0: labels }), - status: 'info', + status: 'warning', }); }, [localize, showToast], diff --git a/packages/api/src/agents/messagePiiFilter.ts b/packages/api/src/agents/messagePiiFilter.ts index 71919038c7..ae436d77f6 100644 --- a/packages/api/src/agents/messagePiiFilter.ts +++ b/packages/api/src/agents/messagePiiFilter.ts @@ -25,6 +25,13 @@ export type CreatePiiFilterOptions = { * collector returned alongside the registry. */ collector?: PiiMatchCollector; + /** + * Invoked from inside the hook (while the response is still open) + * with the matches detected for this prompt. Used by the controller + * to emit a `pii_matches` SSE event for warn mode before + * processStream closes the response. + */ + onMatches?: (matches: PatternMatch[]) => void; }; export type CreatePiiFilterResult = { @@ -32,7 +39,7 @@ export type CreatePiiFilterResult = { collector: PiiMatchCollector; }; -function buildPatternList(config: MessagePiiFilterConfig): SensitivePattern[] { +export function buildPatternList(config: MessagePiiFilterConfig): SensitivePattern[] { const starter = selectStarterPatterns(config.starterPatterns).map( (p): SensitivePattern => ({ id: p.id, @@ -78,6 +85,7 @@ export function createMessagePiiFilterHooks( const { redactionText } = config; const mode = config.onMatch; const collector: PiiMatchCollector = options.collector ?? { matches: [] }; + const { onMatches } = options; const registry = new HookRegistry(); registry.register('UserPromptSubmit', { @@ -93,6 +101,9 @@ export function createMessagePiiFilterHooks( if (mode !== 'silent') { collector.matches.push(...matches); + if (mode === 'warn' && onMatches != null) { + onMatches(matches); + } } if (mode === 'block') { @@ -123,3 +134,25 @@ export function createMessagePiiFilterHooks( return { registry, collector }; } + +/** + * Apply the configured PII filter directly to a text blob, bypassing + * the agents hook plumbing. Used by the agents request controller to + * pre-redact `req.body.text` before the user message is created/saved, + * so the chat-history display and persisted message both have the + * redacted text. Mode semantics are interpreted by the caller. This + * helper just runs the scrubber and returns the result. + */ +export function applyMessagePiiRedaction( + text: string, + config: MessagePiiFilterConfig | undefined, +): { text: string; matches: PatternMatch[] } { + if (config == null || typeof text !== 'string' || text.length === 0) { + return { text: text ?? '', matches: [] }; + } + const patterns = buildPatternList(config); + if (patterns.length === 0) { + return { text, matches: [] }; + } + return redactSensitiveText(text, { patterns, redactionText: config.redactionText }); +} diff --git a/packages/data-schemas/src/app/service.ts b/packages/data-schemas/src/app/service.ts index 19fec9f5a1..19bb62c161 100644 --- a/packages/data-schemas/src/app/service.ts +++ b/packages/data-schemas/src/app/service.ts @@ -110,6 +110,7 @@ export const AppService = async (params?: { const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); const turnstileConfig = loadTurnstileConfig(config, configDefaults); const speech = config.speech; + const messagePiiFilter = config.messagePiiFilter; const defaultConfig = { ocr, @@ -117,6 +118,7 @@ export const AppService = async (params?: { config, memory, speech, + messagePiiFilter, balance, actions, webSearch, diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts index 4562e588ee..71b47c5874 100644 --- a/packages/data-schemas/src/types/app.ts +++ b/packages/data-schemas/src/types/app.ts @@ -62,6 +62,8 @@ export interface AppConfig { summarization?: SummarizationConfig; /** Web search configuration */ webSearch?: TCustomConfig['webSearch']; + /** Message PII filter configuration */ + messagePiiFilter?: TCustomConfig['messagePiiFilter']; /** File storage strategy ('local', 's3', 'firebase', 'azure_blob', 'cloudfront') */ fileStrategy: FileStorage; /** File strategies configuration */