mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🛡️ 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:
parent
6c7fe770e4
commit
f26dff44a2
5 changed files with 147 additions and 29 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, PatternMatch>();
|
||||
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()) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue