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.
This commit is contained in:
Dustin Healy 2026-06-07 16:48:30 -07:00
parent 209e8d1eb6
commit a23f3909fe
9 changed files with 481 additions and 2 deletions

View file

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

View file

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

View file

@ -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> = {}): 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');
});
});
});

View file

@ -27,3 +27,4 @@ export * from './tools';
export * from './validation';
export * from './added';
export * from './load';
export * from './messagePiiFilter';

View file

@ -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<UserPromptSubmitHookOutput> => {
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 };
}

View file

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

View file

@ -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<typeof summarizationConfigSchema>;
export const messagePiiOnMatchSchema = z.enum(['silent', 'warn', 'block']);
export type MessagePiiOnMatch = z.infer<typeof messagePiiOnMatchSchema>;
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<typeof messagePiiCustomPatternSchema>;
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<typeof messagePiiFilterSchema>;
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(),

View file

@ -51,3 +51,4 @@ export * from './feedback';
export * from './parameterSettings';
/* code-execution sandbox */
export * from './codeEnvRef';
export * from './messagePiiPatterns';

View file

@ -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<string, PiiPattern>(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;
}