mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
209e8d1eb6
commit
a23f3909fe
9 changed files with 481 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
139
packages/api/src/agents/__tests__/messagePiiFilter.spec.ts
Normal file
139
packages/api/src/agents/__tests__/messagePiiFilter.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -27,3 +27,4 @@ export * from './tools';
|
|||
export * from './validation';
|
||||
export * from './added';
|
||||
export * from './load';
|
||||
export * from './messagePiiFilter';
|
||||
|
|
|
|||
125
packages/api/src/agents/messagePiiFilter.ts
Normal file
125
packages/api/src/agents/messagePiiFilter.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -51,3 +51,4 @@ export * from './feedback';
|
|||
export * from './parameterSettings';
|
||||
/* code-execution sandbox */
|
||||
export * from './codeEnvRef';
|
||||
export * from './messagePiiPatterns';
|
||||
|
|
|
|||
87
packages/data-provider/src/messagePiiPatterns.ts
Normal file
87
packages/data-provider/src/messagePiiPatterns.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue