feat: add persisted content filter safeguards

This commit is contained in:
Danny Avila 2026-08-04 09:07:01 -04:00
parent d0b869cf18
commit 57a6b46645
35 changed files with 5164 additions and 152 deletions

View file

@ -3275,6 +3275,28 @@ describe('AgentClient - titleConvo', () => {
expect(result).toBeUndefined();
expect(mockProcessMemory).not.toHaveBeenCalled();
});
it('should contain automatic memory rejection and log only bounded metadata', async () => {
const { HumanMessage } = require('@librechat/agents/langchain/messages');
const { logger } = require('@librechat/data-schemas');
const sensitiveValue = 'PRIVATE-MEMORY-REJECTION-CONTENT';
const contentFilterError = new Error(sensitiveValue);
contentFilterError.code = 'content_filter_block';
mockProcessMemory.mockRejectedValueOnce(contentFilterError);
const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
try {
await expect(client.runMemory([new HumanMessage('Safe message')])).resolves.toBeUndefined();
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith('Memory Agent failed to process memory', {
type: 'Error',
});
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(sensitiveValue);
} finally {
errorSpy.mockRestore();
}
});
});
describe('getMessagesForConversation - mapMethod and mapCondition', () => {

View file

@ -46,6 +46,11 @@ describe('Convos Routes', () => {
app.use((req, res, next) => {
req.user = { id: 'test-user-123', role: 'USER' };
req.config = {
messageFilter: {
pii: {
starterPatterns: ['sk_prefix'],
},
},
filters: {
messages: {
pii: {
@ -87,6 +92,9 @@ describe('Convos Routes', () => {
},
},
},
legacyPii: {
starterPatterns: ['sk_prefix'],
},
});
});
@ -140,6 +148,9 @@ describe('Convos Routes', () => {
},
},
},
legacyPii: {
starterPatterns: ['sk_prefix'],
},
}),
);
});
@ -193,6 +204,9 @@ describe('Convos Routes', () => {
},
},
},
legacyPii: {
starterPatterns: ['sk_prefix'],
},
}),
);
});

View file

@ -171,6 +171,7 @@ const buildApp = ({
retentionMode = RetentionMode.TEMPORARY,
user = { id: 'user-123' },
filters,
messageFilter,
} = {}) => {
const app = express();
app.use(express.json());
@ -179,6 +180,7 @@ const buildApp = ({
req.config = {
interfaceConfig: { retentionMode },
...(filters == null ? {} : { filters }),
...(messageFilter == null ? {} : { messageFilter }),
};
next();
});
@ -311,6 +313,60 @@ describe('share routes', () => {
});
});
it('threads a legacy-only detector into strict shared-content preflight', async () => {
const strictFilters = {
messages: { unattributedAssistantContent: 'inspect' },
};
const legacyPii = {
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-[A-Z]+' }],
};
const share = {
shareId: 'share-123',
title: 'Protected Conversation',
messages: [{ isCreatedByUser: false, role: 'assistant', text: 'safe model output' }],
};
mockSharedMessagesResult(share);
const response = await request(
buildApp({ filters: strictFilters, messageFilter: { pii: legacyPii } }),
).get('/api/share/share-123');
expect(response.status).toBe(200);
expect(mockAssertConversationContentAllowed).toHaveBeenCalledWith(
strictFilters,
{
conversations: [{ title: share.title }],
messages: share.messages,
},
{ legacyPii },
);
});
it('keeps shared-message preflight active with only legacy message filtering', async () => {
const legacyPii = { starterPatterns: ['sk_prefix'] };
const share = {
shareId: 'share-123',
title: 'Protected Conversation',
messages: [{ isCreatedByUser: true, text: 'safe user input' }],
};
mockSharedMessagesResult(share);
const response = await request(buildApp({ messageFilter: { pii: legacyPii } })).get(
'/api/share/share-123',
);
expect(response.status).toBe(200);
expect(mockAssertConversationContentAllowed).toHaveBeenCalledWith(
undefined,
{
conversations: [{ title: share.title }],
messages: share.messages,
},
{ legacyPii },
);
});
it('returns a raw-free 400 when existing shared metadata fails current policy', async () => {
const error = Object.assign(new Error('PRIVATE-SENTINEL'), {
code: 'content_filter_block',

View file

@ -131,6 +131,34 @@ describe('assistant route content filters', () => {
expect(mockCreateAssistantV1).not.toHaveBeenCalled();
});
it.each([
['V1', './v1', mockPatchAssistantV1],
['V2', './v2', mockPatchAssistantV2],
])(
'allows a safe partial assistant patch on %s while instruction filtering is active',
async (_version, route, controller) => {
const module = require(route);
const app = createApp(module.v1 ?? module, {
filters: {
agentInstructions: {
pii: {
fields: ['instructions'],
starterPatterns: [],
customPatterns: [customPattern],
},
},
},
});
const response = await request(app)
.patch('/assistant-id')
.send({ description: 'Safe remediation metadata edit.' });
expect(response.status).toBe(200);
expect(controller).toHaveBeenCalledTimes(1);
},
);
it('blocks function parameter schemas on V2 patch before the controller', async () => {
const app = createApp(require('./v2'), {
filters: {

View file

@ -349,6 +349,9 @@ router.post(
userRole: req.user.role,
interfaceConfig: req.config?.interfaceConfig,
filters: req.config?.filters,
...(req.config?.messageFilter?.pii == null
? {}
: { legacyPii: req.config.messageFilter.pii }),
});
res.status(201).json({ message: 'Conversation(s) imported successfully' });
} catch (error) {
@ -382,6 +385,9 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, configMiddleware, async (re
splitAtTarget,
option,
filters: req.config?.filters,
...(req.config?.messageFilter?.pii == null
? {}
: { legacyPii: req.config.messageFilter.pii }),
});
res.json(result);
@ -409,6 +415,9 @@ router.post(
conversationId,
title,
filters: req.config?.filters,
...(req.config?.messageFilter?.pii == null
? {}
: { legacyPii: req.config.messageFilter.pii }),
});
res.status(201).json(result);
} catch (error) {

View file

@ -111,7 +111,8 @@ const omitUnsharedMessageFiles = (messages) =>
}));
const createShareContentPreflight = (filters, options = {}) => {
if (filters == null) {
const legacyPii = options.legacyPii;
if (filters == null && legacyPii == null) {
return undefined;
}
return async ({ title, messages, shareId }) => {
@ -126,11 +127,16 @@ const createShareContentPreflight = (filters, options = {}) => {
: messages,
};
if (options.user == null) {
await assertConversationContentAllowed(filters, snapshot);
if (legacyPii == null) {
await assertConversationContentAllowed(filters, snapshot);
} else {
await assertConversationContentAllowed(filters, snapshot, { legacyPii });
}
} else {
await assertConversationContentAllowed(filters, snapshot, {
user: options.user,
getFiles,
...(legacyPii == null ? {} : { legacyPii }),
});
}
if (!inspectSharedFileMetadata) {
@ -320,6 +326,7 @@ if (allowSharedLinks) {
try {
const contentPreflight = createShareContentPreflight(req.config?.filters, {
sharedFileMetadata: true,
legacyPii: req.config?.messageFilter?.pii,
});
const share = await getSharedMessages(req.params.shareId, req.shareResourceId, {
// Viewer-independent: the per-link choice (stored on the share) decides
@ -364,6 +371,7 @@ if (allowSharedLinks) {
snapshotFiles: !isFileSnapshotKillSwitchActive(),
sharedContentPreflight: createShareContentPreflight(req.config?.filters, {
sharedFileMetadata: true,
legacyPii: req.config?.messageFilter?.pii,
}),
});
if (!result) {
@ -559,6 +567,7 @@ router.post(
user: req.user,
sharedFileMetadata: true,
sharedFileMetadataFiles: false,
legacyPii: req.config?.messageFilter?.pii,
});
const created = await createSharedLink(
@ -611,6 +620,7 @@ router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) =>
user: req.user,
sharedFileMetadata: true,
sharedFileMetadataFiles: false,
legacyPii: req.config?.messageFilter?.pii,
});
const updatedShare = await updateSharedLink(
req.user.id,

View file

@ -82,7 +82,8 @@ function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) {
* @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level.
* @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true.
* @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted.
* @param {(userId: string, interfaceConfig?: object, filters?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted.
* @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @returns {Promise<TForkConvoResponse>} The response after forking the conversation.
*/
async function forkConversation({
@ -95,6 +96,7 @@ async function forkConversation({
splitAtTarget = false,
latestMessageId,
filters,
legacyPii,
builderFactory = createImportBatchBuilder,
}) {
try {
@ -112,7 +114,10 @@ async function forkConversation({
targetMessageId = latestMessageId;
}
const importBatchBuilder = builderFactory(requestUserId, undefined, filters);
const importBatchBuilder =
legacyPii == null
? builderFactory(requestUserId, undefined, filters)
: builderFactory(requestUserId, undefined, filters, legacyPii);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
let messagesToClone = [];
@ -394,7 +399,7 @@ function stripSharedFileIds(message) {
* @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server.
* @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored.
* @param {(snapshot: object) => Promise<void>} [params.sharedContentPreflight] - Reapplies current policy to the exact public projection before a legacy shared-file snapshot is persisted.
* @param {(userId: string, interfaceConfig?: object, filters?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @param {(options: object) => Promise<object>} [params.loadAppConfig] - Resolves the app config; injectable for tests. Called inside the requesting user's tenant context so retention policy is read from the viewer's tenant, not the share owner's.
* @returns {Promise<TForkConvoResponse | null>} The new conversation and messages, or null when the share is missing or empty.
*/
@ -477,11 +482,15 @@ async function forkSharedConversation({
// can actually use; hard-coding OpenAI breaks the first follow-up message on
// deployments that don't expose it.
const { endpoint, model } = await resolveImportDefaultEndpoint({ requestUserId, userRole });
const importBatchBuilder = builderFactory(
requestUserId,
appConfig?.interfaceConfig,
appConfig?.filters,
);
const importBatchBuilder =
appConfig?.messageFilter?.pii == null
? builderFactory(requestUserId, appConfig?.interfaceConfig, appConfig?.filters)
: builderFactory(
requestUserId,
appConfig?.interfaceConfig,
appConfig?.filters,
appConfig.messageFilter.pii,
);
importBatchBuilder.startConversation(endpoint);
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
@ -514,7 +523,8 @@ async function forkSharedConversation({
* @param {string} params.conversationId - The ID of the conversation to duplicate.
* @param {string} [params.title] - Optional title override for the duplicate.
* @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted.
* @param {(userId: string, interfaceConfig?: object, filters?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted.
* @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages.
*/
async function duplicateConversation({
@ -522,6 +532,7 @@ async function duplicateConversation({
conversationId,
title,
filters,
legacyPii,
builderFactory = createImportBatchBuilder,
}) {
const originalConvo = await getConvo(userId, conversationId);
@ -539,7 +550,10 @@ async function duplicateConversation({
originalMessages[originalMessages.length - 1].messageId,
);
const importBatchBuilder = builderFactory(userId, undefined, filters);
const importBatchBuilder =
legacyPii == null
? builderFactory(userId, undefined, filters)
: builderFactory(userId, undefined, filters, legacyPii);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);

View file

@ -298,6 +298,38 @@ describe('forkConversation', () => {
expect(bulkSaveMessages).not.toHaveBeenCalled();
expect(bulkIncrementTagCounts).not.toHaveBeenCalled();
});
test('blocks unattributed assistant prose with strict legacy-only policy before forking', async () => {
getMessages.mockResolvedValue([
{
messageId: 'private-assistant-message',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: false,
text: 'Legacy unattributed PRIVATE-SENTINEL',
createdAt: '2021-01-01',
},
]);
await expect(
forkConversation({
originalConvoId: 'abc123',
targetMessageId: 'private-assistant-message',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
filters: { messages: { unattributedAssistantContent: 'inspect' } },
legacyPii: {
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-[A-Z]+' }],
},
}),
).rejects.toMatchObject({
code: 'content_filter_block',
body: expect.objectContaining({ source: 'message', field: 'text' }),
});
expect(bulkSaveConvos).not.toHaveBeenCalled();
expect(bulkSaveMessages).not.toHaveBeenCalled();
});
});
describe('duplicateConversation', () => {
@ -682,6 +714,25 @@ describe('forkSharedConversation', () => {
expect(builderFactory).toHaveBeenCalledWith('user1', interfaceConfig, undefined);
});
test('passes strict attribution and legacy message policy to the shared-fork builder', async () => {
const filters = { messages: { unattributedAssistantContent: 'inspect' } };
const legacyPii = { starterPatterns: [] };
const loadAppConfig = jest.fn().mockResolvedValue({
filters,
messageFilter: { pii: legacyPii },
});
const builderFactory = jest.fn((...args) => createImportBatchBuilder(...args));
await forkSharedConversation({
shareId: 'share123',
requestUserId: 'user1',
loadAppConfig,
builderFactory,
});
expect(builderFactory).toHaveBeenCalledWith('user1', undefined, filters, legacyPii);
});
test('should resolve the app config under the requesting user tenant', async () => {
const { tenantStorage, getTenantId } = require('@librechat/data-schemas');
let tenantDuringConfigLoad;

View file

@ -3,9 +3,11 @@ const {
ContentFilterError,
UninspectableFileError,
assertModelBoundContent,
createConfiguredContentInspector,
extractConversationImportContent,
getBlockedOpaqueFileField,
getContentTraversalFragments,
getUserSubmittedPathState,
inspectContent,
isContentTraversalProtected,
isContentTraversalLimitError,
@ -31,10 +33,11 @@ const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults');
* @param {string} requestUserId - The ID of the user making the request.
* @param {object} [interfaceConfig] - Runtime interface config for import retention.
* @param {object} [filters] - Source-aware content filters for submitted imports.
* @param {object} [legacyPii] - Legacy messageFilter.pii configuration.
* @returns {ImportBatchBuilder} - The newly created ImportBatchBuilder instance.
*/
function createImportBatchBuilder(requestUserId, interfaceConfig, filters) {
return new ImportBatchBuilder(requestUserId, interfaceConfig, filters);
function createImportBatchBuilder(requestUserId, interfaceConfig, filters, legacyPii) {
return new ImportBatchBuilder(requestUserId, interfaceConfig, filters, legacyPii);
}
/**
@ -47,6 +50,7 @@ function createImportBatchBuilder(requestUserId, interfaceConfig, filters) {
* @param {{ id?: string, tenantId?: string }} [resolutionContext.user] - Snapshot owner.
* @param {Function} [resolutionContext.getFiles] - Canonical file lookup.
* @param {object[]} [resolutionContext.trustedLiveFiles] - Server-hydrated canonical rows.
* @param {object} [resolutionContext.legacyPii] - Legacy messageFilter.pii configuration.
* @returns {Promise<void>}
* @throws {ContentFilterError|UninspectableFileError|import('@librechat/api').ContentTraversalLimitError}
*/
@ -55,37 +59,40 @@ async function assertConversationContentAllowed(
{ conversations, messages },
resolutionContext = {},
) {
if (filters == null) {
const { legacyPii } = resolutionContext;
if (filters == null && legacyPii == null) {
return;
}
let conversationFragments;
let conversationTraversalError;
try {
conversationFragments = extractConversationImportContent({
conversations,
messages: [],
});
conversationFragments = [...conversationFragments];
} catch (error) {
if (!isContentTraversalLimitError(error)) {
throw error;
if (filters != null) {
let conversationFragments;
let conversationTraversalError;
try {
conversationFragments = extractConversationImportContent({
conversations,
messages: [],
});
conversationFragments = [...conversationFragments];
} catch (error) {
if (!isContentTraversalLimitError(error)) {
throw error;
}
conversationFragments = getContentTraversalFragments(error);
conversationTraversalError = error;
}
const conversationFinding = inspectContent(conversationFragments, { filters });
if (conversationFinding != null) {
throw new ContentFilterError(conversationFinding);
}
if (
conversationTraversalError != null &&
isContentTraversalProtected({
error: conversationTraversalError,
filters,
})
) {
throw conversationTraversalError;
}
conversationFragments = getContentTraversalFragments(error);
conversationTraversalError = error;
}
const conversationFinding = inspectContent(conversationFragments, { filters });
if (conversationFinding != null) {
throw new ContentFilterError(conversationFinding);
}
if (
conversationTraversalError != null &&
isContentTraversalProtected({
error: conversationTraversalError,
filters,
})
) {
throw conversationTraversalError;
}
/**
@ -96,7 +103,7 @@ async function assertConversationContentAllowed(
*/
let storedMessages = messages;
let resolvedFiles = [];
if (filters.files?.pii != null) {
if (filters?.files?.pii != null) {
const fileInspection = await resolveCanonicalFileReferences({
filters,
input: messages,
@ -119,6 +126,7 @@ async function assertConversationContentAllowed(
try {
assertModelBoundContent({
filters,
legacyPii,
storedMessages: [message],
});
} catch (error) {
@ -126,22 +134,20 @@ async function assertConversationContentAllowed(
throw error;
}
const explicitPaths = Array.isArray(message.userSubmittedPaths)
? message.userSubmittedPaths.filter(
(path) => typeof path === 'string' && path.startsWith('/'),
)
: [];
if (Array.isArray(message.content)) {
for (let index = 0; index < message.content.length; index++) {
if (message.content[index]?.type === 'steer') {
explicitPaths.push(`/content/${index}`);
}
}
}
const submittedPathState = getUserSubmittedPathState(message);
const explicitPaths = submittedPathState.paths;
const isStrictUnattributedAssistant =
filters?.messages?.unattributedAssistantContent === 'inspect' &&
typeof message.isUserSubmitted !== 'boolean' &&
explicitPaths.length === 0 &&
(message.isCreatedByUser === false ||
message.role === 'assistant' ||
message.role === 'ai');
const isWholeMessageSubmitted =
message.isCreatedByUser === true ||
message.isUserSubmitted === true ||
new Set(explicitPaths).size > 256;
submittedPathState.overflowed ||
isStrictUnattributedAssistant;
const relevantFragments = getContentTraversalFragments(error).filter(
(fragment) =>
isWholeMessageSubmitted ||
@ -150,7 +156,9 @@ async function assertConversationContentAllowed(
(path) => fragment.path === path || fragment.path.startsWith(`${path}/`),
),
);
const messageFinding = inspectContent(relevantFragments, { filters });
const messageFinding = createConfiguredContentInspector({ filters, legacyPii })?.inspect(
relevantFragments,
);
if (messageFinding != null) {
throw new ContentFilterError(messageFinding);
}
@ -162,13 +170,14 @@ async function assertConversationContentAllowed(
}
}
const traversalFilters =
isWholeMessageSubmitted || explicitPaths.length > 0
? filters
: { ...filters, messages: undefined };
let traversalFilters = filters;
if (!isWholeMessageSubmitted && explicitPaths.length === 0 && filters != null) {
traversalFilters = { ...filters, messages: undefined };
}
if (
isNestedMessageTraversalProtected({
filters: traversalFilters,
legacyPii: isWholeMessageSubmitted || explicitPaths.length > 0 ? legacyPii : undefined,
roles:
isWholeMessageSubmitted || explicitPaths.length > 0 ? ['user'] : [message.role, 'tool'],
})
@ -188,11 +197,13 @@ class ImportBatchBuilder {
* @param {string} requestUserId - The ID of the user making the import request.
* @param {object} [interfaceConfig] - Runtime interface config for import retention.
* @param {object} [filters] - Source-aware content filters for submitted imports.
* @param {object} [legacyPii] - Legacy messageFilter.pii configuration.
*/
constructor(requestUserId, interfaceConfig, filters) {
constructor(requestUserId, interfaceConfig, filters, legacyPii) {
this.requestUserId = requestUserId;
this.interfaceConfig = interfaceConfig;
this.filters = filters;
this.legacyPii = legacyPii;
this.conversations = [];
this.messages = [];
this.retentionFields = undefined;
@ -312,6 +323,7 @@ class ImportBatchBuilder {
{
user: { id: this.requestUserId },
getFiles,
...(this.legacyPii == null ? {} : { legacyPii: this.legacyPii }),
},
);

View file

@ -98,6 +98,91 @@ describe('ImportBatchBuilder content filtering', () => {
expect(bulkSaveMessages).toHaveBeenCalledTimes(1);
});
it('applies strict legacy attribution with a legacy-only message detector', async () => {
const builder = new ImportBatchBuilder(
'user-123',
undefined,
{ messages: { unattributedAssistantContent: 'inspect' } },
{
starterPatterns: [],
customPatterns: [pattern],
},
);
builder.startConversation(EModelEndpoint.openAI);
builder.saveMessage({
sender: 'Assistant',
isCreatedByUser: false,
text: 'Legacy unattributed IMPORT-SECRET',
});
builder.finishConversation('safe title', new Date('2026-01-01T00:00:00.000Z'));
await expect(builder.saveBatch()).rejects.toMatchObject({
body: {
error: 'content_filter_block',
source: 'message',
field: 'text',
},
});
expect(bulkSaveMessages).not.toHaveBeenCalled();
});
it('keeps strict attribution in the traversal fallback for ineffective provenance paths', async () => {
const builder = new ImportBatchBuilder('user-123', undefined, {
...filtersFor('messages', ['text']),
messages: {
...filtersFor('messages', ['text']).messages,
unattributedAssistantContent: 'inspect',
},
});
builder.startConversation(EModelEndpoint.openAI);
builder.saveMessage({
sender: 'Assistant',
role: 'assistant',
isCreatedByUser: false,
text: 'Legacy unattributed IMPORT-SECRET',
userSubmittedPaths: ['/messageId'],
});
builder.finishConversation('safe title', new Date('2026-01-01T00:00:00.000Z'));
mockAssertModelBoundContent.mockImplementationOnce(() => {
throw new actualApi.ContentTraversalLimitError([
{
id: 'stored-message.text',
path: '/text',
text: 'Legacy unattributed IMPORT-SECRET',
source: 'message',
field: 'text',
format: 'plain',
treatment: 'inspect_only',
provenance: 'user',
},
]);
});
await expect(builder.saveBatch()).rejects.toMatchObject({
body: {
error: 'content_filter_block',
source: 'message',
field: 'text',
},
});
expect(bulkSaveMessages).not.toHaveBeenCalled();
});
it('keeps legacy-only filtering active for explicitly submitted imported rows', async () => {
const builder = new ImportBatchBuilder('user-123', undefined, undefined, {
starterPatterns: [],
customPatterns: [pattern],
});
builder.startConversation(EModelEndpoint.openAI);
builder.addUserMessage('Imported IMPORT-SECRET');
builder.finishConversation('safe title', new Date('2026-01-01T00:00:00.000Z'));
await expect(builder.saveBatch()).rejects.toMatchObject({
body: expect.objectContaining({ source: 'message', field: 'text' }),
});
expect(bulkSaveMessages).not.toHaveBeenCalled();
});
it('blocks provenance-marked assistant content while ignoring adjacent model prose', async () => {
const builder = new ImportBatchBuilder(
'user-123',

View file

@ -8,10 +8,10 @@ const maxFileSize = resolveImportMaxFileSize();
/**
* Job definition for importing a conversation.
* @param {{ filepath: string, requestUserId: string, userRole?: string, interfaceConfig?: object, filters?: object }} job
* @param {{ filepath: string, requestUserId: string, userRole?: string, interfaceConfig?: object, filters?: object, legacyPii?: object }} job
*/
const importConversations = async (job) => {
const { filepath, requestUserId, userRole, interfaceConfig, filters } = job;
const { filepath, requestUserId, userRole, interfaceConfig, filters, legacyPii } = job;
try {
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
@ -28,7 +28,10 @@ const importConversations = async (job) => {
await importer(
jsonData,
requestUserId,
(userId) => createImportBatchBuilder(userId, interfaceConfig, filters),
(userId) =>
legacyPii == null
? createImportBatchBuilder(userId, interfaceConfig, filters)
: createImportBatchBuilder(userId, interfaceConfig, filters, legacyPii),
userRole,
);
logger.debug(`user: ${requestUserId} | Finished importing conversations`);

View file

@ -81,4 +81,42 @@ describe('importConversations content filtering', () => {
expect(bulkSaveMessages).not.toHaveBeenCalled();
expect(bulkIncrementTagCounts).not.toHaveBeenCalled();
});
it('threads strict attribution with a legacy-only detector into imported rows', async () => {
getImporter.mockReturnValue(async (_jsonData, requestUserId, builderFactory) => {
const builder = builderFactory(requestUserId);
builder.startConversation(EModelEndpoint.openAI);
builder.saveMessage({
sender: 'Assistant',
isCreatedByUser: false,
text: 'Legacy unattributed IMPORT-SECRET',
});
builder.finishConversation('safe title');
await builder.saveBatch();
});
await expect(
importConversations({
filepath,
requestUserId: 'user-123',
filters: { messages: { unattributedAssistantContent: 'inspect' } },
legacyPii: {
starterPatterns: [],
customPatterns: [
{
id: 'import-secret',
label: 'restricted import value',
regex: 'IMPORT-SECRET',
},
],
},
}),
).rejects.toMatchObject({
code: 'content_filter_block',
body: expect.objectContaining({ source: 'message', field: 'text' }),
});
await expect(fs.stat(filepath)).rejects.toMatchObject({ code: 'ENOENT' });
expect(bulkSaveMessages).not.toHaveBeenCalled();
});
});

View file

@ -24,6 +24,10 @@ mcpSettings:
allowedDomains:
- https://allowed.example.com
actions:
allowedDomains:
- example.com
mcpServers:
e2e-memory:
type: stdio

View file

@ -13,6 +13,8 @@ const labelServerPath = path.resolve(rootPath, 'e2e/setup/fake-label-server.js')
* `writeRuntimeMockConfig` substitutes any override into the generated copy. */
const LABEL_PORT = process.env.E2E_LABEL_PORT || '8889';
const fakeModelHookPath = path.resolve(rootPath, 'e2e/setup/fake-model.js');
const assistantsServerPath = path.resolve(rootPath, 'e2e/setup/fake-assistants-server.js');
const ASSISTANTS_PORT = process.env.E2E_ASSISTANTS_PORT || '8890';
const configTemplatePath = path.resolve(rootPath, 'e2e/config/librechat.e2e.yaml');
const configPath = path.resolve(rootPath, 'e2e/.generated/librechat.e2e.yaml');
const reportPath = path.resolve(rootPath, 'e2e/playwright-report');
@ -29,7 +31,10 @@ const vanillaOverrides = {
OPENID_AUTO_REDIRECT: 'false',
ALLOW_SOCIAL_LOGIN: 'false',
ALLOW_SOCIAL_REGISTRATION: 'false',
ALLOW_SHARED_LINKS_PUBLIC: 'true',
STREAM_KEEP_COMPLETED_JOBS: 'true',
FORK_IP_MAX: '100',
FORK_USER_MAX: '100',
/** A local `.env` may enable balance enforcement, which `neutralizeCredentialEnv`
* does not blank (not credential-shaped); the fresh e2e user has no balance
* record, so every streaming spec would be refused with a token_balance
@ -43,6 +48,10 @@ const baseEnv = {
DEPLOYMENT_SKILLS_DIR: deploymentSkillsPath,
/** Loaded in-process by `@librechat/api`'s `createRun` to swap in a fake model. */
LIBRECHAT_TEST_RUN_HOOK: fakeModelHookPath,
/** The Assistants runtime uses the OpenAI SDK directly, outside the agents run hook. */
ASSISTANTS_API_KEY: 'e2e-mock-assistants-key',
ASSISTANTS_BASE_URL: `http://127.0.0.1:${ASSISTANTS_PORT}/v1`,
ASSISTANTS_MODELS: 'gpt-4o-mini',
...vanillaOverrides,
};
@ -176,5 +185,15 @@ export default defineConfig({
timeout: 60_000,
reuseExistingServer: false,
},
{
// Stateful provider-boundary fake for Assistant CRUD and streamed runs.
command: `node ${assistantsServerPath}`,
cwd: rootPath,
env: { ...process.env, E2E_ASSISTANTS_PORT: ASSISTANTS_PORT },
url: `http://127.0.0.1:${ASSISTANTS_PORT}/`,
stdout: 'pipe',
timeout: 60_000,
reuseExistingServer: false,
},
],
});

View file

@ -0,0 +1,517 @@
/**
* Stateful OpenAI Assistants API fixture for credential-free mock e2e tests.
*
* This deliberately implements only the provider operations LibreChat uses for
* Assistant CRUD and a text-only streamed run. It is a provider-boundary fake:
* LibreChat's real routes, OpenAI SDK client, persistence, content preflights,
* and SSE handling all remain in the request path.
*/
const http = require('http');
const { randomUUID } = require('crypto');
const PORT = Number(process.env.E2E_ASSISTANTS_PORT) || 8890;
const DEFAULT_REPLY = process.env.E2E_ASSISTANTS_REPLY || 'E2E mock assistant reply: pong';
const MAX_BODY_BYTES = 1024 * 1024;
const assistants = new Map();
const threads = new Map();
const runs = new Map();
const requests = [];
function now() {
return Math.floor(Date.now() / 1000);
}
function createId(prefix) {
return `${prefix}_${randomUUID().replaceAll('-', '')}`;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let raw = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
raw += chunk;
if (Buffer.byteLength(raw) > MAX_BODY_BYTES) {
reject(new Error('Request body exceeds fixture limit'));
req.destroy();
}
});
req.on('end', () => {
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error('Request body must be valid JSON'));
}
});
req.on('error', reject);
});
}
function sendJson(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function sendError(res, status, message) {
sendJson(res, status, {
error: {
message,
type: status === 404 ? 'invalid_request_error' : 'e2e_fixture_error',
param: null,
code: null,
},
});
}
function asTextContent(content) {
if (typeof content === 'string') {
return [{ type: 'text', text: { value: content, annotations: [] } }];
}
if (!Array.isArray(content)) {
return [];
}
return content.map((part) => {
if (part?.type !== 'text') {
return part;
}
if (typeof part.text === 'string') {
return { ...part, text: { value: part.text, annotations: [] } };
}
return {
...part,
text: {
value: part.text?.value ?? '',
annotations: part.text?.annotations ?? [],
},
};
});
}
function createMessage({
threadId,
role,
content,
assistantId = null,
runId = null,
metadata = {},
}) {
return {
id: createId('msg'),
object: 'thread.message',
created_at: now(),
assistant_id: assistantId,
thread_id: threadId,
run_id: runId,
role,
content: asTextContent(content),
attachments: [],
metadata,
status: 'completed',
incomplete_details: null,
completed_at: now(),
incomplete_at: null,
};
}
function createAssistant(body) {
const createdAt = now();
return {
...body,
id: createId('asst'),
object: 'assistant',
created_at: createdAt,
name: body.name ?? null,
description: body.description ?? null,
instructions: body.instructions ?? null,
model: body.model,
tools: body.tools ?? [],
tool_resources: body.tool_resources ?? {},
metadata: body.metadata ?? {},
response_format: body.response_format ?? 'auto',
temperature: body.temperature ?? 1,
top_p: body.top_p ?? 1,
};
}
function listResponse(data) {
return {
object: 'list',
data,
first_id: data[0]?.id ?? null,
last_id: data[data.length - 1]?.id ?? null,
has_more: false,
};
}
function assistantReply(thread) {
const latestUserMessage = [...thread.messages]
.reverse()
.find((message) => message.role === 'user');
const text = latestUserMessage?.content
?.filter((part) => part?.type === 'text')
.map((part) => part.text?.value ?? '')
.join('\n');
const marker = text?.match(/E2E_REPLY:([A-Za-z0-9._-]+)/)?.[1];
return marker ? `E2E assistant reply ${marker}` : DEFAULT_REPLY;
}
function runObject({ id, threadId, assistant, status, usage = null }) {
const timestamp = now();
return {
id,
object: 'thread.run',
created_at: timestamp,
assistant_id: assistant.id,
thread_id: threadId,
status,
started_at: timestamp,
expires_at: timestamp + 600,
cancelled_at: null,
failed_at: null,
completed_at: status === 'completed' ? timestamp : null,
required_action: null,
last_error: null,
model: assistant.model,
instructions: assistant.instructions ?? '',
tools: assistant.tools ?? [],
tool_resources: assistant.tool_resources ?? {},
metadata: {},
incomplete_details: null,
usage,
temperature: assistant.temperature ?? 1,
top_p: assistant.top_p ?? 1,
max_prompt_tokens: null,
max_completion_tokens: null,
truncation_strategy: { type: 'auto', last_messages: null },
response_format: assistant.response_format ?? 'auto',
tool_choice: 'auto',
parallel_tool_calls: true,
};
}
function runStep({ id, runId, threadId, assistantId, messageId, status }) {
const timestamp = now();
return {
id,
object: 'thread.run.step',
created_at: timestamp,
assistant_id: assistantId,
thread_id: threadId,
run_id: runId,
type: 'message_creation',
status,
step_details: {
type: 'message_creation',
message_creation: { message_id: messageId },
},
last_error: null,
expired_at: null,
cancelled_at: null,
failed_at: null,
completed_at: status === 'completed' ? timestamp : null,
metadata: null,
usage:
status === 'completed' ? { prompt_tokens: 8, completion_tokens: 6, total_tokens: 14 } : null,
};
}
function sendAssistantStream(res, { assistant, thread }) {
const runId = createId('run');
const stepId = createId('step');
const reply = assistantReply(thread);
const message = createMessage({
threadId: thread.id,
role: 'assistant',
content: reply,
assistantId: assistant.id,
runId,
});
const createdRun = runObject({
id: runId,
threadId: thread.id,
assistant,
status: 'queued',
});
const completedRun = runObject({
id: runId,
threadId: thread.id,
assistant,
status: 'completed',
usage: { prompt_tokens: 8, completion_tokens: 6, total_tokens: 14 },
});
const createdStep = runStep({
id: stepId,
runId,
threadId: thread.id,
assistantId: assistant.id,
messageId: message.id,
status: 'in_progress',
});
const completedStep = runStep({
id: stepId,
runId,
threadId: thread.id,
assistantId: assistant.id,
messageId: message.id,
status: 'completed',
});
const createdMessage = { ...message, content: [], status: 'in_progress', completed_at: null };
const messageDelta = {
id: message.id,
object: 'thread.message.delta',
delta: {
content: [
{
index: 0,
type: 'text',
text: { value: reply, annotations: [] },
},
],
},
};
runs.set(runId, completedRun);
thread.messages.push(message);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const sendEvent = (event, data) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
sendEvent('thread.run.created', createdRun);
sendEvent('thread.run.step.created', createdStep);
sendEvent('thread.message.created', createdMessage);
sendEvent('thread.message.delta', messageDelta);
sendEvent('thread.message.completed', message);
sendEvent('thread.run.step.completed', completedStep);
sendEvent('thread.run.completed', completedRun);
res.write('data: [DONE]\n\n');
res.end();
}
function recordRequest(req, url, body) {
requests.push({
method: req.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams),
body,
});
}
function pathMatch(pathname, pattern) {
const match = pathname.match(pattern);
return match?.slice(1).map(decodeURIComponent) ?? null;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (req.method === 'GET' && url.pathname === '/') {
sendJson(res, 200, { ok: true, service: 'fake-assistants-server' });
return;
}
if (req.method === 'GET' && url.pathname === '/__e2e/requests') {
sendJson(res, 200, { count: requests.length, requests });
return;
}
if (req.method === 'POST' && url.pathname === '/__e2e/reset') {
assistants.clear();
threads.clear();
runs.clear();
requests.length = 0;
sendJson(res, 200, { ok: true });
return;
}
try {
const body = req.method === 'GET' || req.method === 'DELETE' ? {} : await readBody(req);
recordRequest(req, url, body);
if (req.method === 'GET' && url.pathname === '/v1/models') {
sendJson(res, 200, { object: 'list', data: [{ id: 'gpt-4o-mini', object: 'model' }] });
return;
}
if (url.pathname === '/v1/assistants') {
if (req.method === 'POST') {
if (typeof body.model !== 'string' || body.model.length === 0) {
sendError(res, 400, 'model is required');
return;
}
const assistant = createAssistant(body);
assistants.set(assistant.id, assistant);
sendJson(res, 200, assistant);
return;
}
if (req.method === 'GET') {
const order = url.searchParams.get('order') ?? 'desc';
const data = [...assistants.values()].sort((a, b) =>
order === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at,
);
sendJson(res, 200, listResponse(data));
return;
}
}
const assistantPath = pathMatch(url.pathname, /^\/v1\/assistants\/([^/]+)$/);
if (assistantPath) {
const [assistantId] = assistantPath;
const assistant = assistants.get(assistantId);
if (!assistant) {
sendError(res, 404, `No assistant found with id '${assistantId}'`);
return;
}
if (req.method === 'GET') {
sendJson(res, 200, assistant);
return;
}
if (req.method === 'POST') {
const updated = {
...assistant,
...body,
id: assistant.id,
object: assistant.object,
created_at: assistant.created_at,
};
assistants.set(assistantId, updated);
sendJson(res, 200, updated);
return;
}
if (req.method === 'DELETE') {
assistants.delete(assistantId);
sendJson(res, 200, { id: assistantId, object: 'assistant.deleted', deleted: true });
return;
}
}
if (req.method === 'POST' && url.pathname === '/v1/threads') {
const threadId = createId('thread');
const thread = {
id: threadId,
object: 'thread',
created_at: now(),
metadata: body.metadata ?? {},
tool_resources: body.tool_resources ?? {},
messages: (body.messages ?? []).map((message) =>
createMessage({
threadId,
role: message.role,
content: message.content,
metadata: message.metadata ?? {},
}),
),
};
threads.set(threadId, thread);
const { messages: _messages, ...response } = thread;
sendJson(res, 200, response);
return;
}
const messagesPath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/messages$/);
if (messagesPath) {
const [threadId] = messagesPath;
const thread = threads.get(threadId);
if (!thread) {
sendError(res, 404, `No thread found with id '${threadId}'`);
return;
}
if (req.method === 'POST') {
const message = createMessage({
threadId,
role: body.role,
content: body.content,
metadata: body.metadata ?? {},
});
thread.messages.push(message);
sendJson(res, 200, message);
return;
}
if (req.method === 'GET') {
const order = url.searchParams.get('order') ?? 'desc';
const data = [...thread.messages].sort((a, b) =>
order === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at,
);
sendJson(res, 200, listResponse(data));
return;
}
}
const messagePath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/messages\/([^/]+)$/);
if (messagePath) {
const [threadId, messageId] = messagePath;
const thread = threads.get(threadId);
const message = thread?.messages.find((candidate) => candidate.id === messageId);
if (!message) {
sendError(res, 404, `No message found with id '${messageId}'`);
return;
}
if (req.method === 'GET') {
sendJson(res, 200, message);
return;
}
if (req.method === 'POST') {
Object.assign(message, body);
sendJson(res, 200, message);
return;
}
}
const createRunPath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/runs$/);
if (createRunPath && req.method === 'POST') {
const [threadId] = createRunPath;
const thread = threads.get(threadId);
const assistant = assistants.get(body.assistant_id);
if (!thread) {
sendError(res, 404, `No thread found with id '${threadId}'`);
return;
}
if (!assistant) {
sendError(res, 404, `No assistant found with id '${body.assistant_id}'`);
return;
}
if (body.stream !== true) {
sendError(res, 400, 'Only streamed runs are supported by the e2e fixture');
return;
}
sendAssistantStream(res, { assistant, thread });
return;
}
const runPath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/runs\/([^/]+)$/);
if (runPath && req.method === 'GET') {
const [threadId, runId] = runPath;
const run = runs.get(runId);
if (!run || run.thread_id !== threadId) {
sendError(res, 404, `No run found with id '${runId}'`);
return;
}
sendJson(res, 200, run);
return;
}
sendError(res, 404, `Unhandled ${req.method} ${url.pathname}`);
} catch (error) {
if (!res.headersSent) {
sendError(res, 400, error.message);
}
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[e2e] fake assistants server listening on http://127.0.0.1:${PORT}`);
});

View file

@ -0,0 +1,336 @@
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import yaml from 'js-yaml';
import { expect } from '@playwright/test';
import { configSchema } from 'librechat-data-provider';
import type { APIRequestContext } from '@playwright/test';
import type { FiltersConfig } from 'librechat-data-provider';
import { getPrimaryE2EUser } from '../../setup/users.mock';
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
const GENERATED_CONFIG_ROOT = path.join(PROJECT_ROOT, 'e2e/.generated');
const RELOAD_SENTINEL = `e2e-content-filter-reload-${process.pid}-${randomUUID()}`;
const RELOAD_SENTINEL_PATH = `/api/admin/config/user/${encodeURIComponent(RELOAD_SENTINEL)}`;
const RELOAD_PRIORITY = 10;
type RequestFetchOptions = NonNullable<Parameters<APIRequestContext['fetch']>[1]>;
type RuntimeConfig = {
filters?: FiltersConfig;
[key: string]: unknown;
};
type BaselineState = {
configPath: string;
contents: Buffer;
mode: number;
};
export type RequestResult = {
ok: boolean;
status: number;
text: string;
body: unknown;
};
export type RequestResultOptions = {
path: string;
token?: string;
method?: string;
data?: RequestFetchOptions['data'];
multipart?: RequestFetchOptions['multipart'];
};
export type ContentFilterBlockExpectation = {
source: string;
field: string;
marker: string;
};
let baselineState: BaselineState | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function isWithin(parent: string, candidate: string): boolean {
const relative = path.relative(parent, candidate);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..');
}
function getRuntimeConfigPath(): string {
const configuredPath = process.env.CONFIG_PATH?.trim();
if (!configuredPath) {
throw new Error('CONFIG_PATH must be set for content-filter e2e tests');
}
const configPath = path.resolve(configuredPath);
if (!isWithin(GENERATED_CONFIG_ROOT, configPath) || configPath === GENERATED_CONFIG_ROOT) {
throw new Error(
`Refusing to modify CONFIG_PATH outside ${GENERATED_CONFIG_ROOT}: ${configPath}`,
);
}
const generatedRootStat = fs.lstatSync(GENERATED_CONFIG_ROOT);
if (!generatedRootStat.isDirectory() || generatedRootStat.isSymbolicLink()) {
throw new Error(`Expected a non-symlink generated config directory: ${GENERATED_CONFIG_ROOT}`);
}
const configStat = fs.lstatSync(configPath);
if (!configStat.isFile() || configStat.isSymbolicLink()) {
throw new Error(`Expected a non-symlink generated config file: ${configPath}`);
}
const realGeneratedRoot = fs.realpathSync(GENERATED_CONFIG_ROOT);
const realConfigDirectory = fs.realpathSync(path.dirname(configPath));
if (!isWithin(realGeneratedRoot, realConfigDirectory)) {
throw new Error(`Refusing to modify CONFIG_PATH through an external directory: ${configPath}`);
}
return configPath;
}
function parseRuntimeConfig(contents: Buffer): RuntimeConfig {
const parsed = yaml.load(contents.toString('utf8'));
if (!isRecord(parsed)) {
throw new Error('Generated LibreChat config must contain a YAML object');
}
return parsed as RuntimeConfig;
}
function captureBaseline(): BaselineState {
const configPath = getRuntimeConfigPath();
if (baselineState) {
if (baselineState.configPath !== configPath) {
throw new Error('CONFIG_PATH changed while a content-filter baseline was active');
}
return baselineState;
}
const contents = fs.readFileSync(configPath);
const config = parseRuntimeConfig(contents);
if (Object.prototype.hasOwnProperty.call(config, 'filters')) {
throw new Error('Content-filter e2e baseline must not define filters');
}
baselineState = {
configPath,
contents,
mode: fs.statSync(configPath).mode & 0o777,
};
return baselineState;
}
function validateRuntimeConfig(config: RuntimeConfig): void {
const result = configSchema.strict().safeParse(config);
if (result.success) {
return;
}
const issues = result.error.issues
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
.join('; ');
throw new Error(`Invalid generated LibreChat config: ${issues}`);
}
function atomicWrite(state: BaselineState, contents: string | Buffer): void {
const currentPath = getRuntimeConfigPath();
if (currentPath !== state.configPath) {
throw new Error('CONFIG_PATH changed before the generated config write');
}
const temporaryPath = path.join(
path.dirname(state.configPath),
`.${path.basename(state.configPath)}.${process.pid}.${randomUUID()}.tmp`,
);
try {
fs.writeFileSync(temporaryPath, contents, { mode: state.mode });
fs.renameSync(temporaryPath, state.configPath);
} finally {
if (fs.existsSync(temporaryPath)) {
fs.unlinkSync(temporaryPath);
}
}
}
function parseResponseBody(text: string): unknown {
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
function getConfigFromResult(result: RequestResult): RuntimeConfig {
expect(result.ok, `Expected base-config request to succeed: ${result.text}`).toBe(true);
if (!isRecord(result.body) || !isRecord(result.body.config)) {
throw new Error(`Expected base-config response to contain a config object: ${result.text}`);
}
return result.body.config as RuntimeConfig;
}
async function triggerConfigReload(request: APIRequestContext, token: string): Promise<void> {
const result = await requestResult(request, {
path: RELOAD_SENTINEL_PATH,
token,
method: 'PUT',
data: { overrides: {}, priority: RELOAD_PRIORITY },
});
expect(result.ok, `Expected config reload trigger to succeed: ${result.text}`).toBe(true);
expect(result.body, result.text).toEqual(
expect.objectContaining({
config: expect.objectContaining({ principalId: RELOAD_SENTINEL }),
}),
);
}
async function getLoadedConfig(
request: APIRequestContext,
token: string,
baseOnly: boolean,
): Promise<RuntimeConfig> {
const result = await requestResult(request, {
path: `/api/admin/config/base${baseOnly ? '?baseOnly=true' : ''}`,
token,
});
return getConfigFromResult(result);
}
async function getLoadedFilterState(
request: APIRequestContext,
token: string,
): Promise<{ base: FiltersConfig | undefined; effective: FiltersConfig | undefined }> {
const [baseConfig, effectiveConfig] = await Promise.all([
getLoadedConfig(request, token, true),
getLoadedConfig(request, token, false),
]);
return { base: baseConfig.filters, effective: effectiveConfig.filters };
}
async function deleteReloadSentinel(request: APIRequestContext, token: string): Promise<void> {
const result = await requestResult(request, {
path: RELOAD_SENTINEL_PATH,
token,
method: 'DELETE',
});
expect([200, 404], `Expected reload sentinel cleanup to succeed: ${result.text}`).toContain(
result.status,
);
}
export async function loginAdmin(request: APIRequestContext): Promise<string> {
const { email, password } = getPrimaryE2EUser();
const response = await request.post('/api/auth/login', {
data: { email, password },
failOnStatusCode: false,
});
const ok = response.ok();
const status = response.status();
const text = await response.text();
await response.dispose();
const body = parseResponseBody(text);
if (!ok) {
throw new Error(`Admin login failed with status ${status}`);
}
if (!isRecord(body) || typeof body.token !== 'string' || body.token.length === 0) {
throw new Error('Admin login response did not include an access token');
}
return body.token;
}
export async function requestResult(
request: APIRequestContext,
options: RequestResultOptions,
): Promise<RequestResult> {
if (options.data !== undefined && options.multipart !== undefined) {
throw new Error('requestResult accepts either data or multipart, not both');
}
const fetchOptions: RequestFetchOptions = {
method: options.method ?? 'GET',
failOnStatusCode: false,
};
if (options.token?.trim()) {
fetchOptions.headers = { Authorization: `Bearer ${options.token}` };
}
if (options.data !== undefined) {
fetchOptions.data = options.data;
}
if (options.multipart !== undefined) {
fetchOptions.multipart = options.multipart;
}
const response = await request.fetch(options.path, fetchOptions);
const result: RequestResult = {
ok: response.ok(),
status: response.status(),
text: await response.text(),
body: null,
};
result.body = parseResponseBody(result.text);
await response.dispose();
return result;
}
export async function setRuntimeFilters(
request: APIRequestContext,
token: string,
filters: FiltersConfig,
): Promise<void> {
const baseline = captureBaseline();
const config = { ...parseRuntimeConfig(baseline.contents), filters };
validateRuntimeConfig(config);
atomicWrite(baseline, yaml.dump(config, { noRefs: true, lineWidth: 120 }));
await triggerConfigReload(request, token);
await expect
.poll(async () => getLoadedFilterState(request, token), {
timeout: 30000,
intervals: [100, 250, 500, 1000],
})
.toEqual({ base: filters, effective: filters });
}
export async function restoreRuntimeFilters(
request: APIRequestContext,
token: string,
): Promise<void> {
const baseline = captureBaseline();
atomicWrite(baseline, baseline.contents);
try {
await triggerConfigReload(request, token);
await expect
.poll(async () => getLoadedFilterState(request, token), {
timeout: 30000,
intervals: [100, 250, 500, 1000],
})
.toEqual({ base: undefined, effective: undefined });
} finally {
await deleteReloadSentinel(request, token);
}
baselineState = undefined;
}
export function expectContentFilterBlock(
result: RequestResult,
expectation: ContentFilterBlockExpectation,
): void {
expect(result.status).toBe(400);
expect(result.body).toEqual(
expect.objectContaining({
error: 'content_filter_block',
source: expectation.source,
field: expectation.field,
}),
);
expect(result.text).not.toContain(expectation.marker);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,888 @@
import { randomUUID } from 'crypto';
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import type { FiltersConfig } from 'librechat-data-provider';
import { withMongo } from './db';
import { MOCK_ENDPOINTS } from './helpers';
import {
expectContentFilterBlock,
loginAdmin,
requestResult,
restoreRuntimeFilters,
setRuntimeFilters,
} from './content-filters.helpers';
const NO_PARENT = '00000000-0000-0000-0000-000000000000';
type JsonObject = Record<string, unknown>;
type RequestResult = Awaited<ReturnType<typeof requestResult>>;
const asObject = (value: unknown): JsonObject =>
value != null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {};
const expectSuccess = (result: RequestResult, status?: number) => {
expect(result.ok, result.text).toBe(true);
if (status != null) {
expect(result.status, result.text).toBe(status);
}
};
async function expectNoStoredDocument(
collection: string,
filter: JsonObject,
label: string,
): Promise<void> {
await withMongo(async (db) => {
expect(await db.collection(collection).findOne(filter), label).toBeNull();
});
}
async function expectAsyncStreamCompleted(
request: APIRequestContext,
token: string,
started: RequestResult,
): Promise<string> {
expectSuccess(started, 200);
const startBody = asObject(started.body);
expect(startBody.status).toBe('started');
expect(typeof startBody.conversationId).toBe('string');
expect(typeof startBody.streamId).toBe('string');
const conversationId = startBody.conversationId as string;
const streamId = startBody.streamId as string;
await expect
.poll(
async () => {
const status = await requestResult(request, {
path: `/api/agents/chat/status/${encodeURIComponent(conversationId)}`,
token,
});
if (status.status === 503) {
return { active: true, status: 'pending' };
}
expectSuccess(status, 200);
const statusBody = asObject(status.body);
return { active: statusBody.active, status: statusBody.status };
},
{ timeout: 30000, intervals: [100, 250, 500, 1000] },
)
.toEqual({ active: false, status: 'complete' });
const stream = await requestResult(request, {
path: `/api/agents/chat/stream/${encodeURIComponent(streamId)}?resume=true`,
token,
});
expectSuccess(stream, 200);
expect(stream.text).not.toContain('event: error');
return conversationId;
}
const createAgentPayload = (suffix: string, overrides: JsonObject = {}) => ({
name: `E2E content-filter agent ${suffix}`,
description: 'Safe agent used by the content-filter submission matrix.',
instructions: 'Keep this reusable test agent safe and deterministic.',
provider: MOCK_ENDPOINTS[0].label,
model: MOCK_ENDPOINTS[0].model,
model_parameters: {},
tools: [],
conversation_starters: ['Ask a safe question'],
...overrides,
});
test.describe.serial('source-aware content filters', () => {
test('rejects fresh protected submissions for each configured source', async ({ request }) => {
test.setTimeout(180000);
const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const markers = {
messages: `E2E-CF-MESSAGE-${suffix}`,
prompts: `E2E-CF-PROMPT-${suffix}`,
agentInstructions: `E2E-CF-AGENT-INSTRUCTION-${suffix}`,
conversationStarters: `E2E-CF-CONVERSATION-STARTER-${suffix}`,
conversationTitles: `E2E-CF-CONVERSATION-TITLE-${suffix}`,
feedback: `E2E-CF-FEEDBACK-${suffix}`,
skills: `E2E-CF-SKILL-${suffix}`,
memories: `E2E-CF-MEMORY-${suffix}`,
files: `E2E-CF-FILE-${suffix}`,
toolArguments: `E2E-CF-TOOL-ARGUMENT-${suffix}`,
modelParameters: `E2E-CF-MODEL-PARAMETER-${suffix}`,
actionMetadata: `E2E-CF-ACTION-METADATA-${suffix}`,
} as const;
const memoryKeySuffix = Array.from(randomUUID().replace(/-/g, ''), (character) =>
String.fromCharCode(97 + Number.parseInt(character, 16)),
).join('');
const pii = (id: string, field: string, marker: string) => ({
fields: [field],
starterPatterns: [],
customPatterns: [
{
id: `e2e-${id}-${suffix}`,
label: 'E2E protected value',
regex: `^${marker}$`,
},
],
});
const filters = {
messages: { pii: pii('messages', 'text', markers.messages) },
prompts: { pii: pii('prompts', 'text', markers.prompts) },
agentInstructions: {
pii: pii('agent-instructions', 'instructions', markers.agentInstructions),
},
conversationStarters: {
pii: pii('conversation-starters', 'text', markers.conversationStarters),
},
conversationTitles: {
pii: pii('conversation-titles', 'title', markers.conversationTitles),
},
feedback: { pii: pii('feedback', 'text', markers.feedback) },
skills: { pii: pii('skills', 'instructions', markers.skills) },
memories: { pii: pii('memories', 'value', markers.memories) },
files: { pii: pii('files', 'content', markers.files) },
toolArguments: {
pii: pii('tool-arguments', 'arguments', markers.toolArguments),
},
modelParameters: {
pii: pii('model-parameters', 'stop', markers.modelParameters),
},
actionMetadata: {
pii: pii('action-metadata', 'privacy_policy_url', markers.actionMetadata),
},
} as FiltersConfig;
const token = await loginAdmin(request);
let filtersAttempted = false;
let filtersActive = false;
let conversationId: string | undefined;
let safeUserMessageId: string | undefined;
let promptGroupId: string | undefined;
let agentId: string | undefined;
let skillId: string | undefined;
let memoryKey: string | undefined;
let uploadedFile: { file_id: string; filepath: string } | undefined;
let actionId: string | undefined;
try {
filtersAttempted = true;
await setRuntimeFilters(request, token, filters);
filtersActive = true;
await test.step('messages', async () => {
const blockedMessageId = randomUUID();
const blocked = await requestResult(request, {
path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`,
token,
method: 'POST',
data: {
text: markers.messages,
sender: 'User',
clientTimestamp: new Date().toISOString(),
isCreatedByUser: true,
parentMessageId: NO_PARENT,
conversationId: 'new',
messageId: blockedMessageId,
responseMessageId: `${blockedMessageId}_response`,
endpoint: MOCK_ENDPOINTS[0].label,
endpointType: 'custom',
model: MOCK_ENDPOINTS[0].model,
isTemporary: false,
isRegenerate: false,
error: false,
},
});
expectContentFilterBlock(blocked, {
source: 'message',
field: 'text',
marker: markers.messages,
});
await expectNoStoredDocument(
'messages',
{ messageId: blockedMessageId },
'Blocked chat message must not be persisted',
);
const chatMessageId = randomUUID();
const chat = await requestResult(request, {
path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`,
token,
method: 'POST',
data: {
text: `Safe content-filter conversation control ${suffix}`,
sender: 'User',
clientTimestamp: new Date().toISOString(),
isCreatedByUser: true,
parentMessageId: NO_PARENT,
conversationId: 'new',
messageId: chatMessageId,
responseMessageId: `${chatMessageId}_response`,
endpoint: MOCK_ENDPOINTS[0].label,
endpointType: 'custom',
model: MOCK_ENDPOINTS[0].model,
isTemporary: false,
isRegenerate: false,
error: false,
},
});
conversationId = await expectAsyncStreamCompleted(request, token, chat);
safeUserMessageId = randomUUID();
const safe = await requestResult(request, {
path: `/api/messages/${encodeURIComponent(conversationId!)}`,
token,
method: 'POST',
data: {
text: `Safe content-filter control ${suffix}`,
name: markers.messages,
sender: 'User',
clientTimestamp: new Date().toISOString(),
isCreatedByUser: true,
parentMessageId: NO_PARENT,
conversationId,
messageId: safeUserMessageId,
endpoint: MOCK_ENDPOINTS[0].label,
model: MOCK_ENDPOINTS[0].model,
isTemporary: false,
error: false,
},
});
expectSuccess(safe, 201);
});
await test.step('prompts', async () => {
const blockedGroupName = `E2E blocked prompt ${suffix}`;
const blocked = await requestResult(request, {
path: '/api/prompts',
token,
method: 'POST',
data: {
prompt: { prompt: markers.prompts, type: 'text' },
group: { name: blockedGroupName },
},
});
expectContentFilterBlock(blocked, {
source: 'prompt',
field: 'text',
marker: markers.prompts,
});
await expectNoStoredDocument(
'prompts',
{ prompt: markers.prompts },
'Blocked prompt must not be persisted',
);
await expectNoStoredDocument(
'promptgroups',
{ name: blockedGroupName },
'Blocked prompt group must not be persisted',
);
const safe = await requestResult(request, {
path: '/api/prompts',
token,
method: 'POST',
data: {
prompt: { prompt: 'A safe reusable prompt.', type: 'text' },
group: { name: markers.prompts },
},
});
expectSuccess(safe, 200);
const safeBody = asObject(safe.body);
const group = asObject(safeBody.group);
const prompt = asObject(safeBody.prompt);
promptGroupId = (group._id ?? prompt.groupId) as string | undefined;
expect(promptGroupId).toBeTruthy();
});
await test.step('agent instructions', async () => {
const blockedAgentName = `E2E content-filter agent ${suffix}-blocked-instructions`;
const blocked = await requestResult(request, {
path: '/api/agents',
token,
method: 'POST',
data: createAgentPayload(`${suffix}-blocked-instructions`, {
instructions: markers.agentInstructions,
}),
});
expectContentFilterBlock(blocked, {
source: 'agent_instruction',
field: 'instructions',
marker: markers.agentInstructions,
});
await expectNoStoredDocument(
'agents',
{ name: blockedAgentName },
'Blocked agent must not be persisted',
);
const blockedAssistantName = `E2E blocked assistant ${suffix}`;
const blockedAssistant = await requestResult(request, {
path: '/api/assistants/v1',
token,
method: 'POST',
data: { name: blockedAssistantName, instructions: markers.agentInstructions },
});
expectContentFilterBlock(blockedAssistant, {
source: 'agent_instruction',
field: 'instructions',
marker: markers.agentInstructions,
});
await expectNoStoredDocument(
'assistants',
{ name: blockedAssistantName },
'Blocked assistant must not be persisted',
);
const safe = await requestResult(request, {
path: '/api/agents',
token,
method: 'POST',
data: createAgentPayload(`${suffix}-safe`, {
description: markers.agentInstructions,
}),
});
expectSuccess(safe, 201);
agentId = asObject(safe.body).id as string | undefined;
expect(agentId).toBeTruthy();
});
await test.step('conversation starters', async () => {
const blockedAgentName = `E2E content-filter agent ${suffix}-blocked-starter`;
const blocked = await requestResult(request, {
path: '/api/agents',
token,
method: 'POST',
data: createAgentPayload(`${suffix}-blocked-starter`, {
conversation_starters: [markers.conversationStarters],
}),
});
expectContentFilterBlock(blocked, {
source: 'conversation_starter',
field: 'text',
marker: markers.conversationStarters,
});
await expectNoStoredDocument(
'agents',
{ name: blockedAgentName },
'Agent with a blocked conversation starter must not be persisted',
);
const safe = await requestResult(request, {
path: `/api/agents/${encodeURIComponent(agentId!)}`,
token,
method: 'PATCH',
data: { conversation_starters: ['A safe conversation starter'] },
});
expectSuccess(safe, 200);
});
await test.step('conversation titles', async () => {
const blocked = await requestResult(request, {
path: '/api/convos/update',
token,
method: 'POST',
data: { arg: { conversationId, title: markers.conversationTitles } },
});
expectContentFilterBlock(blocked, {
source: 'conversation_title',
field: 'title',
marker: markers.conversationTitles,
});
await expectNoStoredDocument(
'conversations',
{ conversationId, title: markers.conversationTitles },
'Blocked conversation title must not be persisted',
);
const safe = await requestResult(request, {
path: '/api/convos/update',
token,
method: 'POST',
data: { arg: { conversationId, title: `E2E safe title ${suffix}` } },
});
expectSuccess(safe, 201);
});
await test.step('feedback', async () => {
const path = `/api/messages/${encodeURIComponent(conversationId!)}/${encodeURIComponent(
safeUserMessageId!,
)}/feedback`;
const blocked = await requestResult(request, {
path,
token,
method: 'PUT',
data: {
feedback: { rating: 'thumbsDown', tag: 'other', text: markers.feedback },
},
});
expectContentFilterBlock(blocked, {
source: 'feedback',
field: 'text',
marker: markers.feedback,
});
await expectNoStoredDocument(
'messages',
{ messageId: safeUserMessageId, 'feedback.text': markers.feedback },
'Blocked feedback must not be persisted',
);
const safe = await requestResult(request, {
path,
token,
method: 'PUT',
data: {
feedback: { rating: 'thumbsDown', tag: 'other', text: 'Safe feedback.' },
},
});
expectSuccess(safe, 200);
});
await test.step('skills', async () => {
const blockedSkillName = `e2e-blocked-skill-${suffix}`;
const blocked = await requestResult(request, {
path: '/api/skills',
token,
method: 'POST',
data: {
name: blockedSkillName,
description: 'Blocked skill submission control.',
body: markers.skills,
},
});
expectContentFilterBlock(blocked, {
source: 'skill',
field: 'instructions',
marker: markers.skills,
});
await expectNoStoredDocument(
'skills',
{ name: blockedSkillName },
'Blocked skill must not be persisted',
);
const safe = await requestResult(request, {
path: '/api/skills',
token,
method: 'POST',
data: {
name: `e2e-safe-skill-${suffix}`,
description: markers.skills,
body: 'Use only safe deterministic content.',
},
});
expectSuccess(safe, 201);
skillId = asObject(safe.body)._id as string | undefined;
expect(skillId).toBeTruthy();
});
await test.step('memories', async () => {
const blockedMemoryKey = `e_to_e_blocked_memory_${memoryKeySuffix}`;
const blocked = await requestResult(request, {
path: '/api/memories',
token,
method: 'POST',
data: {
key: blockedMemoryKey,
value: markers.memories,
},
});
expectContentFilterBlock(blocked, {
source: 'memory',
field: 'value',
marker: markers.memories,
});
await expectNoStoredDocument(
'memoryentries',
{ key: blockedMemoryKey },
'Blocked memory must not be persisted',
);
memoryKey = `e_to_e_safe_memory_${memoryKeySuffix}`;
const safe = await requestResult(request, {
path: '/api/memories',
token,
method: 'POST',
data: { key: memoryKey, value: 'Safe memory value.' },
});
expectSuccess(safe, 201);
});
await test.step('files', async () => {
const blockedFileId = randomUUID();
const blocked = await requestResult(request, {
path: '/api/files',
token,
method: 'POST',
multipart: {
endpoint: MOCK_ENDPOINTS[0].label,
endpointType: 'custom',
message_file: 'true',
file_id: blockedFileId,
file: {
name: `e2e-blocked-${suffix}.txt`,
mimeType: 'text/plain',
buffer: Buffer.from(markers.files),
},
},
});
expectContentFilterBlock(blocked, {
source: 'file',
field: 'content',
marker: markers.files,
});
await expectNoStoredDocument(
'files',
{ file_id: blockedFileId },
'Blocked file must not be persisted',
);
const safe = await requestResult(request, {
path: '/api/files',
token,
method: 'POST',
multipart: {
endpoint: MOCK_ENDPOINTS[0].label,
endpointType: 'custom',
message_file: 'true',
file_id: randomUUID(),
file: {
name: markers.files,
mimeType: 'text/plain',
buffer: Buffer.from('Safe file content.'),
},
},
});
expectSuccess(safe, 200);
const safeBody = asObject(safe.body);
if (typeof safeBody.file_id === 'string' && typeof safeBody.filepath === 'string') {
uploadedFile = { file_id: safeBody.file_id, filepath: safeBody.filepath };
}
expect(uploadedFile).toBeTruthy();
});
await test.step('tool arguments', async () => {
const messagePath = `/api/messages/${encodeURIComponent(conversationId!)}`;
const blockedToolMessageId = randomUUID();
const blocked = await requestResult(request, {
path: messagePath,
token,
method: 'POST',
data: {
messageId: blockedToolMessageId,
parentMessageId: safeUserMessageId,
sender: 'User',
endpoint: MOCK_ENDPOINTS[0].label,
model: MOCK_ENDPOINTS[0].model,
isCreatedByUser: true,
content: [
{
type: 'tool_call',
tool_call: {
id: `call_blocked_${suffix}`,
name: 'safe_lookup',
args: markers.toolArguments,
},
},
],
},
});
expectContentFilterBlock(blocked, {
source: 'tool_argument',
field: 'arguments',
marker: markers.toolArguments,
});
await expectNoStoredDocument(
'messages',
{ messageId: blockedToolMessageId },
'Message with blocked tool arguments must not be persisted',
);
const safe = await requestResult(request, {
path: messagePath,
token,
method: 'POST',
data: {
messageId: randomUUID(),
parentMessageId: safeUserMessageId,
sender: 'User',
endpoint: MOCK_ENDPOINTS[0].label,
model: MOCK_ENDPOINTS[0].model,
isCreatedByUser: true,
content: [
{
type: 'tool_call',
tool_call: {
id: `call_safe_${suffix}`,
name: 'safe_lookup',
args: '{"query":"safe"}',
},
},
],
},
});
expectSuccess(safe, 201);
});
await test.step('model parameters', async () => {
const blockedAgentName = `E2E content-filter agent ${suffix}-blocked-model-parameters`;
const blocked = await requestResult(request, {
path: '/api/agents',
token,
method: 'POST',
data: createAgentPayload(`${suffix}-blocked-model-parameters`, {
model_parameters: { stop: [markers.modelParameters] },
}),
});
expectContentFilterBlock(blocked, {
source: 'model_parameter',
field: 'stop',
marker: markers.modelParameters,
});
await expectNoStoredDocument(
'agents',
{ name: blockedAgentName },
'Agent with blocked model parameters must not be persisted',
);
const safe = await requestResult(request, {
path: `/api/agents/${encodeURIComponent(agentId!)}`,
token,
method: 'PATCH',
data: { model_parameters: { stop: ['SAFE-STOP-SEQUENCE'] } },
});
expectSuccess(safe, 200);
});
await test.step('action metadata', async () => {
const actionPayload = (privacyPolicyUrl: string) => ({
functions: [
{
type: 'function',
function: {
name: `safe_lookup_${suffix.replace(/-/g, '_')}`,
description: 'Return a safe deterministic lookup result.',
parameters: { type: 'object', properties: {} },
},
},
],
metadata: {
domain: 'https://example.com',
privacy_policy_url: privacyPolicyUrl,
},
});
const blocked = await requestResult(request, {
path: `/api/agents/actions/${encodeURIComponent(agentId!)}`,
token,
method: 'POST',
data: actionPayload(markers.actionMetadata),
});
expectContentFilterBlock(blocked, {
source: 'action_metadata',
field: 'privacy_policy_url',
marker: markers.actionMetadata,
});
await expectNoStoredDocument(
'actions',
{ agent_id: agentId, 'metadata.privacy_policy_url': markers.actionMetadata },
'Action with blocked metadata must not be persisted',
);
const safe = await requestResult(request, {
path: `/api/agents/actions/${encodeURIComponent(agentId!)}`,
token,
method: 'POST',
data: actionPayload('https://example.com/privacy'),
});
expectSuccess(safe, 200);
const responseItems = Array.isArray(safe.body) ? safe.body : [];
actionId = asObject(responseItems[1]).action_id as string | undefined;
expect(actionId).toBeTruthy();
});
} finally {
try {
if (filtersAttempted || filtersActive) {
await restoreRuntimeFilters(request, token);
filtersActive = false;
}
} finally {
if (actionId && agentId) {
await requestResult(request, {
path: `/api/agents/actions/${encodeURIComponent(agentId)}/${encodeURIComponent(actionId)}`,
token,
method: 'DELETE',
});
}
if (uploadedFile) {
await requestResult(request, {
path: '/api/files',
token,
method: 'DELETE',
data: { files: [uploadedFile] },
});
}
if (memoryKey) {
await requestResult(request, {
path: `/api/memories/${encodeURIComponent(memoryKey)}`,
token,
method: 'DELETE',
});
}
if (skillId) {
await requestResult(request, {
path: `/api/skills/${encodeURIComponent(skillId)}`,
token,
method: 'DELETE',
});
}
if (agentId) {
await requestResult(request, {
path: `/api/agents/${encodeURIComponent(agentId)}`,
token,
method: 'DELETE',
});
}
if (promptGroupId) {
await requestResult(request, {
path: `/api/prompts/groups/${encodeURIComponent(promptGroupId)}`,
token,
method: 'DELETE',
});
}
if (conversationId) {
await requestResult(request, {
path: '/api/convos',
token,
method: 'DELETE',
data: { arg: { conversationId } },
});
}
}
}
});
test('honors omitted and explicit message filter selector defaults', async ({ request }) => {
test.setTimeout(120000);
const token = await loginAdmin(request);
const marker = `E2E-CF-CONFIG-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const bearerValue = 'Authorization: Bearer e2e-config-contract-token';
const nonmatchingCustomPatterns = [
{
id: `e2e-config-nonmatching-${Date.now()}`,
label: 'E2E nonmatching config detector',
regex: '^E2E-CF-NEVER-MATCH$',
},
];
let filtersAttempted = false;
let filtersActive = false;
let conversationId: string | undefined;
const applyFilters = async (filters: FiltersConfig): Promise<void> => {
filtersAttempted = true;
await setRuntimeFilters(request, token, filters);
filtersActive = true;
};
const submitMessage = async (text: string, name?: string) => {
const messageId = randomUUID();
const result = await requestResult(request, {
path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`,
token,
method: 'POST',
data: {
text,
...(name ? { name } : {}),
sender: 'User',
clientTimestamp: new Date().toISOString(),
isCreatedByUser: true,
parentMessageId: NO_PARENT,
conversationId: 'new',
messageId,
responseMessageId: `${messageId}_response`,
endpoint: MOCK_ENDPOINTS[0].label,
endpointType: 'custom',
model: MOCK_ENDPOINTS[0].model,
isTemporary: false,
isRegenerate: false,
error: false,
},
});
return { messageId, result };
};
try {
await applyFilters({
messages: {
pii: {
starterPatterns: [],
customPatterns: [
{
id: `e2e-config-fields-${Date.now()}`,
label: 'E2E config field selector',
regex: `^${marker}$`,
},
],
},
},
});
const omittedFields = await submitMessage('Safe field-selector control.', marker);
expectContentFilterBlock(omittedFields.result, {
source: 'message',
field: 'name',
marker,
});
await expectNoStoredDocument(
'messages',
{ messageId: omittedFields.messageId },
'Message blocked by the default field selection must not be persisted',
);
await applyFilters({
messages: { pii: { fields: ['text'], customPatterns: nonmatchingCustomPatterns } },
});
const omittedStarters = await submitMessage(bearerValue);
expectContentFilterBlock(omittedStarters.result, {
source: 'message',
field: 'text',
marker: bearerValue,
});
await expectNoStoredDocument(
'messages',
{ messageId: omittedStarters.messageId },
'Message blocked by default starter patterns must not be persisted',
);
await applyFilters({
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: nonmatchingCustomPatterns,
},
},
});
const explicitEmptyStarters = await submitMessage(bearerValue);
conversationId = await expectAsyncStreamCompleted(
request,
token,
explicitEmptyStarters.result,
);
} finally {
try {
if (filtersAttempted || filtersActive) {
await restoreRuntimeFilters(request, token);
filtersActive = false;
}
} finally {
if (conversationId) {
await requestResult(request, {
path: '/api/convos',
token,
method: 'DELETE',
data: { arg: { conversationId } },
});
}
}
}
});
});

View file

@ -887,7 +887,12 @@ endpoints:
# Apply opt-in content filters to source-classified submitted and reusable content.
# `filters` is base-config-only: database overrides and tombstones cannot add,
# change, or remove this policy for individual users, groups, or roles.
# Omit `filters`, a source, or its `pii` block to leave that scope disabled.
# In multi-replica deployments, use a coordinated deploy or restart and verify
# every replica loaded the same base config before considering policy active;
# local cache invalidation is not a rollout barrier.
# Omit `filters` or a source to leave that scope disabled. Omitting a source's
# `pii` block disables its source-aware detectors; for messages, `inspect` can
# still change how an enabled legacy `messageFilter.pii` attributes old rows.
# Omit `fields` to filter every supported field for an enabled source.
# `starterPatterns` and `customPatterns` can be configured independently
# under any source so each input surface can use a different policy. Starter
@ -895,8 +900,26 @@ endpoints:
# linear-time syntax; unsupported constructs are rejected at config load.
# 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.
# Current policy rechecks protected fields when they are resubmitted and when
# records are copied, shared, or become model-bound. Safe partial metadata edits
# can succeed so records remain repairable, but persisted protected fields remain
# unusable on those paths until repaired. Protected prompt or preset fields may
# be blanked with `contentFilterBlocked: true` in management views. Prompt-group
# metadata blocked by policy returns an explicit error on direct GET, while
# collection and reuse responses omit that group.
# Automatic memory maintenance may log and skip a rejected background update
# while allowing the main chat response to continue.
# Legacy assistant rows without provenance default to `model_output`, preserving
# legacy behavior. Inventory or migrate those records before relying on retroactive
# enforcement. Opting into `inspect` treats otherwise unattributed assistant content,
# including selected attachment projections, as submitted; explicit model provenance
# remains exempt.
# Roll out strict file inspection deliberately: `uninspectable: block` can make
# older opaque files unavailable for reuse until inspectable text is present.
# filters:
# messages:
# unattributedAssistantContent: model_output # `model_output` (default) or `inspect`
# pii:
# fields: [name, text, summary, quote, answer, decision_response, decision_reason, content_part, attachment_reference, assembled_context]
# starterPatterns: [sk_prefix, bearer_header, api_key_header]

View file

@ -13,8 +13,8 @@ import type {
} from '@librechat/agents';
import type { StructuredToolInterface } from '@librechat/agents/langchain/tools';
import type { CodeEnvRef } from 'librechat-data-provider';
import type { TextContentFragment } from '~/protection';
import type { SkillFileRecord, PrimeSkillFilesResult } from './skillFiles';
import type { TextContentFragment } from '~/protection';
import type { ServerRequest } from '~/types';
import {
backgroundTaskRegistry,
@ -54,13 +54,13 @@ import {
contentFilterModelBoundBlockResponse,
isContentFilterError,
} from '~/middleware/contentFilter';
import { getSafeErrorMetadata, logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils';
import {
hasIntentArg,
stripIntentArg,
stripIntentLabelsFromToolDefinitions,
INTENT_ARG,
} from './intent';
import { getSafeErrorMetadata, logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils';
import { buildSkillPrimeMessage, SKILL_FILE_PREFIX } from './skills';
import { parseFrontmatter } from '../skills/import';
import { cleanCodeToolOutput } from './cleanup';

View file

@ -74,8 +74,8 @@ import {
import { extractAgentContent, extractSkillContent } from '../protection/adapters/submissions';
import { assertModelBoundContent } from '../middleware/modelBoundContent';
import { registerMemoryTools, memoryToolUsageGuard } from './memory';
import { ContentFilterError } from '../middleware/contentFilter';
import { applyIntentLabels, sanitizeIntentLabels } from './intent';
import { ContentFilterError } from '../middleware/contentFilter';
import { applyBackgroundToolCalls } from './background';
import { inspectContent } from '../protection/runtime';
import { filterFilesByEndpointConfig } from '~/files';

View file

@ -167,6 +167,28 @@ describe('assertModelBoundContent', () => {
).not.toThrow();
});
it('applies legacy-only rules across adjacent persisted submitted content parts', () => {
expect(() =>
assertModelBoundContent({
legacyPii: {
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-VALUE' }],
},
storedMessages: [
{
isCreatedByUser: false,
content: [
{ type: 'text', text: 'Model output' },
{ type: 'text', text: 'PRIVATE-' },
{ type: 'text', text: 'VALUE' },
],
userSubmittedPaths: ['/content/1/text', '/content/2/text'],
},
],
}),
).toThrow('Submitted content contains a private value');
});
it('treats persisted steer parts as user-submitted without classifying neighboring model prose', () => {
const mixedFilters: FiltersConfig = {
messages: {
@ -347,6 +369,154 @@ describe('assertModelBoundContent', () => {
).not.toThrow();
});
it('keeps explicit model_output attribution compatible with omission', () => {
expect(() =>
assertModelBoundContent({
filters: {
...filters,
messages: {
...filters.messages,
unattributedAssistantContent: 'model_output',
},
},
storedMessages: [
{
isCreatedByUser: false,
role: 'assistant',
text: 'Legacy model output PRIVATE-VALUE',
},
],
}),
).not.toThrow();
});
it('inspects unattributed assistant rows when strict legacy attribution is enabled', () => {
expect(() =>
assertModelBoundContent({
filters: {
...filters,
messages: {
...filters.messages,
unattributedAssistantContent: 'inspect',
},
},
storedMessages: [
{
isCreatedByUser: false,
role: 'assistant',
text: 'Legacy unattributed PRIVATE-VALUE',
},
],
}),
).toThrow('Submitted content contains a private value');
});
it('recognizes an assistant role as unattributed even without an author flag', () => {
expect(() =>
assertModelBoundContent({
filters: {
...filters,
messages: {
...filters.messages,
unattributedAssistantContent: 'inspect',
},
},
storedMessages: [
{
role: 'assistant',
text: 'Legacy unattributed PRIVATE-VALUE',
},
],
}),
).toThrow('Submitted content contains a private value');
});
it('honors explicit model attribution and path-scoped user attribution in strict mode', () => {
const strictFilters: FiltersConfig = {
messages: {
pii: {
fields: ['text', 'content_part'],
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-[A-Z]+' }],
},
unattributedAssistantContent: 'inspect',
},
};
expect(() =>
assertModelBoundContent({
filters: strictFilters,
storedMessages: [
{
isCreatedByUser: false,
isUserSubmitted: false,
role: 'assistant',
text: 'Explicit model output PRIVATE-VALUE',
},
],
}),
).not.toThrow();
expect(() =>
assertModelBoundContent({
filters: strictFilters,
storedMessages: [
{
isCreatedByUser: false,
isUserSubmitted: false,
role: 'assistant',
text: 'Model output PRIVATE-MODEL',
content: [{ type: 'text', text: 'Safe user edit' }],
userSubmittedPaths: ['/content/0/text'],
},
],
}),
).not.toThrow();
expect(() =>
assertModelBoundContent({
filters: strictFilters,
storedMessages: [
{
isCreatedByUser: false,
role: 'assistant',
text: 'Model output PRIVATE-MODEL',
content: [
{ type: 'text', text: 'Model content' },
{ type: 'steer', steer: 'Safe user steer' },
],
},
],
}),
).not.toThrow();
});
it.each(['not-a-json-pointer', '/missing', '/messageId', '/__proto__/polluted'])(
'treats ineffective provenance path %s as unattributed in strict mode',
(userSubmittedPath) => {
expect(() =>
assertModelBoundContent({
filters: {
...filters,
messages: {
...filters.messages,
unattributedAssistantContent: 'inspect',
},
},
storedMessages: [
{
isCreatedByUser: false,
role: 'assistant',
messageId: 'legacy-message',
text: 'Legacy unattributed PRIVATE-VALUE',
userSubmittedPaths: [userSubmittedPath],
},
],
}),
).toThrow('Submitted content contains a private value');
},
);
it('re-inspects structured historical tool output without treating assistant prose as a message', () => {
expect(() =>
assertModelBoundContent({

View file

@ -35,6 +35,10 @@ import {
isContentTraversalLimitError,
isNestedMessageTraversalProtected,
} from '../protection/adapters/nested';
import {
getSafeUserSubmittedPathSegments,
getUserSubmittedPathState,
} from '../protection/provenance';
import { createConfiguredContentInspector } from '../protection/runtime';
import { extractMessageContent } from '../protection/adapters/messages';
import { ContentFilterError } from './contentFilter';
@ -131,48 +135,6 @@ function getHydratedAgentFiles(
return files;
}
const MAX_USER_SUBMITTED_PATHS = 256;
const MAX_USER_SUBMITTED_PATH_LENGTH = 2048;
const blockedPointerSegments = new Set(['__proto__', 'constructor', 'prototype']);
interface NormalizedUserSubmittedPaths {
readonly paths: JsonPointer[];
readonly overflowed: boolean;
}
function normalizeUserSubmittedPaths(
paths: readonly string[] | undefined,
): NormalizedUserSubmittedPaths {
const normalized: JsonPointer[] = [];
const seen = new Set<string>();
for (const path of paths ?? []) {
if (
typeof path !== 'string' ||
!path.startsWith('/') ||
path.length > MAX_USER_SUBMITTED_PATH_LENGTH ||
seen.has(path)
) {
continue;
}
seen.add(path);
if (normalized.length >= MAX_USER_SUBMITTED_PATHS) {
return { paths: normalized, overflowed: true };
}
normalized.push(path as JsonPointer);
}
return { paths: normalized, overflowed: false };
}
function getSemanticUserSubmittedPaths(message: StoredModelBoundMessage): JsonPointer[] {
const paths: JsonPointer[] = [];
for (let index = 0; index < (message.content?.length ?? 0); index++) {
if (message.content?.[index]?.type === 'steer') {
paths.push(`/content/${index}` as JsonPointer);
}
}
return paths;
}
function isFragmentWithinPath(fragment: TextContentFragment, path: JsonPointer): boolean {
return fragment.path === path || fragment.path.startsWith(`${path}/`);
}
@ -226,10 +188,6 @@ function getUserSubmittedAssembledContext(
};
}
function decodeJsonPointerSegment(segment: string): string {
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
}
/**
* Builds a sparse object containing only marked fields while retaining their
* original keys and ancestry. File fail-close checks need that shape to
@ -243,8 +201,8 @@ function projectUserSubmittedPaths(
let projected = false;
for (const path of paths) {
const segments = path.slice(1).split('/').map(decodeJsonPointerSegment);
if (segments.length === 0 || segments.some((segment) => blockedPointerSegments.has(segment))) {
const segments = getSafeUserSubmittedPathSegments(path);
if (segments == null) {
continue;
}
@ -378,14 +336,18 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void {
}
const storedUserMessages: StoredModelBoundMessage[] = [];
for (const message of input.storedMessages ?? []) {
const normalizedPaths = normalizeUserSubmittedPaths([
...(message.userSubmittedPaths ?? []),
...getSemanticUserSubmittedPaths(message),
]);
const submittedPathState = getUserSubmittedPathState(message);
const effectiveUserSubmittedPaths = submittedPathState.paths;
const isUnattributedAssistant =
input.filters?.messages?.unattributedAssistantContent === 'inspect' &&
typeof message.isUserSubmitted !== 'boolean' &&
effectiveUserSubmittedPaths.length === 0 &&
(message.isCreatedByUser === false || normalizeRole(message) === 'assistant');
const isEntireMessageUserSubmitted =
message?.isCreatedByUser === true ||
message?.isUserSubmitted === true ||
normalizedPaths.overflowed;
submittedPathState.overflowed ||
isUnattributedAssistant;
let messageFragments: readonly TextContentFragment[];
let traversalError: ContentTraversalLimitError | null = null;
try {
@ -398,7 +360,7 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void {
messageFragments = getContentTraversalFragments(error);
}
if (!isEntireMessageUserSubmitted) {
const userSubmittedPaths = normalizedPaths.paths;
const userSubmittedPaths = effectiveUserSubmittedPaths;
const projectedMessage = projectUserSubmittedPaths(message, userSubmittedPaths);
if (projectedMessage != null) {
assertInspectableFileInput(
@ -406,8 +368,9 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void {
omitResolvedCanonicalFileLocators(projectedMessage, resolvedFilesById),
);
}
/** Legacy unmarked assistant rows are treated as model-generated to avoid
* retroactively blocking model output. Structured tool calls/results
/** Legacy unmarked assistant rows are treated as model-generated by
* default. Strict attribution can inspect an otherwise unattributed
* 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. */
const submittedFragments = messageFragments.filter((fragment) =>

View file

@ -1,6 +1,7 @@
export * from './types';
export * from './runtime';
export * from './legacy';
export * from './provenance';
export * from './files';
export * from './adapters/chat';
export * from './adapters/nested';

View file

@ -49,6 +49,63 @@ describe('legacy content protection', () => {
});
});
it('applies legacy message rules to provenance-selected stored message prose', () => {
const config: MessageFilterPiiConfig = {
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-VALUE' }],
};
expect(
inspectLegacyPii([fragment('stored-message.text', 'PRIVATE-VALUE')], config),
).toMatchObject({
ruleId: 'private',
source: 'message',
field: 'text',
});
expect(
inspectLegacyPii(
[
{
...fragment('stored-message.name.sender', 'PRIVATE-VALUE'),
field: 'name',
},
],
config,
),
).toBeNull();
});
it.each(['stored-message.assembled', 'stored-message.user-submitted-assembled'])(
'applies legacy rules to split submitted prose through %s',
(id) => {
const config: MessageFilterPiiConfig = {
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-VALUE' }],
};
const assembled: TextContentFragment = {
...fragment(id, 'PRIVATE-VALUE'),
source: 'assembled_context',
field: 'assembled_context',
treatment: 'inspect_only',
};
expect(
inspectLegacyPii(
[
fragment('stored-message.part.0', 'PRIVATE-'),
fragment('stored-message.part.1', 'VALUE'),
],
config,
),
).toBeNull();
expect(inspectLegacyPii([assembled], config)).toMatchObject({
ruleId: 'private',
source: 'assembled_context',
field: 'assembled_context',
});
},
);
it('preserves candidate-first ordering when different rules match different fields', () => {
const config: MessageFilterPiiConfig = {
starterPatterns: [],

View file

@ -11,12 +11,25 @@ export interface LegacyPiiInspector {
inspect(fragments: Iterable<TextContentFragment>): ProtectionFinding | null;
}
const LEGACY_STORED_MESSAGE_FIELDS = new Set([
'text',
'quote',
'answer',
'decision_response',
'decision_reason',
'content_part',
]);
const LEGACY_INSPECTOR_CACHE = new WeakMap<object, LegacyPiiInspector>();
const INACTIVE_LEGACY_CONFIGS = new WeakSet<object>();
export function isLegacyPiiFragment(fragment: TextContentFragment): boolean {
if (fragment.source === 'assembled_context' && fragment.id === 'chat.assembled.quote-text') {
return true;
if (fragment.source === 'assembled_context') {
return (
fragment.id === 'chat.assembled.quote-text' ||
fragment.id === 'stored-message.assembled' ||
fragment.id === 'stored-message.user-submitted-assembled'
);
}
if (fragment.source === 'tool_argument') {
return /^chat\.decision\.\d+\.arguments$/.test(fragment.id);
@ -24,6 +37,9 @@ export function isLegacyPiiFragment(fragment: TextContentFragment): boolean {
if (fragment.source !== 'message') {
return false;
}
if (fragment.id.startsWith('stored-message.')) {
return LEGACY_STORED_MESSAGE_FIELDS.has(fragment.field);
}
return (
fragment.id === 'chat.text' ||
fragment.id === 'chat.answer' ||

View file

@ -0,0 +1,71 @@
import { getUserSubmittedPathState } from './provenance';
describe('getUserSubmittedPathState', () => {
it('keeps only pointers that resolve through safe own properties and expands steer parts', () => {
const inherited = { inherited: 'not submitted' };
const message = Object.assign(Object.create(inherited), {
text: 'submitted text',
messageId: 'not submitted content',
attachments: [{ file_id: 'submitted-file' }],
content: [
{ type: 'text', text: 'model text' },
{ type: 'steer', steer: 'submitted steer' },
Object.create({ type: 'steer' }),
],
userSubmittedPaths: [
'/text',
'/attachments/0',
'/text',
'/missing',
'/messageId',
'/inherited',
'/__proto__/polluted',
'/content/~2invalid',
'not-a-pointer',
],
});
expect(getUserSubmittedPathState(message)).toEqual({
paths: ['/text', '/attachments/0', '/content/1'],
overflowed: false,
});
});
it('supports the additional protected metadata roots in shared-message projections', () => {
const message = {
iconURL: 'submitted icon',
userSubmittedPaths: ['/iconURL'],
};
expect(getUserSubmittedPathState(message)).toEqual({ paths: [], overflowed: false });
expect(getUserSubmittedPathState(message, { scope: 'shared_message' })).toEqual({
paths: ['/iconURL'],
overflowed: false,
});
});
it('fails closed when unique bounded pointer candidates exceed 256', () => {
const content = Array.from({ length: 257 }, (_, index) => ({ text: `part-${index}` }));
const result = getUserSubmittedPathState({
content,
userSubmittedPaths: content.map((_, index) => `/content/${index}/text`),
});
expect(result.overflowed).toBe(true);
expect(result.paths).toHaveLength(256);
expect(result.paths[0]).toBe('/content/0/text');
expect(result.paths[255]).toBe('/content/255/text');
});
it('ignores overlong pointers without weakening effective bounded paths', () => {
const overlong = `/${'x'.repeat(2048)}`;
expect(
getUserSubmittedPathState({
text: 'submitted text',
userSubmittedPaths: [overlong, '/text'],
}),
).toEqual({ paths: ['/text'], overflowed: false });
});
});

View file

@ -0,0 +1,140 @@
import type { JsonPointer } from './types';
export const MAX_USER_SUBMITTED_PATHS = 256;
export const MAX_USER_SUBMITTED_PATH_LENGTH = 2048;
const BLOCKED_POINTER_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
const STORED_MESSAGE_SUBMITTED_ROOTS = new Set([
'attachments',
'content',
'files',
'name',
'original',
'quotes',
'sender',
'summary',
'text',
'tool_calls',
'updated',
]);
const SHARED_MESSAGE_SUBMITTED_ROOTS = new Set([
...STORED_MESSAGE_SUBMITTED_ROOTS,
'alwaysAppliedSkills',
'finish_reason',
'iconURL',
'manualSkills',
]);
export interface UserSubmittedPathState {
readonly paths: JsonPointer[];
readonly overflowed: boolean;
}
export interface UserSubmittedPathOptions {
readonly scope?: 'stored_message' | 'shared_message';
}
type UserSubmittedPathCarrier = object & {
readonly userSubmittedPaths?: readonly unknown[];
readonly content?: readonly unknown[];
};
function decodeJsonPointerSegment(segment: string): string {
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
}
export function getSafeUserSubmittedPathSegments(path: JsonPointer): readonly string[] | undefined {
const encodedSegments = path.slice(1).split('/');
if (encodedSegments.some((segment) => /~(?:[^01]|$)/.test(segment))) {
return undefined;
}
const segments = encodedSegments.map(decodeJsonPointerSegment);
if (segments.length === 0 || segments.some((segment) => BLOCKED_POINTER_SEGMENTS.has(segment))) {
return undefined;
}
return segments;
}
function isEffectiveUserSubmittedPath(
message: UserSubmittedPathCarrier,
path: JsonPointer,
scope: NonNullable<UserSubmittedPathOptions['scope']>,
): boolean {
const segments = getSafeUserSubmittedPathSegments(path);
if (segments == null) {
return false;
}
const submittedRoots =
scope === 'shared_message' ? SHARED_MESSAGE_SUBMITTED_ROOTS : STORED_MESSAGE_SUBMITTED_ROOTS;
if (!submittedRoots.has(segments[0])) {
return false;
}
let source: unknown = message;
for (const segment of segments) {
if (
source == null ||
typeof source !== 'object' ||
!Object.prototype.hasOwnProperty.call(source, segment)
) {
return false;
}
source = (source as Record<string, unknown>)[segment];
}
return source !== undefined;
}
function getSemanticUserSubmittedPaths(message: UserSubmittedPathCarrier): JsonPointer[] {
if (!Array.isArray(message.content)) {
return [];
}
const paths: JsonPointer[] = [];
for (let index = 0; index < message.content.length; index++) {
const part = message.content[index];
if (
part != null &&
typeof part === 'object' &&
Object.prototype.hasOwnProperty.call(part, 'type') &&
(part as Record<string, unknown>).type === 'steer'
) {
paths.push(`/content/${index}` as JsonPointer);
}
}
return paths;
}
/**
* Resolves durable caller-authorship pointers against the exact stored row.
* Ineffective or unsafe pointers never suppress strict whole-row attribution;
* excessive unique bounded candidates still fail closed via `overflowed`.
*/
export function getUserSubmittedPathState(
message: UserSubmittedPathCarrier,
options: UserSubmittedPathOptions = {},
): UserSubmittedPathState {
const candidates = [
...(Array.isArray(message.userSubmittedPaths) ? message.userSubmittedPaths : []),
...getSemanticUserSubmittedPaths(message),
];
const paths: JsonPointer[] = [];
const seen = new Set<string>();
for (const path of candidates) {
if (
typeof path !== 'string' ||
!path.startsWith('/') ||
path.length > MAX_USER_SUBMITTED_PATH_LENGTH ||
seen.has(path)
) {
continue;
}
seen.add(path);
if (seen.size > MAX_USER_SUBMITTED_PATHS) {
return { paths, overflowed: true };
}
const pointer = path as JsonPointer;
if (isEffectiveUserSubmittedPath(message, pointer, options.scope ?? 'stored_message')) {
paths.push(pointer);
}
}
return { paths, overflowed: false };
}

View file

@ -179,8 +179,29 @@ describe('shared file metadata protection', () => {
});
});
it('treats legacy model metadata conservatively but honors explicit server provenance', () => {
const legacyError = capturePolicyError(() =>
it('does not let ineffective provenance paths suppress conservative shared metadata checks', () => {
const error = capturePolicyError(() =>
assertSharedFileMetadataAllowed({
filters: attachmentFilters,
messages: [
{
isCreatedByUser: false,
userSubmittedPaths: ['/missing'],
iconURL: 'https://example.test/PRIVATE-SENTINEL',
},
],
shareId: 'share-123',
}),
);
expect(error.body).toMatchObject({
source: 'message',
field: 'attachment_reference',
});
});
it('applies the configured attribution policy to legacy assistant metadata', () => {
const compatibilityError = capturePolicyError(() =>
assertSharedFileMetadataAllowed({
filters: attachmentFilters,
messages: [
@ -192,14 +213,41 @@ describe('shared file metadata protection', () => {
shareId: 'share-123',
}),
);
expect(legacyError.body).toMatchObject({
expect(compatibilityError.body).toMatchObject({
source: 'message',
field: 'attachment_reference',
});
const strictError = capturePolicyError(() =>
assertSharedFileMetadataAllowed({
filters: {
messages: {
...attachmentFilters.messages,
unattributedAssistantContent: 'inspect',
},
},
messages: [
{
isCreatedByUser: false,
iconURL: 'https://example.test/PRIVATE-SENTINEL',
},
],
shareId: 'share-123',
}),
);
expect(strictError.body).toMatchObject({
source: 'message',
field: 'attachment_reference',
});
expect(() =>
assertSharedFileMetadataAllowed({
filters: attachmentFilters,
filters: {
messages: {
...attachmentFilters.messages,
unattributedAssistantContent: 'inspect',
},
},
messages: [
{
isCreatedByUser: false,
@ -213,7 +261,12 @@ describe('shared file metadata protection', () => {
const error = capturePolicyError(() =>
assertSharedFileMetadataAllowed({
filters: attachmentFilters,
filters: {
messages: {
...attachmentFilters.messages,
unattributedAssistantContent: 'inspect',
},
},
messages: [
{
isCreatedByUser: false,
@ -230,6 +283,103 @@ describe('shared file metadata protection', () => {
});
});
it('applies legacy attribution to shared assistant attachment projections', () => {
const legacyAttachment = {
reference: { label: 'PRIVATE-SENTINEL' },
};
const strictFilters: FiltersConfig = {
messages: {
...attachmentFilters.messages,
unattributedAssistantContent: 'inspect',
},
};
expect(() =>
assertSharedFileMetadataAllowed({
filters: attachmentFilters,
messages: [{ isCreatedByUser: false, attachments: [legacyAttachment] }],
shareId: 'share-123',
}),
).not.toThrow();
expect(() =>
assertSharedFileMetadataAllowed({
filters: strictFilters,
messages: [{ isCreatedByUser: false, attachments: [legacyAttachment] }],
shareId: 'share-123',
}),
).toThrow(ContentFilterError);
expect(() =>
assertSharedFileMetadataAllowed({
filters: strictFilters,
messages: [
{
isCreatedByUser: false,
isUserSubmitted: false,
attachments: [legacyAttachment],
},
],
shareId: 'share-123',
}),
).not.toThrow();
expect(() =>
assertSharedFileMetadataAllowed({
filters: strictFilters,
messages: [
{
isCreatedByUser: false,
isUserSubmitted: false,
userSubmittedPaths: ['/attachments/0'],
attachments: [legacyAttachment],
},
],
shareId: 'share-123',
}),
).toThrow(ContentFilterError);
});
it('preserves conservative attachment attribution for role-only legacy rows', () => {
expect(() =>
assertSharedFileMetadataAllowed({
filters: attachmentFilters,
messages: [
{
role: 'assistant',
attachments: [{ reference: { label: 'PRIVATE-SENTINEL' } }],
},
],
shareId: 'share-123',
}),
).toThrow(ContentFilterError);
});
it.each(['/missing', '/role', '/__proto__/polluted'])(
'does not let ineffective shared provenance path %s suppress strict attribution',
(userSubmittedPath) => {
expect(() =>
assertSharedFileMetadataAllowed({
filters: {
messages: {
...attachmentFilters.messages,
unattributedAssistantContent: 'inspect',
},
},
messages: [
{
isCreatedByUser: false,
role: 'assistant',
userSubmittedPaths: [userSubmittedPath],
attachments: [{ reference: { label: 'PRIVATE-SENTINEL' } }],
},
],
shareId: 'share-123',
}),
).toThrow(ContentFilterError);
},
);
it('keeps shared response metadata protection default-off', () => {
expect(() =>
assertSharedFileMetadataAllowed({

View file

@ -25,6 +25,7 @@ import {
extractStoredMessageContent,
} from '../protection/adapters/submissions';
import { assertModelBoundContent } from '../middleware/modelBoundContent';
import { getUserSubmittedPathState } from '../protection/provenance';
import { ContentFilterError } from '../middleware/contentFilter';
import { inspectContent } from '../protection/runtime';
@ -42,6 +43,7 @@ export interface SerializedSharedMessage {
readonly isCreatedByUser?: boolean;
readonly isUserSubmitted?: boolean;
readonly userSubmittedPaths?: readonly string[];
readonly role?: string;
readonly iconURL?: string;
readonly finish_reason?: string;
readonly manualSkills?: readonly (string | null | undefined)[];
@ -164,16 +166,36 @@ function isSubmittedPath(path: string, submittedPaths: readonly string[]): boole
);
}
function isEntireMessageSubmitted(message: SerializedSharedMessage): boolean {
function isSharedAssistantMessage(message: SerializedSharedMessage): boolean {
return message.isCreatedByUser === false || message.role === 'assistant' || message.role === 'ai';
}
function isEntireMessageSubmitted(
message: SerializedSharedMessage,
filters: FiltersConfig | undefined,
submittedPaths: ReturnType<typeof getUserSubmittedPathState>,
): boolean {
if (message.isCreatedByUser === true || message.isUserSubmitted === true) {
return true;
}
if (submittedPaths.overflowed) {
return true;
}
if (typeof message.isUserSubmitted === 'boolean' || submittedPaths.paths.length > 0) {
return false;
}
if (message.isCreatedByUser == null) {
return true;
}
return (
message.isCreatedByUser === true ||
message.isUserSubmitted === true ||
(message.isCreatedByUser == null && message.isUserSubmitted == null)
isSharedAssistantMessage(message) &&
filters?.messages?.unattributedAssistantContent === 'inspect'
);
}
function collectSerializedFiles(
messages: readonly SerializedSharedMessage[],
filters: FiltersConfig | undefined,
): CollectedSerializedFile[] {
const files: CollectedSerializedFile[] = [];
const append = (
@ -201,8 +223,9 @@ function collectSerializedFiles(
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const message = messages[messageIndex];
const entireMessageSubmitted = isEntireMessageSubmitted(message);
const submittedPaths = message.userSubmittedPaths ?? [];
const submittedPathState = getUserSubmittedPathState(message, { scope: 'shared_message' });
const submittedPaths = submittedPathState.paths;
const entireMessageSubmitted = isEntireMessageSubmitted(message, filters, submittedPathState);
append(
message.files,
'file',
@ -236,15 +259,22 @@ function collectSerializedFiles(
function extractSharedMessageMetadataFragments(
messages: readonly SerializedSharedMessage[],
filters: FiltersConfig | undefined,
): TextContentFragment[] {
const fragments: TextContentFragment[] = [];
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const message = messages[messageIndex];
const submittedPaths = message.userSubmittedPaths ?? [];
const submittedPathState = getUserSubmittedPathState(message, { scope: 'shared_message' });
const submittedPaths = submittedPathState.paths;
/** Shared response-only metadata predates message provenance markers and
* can be authored by users or reusable configuration even on assistant
* rows, so retain the existing conservative default for unmarked fields. */
const legacyMetadataIsUnattributed =
message.isUserSubmitted == null && message.userSubmittedPaths == null;
message.isUserSubmitted == null &&
submittedPathState.paths.length === 0 &&
!submittedPathState.overflowed;
const isSubmitted = (path: string) =>
isEntireMessageSubmitted(message) ||
isEntireMessageSubmitted(message, filters, submittedPathState) ||
legacyMetadataIsUnattributed ||
isSubmittedPath(path, submittedPaths);
const appendMessageValue = (
@ -400,7 +430,7 @@ function getNestedTarget(file: CollectedSerializedFile): NestedSerializedPayload
if (file.userSubmitted) {
return 'attachment';
}
return isToolAttachment(file.file) ? 'tool' : 'attachment';
return isToolAttachment(file.file) ? 'tool' : 'file';
}
function getNestedClassification(
@ -439,13 +469,14 @@ function getDistinctClassifications(
function getNestedClassifications(
target: NestedSerializedPayloadTarget,
rootKey: string,
includeMessageClassification: boolean,
): NestedPayloadClassification[] {
const classifications: NestedPayloadClassification[] = [getNestedClassification(target, rootKey)];
const fileField = FILE_FIELD_BY_STANDARD_KEY.get(rootKey);
if (fileField != null) {
classifications.push({ source: 'file', field: fileField, provenance: 'user' });
}
if (MESSAGE_ATTACHMENT_STANDARD_KEYS.has(rootKey)) {
if (includeMessageClassification && MESSAGE_ATTACHMENT_STANDARD_KEYS.has(rootKey)) {
classifications.push({
source: 'message',
field: 'attachment_reference',
@ -858,7 +889,7 @@ function extractNestedSerializedPayloadFragments(
let visitedNodes = 0;
for (const [key, value] of entries) {
const classifications = getNestedClassifications(target, key);
const classifications = getNestedClassifications(target, key, collectedFile.userSubmitted);
const activeClassifications = classifications.filter((classification) =>
isClassificationActive(filters, classification),
);
@ -1061,7 +1092,7 @@ function extractLocatorAliasFragments(
field: 'uri',
provenance: 'user',
} as const;
if (isClassificationActive(filters, messageClassification)) {
if (collectedFile.userSubmitted && isClassificationActive(filters, messageClassification)) {
appendClassifiedFragment(state, messageClassification, value, path, 'uri');
}
if (isClassificationActive(filters, fileClassification)) {
@ -1082,7 +1113,7 @@ function extractLocatorAliasFragments(
if (decodedUri == null || decodedUri === value) {
continue;
}
if (isClassificationActive(filters, messageClassification)) {
if (collectedFile.userSubmitted && isClassificationActive(filters, messageClassification)) {
appendClassifiedFragment(state, messageClassification, decodedUri, path, 'uri');
}
if (isClassificationActive(filters, fileClassification)) {
@ -1206,16 +1237,18 @@ export function assertSharedFileMetadataAllowed({
if (filters == null) {
return;
}
const messageMetadataFragments = extractSharedMessageMetadataFragments(messages);
const messageMetadataFragments = extractSharedMessageMetadataFragments(messages, filters);
const collectedFiles =
includeFiles && hasSerializedFilePolicy(filters) ? collectSerializedFiles(messages) : [];
includeFiles && hasSerializedFilePolicy(filters)
? collectSerializedFiles(messages, filters)
: [];
if (collectedFiles.length === 0 && messageMetadataFragments.length === 0) {
return;
}
const files = collectedFiles.map(({ file }) => file);
const attachmentFragments = extractStoredMessageContent({
files,
files: collectedFiles.filter(({ userSubmitted }) => userSubmitted).map(({ file }) => file),
}).filter(
(fragment) => fragment.source === 'message' && fragment.field === 'attachment_reference',
);

View file

@ -55,6 +55,34 @@ describe('filtersConfigSchema', () => {
).toBe(false);
});
it('accepts an explicit attribution policy for legacy assistant content', () => {
expect(
filtersConfigSchema.parse({
messages: { unattributedAssistantContent: 'inspect' },
}),
).toEqual({ messages: { unattributedAssistantContent: 'inspect' } });
expect(
filtersConfigSchema.parse({
messages: { unattributedAssistantContent: 'model_output' },
}),
).toEqual({ messages: { unattributedAssistantContent: 'model_output' } });
expect(
filtersConfigSchema.safeParse({
messages: { unattributedAssistantContent: 'block' },
}).success,
).toBe(false);
expect(
hasActiveFiltersConfig({
messages: { unattributedAssistantContent: 'inspect' },
}),
).toBe(true);
expect(
hasActiveFiltersConfig({
messages: { unattributedAssistantContent: 'model_output' },
}),
).toBe(false);
});
it('keeps default patterns, custom patterns, and explicit file fail-close active', () => {
expect(hasActivePiiPatterns({})).toBe(true);
expect(

View file

@ -117,6 +117,8 @@ 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 actionMetadataFilterFieldSchema = z.enum(ACTION_METADATA_FILTER_FIELDS);
export const unattributedAssistantContentSchema = z.enum(['model_output', 'inspect']);
export type UnattributedAssistantContent = z.infer<typeof unattributedAssistantContentSchema>;
export type MessageFilterField = z.infer<typeof messageFilterFieldSchema>;
export type PromptFilterField = z.infer<typeof promptFilterFieldSchema>;
@ -165,6 +167,9 @@ export function hasActiveFiltersConfig(filters: FiltersConfig | null | undefined
if (filters == null) {
return false;
}
if (filters.messages?.unattributedAssistantContent === 'inspect') {
return true;
}
const sourcePatterns = [
filters.messages?.pii,
filters.prompts?.pii,
@ -233,6 +238,13 @@ function createSourceFilterSchema<Field extends z.ZodTypeAny>(fieldSchema: Field
.strict();
}
const messageSourceFilterSchema = z
.object({
pii: createPiiFilterSchema(messageFilterFieldSchema).optional(),
unattributedAssistantContent: unattributedAssistantContentSchema.optional(),
})
.strict();
const fileSourceFilterSchema = z
.object({
pii: createPiiFilterSchema(fileFilterFieldSchema)
@ -245,7 +257,7 @@ const fileSourceFilterSchema = z
export const filtersConfigSchema = z
.object({
messages: createSourceFilterSchema(messageFilterFieldSchema).optional(),
messages: messageSourceFilterSchema.optional(),
prompts: createSourceFilterSchema(promptFilterFieldSchema).optional(),
agentInstructions: createSourceFilterSchema(agentInstructionFilterFieldSchema).optional(),
conversationStarters: createSourceFilterSchema(conversationStarterFilterFieldSchema).optional(),

View file

@ -103,6 +103,23 @@ describe('loadFiltersConfig', () => {
).toBeUndefined();
});
it('retains strict legacy attribution without source-aware PII patterns', () => {
expect(
loadFiltersConfig({
filters: {
messages: { unattributedAssistantContent: 'inspect' },
},
}),
).toEqual({ messages: { unattributedAssistantContent: 'inspect' } });
expect(
loadFiltersConfig({
filters: {
messages: { unattributedAssistantContent: 'model_output' },
},
}),
).toBeUndefined();
});
it('returns a validated source-aware filter config', () => {
const result = loadFiltersConfig({
filters: {

View file

@ -123,6 +123,32 @@ describe('Message Operations', () => {
expect(savedMessage?.userSubmittedPaths).toEqual(['/content/0/text']);
});
it('stamps native model output without overriding explicit provenance', async () => {
const result = await saveMessage(mockCtx, {
...mockMessageData,
isCreatedByUser: false,
});
expect(result?.isUserSubmitted).toBe(false);
const explicitlySubmitted = await saveMessage(mockCtx, {
...mockMessageData,
messageId: 'msg-explicit-submitted',
isCreatedByUser: false,
isUserSubmitted: true,
});
expect(explicitlySubmitted?.isUserSubmitted).toBe(true);
const pathScoped = await saveMessage(mockCtx, {
...mockMessageData,
messageId: 'msg-path-scoped',
isCreatedByUser: false,
userSubmittedPaths: ['/content/0/text'],
});
expect(pathScoped?.isUserSubmitted).toBe(false);
expect(pathScoped?.userSubmittedPaths).toEqual(['/content/0/text']);
});
it('bounds and validates stored user-submitted provenance paths', async () => {
const submittedPaths = [
'not-a-pointer',
@ -184,6 +210,100 @@ describe('Message Operations', () => {
});
});
describe('bulkSaveMessages', () => {
it('preserves unknown provenance when cloning an unmarked assistant row', async () => {
const conversationId = uuidv4();
await bulkSaveMessages([
{
user: 'user123',
messageId: 'bulk-unmarked-assistant',
conversationId,
isCreatedByUser: false,
},
{
user: 'user123',
messageId: 'bulk-user-submitted',
conversationId,
isCreatedByUser: false,
isUserSubmitted: true,
},
]);
const rows = await Message.find({ conversationId }).lean();
expect(
rows.find(({ messageId }) => messageId === 'bulk-unmarked-assistant'),
).not.toHaveProperty('isUserSubmitted');
expect(
rows.find(({ messageId }) => messageId === 'bulk-user-submitted')?.isUserSubmitted,
).toBe(true);
});
});
describe('recordMessage', () => {
it('stamps native model output without overriding explicit provenance', async () => {
const conversationId = uuidv4();
const modelOutput = await recordMessage({
user: 'user123',
messageId: 'record-model-output',
conversationId,
isCreatedByUser: false,
});
expect(modelOutput?.isUserSubmitted).toBe(false);
const explicitlySubmitted = await recordMessage({
user: 'user123',
messageId: 'record-user-submitted',
conversationId,
isCreatedByUser: false,
isUserSubmitted: true,
});
expect(explicitlySubmitted?.isUserSubmitted).toBe(true);
});
});
it('preserves unknown provenance when message savers update legacy assistant rows', async () => {
const conversationId = uuidv4();
const messageIds = ['legacy-save', 'legacy-bulk', 'legacy-record'];
await Message.collection.insertMany(
messageIds.map((messageId) => ({
user: 'user123',
messageId,
conversationId,
isCreatedByUser: false,
text: 'Legacy assistant output',
})),
);
await saveMessage(mockCtx, {
messageId: 'legacy-save',
conversationId,
isCreatedByUser: false,
text: 'Updated assistant output',
});
await bulkSaveMessages([
{
user: 'user123',
messageId: 'legacy-bulk',
conversationId,
isCreatedByUser: false,
text: 'Updated assistant output',
},
]);
await recordMessage({
user: 'user123',
messageId: 'legacy-record',
conversationId,
isCreatedByUser: false,
text: 'Updated assistant output',
});
const rows = await Message.find({ messageId: { $in: messageIds } }).lean();
expect(rows).toHaveLength(3);
for (const row of rows) {
expect(row).not.toHaveProperty('isUserSubmitted');
}
});
describe('updateMessageText', () => {
it('should update message text for the authenticated user', async () => {
// First save a message

View file

@ -188,11 +188,18 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
...getSteerUserSubmittedPaths(params.content),
]);
delete update.userSubmittedPaths;
const stampModelOutputOnInsert =
params.isCreatedByUser === false && params.isUserSubmitted === undefined;
const messageUpdate =
userSubmittedPaths.length > 0
userSubmittedPaths.length > 0 || stampModelOutputOnInsert
? {
$set: update,
$addToSet: { userSubmittedPaths: { $each: userSubmittedPaths } },
...(userSubmittedPaths.length > 0 && {
$addToSet: { userSubmittedPaths: { $each: userSubmittedPaths } },
}),
...(stampModelOutputOnInsert && {
$setOnInsert: { isUserSubmitted: false },
}),
}
: update;
const message = await Message.findOneAndUpdate(
@ -300,8 +307,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
parentMessageId,
...rest,
};
const update =
rest.isCreatedByUser === false && rest.isUserSubmitted === undefined
? { $set: message, $setOnInsert: { isUserSubmitted: false } }
: message;
return await Message.findOneAndUpdate({ user, messageId }, message, {
return await Message.findOneAndUpdate({ user, messageId }, update, {
upsert: true,
new: true,
});