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 */