🔭 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:
Danny Avila 2026-09-02 10:36:06 -04:00 • committed by GitHub
parent a20048ab2d
commit 057039570b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 464 additions and 35 deletions

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

@ -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']))
) {

View file

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

View file

@ -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',

View 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);

View file

@ -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: {

View file

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

View file

@ -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) => [

View file

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

View file

@ -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();
});
});

View file

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

View file

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

View file

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