From a23f3909fe40e712d41e96797f6064cde1574b15 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:48:30 -0700 Subject: [PATCH] feat: Configurable Message PII Filter via UserPromptSubmit Hook Adds a `messagePiiFilter` section to `librechat.yaml` that wires a UserPromptSubmit hook in `@librechat/agents` to scrub credential-shaped substrings from user prompts before the agent reaches the LLM. Per- user/group/role/tenant overrides flow through the existing TCustomConfig + AdminConfig override system. No new collection, no new admin endpoints, no new resolver. Three behavior modes via `onMatch`: silent server-side redact, no UI feedback warn server-side redact + sendEvent({type:'pii_matches',...}) after processStream resolves (client toast is a follow-up) block server denies on match + sendEvent({type:'pii_blocked',...}) so the frontend can render a real error instead of a silent empty assistant bubble Pattern catalog lives in `packages/data-provider/src/messagePiiPatterns.ts`. Three starter patterns ported from #13561's winston log redaction ship as default (sk_prefix, bearer_header, api_key_header, all /gi where appropriate). Two further patterns (api_key_query, key_query) are exported as OPT_IN_PII_PATTERNS, available by id but kept out of the starter default because they over-trigger on normal code-help conversations mentioning `?key=value`. Operators add their own regexes under `customPatterns`; the Zod schema refines each entry's regex via `new RegExp(value, 'g')` so a malformed pattern is caught at config load, not at request time. Files: - packages/data-provider/src/messagePiiPatterns.ts: STARTER + OPT_IN catalogs, PiiPattern type, selectStarterPatterns helper (returns a fresh array so callers can mutate without affecting module state) - packages/data-provider/src/config.ts: messagePiiFilterSchema with onMatch enum + starterPatterns + customPatterns + redactionText, custom regex validation via .refine - packages/api/src/agents/messagePiiFilter.ts: createMessagePiiFilterHooks factory returns { registry, collector } per request; collector captures matches for the controller to surface - packages/api/src/agents/run.ts: createRun accepts optional hooks: HookRegistry and forwards to Run.create - api/server/controllers/agents/client.js: passes the registry to createRun, after processStream resolves checks run.getHaltReason() to emit a pii_blocked event for block mode and a pii_matches event for warn mode (both guarded by res.writableEnded) - librechat.example.yaml: documents the new section with vendor- specific custom-pattern examples (AWS, GitHub, Slack, etc.) Scope note: the warn-mode toast handler and any client-side detection (which would let block mode catch prompts before they hit the wire) are deferred to a follow-up PR. Today the SSE events fire server-side; the frontend handlers that turn them into toasts and banners are next. Depends on the @librechat/agents PR that adds updatedPrompt to UserPromptSubmitHookOutput and exports redactSensitiveText. Bump @librechat/agents in api/package.json + packages/api/package.json before merging. Tests: packages/api/src/agents/__tests__/messagePiiFilter.spec.ts: 11/11 cover all three modes, starter/custom pattern composition, custom redactionText, and the layered pattern subsetting. --- api/server/controllers/agents/client.js | 34 +++++ librechat.example.yaml | 49 ++++++ .../agents/__tests__/messagePiiFilter.spec.ts | 139 ++++++++++++++++++ packages/api/src/agents/index.ts | 1 + packages/api/src/agents/messagePiiFilter.ts | 125 ++++++++++++++++ packages/api/src/agents/run.ts | 6 +- packages/data-provider/src/config.ts | 41 +++++- packages/data-provider/src/index.ts | 1 + .../data-provider/src/messagePiiPatterns.ts | 87 +++++++++++ 9 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/agents/__tests__/messagePiiFilter.spec.ts create mode 100644 packages/api/src/agents/messagePiiFilter.ts create mode 100644 packages/data-provider/src/messagePiiPatterns.ts diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index abef2061a7..6c76a2355a 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -2,7 +2,9 @@ require('events').EventEmitter.defaultMaxListeners = 100; const { logger } = require('@librechat/data-schemas'); const { getBufferString, HumanMessage } = require('@librechat/agents/langchain/messages'); const { + sendEvent, createRun, + createMessagePiiFilterHooks, isEnabled, checkAccess, buildToolSet, @@ -1055,6 +1057,8 @@ class AgentClient extends BaseClient { ); } + const piiFilterResult = createMessagePiiFilterHooks(appConfig?.messagePiiFilter); + run = await createRun({ agents, messages, @@ -1070,6 +1074,7 @@ class AgentClient extends BaseClient { summarizationConfig: appConfig?.summarization, appConfig, tokenCounter, + hooks: piiFilterResult?.registry, }); if (!run) { @@ -1100,6 +1105,35 @@ class AgentClient extends BaseClient { }); config.signal = null; + + if ( + piiFilterResult != null && + this.options.res != null && + !this.options.res.writableEnded + ) { + const piiHaltReason = run.getHaltReason?.(); + if (piiHaltReason === 'message_pii_filter_block') { + // Surface a friendly error on block mode so the frontend + // doesn't render an empty assistant bubble. The existing + // error event vocabulary is whatever the host expects; use + // a typed payload so a downstream toast/banner can render + // a real message rather than the silent halt LibreChat + // would otherwise show. + sendEvent(this.options.res, { + type: 'pii_blocked', + reason: piiHaltReason, + matches: piiFilterResult.collector.matches, + }); + } else if ( + appConfig?.messagePiiFilter?.onMatch === 'warn' && + piiFilterResult.collector.matches.length > 0 + ) { + sendEvent(this.options.res, { + type: 'pii_matches', + matches: piiFilterResult.collector.matches, + }); + } + } }; const hideSequentialOutputs = config.configurable.hide_sequential_outputs; diff --git a/librechat.example.yaml b/librechat.example.yaml index 748a2a1ab1..4825a3bf93 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -770,3 +770,52 @@ endpoints: # # instructions: "You are a memory management assistant. Store and manage user information accurately." # # model_parameters: # # temperature: 0.1 + +# Message PII filter +# Registers a UserPromptSubmit hook in @librechat/agents that scrubs +# credential-shaped substrings from user prompts before the agent reaches +# the LLM. Per-user/group/role/tenant overrides flow through the existing +# AppConfig override system, same as every other librechat.yaml section. +# +# onMatch modes: +# silent - server-side redact, no UI feedback +# warn - server-side redact + sendEvent(piiMatches) for client toast +# block - server always denies on match +# +# messagePiiFilter: +# onMatch: warn +# +# # Starter patterns ported from danny-avila/LibreChat#13561's winston log +# # redaction. Omit the field to enable all 5; pass an empty list to enable +# # none and rely entirely on customPatterns. +# # Available ids: sk_prefix, bearer_header, api_key_header, api_key_query, key_query +# starterPatterns: [sk_prefix, bearer_header, api_key_header, api_key_query, key_query] +# +# # Operator-defined regex additions. Each entry needs a stable id (used in +# # logs and the SSE payload), a human-readable label (shown to users in the +# # warn toast), and a JavaScript-flavor regex. +# customPatterns: +# - id: anthropic_api_key +# label: Anthropic API key +# regex: "sk-ant-[A-Za-z0-9_-]{20,}" +# - id: aws_access_key +# label: AWS access key +# regex: "(AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{12,16}" +# - id: github_token +# label: GitHub token +# regex: "gh[poshru]_[A-Za-z0-9]{36,255}" +# - id: slack_token +# label: Slack token +# regex: "xox[bpasr]-[A-Za-z0-9-]{10,200}" +# - id: google_api_key +# label: Google API key +# regex: "AIza[A-Za-z0-9_-]{35}" +# - id: stripe_key +# label: Stripe key +# regex: "(sk|pk|rk)_(live|test|prod)_[A-Za-z0-9]{10,99}" +# - id: jwt +# label: JWT +# regex: "eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+" +# +# # Replacement string inserted after the matched prefix. Defaults to [REDACTED]. +# redactionText: "[REDACTED]" diff --git a/packages/api/src/agents/__tests__/messagePiiFilter.spec.ts b/packages/api/src/agents/__tests__/messagePiiFilter.spec.ts new file mode 100644 index 0000000000..dadecde599 --- /dev/null +++ b/packages/api/src/agents/__tests__/messagePiiFilter.spec.ts @@ -0,0 +1,139 @@ +import { executeHooks } from '@librechat/agents'; +import type { MessagePiiFilterConfig } from 'librechat-data-provider'; +import type { UserPromptSubmitHookInput } from '@librechat/agents'; +import { createMessagePiiFilterHooks } from '../messagePiiFilter'; + +function promptInput(prompt: string): UserPromptSubmitHookInput { + return { + hook_event_name: 'UserPromptSubmit', + runId: 'run-1', + threadId: 'thread-1', + agentId: 'agent-1', + prompt, + }; +} + +function silent(overrides: Partial = {}): MessagePiiFilterConfig { + return { + onMatch: 'silent', + redactionText: '[REDACTED]', + ...overrides, + }; +} + +describe('createMessagePiiFilterHooks', () => { + it('returns undefined when config is undefined', () => { + expect(createMessagePiiFilterHooks(undefined)).toBeUndefined(); + }); + + it('returns undefined when no patterns would be selected', () => { + const result = createMessagePiiFilterHooks(silent({ starterPatterns: ['nonexistent'] })); + expect(result).toBeUndefined(); + }); + + it('exposes a per-request collector for matches', () => { + const result = createMessagePiiFilterHooks(silent()); + expect(result?.collector).toEqual({ matches: [] }); + }); + + describe('silent mode', () => { + it('redacts and surfaces updatedPrompt without blocking', async () => { + const built = createMessagePiiFilterHooks(silent()); + expect(built).toBeDefined(); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('key sk-ant-FAKE1234567890 please'), + }); + + expect(result.updatedPrompt).toBe('key sk-[REDACTED] please'); + expect(result.decision).toBeUndefined(); + expect(built!.collector.matches).toHaveLength(1); + }); + + it('is a no-op when nothing matches', async () => { + const built = createMessagePiiFilterHooks(silent()); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('a perfectly normal question'), + }); + + expect(result.updatedPrompt).toBeUndefined(); + expect(built!.collector.matches).toEqual([]); + }); + }); + + describe('warn mode', () => { + it('redacts and reports matches identically to silent (controller surfaces them)', async () => { + const built = createMessagePiiFilterHooks(silent({ onMatch: 'warn' })); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('key sk-ant-FAKE1234567890 please'), + }); + + expect(result.updatedPrompt).toBe('key sk-[REDACTED] please'); + expect(built!.collector.matches.map((m) => m.patternId)).toEqual(['sk_prefix']); + }); + }); + + describe('block mode', () => { + it('always denies on match', async () => { + const built = createMessagePiiFilterHooks(silent({ onMatch: 'block' })); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('key sk-ant-FAKE1234567890 please'), + }); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('message_pii_filter_block'); + expect(result.updatedPrompt).toBeUndefined(); + }); + + it('passes through when nothing matches', async () => { + const built = createMessagePiiFilterHooks(silent({ onMatch: 'block' })); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('plain words'), + }); + + expect(result.decision).toBeUndefined(); + }); + }); + + describe('pattern selection', () => { + it('honors a starterPatterns subset', async () => { + const built = createMessagePiiFilterHooks(silent({ starterPatterns: ['bearer_header'] })); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('auth Bearer abc.def-ghi and key sk-ant-1234567890ABC'), + }); + + expect(result.updatedPrompt).toBe('auth Bearer [REDACTED] and key sk-ant-1234567890ABC'); + }); + + it('layers customPatterns on top of starters', async () => { + const built = createMessagePiiFilterHooks( + silent({ + starterPatterns: [], + customPatterns: [{ id: 'acme', label: 'Acme token', regex: '\\bACME-[A-Z0-9]{6,}' }], + }), + ); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('token ACME-DEADBEEF12 ok'), + }); + + expect(result.updatedPrompt).toBe('token [REDACTED] ok'); + expect(built!.collector.matches.map((m) => m.patternId)).toEqual(['acme']); + }); + + it('honors a custom redactionText', async () => { + const built = createMessagePiiFilterHooks(silent({ redactionText: '[scrubbed]' })); + const result = await executeHooks({ + registry: built!.registry, + input: promptInput('key sk-ant-FAKE1234567890 found'), + }); + + expect(result.updatedPrompt).toBe('key sk-[scrubbed] found'); + }); + }); +}); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 14e0f480fd..5f04155053 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -27,3 +27,4 @@ export * from './tools'; export * from './validation'; export * from './added'; export * from './load'; +export * from './messagePiiFilter'; diff --git a/packages/api/src/agents/messagePiiFilter.ts b/packages/api/src/agents/messagePiiFilter.ts new file mode 100644 index 0000000000..9879c4e3fd --- /dev/null +++ b/packages/api/src/agents/messagePiiFilter.ts @@ -0,0 +1,125 @@ +import { logger } from '@librechat/data-schemas'; +import { selectStarterPatterns, type MessagePiiFilterConfig } from 'librechat-data-provider'; +import { + HookRegistry, + redactSensitiveText, + type SensitivePattern, + type PatternMatch, +} from '@librechat/agents'; +import type { UserPromptSubmitHookOutput } from '@librechat/agents'; + +/** + * Per-request match collector. The factory creates one for each + * request; the controller reads it after `processStream` resolves to + * surface matches to the client (e.g. via SSE for `warn` mode) or to + * log them. + */ +export type PiiMatchCollector = { + matches: PatternMatch[]; +}; + +export type CreatePiiFilterOptions = { + /** + * Optional pre-allocated collector. The factory pushes matches into + * this object's `matches` array as the hook fires. Default: a fresh + * collector returned alongside the registry. + */ + collector?: PiiMatchCollector; +}; + +export type CreatePiiFilterResult = { + registry: HookRegistry; + collector: PiiMatchCollector; +}; + +function buildPatternList(config: MessagePiiFilterConfig): SensitivePattern[] { + const starter = selectStarterPatterns(config.starterPatterns).map( + (p): SensitivePattern => ({ + id: p.id, + label: p.label, + pattern: p.pattern, + }), + ); + const custom = (config.customPatterns ?? []).map( + (p): SensitivePattern => ({ + id: p.id, + label: p.label, + // Force global flag; redactSensitiveText uses .replace with /g. + pattern: new RegExp(p.regex, 'g'), + }), + ); + return [...starter, ...custom]; +} + +/** + * Builds a HookRegistry that registers a single `UserPromptSubmit` + * hook configured per `messagePiiFilter.onMatch`. Returns `undefined` + * when the filter is disabled (no config) or selects zero patterns. + * + * Mode semantics: + * - silent: redact + return rewritten prompt + * - warn: redact + return rewritten prompt + matches in collector + * (controller is expected to surface them to the client) + * - block: always block with `decision: 'deny'` on match + */ +export function createMessagePiiFilterHooks( + config: MessagePiiFilterConfig | undefined, + options: CreatePiiFilterOptions = {}, +): CreatePiiFilterResult | undefined { + if (config == null) { + return undefined; + } + + const patterns = buildPatternList(config); + if (patterns.length === 0) { + return undefined; + } + + const { redactionText } = config; + const mode = config.onMatch; + const collector: PiiMatchCollector = options.collector ?? { matches: [] }; + const registry = new HookRegistry(); + + registry.register('UserPromptSubmit', { + hooks: [ + async (input): Promise => { + const { text, matches } = redactSensitiveText(input.prompt, { + patterns, + redactionText, + }); + if (matches.length === 0) { + return {}; + } + + collector.matches.push(...matches); + + if (mode === 'block') { + logger.info( + `[messagePiiFilter] blocked send (mode=block, patterns=${matches + .map((m) => m.patternId) + .join(',')})`, + ); + return { + decision: 'deny', + reason: 'message_pii_filter_block', + }; + } + + // silent + warn both redact server-side. The difference is + // that warn surfaces the matches to the UI via the controller + // (which reads collector.matches after processStream resolves). + if (mode === 'warn') { + logger.info( + `[messagePiiFilter] redacted ${matches.length} match(es) (mode=warn, patterns=${matches + .map((m) => m.patternId) + .join(',')})`, + ); + } + + return { updatedPrompt: text }; + }, + ], + }); + + return { registry, collector }; +} diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 07aa726c01..c92c3bf5df 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import { Run, Providers, Constants } from '@librechat/agents'; +import { Run, HookRegistry, Providers, Constants } from '@librechat/agents'; import { KnownEndpoints, MAX_SUBAGENT_DEPTH, @@ -777,6 +777,7 @@ export async function createRun({ runId, signal, agents, + hooks, messages, requestBody, user, @@ -810,6 +811,8 @@ export async function createRun({ * (e.g. "Ollama") in the summarization config to SDK-recognized providers. */ appConfig?: AppConfig; + /** Optional hook registry, passed through to `Run.create`. */ + hooks?: HookRegistry; } & Pick< RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap' | 'initialSessions' @@ -1045,6 +1048,7 @@ export async function createRun({ // feedback route in api/server/routes/messages.js). No-op unless Langfuse // tracing is enabled. Requires @librechat/agents >= 3.2.21. langfuse: { deterministicTraceId: true }, + ...(hooks != null && { hooks }), ...(enableToolOutputReferences && { toolOutputReferences: { enabled: true }, }), diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 238a5bde6c..8b36792520 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -10,11 +10,11 @@ import { } from './schemas'; import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; import { specsConfigSchema, TSpecsConfig } from './models'; +import { REFILL_INTERVAL_UNITS } from './balance'; import { fileConfigSchema } from './file-config'; import { apiBaseUrl } from './api-endpoints'; import { FileSources } from './types/files'; import { MCPServersSchema } from './mcp'; -import { REFILL_INTERVAL_UNITS } from './balance'; export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml']; @@ -1417,6 +1417,44 @@ export const summarizationConfigSchema = z.object({ export type SummarizationConfig = z.infer; +export const messagePiiOnMatchSchema = z.enum(['silent', 'warn', 'block']); + +export type MessagePiiOnMatch = z.infer; + +export const messagePiiCustomPatternSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + regex: z + .string() + .min(1) + .refine( + (value) => { + try { + new RegExp(value, 'g'); + return true; + } catch { + return false; + } + }, + { message: 'regex must compile via `new RegExp(value, "g")`' }, + ), + }) + .strict(); + +export type MessagePiiCustomPattern = z.infer; + +export const messagePiiFilterSchema = z + .object({ + onMatch: messagePiiOnMatchSchema, + starterPatterns: z.array(z.string()).optional(), + customPatterns: z.array(messagePiiCustomPatternSchema).optional(), + redactionText: z.string().default('[REDACTED]'), + }) + .strict(); + +export type MessagePiiFilterConfig = z.infer; + const customEndpointsSchema = z.array(endpointSchema.partial()).optional(); export const configSchema = z.object({ @@ -1426,6 +1464,7 @@ export const configSchema = z.object({ webSearch: webSearchSchema.optional(), memory: memorySchema.optional(), summarization: summarizationConfigSchema.optional(), + messagePiiFilter: messagePiiFilterSchema.optional(), secureImageLinks: z.boolean().optional(), imageOutputType: z.nativeEnum(EImageOutputType).default(EImageOutputType.PNG), includedTools: z.array(z.string()).optional(), diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index bb166d975a..270a2d53d1 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -51,3 +51,4 @@ export * from './feedback'; export * from './parameterSettings'; /* code-execution sandbox */ export * from './codeEnvRef'; +export * from './messagePiiPatterns'; diff --git a/packages/data-provider/src/messagePiiPatterns.ts b/packages/data-provider/src/messagePiiPatterns.ts new file mode 100644 index 0000000000..e5986f22fb --- /dev/null +++ b/packages/data-provider/src/messagePiiPatterns.ts @@ -0,0 +1,87 @@ +/** + * Starter PII regex catalog. Patterns ported from LibreChat#13561's + * winston log redaction (`packages/data-schemas/src/config/parsers.ts`), + * which already had production CI coverage there. These are the + * defaults the `messagePiiFilter` ships with; operators can subset + * them via `starterPatterns: [ids...]` or add their own under + * `customPatterns` in `librechat.yaml`. + * + * Each pattern's first capture group is the visible prefix that + * survives redaction (so trace/log readers can still tell which + * family of secret matched). All patterns use the `g` flag, required + * by the agents-side scrubber to scan past the first match. + */ + +export type PiiPattern = { + id: string; + label: string; + pattern: RegExp; +}; + +/** + * Lower-false-positive starter set. Enabled by default when the + * messagePiiFilter section is present and `starterPatterns` is omitted. + */ +export const STARTER_PII_PATTERNS: PiiPattern[] = [ + { + id: 'sk_prefix', + label: 'sk- prefix token (OpenAI/Anthropic/Langfuse/etc.)', + pattern: /\b(sk-)[a-zA-Z0-9_-]+/g, + }, + { + id: 'bearer_header', + label: 'Bearer token', + pattern: /\b(Bearer )[^\s"']+/gi, + }, + { + id: 'api_key_header', + label: 'api-key header', + pattern: /\b(api-key:?\s+)[^\s"']+/gi, + }, +]; + +/** + * Patterns that are useful but high-false-positive in prompt contexts: + * `?key=value` and `?api_key=…` match normal sentences in code-help + * conversations. Available by id but NOT in the default starter set. + * operators opt in explicitly via `starterPatterns: [api_key_query, ...]`. + */ +export const OPT_IN_PII_PATTERNS: PiiPattern[] = [ + { + id: 'api_key_query', + label: 'api_key URL param', + pattern: /\b(api_key=)[^\s"'&]+/gi, + }, + { + id: 'key_query', + label: 'key URL param', + pattern: /\b(key=)[^\s"'&]+/g, + }, +]; + +const ALL_PII_PATTERNS: PiiPattern[] = [...STARTER_PII_PATTERNS, ...OPT_IN_PII_PATTERNS]; + +export const STARTER_PATTERN_IDS = STARTER_PII_PATTERNS.map((p) => p.id); + +const PATTERN_BY_ID = new Map(ALL_PII_PATTERNS.map((p) => [p.id, p])); + +/** + * Picks patterns from the starter + opt-in catalog by id. Returns a + * fresh array so callers can mutate without affecting module state. + * Pass `undefined` for the default starter set; pass explicit ids to + * select from both starter and opt-in patterns. Unknown ids are + * silently dropped. + */ +export function selectStarterPatterns(ids?: string[]): PiiPattern[] { + if (ids == null) { + return [...STARTER_PII_PATTERNS]; + } + const selected: PiiPattern[] = []; + for (const id of ids) { + const entry = PATTERN_BY_ID.get(id); + if (entry != null) { + selected.push(entry); + } + } + return selected; +}