diff --git a/librechat.example.yaml b/librechat.example.yaml index 55e759ea44..e1038a38ae 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -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: diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 32973bab5d..d39e6b261b 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -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', () => { diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index f003062c03..f506989abc 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -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; } diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 0b7a6ad256..61b762eb0a 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -137,6 +137,7 @@ function assertResolvedSkillContentAllowed( const selectedFields = pii?.fields == null ? null : new Set(pii.fields); const selected = (field: string): boolean => selectedFields == null || selectedFields.has(field); const traversalErrors: ContentTraversalLimitError[] = []; + let firstFinding: ReturnType = 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 }), ); diff --git a/packages/api/src/middleware/modelBoundContent.spec.ts b/packages/api/src/middleware/modelBoundContent.spec.ts index f383f4453b..aeb41fb9ce 100644 --- a/packages/api/src/middleware/modelBoundContent.spec.ts +++ b/packages/api/src/middleware/modelBoundContent.spec.ts @@ -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, diff --git a/packages/api/src/middleware/modelBoundContent.ts b/packages/api/src/middleware/modelBoundContent.ts index cce2094a03..b25130c33e 100644 --- a/packages/api/src/middleware/modelBoundContent.ts +++ b/packages/api/src/middleware/modelBoundContent.ts @@ -3399,15 +3399,18 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void { return; } const inspectionSession = inspector?.createSession(); + const shouldContinueAfterFinding = inspectionSession?.hasAuditRules === true; let finding: ReturnType['inspect']> = null; const inspectFragments = (fragments: Iterable): 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(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 > = []; @@ -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'])) ) { diff --git a/packages/api/src/protection/adapters/nested.ts b/packages/api/src/protection/adapters/nested.ts index 42ec41c5cc..e2f66d2478 100644 --- a/packages/api/src/protection/adapters/nested.ts +++ b/packages/api/src/protection/adapters/nested.ts @@ -187,6 +187,12 @@ function hasActivePatterns( ); } +type PiiActionConfig = Pick['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['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( diff --git a/packages/api/src/protection/files.spec.ts b/packages/api/src/protection/files.spec.ts index 771a16d878..9dd6cf322e 100644 --- a/packages/api/src/protection/files.spec.ts +++ b/packages/api/src/protection/files.spec.ts @@ -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', diff --git a/packages/api/src/protection/files.ts b/packages/api/src/protection/files.ts index 0aad66cb74..c1ffb014f2 100644 --- a/packages/api/src/protection/files.ts +++ b/packages/api/src/protection/files.ts @@ -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); diff --git a/packages/api/src/protection/runtime.spec.ts b/packages/api/src/protection/runtime.spec.ts index 5c6030f715..e420216732 100644 --- a/packages/api/src/protection/runtime.spec.ts +++ b/packages/api/src/protection/runtime.spec.ts @@ -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: { diff --git a/packages/api/src/protection/runtime.ts b/packages/api/src/protection/runtime.ts index fc7e686e1d..b5b41aa908 100644 --- a/packages/api/src/protection/runtime.ts +++ b/packages/api/src/protection/runtime.ts @@ -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; readonly fields: ReadonlySet | 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): 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 | 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>(); 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, inspectedTextByRule: Array | 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 | undefined> = new Array(rules.length); return { + hasAuditRules, inspectFragment(fragment) { return inspectFragment(fragment, inspectedTextByRule); }, diff --git a/packages/api/src/shared-links/protection.spec.ts b/packages/api/src/shared-links/protection.spec.ts index 1bf6aff00c..86cf9eee6e 100644 --- a/packages/api/src/shared-links/protection.spec.ts +++ b/packages/api/src/shared-links/protection.spec.ts @@ -1497,6 +1497,30 @@ describe('shared file metadata protection', () => { }); }); + it('allows malformed metadata traversal failures for an audit-only policy', () => { + let malformedUrl: Record = { 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) => [ diff --git a/packages/api/src/shared-links/protection.ts b/packages/api/src/shared-links/protection.ts index c7571f40eb..6831cef9e5 100644 --- a/packages/api/src/shared-links/protection.ts +++ b/packages/api/src/shared-links/protection.ts @@ -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]); diff --git a/packages/api/src/tools/protection.spec.ts b/packages/api/src/tools/protection.spec.ts index 7ed769a206..11e7fcef90 100644 --- a/packages/api/src/tools/protection.spec.ts +++ b/packages/api/src/tools/protection.spec.ts @@ -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(); + }); }); diff --git a/packages/api/src/tools/protection.ts b/packages/api/src/tools/protection.ts index 8f5f29a5e0..c73e7b5a97 100644 --- a/packages/api/src/tools/protection.ts +++ b/packages/api/src/tools/protection.ts @@ -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; + } } } diff --git a/packages/data-provider/src/filters.spec.ts b/packages/data-provider/src/filters.spec.ts index 193a10a6ad..c92df1ea8a 100644 --- a/packages/data-provider/src/filters.spec.ts +++ b/packages/data-provider/src/filters.spec.ts @@ -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); diff --git a/packages/data-provider/src/filters.ts b/packages/data-provider/src/filters.ts index 793de03012..9d010c727e 100644 --- a/packages/data-provider/src/filters.ts +++ b/packages/data-provider/src/filters.ts @@ -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; @@ -178,6 +179,7 @@ export type FileFilterField = z.infer; export type ToolArgumentFilterField = z.infer; export type ModelParameterFilterField = z.infer; export type ActionMetadataFilterField = z.infer; +export type FilterPiiAction = z.infer; export const userSubmittedMessageFieldPathSchema = z .object({ @@ -285,6 +287,7 @@ export type FilterPiiCustomPatternConfig = z.infer(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)