mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 09:32:24 +00:00
🔭 feat: Audit PII Filter Findings (#15508)
* feat: add audit-only content filter action * refactor: align filter action typing and tests * fix: preserve audit-only filter behavior * fix: enforce audit mode across content paths * fix: honor audit mode for canonical files
This commit is contained in:
parent
a20048ab2d
commit
057039570b
17 changed files with 464 additions and 35 deletions
|
|
@ -1205,6 +1205,11 @@ endpoints:
|
|||
# under any source so each input surface can use a different policy. Starter
|
||||
# ids are closed to the catalog below. Custom regexes use bounded,
|
||||
# linear-time syntax; unsupported constructs are rejected at config load.
|
||||
# `action` defaults to `block`. Set it to `audit` for a shadow rollout that
|
||||
# records raw-free findings without rejecting or modifying content. Redaction
|
||||
# is intentionally not an action yet because safe replacement requires a
|
||||
# mutation-aware boundary and match spans; configuring an unsupported action
|
||||
# is rejected rather than silently treated as enforcement.
|
||||
# Omit `starterPatterns` to enable the full starter catalog; set it to `[]`
|
||||
# to disable starters while retaining any configured custom patterns.
|
||||
# Enabling or changing this policy does not rewrite or delete stored records.
|
||||
|
|
@ -1228,6 +1233,7 @@ endpoints:
|
|||
# messages:
|
||||
# unattributedAssistantContent: model_output # `model_output` (default) or `inspect`
|
||||
# pii:
|
||||
# action: audit # `block` (default) or `audit`
|
||||
# fields: [name, text, summary, quote, answer, decision_response, decision_reason, content_part, attachment_reference, assembled_context]
|
||||
# starterPatterns: [sk_prefix, bearer_header, api_key_header]
|
||||
# customPatterns:
|
||||
|
|
|
|||
|
|
@ -886,6 +886,60 @@ describe('createToolExecuteHandler', () => {
|
|||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows audit-only tool output that cannot be completely traversed', async () => {
|
||||
const opaqueArtifact = new Proxy(
|
||||
{ value: 'hidden' },
|
||||
{
|
||||
ownKeys: () => {
|
||||
throw new Error('opaque');
|
||||
},
|
||||
},
|
||||
);
|
||||
const toolEndCallback = jest.fn();
|
||||
const tool = {
|
||||
name: 'opaque_output_tool',
|
||||
invoke: jest.fn(async () => ({ content: 'safe result', artifact: opaqueArtifact })),
|
||||
};
|
||||
const handler = createToolExecuteHandler({
|
||||
loadTools: async () => ({
|
||||
loadedTools: [tool] as never[],
|
||||
configurable: {
|
||||
req: {
|
||||
config: {
|
||||
filters: {
|
||||
toolArguments: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
fields: ['output'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [
|
||||
{
|
||||
id: 'protected-value',
|
||||
label: 'protected value',
|
||||
regex: 'PROTECTED-[A-Z-]+',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
toolEndCallback,
|
||||
});
|
||||
|
||||
const [result] = await invokeHandler(handler, [
|
||||
{ id: 'call_opaque_audit_output', name: 'opaque_output_tool', args: {} },
|
||||
]);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.artifact).toBe(opaqueArtifact);
|
||||
expect(toolEndCallback).toHaveBeenCalledTimes(1);
|
||||
expect(toolEndCallback.mock.calls[0][0].outputFiltered).toBeUndefined();
|
||||
expect(toolEndCallback.mock.calls[0][0].output.artifact).toBe(opaqueArtifact);
|
||||
});
|
||||
});
|
||||
|
||||
describe('programmatic tool config', () => {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import {
|
|||
getBlockedUninspectableFileField,
|
||||
inspectContent,
|
||||
isContentTraversalLimitError,
|
||||
isContentTraversalProtected,
|
||||
} from '~/protection';
|
||||
import {
|
||||
CREATE_FILE_TOOL_NAME,
|
||||
|
|
@ -872,10 +873,13 @@ function filteredToolArgumentsResult(
|
|||
if (!isContentTraversalLimitError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return (
|
||||
filteredContentResult(tc, req, getContentTraversalFragments(error)) ??
|
||||
errorResult(tc, error.body.message)
|
||||
);
|
||||
const filtered = filteredContentResult(tc, req, getContentTraversalFragments(error));
|
||||
if (filtered != null) {
|
||||
return filtered;
|
||||
}
|
||||
return isContentTraversalProtected({ error, filters: req?.config?.filters })
|
||||
? errorResult(tc, error.body.message)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -923,10 +927,13 @@ function filteredToolOutputResult(
|
|||
if (!isContentTraversalLimitError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return (
|
||||
filteredContentResult(tc, req, getContentTraversalFragments(error)) ??
|
||||
errorResult(tc, error.body.message)
|
||||
);
|
||||
const filtered = filteredContentResult(tc, req, getContentTraversalFragments(error));
|
||||
if (filtered != null) {
|
||||
return filtered;
|
||||
}
|
||||
return isContentTraversalProtected({ error, filters: req?.config?.filters })
|
||||
? errorResult(tc, error.body.message)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -971,7 +978,7 @@ function isFilteredSkillProjection(
|
|||
return filteredSkillResult(tc, req, input) != null;
|
||||
} catch (error) {
|
||||
if (isContentTraversalLimitError(error)) {
|
||||
return true;
|
||||
return isContentTraversalProtected({ error, filters: req?.config?.filters });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ function assertResolvedSkillContentAllowed(
|
|||
const selectedFields = pii?.fields == null ? null : new Set<string>(pii.fields);
|
||||
const selected = (field: string): boolean => selectedFields == null || selectedFields.has(field);
|
||||
const traversalErrors: ContentTraversalLimitError[] = [];
|
||||
let firstFinding: ReturnType<typeof inspectionSession.inspect> = null;
|
||||
for (const skill of skills) {
|
||||
const projected: SkillContentInput = {
|
||||
...(selected('name') && { name: skill.name }),
|
||||
|
|
@ -168,9 +169,15 @@ function assertResolvedSkillContentAllowed(
|
|||
}
|
||||
const finding = inspectionSession.inspect(fragments);
|
||||
if (finding != null) {
|
||||
throw new ContentFilterError(finding);
|
||||
firstFinding ??= finding;
|
||||
if (!inspectionSession.hasAuditRules) {
|
||||
throw new ContentFilterError(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (firstFinding != null) {
|
||||
throw new ContentFilterError(firstFinding);
|
||||
}
|
||||
const protectedTraversal = traversalErrors.find((error) =>
|
||||
isContentTraversalProtected({ error, filters }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { StreamLimitExceededError } from '@librechat/agents';
|
||||
import type { FiltersConfig } from 'librechat-data-provider';
|
||||
import {
|
||||
|
|
@ -99,6 +100,41 @@ describe('hasModelBoundContentProtection', () => {
|
|||
});
|
||||
|
||||
describe('assertModelBoundContent', () => {
|
||||
it('records later audit findings before returning an earlier blocking finding', () => {
|
||||
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => logger);
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
assertModelBoundContent({
|
||||
legacyPii: {
|
||||
starterPatterns: [],
|
||||
customPatterns: [{ id: 'legacy-block', label: 'legacy block', regex: 'PRIVATE-BLOCK' }],
|
||||
},
|
||||
filters: {
|
||||
skills: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
fields: ['instructions'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [
|
||||
{ id: 'skill-audit', label: 'skill audit', regex: 'AUDIT-SECRET' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedMessages: [{ role: 'user', content: 'PRIVATE-BLOCK' }],
|
||||
skills: [{ body: 'AUDIT-SECRET' }],
|
||||
}),
|
||||
).toThrow('Submitted content contains a legacy block');
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"ruleId":"skill-audit"'),
|
||||
expect.objectContaining({ action: 'audit', source: 'skill' }),
|
||||
);
|
||||
} finally {
|
||||
infoSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not traverse model-bound content for a zero-rule configuration', () => {
|
||||
const message = {
|
||||
isCreatedByUser: true,
|
||||
|
|
|
|||
|
|
@ -3399,15 +3399,18 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void {
|
|||
return;
|
||||
}
|
||||
const inspectionSession = inspector?.createSession();
|
||||
const shouldContinueAfterFinding = inspectionSession?.hasAuditRules === true;
|
||||
let finding: ReturnType<NonNullable<typeof inspectionSession>['inspect']> = null;
|
||||
const inspectFragments = (fragments: Iterable<TextContentFragment>): void => {
|
||||
if (finding == null) {
|
||||
finding = inspectionSession?.inspect(fragments) ?? null;
|
||||
if (finding == null || shouldContinueAfterFinding) {
|
||||
const nextFinding = inspectionSession?.inspect(fragments) ?? null;
|
||||
finding ??= nextFinding;
|
||||
}
|
||||
};
|
||||
const inspectFragment = (fragment: TextContentFragment): void => {
|
||||
if (finding == null) {
|
||||
finding = inspectionSession?.inspectFragment(fragment) ?? null;
|
||||
if (finding == null || shouldContinueAfterFinding) {
|
||||
const nextFinding = inspectionSession?.inspectFragment(fragment) ?? null;
|
||||
finding ??= nextFinding;
|
||||
}
|
||||
};
|
||||
const traversalErrors: ContentTraversalLimitError[] = [
|
||||
|
|
@ -3616,17 +3619,17 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void {
|
|||
* assistant row as submitted content. Structured tool calls/results
|
||||
* remain externally sourced model-bound content. Explicit paths and
|
||||
* semantic steer parts identify user-authored fragments in mixed rows. */
|
||||
if (finding == null) {
|
||||
if (finding == null || shouldContinueAfterFinding) {
|
||||
const submittedPathSet = new Set<string>(userSubmittedPaths);
|
||||
for (const fragment of messageFragments) {
|
||||
if (fragment.source === 'tool_argument') {
|
||||
inspectFragment(fragment);
|
||||
if (finding != null) {
|
||||
if (finding != null && !shouldContinueAfterFinding) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (finding == null) {
|
||||
if (finding == null || shouldContinueAfterFinding) {
|
||||
const submittedToolOutputs: Array<
|
||||
Extract<TextContentFragment, { source: 'tool_argument' }>
|
||||
> = [];
|
||||
|
|
@ -3637,7 +3640,7 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void {
|
|||
}
|
||||
if (fragment.source !== 'tool_argument') {
|
||||
inspectFragment(fragment);
|
||||
if (finding != null) {
|
||||
if (finding != null && !shouldContinueAfterFinding) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -3651,19 +3654,19 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void {
|
|||
assembledText.push(fragment.text);
|
||||
}
|
||||
}
|
||||
if (finding == null) {
|
||||
if (finding == null || shouldContinueAfterFinding) {
|
||||
for (const fragment of submittedToolOutputs) {
|
||||
inspectFragment(asUserSubmittedMessageFragment(fragment));
|
||||
if (finding != null) {
|
||||
if (finding != null && !shouldContinueAfterFinding) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (finding == null) {
|
||||
if (finding == null || shouldContinueAfterFinding) {
|
||||
inspectFragments(exactMessageFragments);
|
||||
}
|
||||
if (
|
||||
finding == null &&
|
||||
(finding == null || shouldContinueAfterFinding) &&
|
||||
(hasActivePiiPatterns(input.legacyPii) ||
|
||||
hasActivePiiFields(input.filters?.messages?.pii, ['assembled_context']))
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,12 @@ function hasActivePatterns(
|
|||
);
|
||||
}
|
||||
|
||||
type PiiActionConfig = Pick<NonNullable<NonNullable<FiltersConfig['messages']>['pii']>, 'action'>;
|
||||
|
||||
function blocksFindings(pii: PiiActionConfig | null | undefined): boolean {
|
||||
return pii?.action !== 'audit';
|
||||
}
|
||||
|
||||
function isScopedTraversalProtected(
|
||||
scopes: readonly ContentTraversalScope[],
|
||||
source: ContentSource,
|
||||
|
|
@ -195,11 +201,12 @@ function isScopedTraversalProtected(
|
|||
readonly fields?: readonly string[];
|
||||
readonly starterPatterns?: readonly string[];
|
||||
readonly customPatterns?: readonly unknown[];
|
||||
readonly action?: PiiActionConfig['action'];
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): boolean {
|
||||
if (!hasActivePatterns(pii)) {
|
||||
if (!hasActivePatterns(pii) || !blocksFindings(pii)) {
|
||||
return false;
|
||||
}
|
||||
const sourceScopes = scopes.filter((scope) => scope.source === source);
|
||||
|
|
@ -216,7 +223,10 @@ function isScopedFileTraversalProtected(
|
|||
scopes: readonly ContentTraversalScope[],
|
||||
pii: NonNullable<FiltersConfig['files']>['pii'],
|
||||
): boolean {
|
||||
if (pii == null || (!hasActivePatterns(pii) && pii.uninspectable !== 'block')) {
|
||||
if (
|
||||
pii == null ||
|
||||
((!hasActivePatterns(pii) || !blocksFindings(pii)) && pii.uninspectable !== 'block')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const fileScopes = scopes.filter((scope) => scope.source === 'file');
|
||||
|
|
@ -237,6 +247,7 @@ export function isNestedMessageTraversalProtected(params: {
|
|||
if (
|
||||
hasActivePatterns(params.legacyPii) ||
|
||||
(hasActivePatterns(params.filters?.messages?.pii) &&
|
||||
blocksFindings(params.filters?.messages?.pii) &&
|
||||
(isFieldEnabled(params.filters?.messages?.pii, 'content_part') ||
|
||||
isFieldEnabled(params.filters?.messages?.pii, 'assembled_context')))
|
||||
) {
|
||||
|
|
@ -246,6 +257,7 @@ export function isNestedMessageTraversalProtected(params: {
|
|||
if (
|
||||
roles.some((role) => role === 'system' || role === 'developer') &&
|
||||
hasActivePatterns(params.filters?.agentInstructions?.pii) &&
|
||||
blocksFindings(params.filters?.agentInstructions?.pii) &&
|
||||
isFieldEnabled(params.filters?.agentInstructions?.pii, 'instructions')
|
||||
) {
|
||||
return true;
|
||||
|
|
@ -253,6 +265,7 @@ export function isNestedMessageTraversalProtected(params: {
|
|||
return (
|
||||
roles.some((role) => role === 'tool') &&
|
||||
hasActivePatterns(params.filters?.toolArguments?.pii) &&
|
||||
blocksFindings(params.filters?.toolArguments?.pii) &&
|
||||
isFieldEnabled(params.filters?.toolArguments?.pii, 'output')
|
||||
);
|
||||
}
|
||||
|
|
@ -262,7 +275,7 @@ export function isModelParameterTraversalProtected(params: {
|
|||
readonly filters?: FiltersConfig;
|
||||
}): boolean {
|
||||
const pii = params.filters?.modelParameters?.pii;
|
||||
if (!hasActivePatterns(pii)) {
|
||||
if (!hasActivePatterns(pii) || !blocksFindings(pii)) {
|
||||
return false;
|
||||
}
|
||||
const scopes = getContentTraversalScopes(params.error).filter(
|
||||
|
|
|
|||
|
|
@ -376,6 +376,17 @@ describe('file content inspection policy', () => {
|
|||
skills: { pii: { fields: ['file_text'], starterPatterns: [] } },
|
||||
} as FiltersConfig),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getBlockedUninspectableSkillFileField({
|
||||
skills: { pii: { action: 'audit', fields: ['file_text'] } },
|
||||
} as FiltersConfig),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getBlockedUninspectableSkillFileField({
|
||||
skills: { pii: { action: 'audit', fields: ['file_text'] } },
|
||||
files: { pii: { fields: ['content'], uninspectable: 'block' } },
|
||||
} as FiltersConfig),
|
||||
).toBe('content');
|
||||
});
|
||||
|
||||
it('returns a stable raw-free block response', () => {
|
||||
|
|
@ -889,6 +900,40 @@ describe('file content inspection policy', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('allows an oversized canonical file subtree for an audit-only policy', async () => {
|
||||
const input = {
|
||||
files: Array.from({ length: 4_200 }, (_, index) => ({
|
||||
file_id: `file-${index}`,
|
||||
})),
|
||||
};
|
||||
const getFiles = jest.fn();
|
||||
|
||||
await expect(
|
||||
resolveCanonicalFileReferences({
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
fields: ['uri'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [
|
||||
{
|
||||
id: 'private-file-field',
|
||||
label: 'private file field',
|
||||
regex: 'PRIVATE-FILE-[A-Z]+',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
input,
|
||||
user: { id: 'user-1' },
|
||||
getFiles,
|
||||
}),
|
||||
).resolves.toMatchObject({ sanitizedInput: input, hydratedFiles: [] });
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrates discovered file names without rejecting an unrelated oversized subtree', async () => {
|
||||
const canonicalFile = {
|
||||
file_id: 'owned-file',
|
||||
|
|
|
|||
|
|
@ -365,6 +365,7 @@ export function getBlockedUninspectableSkillFileField(
|
|||
const skillPii = filters?.skills?.pii;
|
||||
if (
|
||||
hasActivePiiPatterns(skillPii) &&
|
||||
skillPii?.action !== 'audit' &&
|
||||
(skillPii?.fields == null || skillPii.fields.includes('file_text'))
|
||||
) {
|
||||
return 'content';
|
||||
|
|
@ -922,7 +923,7 @@ function getCanonicalFileReferenceIds(input: unknown): CanonicalReferenceTravers
|
|||
|
||||
function getActivePatternFileField(filters: FiltersConfig | undefined): FileFilterField | null {
|
||||
const pii = getFilePii(filters);
|
||||
if (!hasActivePiiPatterns(pii)) {
|
||||
if (!hasActivePiiPatterns(pii) || pii?.action === 'audit') {
|
||||
return null;
|
||||
}
|
||||
return pii?.fields?.[0] ?? (pii?.fields == null ? FILE_FILTER_FIELDS[0] : null);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RE2JS, RE2Set } from 're2js';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { FILTER_PII_STARTER_PATTERNS } from 'librechat-data-provider';
|
||||
import type { FiltersConfig, MessageFilterPiiConfig } from 'librechat-data-provider';
|
||||
import type { ContentFieldMap, ContentSource, TextContentFragment } from './types';
|
||||
|
|
@ -158,6 +159,140 @@ describe('configured content inspection', () => {
|
|||
expect(JSON.stringify(finding)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('records audit-only findings without returning an enforcement finding or raw text', () => {
|
||||
const filters: FiltersConfig = {
|
||||
messages: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
},
|
||||
},
|
||||
};
|
||||
const secret = 'ORG-DO-NOT-ECHO';
|
||||
|
||||
expect(inspectContent([fragment('message', 'text', secret)], { filters })).toBeNull();
|
||||
const metadata = {
|
||||
action: 'audit',
|
||||
detectorId: 'pii-pattern',
|
||||
ruleId: 'org-token',
|
||||
label: 'organization token',
|
||||
source: 'message',
|
||||
field: 'text',
|
||||
provenance: 'user',
|
||||
};
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
`[content-filter] Audit-only finding ${JSON.stringify(metadata)}`,
|
||||
metadata,
|
||||
);
|
||||
const calls = (logger.info as jest.Mock).mock.calls;
|
||||
expect(JSON.stringify(calls[calls.length - 1])).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('continues past audit-only findings to enforce blocking policies', () => {
|
||||
const filters: FiltersConfig = {
|
||||
messages: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
},
|
||||
},
|
||||
prompts: {
|
||||
pii: {
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
inspectContent([fragment('message', 'text'), fragment('prompt', 'instructions')], {
|
||||
filters,
|
||||
}),
|
||||
).toMatchObject({ source: 'prompt', field: 'instructions' });
|
||||
});
|
||||
|
||||
it('records audit findings even when an earlier legacy rule blocks the same fragment', () => {
|
||||
const filters: FiltersConfig = {
|
||||
messages: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
},
|
||||
},
|
||||
};
|
||||
const legacyPii: MessageFilterPiiConfig = {
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
};
|
||||
|
||||
expect(
|
||||
inspectContent([{ ...fragment('message', 'text'), id: 'chat.text' }], {
|
||||
filters,
|
||||
legacyPii,
|
||||
}),
|
||||
).toMatchObject({ ruleId: 'org-token' });
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"action":"audit"'),
|
||||
expect.objectContaining({ action: 'audit', ruleId: 'org-token' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail closed on incomplete audit-only traversal', () => {
|
||||
const filters: FiltersConfig = {
|
||||
skills: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
fields: ['frontmatter'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
},
|
||||
},
|
||||
};
|
||||
const error = new ContentTraversalLimitError(
|
||||
[fragment('skill', 'frontmatter', 'safe visible value')],
|
||||
[{ source: 'skill', fields: ['frontmatter'] }],
|
||||
);
|
||||
|
||||
expect(
|
||||
inspectContentWithTraversal(
|
||||
() => {
|
||||
throw error;
|
||||
},
|
||||
{ filters },
|
||||
),
|
||||
).toEqual({ finding: null, traversalError: null });
|
||||
});
|
||||
|
||||
it('keeps explicit opaque-file blocking active during an audit-only rollout', () => {
|
||||
const filters: FiltersConfig = {
|
||||
files: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
fields: ['content'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
uninspectable: 'block',
|
||||
},
|
||||
},
|
||||
};
|
||||
const error = new ContentTraversalLimitError(
|
||||
[fragment('file', 'content', 'safe visible value')],
|
||||
[{ source: 'file', fields: ['content'] }],
|
||||
);
|
||||
|
||||
expect(
|
||||
inspectContentWithTraversal(
|
||||
() => {
|
||||
throw error;
|
||||
},
|
||||
{ filters },
|
||||
),
|
||||
).toEqual({ finding: null, traversalError: error });
|
||||
});
|
||||
|
||||
it('treats an omitted fields list as every field within only that source', () => {
|
||||
const filters: FiltersConfig = {
|
||||
skills: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import {
|
||||
MAX_PII_CUSTOM_PATTERNS_TOTAL,
|
||||
MAX_PII_CUSTOM_REGEX_CHARACTERS,
|
||||
|
|
@ -6,7 +7,11 @@ import {
|
|||
MAX_PII_PATTERNS_PER_SOURCE,
|
||||
getPiiRegexProgramSize,
|
||||
} from 'librechat-data-provider';
|
||||
import type { FiltersConfig, MessageFilterPiiConfig } from 'librechat-data-provider';
|
||||
import type {
|
||||
FilterPiiAction,
|
||||
FiltersConfig,
|
||||
MessageFilterPiiConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
PatternContentInspector,
|
||||
PatternContentInspectorConfig,
|
||||
|
|
@ -29,6 +34,7 @@ import { isLegacyPiiFragment } from './legacy';
|
|||
|
||||
interface CompiledFilter {
|
||||
readonly detectorId: string;
|
||||
readonly action: FilterPiiAction;
|
||||
readonly sources: ReadonlySet<ContentSource>;
|
||||
readonly fields: ReadonlySet<string> | null;
|
||||
readonly applies?: (fragment: TextContentFragment) => boolean;
|
||||
|
|
@ -46,6 +52,7 @@ export interface ConfiguredContentInspector {
|
|||
}
|
||||
|
||||
export interface ConfiguredContentInspectionSession {
|
||||
readonly hasAuditRules: boolean;
|
||||
inspectFragment(fragment: TextContentFragment): ProtectionFinding | null;
|
||||
inspect(fragments: Iterable<TextContentFragment>): ProtectionFinding | null;
|
||||
}
|
||||
|
|
@ -67,6 +74,7 @@ const MAX_LINEAR_REGEX_SET_MEMORY_BYTES = 8 * 1_024 * 1_024;
|
|||
|
||||
interface FilterCompilationPlan {
|
||||
readonly detectorId: string;
|
||||
readonly action: FilterPiiAction;
|
||||
readonly config: PatternContentInspectorConfig;
|
||||
readonly sources: readonly ContentSource[];
|
||||
readonly fields: ReadonlySet<string> | null;
|
||||
|
|
@ -134,7 +142,12 @@ function snapshotFilterFields(
|
|||
|
||||
function appendFilterPlan(
|
||||
plans: FilterCompilationPlan[],
|
||||
config: (PatternContentInspectorConfig & { readonly fields?: readonly string[] }) | undefined,
|
||||
config:
|
||||
| (PatternContentInspectorConfig & {
|
||||
readonly action?: FilterPiiAction;
|
||||
readonly fields?: readonly string[];
|
||||
})
|
||||
| undefined,
|
||||
sources: readonly ContentSource[],
|
||||
): void {
|
||||
if (config == null) {
|
||||
|
|
@ -142,6 +155,7 @@ function appendFilterPlan(
|
|||
}
|
||||
plans.push({
|
||||
detectorId: 'pii-pattern',
|
||||
action: config.action ?? 'block',
|
||||
config,
|
||||
sources,
|
||||
fields: snapshotFilterFields(config),
|
||||
|
|
@ -227,6 +241,7 @@ function createCompilationPlans(
|
|||
if (legacyPii != null) {
|
||||
plans.unshift({
|
||||
detectorId: 'legacy-pattern',
|
||||
action: 'block',
|
||||
config: legacyPii,
|
||||
sources: ['message', 'assembled_context', 'tool_argument'],
|
||||
fields: null,
|
||||
|
|
@ -270,6 +285,7 @@ function compilePlans(plans: readonly FilterCompilationPlan[]): readonly Compile
|
|||
}
|
||||
compiled.push({
|
||||
detectorId: plan.detectorId,
|
||||
action: plan.action,
|
||||
sources: new Set(plan.sources),
|
||||
fields: plan.fields,
|
||||
applies: plan.applies,
|
||||
|
|
@ -287,6 +303,7 @@ function compilePlans(plans: readonly FilterCompilationPlan[]): readonly Compile
|
|||
}
|
||||
|
||||
function createInspector(rules: readonly CompiledFilter[]): ConfiguredContentInspector {
|
||||
const hasAuditRules = rules.some((rule) => rule.action === 'audit');
|
||||
const rulesBySource = new Map<ContentSource, Array<readonly [number, CompiledFilter]>>();
|
||||
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
|
||||
const rule = rules[ruleIndex];
|
||||
|
|
@ -308,6 +325,7 @@ function createInspector(rules: readonly CompiledFilter[]): ConfiguredContentIns
|
|||
if (sourceRules == null) {
|
||||
return null;
|
||||
}
|
||||
let firstBlockingFinding: ProtectionFinding | null = null;
|
||||
for (const [ruleIndex, rule] of sourceRules) {
|
||||
if (rule.fields != null && !rule.fields.has(fragment.field)) {
|
||||
continue;
|
||||
|
|
@ -324,29 +342,52 @@ function createInspector(rules: readonly CompiledFilter[]): ConfiguredContentIns
|
|||
}
|
||||
const finding = rule.inspector.inspectFragment(fragment);
|
||||
if (finding != null) {
|
||||
return {
|
||||
const configuredFinding = {
|
||||
...finding,
|
||||
detectorId: rule.detectorId,
|
||||
};
|
||||
if (rule.action === 'audit') {
|
||||
const auditMetadata = {
|
||||
action: rule.action,
|
||||
detectorId: configuredFinding.detectorId,
|
||||
ruleId: configuredFinding.ruleId,
|
||||
label: configuredFinding.label,
|
||||
source: configuredFinding.source,
|
||||
field: configuredFinding.field,
|
||||
provenance: configuredFinding.provenance,
|
||||
};
|
||||
logger.info(
|
||||
`[content-filter] Audit-only finding ${JSON.stringify(auditMetadata)}`,
|
||||
auditMetadata,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
firstBlockingFinding ??= configuredFinding;
|
||||
if (!hasAuditRules) {
|
||||
return firstBlockingFinding;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return firstBlockingFinding;
|
||||
};
|
||||
const inspect = (
|
||||
fragments: Iterable<TextContentFragment>,
|
||||
inspectedTextByRule: Array<Set<string> | undefined>,
|
||||
): ProtectionFinding | null => {
|
||||
let firstBlockingFinding: ProtectionFinding | null = null;
|
||||
for (const fragment of fragments) {
|
||||
const finding = inspectFragment(fragment, inspectedTextByRule);
|
||||
if (finding != null) {
|
||||
return finding;
|
||||
firstBlockingFinding ??= finding;
|
||||
if (firstBlockingFinding != null && !hasAuditRules) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return firstBlockingFinding;
|
||||
};
|
||||
const createSession = (): ConfiguredContentInspectionSession => {
|
||||
const inspectedTextByRule: Array<Set<string> | undefined> = new Array(rules.length);
|
||||
return {
|
||||
hasAuditRules,
|
||||
inspectFragment(fragment) {
|
||||
return inspectFragment(fragment, inspectedTextByRule);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1497,6 +1497,30 @@ describe('shared file metadata protection', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('allows malformed metadata traversal failures for an audit-only policy', () => {
|
||||
let malformedUrl: Record<string, unknown> = { label: 'safe' };
|
||||
for (let index = 0; index < 30; index++) {
|
||||
malformedUrl = { nested: malformedUrl };
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertSharedFileMetadataAllowed({
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
action: 'audit',
|
||||
fields: ['uri'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [BLOCK_PATTERN],
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: [{ files: [{ url: malformedUrl as never }] }],
|
||||
shareId: 'share-123',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not read serialized metadata beyond the traversal budget', () => {
|
||||
const file = Object.fromEntries(
|
||||
Array.from({ length: CONTENT_TRAVERSAL_MAX_NODES }, (_, index) => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
getBoundedOwnEnumerableEntries,
|
||||
isDataUri,
|
||||
isLikelyEncodedPayload,
|
||||
isContentTraversalProtected,
|
||||
} from '../protection/adapters/nested';
|
||||
import {
|
||||
getBlockedUninspectableFileField,
|
||||
|
|
@ -1604,7 +1605,13 @@ export function assertSharedFileMetadataAllowed({
|
|||
payloadInspection.traversalClassifications,
|
||||
);
|
||||
if (traversalClassifications.length > 0) {
|
||||
throw new ContentTraversalLimitError([], traversalClassifications.map(toTraversalScope));
|
||||
const traversalError = new ContentTraversalLimitError(
|
||||
[],
|
||||
traversalClassifications.map(toTraversalScope),
|
||||
);
|
||||
if (isContentTraversalProtected({ error: traversalError, filters })) {
|
||||
throw traversalError;
|
||||
}
|
||||
}
|
||||
for (const field of payloadInspection.uninspectableFileFields) {
|
||||
const blockedField = getBlockedUninspectableFileField(filters, [field]);
|
||||
|
|
|
|||
|
|
@ -38,4 +38,30 @@ describe('direct tool output protection', () => {
|
|||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows an untraversable direct-call output for an audit-only policy', () => {
|
||||
const output = new Proxy(
|
||||
{ value: 'hidden' },
|
||||
{
|
||||
ownKeys: () => {
|
||||
throw new Error('opaque');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
assertDirectToolOutputAllowed(
|
||||
{
|
||||
toolArguments: {
|
||||
pii: {
|
||||
...filters.toolArguments?.pii,
|
||||
action: 'audit',
|
||||
},
|
||||
},
|
||||
},
|
||||
'execute_code',
|
||||
output,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { FiltersConfig } from 'librechat-data-provider';
|
|||
import {
|
||||
getContentTraversalFragments,
|
||||
isContentTraversalLimitError,
|
||||
isContentTraversalProtected,
|
||||
} from '../protection/adapters/nested';
|
||||
import { extractToolArgumentContent } from '../protection/adapters/submissions';
|
||||
import { ContentFilterError } from '../middleware/contentFilter';
|
||||
|
|
@ -34,6 +35,8 @@ export function assertDirectToolOutputAllowed(
|
|||
throw error;
|
||||
}
|
||||
assertToolOutputFragmentsAllowed(filters, getContentTraversalFragments(error));
|
||||
throw error;
|
||||
if (isContentTraversalProtected({ error, filters })) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,24 @@ describe('filtersConfigSchema', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts omitted, audit, and block actions while rejecting unsupported actions', () => {
|
||||
expect(filtersConfigSchema.parse({ messages: { pii: {} } })).toEqual({
|
||||
messages: { pii: {} },
|
||||
});
|
||||
expect(
|
||||
filtersConfigSchema.parse({
|
||||
messages: { pii: { action: 'audit' } },
|
||||
prompts: { pii: { action: 'block' } },
|
||||
}),
|
||||
).toEqual({
|
||||
messages: { pii: { action: 'audit' } },
|
||||
prompts: { pii: { action: 'block' } },
|
||||
});
|
||||
expect(filtersConfigSchema.safeParse({ messages: { pii: { action: 'redact' } } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('combines active-pattern and selected-field checks', () => {
|
||||
expect(hasActivePiiFields({}, ['arguments'])).toBe(true);
|
||||
expect(hasActivePiiFields({ fields: ['name'] }, ['arguments'])).toBe(false);
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ export const fileFilterFieldSchema = z.enum(FILE_FILTER_FIELDS);
|
|||
export const toolArgumentFilterFieldSchema = z.enum(TOOL_ARGUMENT_FILTER_FIELDS);
|
||||
export const modelParameterFilterFieldSchema = z.enum(MODEL_PARAMETER_FILTER_FIELDS);
|
||||
export const filterPiiStarterPatternSchema = z.enum(FILTER_PII_STARTER_PATTERNS);
|
||||
export const filterPiiActionSchema = z.enum(['block', 'audit']);
|
||||
export const actionMetadataFilterFieldSchema = z.enum(ACTION_METADATA_FILTER_FIELDS);
|
||||
export const unattributedAssistantContentSchema = z.enum(['model_output', 'inspect']);
|
||||
export type UnattributedAssistantContent = z.infer<typeof unattributedAssistantContentSchema>;
|
||||
|
|
@ -178,6 +179,7 @@ export type FileFilterField = z.infer<typeof fileFilterFieldSchema>;
|
|||
export type ToolArgumentFilterField = z.infer<typeof toolArgumentFilterFieldSchema>;
|
||||
export type ModelParameterFilterField = z.infer<typeof modelParameterFilterFieldSchema>;
|
||||
export type ActionMetadataFilterField = z.infer<typeof actionMetadataFilterFieldSchema>;
|
||||
export type FilterPiiAction = z.infer<typeof filterPiiActionSchema>;
|
||||
|
||||
export const userSubmittedMessageFieldPathSchema = z
|
||||
.object({
|
||||
|
|
@ -285,6 +287,7 @@ export type FilterPiiCustomPatternConfig = z.infer<typeof filterPiiCustomPattern
|
|||
function createPiiFilterSchema<Field extends z.ZodTypeAny>(fieldSchema: Field) {
|
||||
return z
|
||||
.object({
|
||||
action: filterPiiActionSchema.optional(),
|
||||
fields: z.array(fieldSchema).min(1).max(MAX_PII_PATTERNS_PER_SOURCE).optional(),
|
||||
starterPatterns: z
|
||||
.array(filterPiiStarterPatternSchema)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue