🛡️ fix: Apply PII filter to OpenAI-compat + Responses agents + decouple cross-replica replay

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.
This commit is contained in:
Dustin Healy 2026-06-07 21:45:29 -07:00
parent 6c7fe770e4
commit f26dff44a2
5 changed files with 147 additions and 29 deletions

View file

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

View file

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