🔧 fix: PII filter end-to-end wiring for warn/silent/block modes

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.
This commit is contained in:
Dustin Healy 2026-06-07 19:41:39 -07:00
parent be476cd97b
commit 5ee8e95cbb
6 changed files with 96 additions and 16 deletions

View file

@ -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,
});
}
}
};

View file

@ -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

View file

@ -18,7 +18,7 @@ export default function usePiiHandler() {
}
showToast({
message: localize('com_ui_pii_redacted', { 0: labels }),
status: 'info',
status: 'warning',
});
},
[localize, showToast],

View file

@ -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 });
}

View file

@ -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,

View file

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