🛂 feat: Filter Model-Bound Content by Source (#14425)

* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
This commit is contained in:
Danny Avila 2026-08-21 22:43:32 -04:00 committed by GitHub
parent 10f95c0ce9
commit 67b7b441b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
303 changed files with 75546 additions and 2327 deletions

View file

@ -14,6 +14,10 @@ const {
getTransactionsConfig,
encodeAndFormatDocuments,
getLangfuseTraceMessageFields,
isContentFilterError,
assertModelBoundProviderContent,
collectModelBoundHistoricalFileIdState,
projectModelBoundSourceFiles,
} = require('@librechat/api');
const {
Constants,
@ -28,6 +32,7 @@ const {
isEphemeralAgentId,
supportsBalanceCheck,
isBedrockDocumentType,
HITL_MESSAGE_FILTER_FIELDS,
getEndpointFileConfig,
stripReasoningLabelMetadata,
} = require('librechat-data-provider');
@ -36,37 +41,57 @@ const { logViolation } = require('~/cache');
const TextStream = require('./TextStream');
const db = require('~/models');
const collectHistoricalFileRefs = (message) => {
const refs = [];
if (Array.isArray(message.files)) {
refs.push(...message.files);
}
if (Array.isArray(message.attachments)) {
refs.push(...message.attachments);
}
/** Steer parts carry their own attachment refs inside assistant content;
* collecting them here folds the steer replay stamp's lookup into this
* single per-turn query (see `stampSteerPartMedia`). */
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part?.type === ContentTypes.STEER && Array.isArray(part.files)) {
refs.push(...part.files);
}
}
}
return refs;
};
const omitUnreplayedHistoricalFiles = (messages) =>
messages.map(({ files: _files, attachments: _attachments, ...message }) => ({
...message,
...(Array.isArray(message.content)
? {
content: message.content.map((part) => {
if (part == null || typeof part !== 'object') {
return part;
}
const {
file: _partFile,
files: _partFiles,
image_file: _imageFile,
file_id: _fileId,
...rest
} = part;
return rest;
}),
}
: {}),
}));
const collectHistoricalFileIds = (messages) => {
const fileIds = new Set();
for (const message of messages) {
for (const ref of collectHistoricalFileRefs(message)) {
if (ref?.file_id) {
fileIds.add(ref.file_id);
}
const mergeUserSubmittedPaths = (...pathLists) => [
...new Set(
pathLists
.flat()
.filter((path) => typeof path === 'string' && path.startsWith('/') && path.length <= 2048),
),
];
const hitlMessageFilterFields = new Set(HITL_MESSAGE_FILTER_FIELDS);
const mergeUserSubmittedMessageFieldPaths = (...entryLists) => {
const entries = [];
const seen = new Set();
for (const entry of entryLists.flat()) {
if (
entry == null ||
typeof entry.path !== 'string' ||
!entry.path.startsWith('/') ||
entry.path.length > 2048 ||
!hitlMessageFilterFields.has(entry.field)
) {
continue;
}
const key = `${entry.field}:${entry.path}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
entries.push(entry);
}
return Array.from(fileIds);
return entries;
};
const buildOwnerFileFilter = (fileIds, user) => {
@ -84,6 +109,14 @@ const buildOwnerFileFilter = (fileIds, user) => {
return filter;
};
const getOwnerHistoricalFiles = async (fileIds, user) => {
const fileFilter = buildOwnerFileFilter(fileIds, user);
if (!fileFilter) {
return [];
}
return (await db.getFiles(fileFilter, {}, {})) ?? [];
};
const TOOL_ATTACHMENT_KEYS = [
Tools.file_search,
Tools.web_search,
@ -208,6 +241,71 @@ class BaseClient {
throw new Error("Method 'setOptions' must be implemented.");
}
getModelBoundStoredMessages(messages) {
return this.options.resendFiles === false ? omitUnreplayedHistoricalFiles(messages) : messages;
}
/** @param {TMessage[]} messages */
setModelBoundStoredMessages(messages) {
this.modelBoundStoredMessages = [...(messages ?? [])];
}
getModelBoundFileProjection() {
return projectModelBoundSourceFiles({
messageFilesBySourceMessageId: this.message_file_map,
sourceMessages: this.modelBoundStoredMessages,
steerFileIdsBySourceMessageId: this.modelBoundSteerFileIdsBySourceMessageId,
replayHistoricalFiles: this.options.resendFiles !== false,
historicalFiles: this.authorizedHistoricalFiles,
processedCurrentFiles: Array.isArray(this.options.attachments)
? this.options.attachments
: [],
canonicalCurrentFiles: Array.isArray(this.modelBoundCurrentFiles)
? this.modelBoundCurrentFiles
: [],
initiallyOverflowed: this.modelBoundHistoricalFileIdsOverflowed === true,
});
}
/** Optional pre-build guard for policies that cover restored history
* independently of the final provider selection. */
assertStoredModelBoundContent() {}
/** Agent runs can defer the parent write until their first exact model
* boundary is admitted. Generic clients preserve the historical eager
* persistence behavior. */
shouldDeferUserMessagePersistence() {
return false;
}
/** Returns the request-scoped deferred parent-write controller, when any. */
getModelBoundUserMessagePersistence() {
return this.modelBoundUserMessagePersistence;
}
/**
* Generic clients return their selected model payload from `buildMessages`.
* AgentClient overrides this because its SDK performs pruning later and
* enforces the same projection at the actual chat-model callback instead.
*
* @param {string | Array<Record<string, unknown>>} payload
*/
assertBuiltModelBoundContent(payload) {
const messages = Array.isArray(payload)
? payload
: [{ role: 'user', content: payload, isCreatedByUser: true, isUserSubmitted: true }];
const fileProjection = this.getModelBoundFileProjection();
assertModelBoundProviderContent({
filters: this.options.req?.config?.filters,
legacyPii: this.options.req?.config?.messageFilter?.pii,
providerMessages: messages,
storedMessages: this.modelBoundStoredMessages,
fileIdsBySourceMessageId: fileProjection.fileIdsBySourceMessageId,
resolvedFiles: fileProjection.resolvedFiles,
sourceFileProjectionOverflowed: fileProjection.overflowed,
});
}
async getCompletion() {
throw new Error("Method 'getCompletion' must be implemented.");
}
@ -565,6 +663,9 @@ class BaseClient {
const appConfig = this.options.req?.config;
/** @type {Promise<TMessage>} */
let userMessagePromise;
/** @type {{ promise: Promise<unknown>, isPending: () => boolean, start: () => Promise<unknown>, cancel: () => Promise<unknown> } | undefined} */
let userMessagePersistence;
this.modelBoundUserMessagePersistence = undefined;
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
await this.handleStartMethods(message, opts);
@ -600,8 +701,10 @@ class BaseClient {
const text = editedContent[type];
if (index >= 0 && index < latestMessage.content.length) {
const contentPart = latestMessage.content[index];
let didApplyEdit = false;
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
contentPart[ContentTypes.THINK] = text;
didApplyEdit = true;
delete contentPart.reasoning_label;
delete contentPart.reasoning_label_step_id;
delete contentPart.reasoning_label_attempts;
@ -610,6 +713,13 @@ class BaseClient {
delete contentPart.reasoning_label_status;
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
contentPart[ContentTypes.TEXT] = text;
didApplyEdit = true;
}
if (didApplyEdit) {
latestMessage.userSubmittedPaths = mergeUserSubmittedPaths(
latestMessage.userSubmittedPaths,
[`/content/${index}/${type}`],
);
}
}
}
@ -625,16 +735,36 @@ class BaseClient {
*/
const parentMessageId = isEdited ? head : userMessage.messageId;
this.parentMessageId = parentMessageId;
const modelBoundStoredMessages = this.getModelBoundStoredMessages(this.currentMessages);
this.setModelBoundStoredMessages(modelBoundStoredMessages);
this.assertStoredModelBoundContent();
this.modelBoundCurrentFiles = Array.isArray(this.options.attachments)
? [...this.options.attachments]
: [];
if (this.options.resendFiles !== false && this.authorizedHistoricalFiles == null) {
const historicalFileState = collectModelBoundHistoricalFileIdState(modelBoundStoredMessages);
this.modelBoundHistoricalFileIdsOverflowed ||= historicalFileState.overflowed;
const files = await getOwnerHistoricalFiles(
historicalFileState.fileIds,
this.options.req?.user,
);
this.authorizedHistoricalFiles = new Map(
files
.filter((file) => typeof file?.file_id === 'string' && file.file_id.length > 0)
.map((file) => [file.file_id, file]),
);
}
let {
prompt: payload,
tokenCountMap,
promptTokens,
} = await this.buildMessages(
this.currentMessages,
modelBoundStoredMessages,
parentMessageId,
this.getBuildMessagesOptions(opts),
opts,
);
this.assertBuiltModelBoundContent(payload);
this.options.startupTelemetry?.mark('messages_built');
if (tokenCountMap && tokenCountMap[userMessage.messageId]) {
@ -688,13 +818,74 @@ class BaseClient {
userMessage.alwaysAppliedSkills = names;
}
}
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user).catch(
(err) => {
const startUserMessagePersistence = () => {
this.savedMessageIds.add(userMessage.messageId);
return this.saveMessageToDatabase(userMessage, saveOptions, user).catch((err) => {
logger.error('[BaseClient] Failed to save user message:', err);
return {};
},
);
this.savedMessageIds.add(userMessage.messageId);
});
};
if (this.shouldDeferUserMessagePersistence()) {
let state = 'pending';
let startPersistence = startUserMessagePersistence;
let resolvePersistence;
let removeAbortListener = () => {};
const persistencePromise = new Promise((resolve) => {
resolvePersistence = resolve;
});
const start = () => {
if (state !== 'pending') {
return persistencePromise;
}
state = 'started';
removeAbortListener();
const startDeferredPersistence = startPersistence;
startPersistence = undefined;
try {
Promise.resolve(startDeferredPersistence?.()).then(resolvePersistence, () =>
resolvePersistence({}),
);
} catch (error) {
logger.error('[BaseClient] Failed to start deferred user-message persistence:', error);
resolvePersistence({});
}
return persistencePromise;
};
const cancel = () => {
if (state !== 'pending') {
return persistencePromise;
}
state = 'cancelled';
removeAbortListener();
startPersistence = undefined;
/** Resolve with a non-persisted sentinel. The subagent task store
* validates the result and fails child creation closed, while the
* request's policy error remains the only surfaced rejection. */
resolvePersistence({});
return persistencePromise;
};
userMessagePersistence = Object.freeze({
promise: persistencePromise,
isPending: () => state === 'pending',
start,
cancel,
});
const requestAbortSignal = this.abortController?.signal;
if (requestAbortSignal?.aborted) {
/** Preserve the historical durability contract for Stop: abort
* persistence may publish the partial assistant response before the
* provider unwinds, so its parent write must already be underway. */
start();
} else if (requestAbortSignal != null) {
const startOnAbort = () => start();
requestAbortSignal.addEventListener('abort', startOnAbort, { once: true });
removeAbortListener = () => requestAbortSignal.removeEventListener('abort', startOnAbort);
}
this.modelBoundUserMessagePersistence = userMessagePersistence;
userMessagePromise = persistencePromise;
} else {
userMessagePromise = startUserMessagePersistence();
}
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
userMessagePromise,
@ -704,35 +895,51 @@ class BaseClient {
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
if (
balanceConfig?.enabled &&
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
) {
await checkBalance(
{
req: this.options.req,
res: this.options.res,
txData: {
user: this.user,
tokenType: 'prompt',
amount: promptTokens,
endpoint: this.options.endpoint,
model: this.modelOptions?.model ?? this.model,
endpointTokenConfig: this.options.endpointTokenConfig,
let completionResult;
try {
if (
balanceConfig?.enabled &&
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
) {
await checkBalance(
{
req: this.options.req,
res: this.options.res,
txData: {
user: this.user,
tokenType: 'prompt',
amount: promptTokens,
endpoint: this.options.endpoint,
model: this.modelOptions?.model ?? this.model,
endpointTokenConfig: this.options.endpointTokenConfig,
},
},
},
{
logViolation,
getMultiplier: db.getMultiplier,
findBalanceByUser: db.findBalanceByUser,
createAutoRefillTransaction: db.createAutoRefillTransaction,
balanceConfig,
upsertBalanceFields: db.upsertBalanceFields,
},
);
}
{
logViolation,
getMultiplier: db.getMultiplier,
findBalanceByUser: db.findBalanceByUser,
createAutoRefillTransaction: db.createAutoRefillTransaction,
balanceConfig,
upsertBalanceFields: db.upsertBalanceFields,
},
);
}
const { completion, metadata } = await this.sendCompletion(payload, opts);
completionResult = await this.sendCompletion(payload, opts);
} catch (error) {
if (userMessagePersistence?.isPending()) {
if (isContentFilterError(error)) {
userMessagePersistence.cancel();
} else {
userMessagePersistence.start();
}
}
throw error;
}
/** A safe no-model completion (or a runtime that cannot expose the
* admission callback) must not leave the parent-write gate pending. */
userMessagePersistence?.start();
const { completion, metadata } = completionResult;
if (this.abortController) {
this.abortController.requestCompleted = true;
}
@ -759,6 +966,8 @@ class BaseClient {
...(this.metadata ?? {}),
metadata: Object.keys(metadata ?? {}).length > 0 ? metadata : undefined,
};
let editedSourceMessage;
let editedSourceContentLength = 0;
if (typeof completion === 'string') {
responseMessage.text = completion;
@ -776,6 +985,8 @@ class BaseClient {
if (!latestMessage?.content) {
responseMessage.content = completion;
} else {
editedSourceMessage = latestMessage;
editedSourceContentLength = latestMessage.content.length;
const existingContent = [...latestMessage.content];
const { type: editedType } = opts.editedContent;
responseMessage.content = this.mergeEditedContent(
@ -789,6 +1000,53 @@ class BaseClient {
responseMessage.text = completion.join('');
}
if (Array.isArray(responseMessage.content)) {
const userSubmittedPaths = [];
const userSubmittedMessageFieldPaths = [];
for (let index = 0; index < responseMessage.content.length; index++) {
if (responseMessage.content[index]?.type === ContentTypes.STEER) {
userSubmittedPaths.push(`/content/${index}`);
}
}
if (editedSourceMessage != null) {
userSubmittedPaths.push(
...(editedSourceMessage.userSubmittedPaths ?? []).filter((path) => {
const match = /^\/content\/(\d+)(?:\/|$)/.exec(path);
return match != null && Number(match[1]) < editedSourceContentLength;
}),
);
userSubmittedMessageFieldPaths.push(
...(editedSourceMessage.userSubmittedMessageFieldPaths ?? []).filter((entry) => {
const match = /^\/content\/(\d+)(?:\/|$)/.exec(entry?.path);
return match != null && Number(match[1]) < editedSourceContentLength;
}),
);
if (editedSourceMessage.isUserSubmitted === true) {
for (let index = 0; index < editedSourceContentLength; index++) {
userSubmittedPaths.push(`/content/${index}`);
}
}
const editedIndex = opts.editedContent?.index;
const editedType = opts.editedContent?.type;
if (
Number.isInteger(editedIndex) &&
editedIndex >= 0 &&
editedIndex < editedSourceContentLength &&
(editedType === ContentTypes.TEXT || editedType === ContentTypes.THINK)
) {
userSubmittedPaths.push(`/content/${editedIndex}/${editedType}`);
}
}
if (userSubmittedPaths.length > 0) {
responseMessage.userSubmittedPaths = mergeUserSubmittedPaths(userSubmittedPaths);
}
if (userSubmittedMessageFieldPaths.length > 0) {
responseMessage.userSubmittedMessageFieldPaths = mergeUserSubmittedMessageFieldPaths(
userSubmittedMessageFieldPaths,
);
}
}
if (tokenCountMap && this.recordTokenUsage && this.getTokenCountForResponse) {
let completionTokens;
@ -1549,15 +1807,16 @@ class BaseClient {
}
}
const historicalFileIds = collectHistoricalFileIds(_messages);
const fileFilter = buildOwnerFileFilter(historicalFileIds, this.options.req?.user);
const historicalFileState = collectModelBoundHistoricalFileIdState(_messages);
this.modelBoundHistoricalFileIdsOverflowed ||= historicalFileState.overflowed;
const authorizedFilesById = new Map();
if (fileFilter) {
const files = (await db.getFiles(fileFilter, {}, {})) ?? [];
for (const file of files) {
if (file?.file_id) {
authorizedFilesById.set(file.file_id, file);
}
const files = await getOwnerHistoricalFiles(
historicalFileState.fileIds,
this.options.req?.user,
);
for (const file of files) {
if (file?.file_id) {
authorizedFilesById.set(file.file_id, file);
}
}
/** Owner-scoped docs for THIS turn, including steer-part refs the steer

View file

@ -1,12 +1,15 @@
const { Constants, ContentTypes } = require('librechat-data-provider');
const { ContentFilterError } = require('@librechat/api');
const { FakeClient, initializeFakeClient } = require('./FakeClient');
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve };
return { promise, reject, resolve };
}
jest.mock('~/db/connect');
@ -551,6 +554,86 @@ describe('BaseClient', () => {
expect(response).toEqual(expectedResult);
});
test('persists exact provenance paths for edited and steered assistant content', async () => {
const history = [
{
role: 'user',
isCreatedByUser: true,
text: 'Original question',
messageId: 'user-message',
parentMessageId: Constants.NO_PARENT,
},
{
role: 'assistant',
isCreatedByUser: false,
messageId: 'assistant-message',
parentMessageId: 'user-message',
userSubmittedPaths: ['/content/1/think'],
userSubmittedMessageFieldPaths: [
{ path: '/content/0/tool_call/output', field: 'answer' },
],
content: [
{
type: ContentTypes.TOOL_CALL,
tool_call: { name: 'ask_user_question', output: 'Prior answer' },
},
{ type: ContentTypes.THINK, [ContentTypes.THINK]: 'Prior user-edited reasoning' },
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Original model response' },
],
},
];
TestClient = initializeFakeClient(apiKey, options, history);
TestClient.clientName = 'agents';
TestClient.sendCompletion.mockResolvedValue({
completion: [
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: ' model continuation' },
{ type: ContentTypes.STEER, [ContentTypes.STEER]: 'User steer' },
],
metadata: undefined,
});
const response = await TestClient.sendMessage('ignored during edit', {
conversationId: 'conversation-1',
parentMessageId: 'assistant-message',
responseMessageId: 'assistant-message',
isEdited: true,
isContinued: true,
editedContent: {
index: 2,
text: 'User replacement',
type: ContentTypes.TEXT,
},
});
const modelBoundEditedMessage = TestClient.buildMessages.mock.calls[0][0].at(-1);
expect(modelBoundEditedMessage.userSubmittedPaths).toEqual([
'/content/1/think',
'/content/2/text',
]);
expect(response.content).toEqual([
{
type: ContentTypes.TOOL_CALL,
tool_call: { name: 'ask_user_question', output: 'Prior answer' },
},
{ type: ContentTypes.THINK, [ContentTypes.THINK]: 'Prior user-edited reasoning' },
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: 'User replacement model continuation',
},
{ type: ContentTypes.STEER, [ContentTypes.STEER]: 'User steer' },
]);
expect(response.userSubmittedPaths).toEqual([
'/content/3',
'/content/1/think',
'/content/2/text',
]);
expect(response.userSubmittedMessageFieldPaths).toEqual([
{ path: '/content/0/tool_call/output', field: 'answer' },
]);
expect(response).not.toHaveProperty('isUserSubmitted');
});
test('should replace responseMessageId with new UUID when isRegenerate is true and messageId ends with underscore', async () => {
const mockCrypto = require('crypto');
const newUUID = 'new-uuid-1234';
@ -619,6 +702,577 @@ describe('BaseClient', () => {
expect(TestClient.getSaveOptions).toHaveBeenCalled();
});
test('runs the restored-history guard before building model input', async () => {
const policyError = Object.assign(new Error('Blocked restored history'), {
code: 'content_filter_block',
});
TestClient.assertStoredModelBoundContent = jest.fn(() => {
throw policyError;
});
await expect(TestClient.sendMessage('Safe new message')).rejects.toBe(policyError);
expect(TestClient.assertStoredModelBoundContent).toHaveBeenCalledTimes(1);
expect(TestClient.buildMessages).not.toHaveBeenCalled();
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
});
test('cancels a deferred user-message write when the model boundary rejects content', async () => {
saveMessage.mockClear();
saveConvo.mockClear();
const policyError = new ContentFilterError({ source: 'message', field: 'text' });
const getReqData = jest.fn();
const abortController = new AbortController();
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
TestClient.sendCompletion.mockRejectedValue(policyError);
await expect(
TestClient.sendMessage('Safe new message', { abortController, getReqData }),
).rejects.toBe(policyError);
/** Policy cancellation removes the Stop listener and remains final. */
abortController.abort();
expect(saveMessage).not.toHaveBeenCalled();
expect(saveConvo).not.toHaveBeenCalled();
const persistenceCall = getReqData.mock.calls.find(([data]) => data.userMessagePromise);
await expect(persistenceCall[0].userMessagePromise).resolves.toEqual({});
});
test('starts a deferred user-message write after a safe no-model completion', async () => {
saveMessage.mockClear();
saveConvo.mockClear();
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
TestClient.sendCompletion.mockImplementation(async () => {
expect(saveMessage).not.toHaveBeenCalled();
return { completion: 'Safe response', metadata: undefined };
});
await TestClient.sendMessage('Safe new message');
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
true,
);
});
test('preserves eager user-message persistence for non-policy provider failures', async () => {
saveMessage.mockClear();
saveConvo.mockClear();
const providerError = new Error('Provider unavailable');
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
TestClient.sendCompletion.mockRejectedValue(providerError);
await expect(TestClient.sendMessage('Safe new message')).rejects.toBe(providerError);
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
true,
);
});
test('starts a deferred user-message write when Stop aborts an in-flight completion', async () => {
saveMessage.mockClear();
saveConvo.mockClear();
const completionStarted = deferred();
const completionResult = deferred();
const abortController = new AbortController();
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
TestClient.sendCompletion.mockImplementation(() => {
completionStarted.resolve();
return completionResult.promise;
});
const sendPromise = TestClient.sendMessage('Safe new message', { abortController });
await completionStarted.promise;
expect(saveMessage).not.toHaveBeenCalled();
abortController.abort();
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
true,
);
completionResult.resolve({ completion: 'Partial response', metadata: undefined });
await sendPromise;
});
test('keeps an abort-started write when a late policy error loses the settlement race', async () => {
saveMessage.mockClear();
saveConvo.mockClear();
const completionStarted = deferred();
const completionResult = deferred();
const policyError = new ContentFilterError({ source: 'message', field: 'text' });
const abortController = new AbortController();
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
TestClient.sendCompletion.mockImplementation(() => {
completionStarted.resolve();
return completionResult.promise;
});
const sendPromise = TestClient.sendMessage('Safe new message', { abortController });
await completionStarted.promise;
abortController.abort();
completionResult.reject(policyError);
await expect(sendPromise).rejects.toBe(policyError);
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
true,
);
});
test('blocks persisted user text selected by the built model payload', async () => {
const secret = 'PRIVATE-HISTORICAL-VALUE';
const history = [
{
role: 'user',
isCreatedByUser: true,
text: `Previously stored ${secret}`,
messageId: 'persisted-user',
parentMessageId: Constants.NO_PARENT,
},
{
role: 'assistant',
isCreatedByUser: false,
text: 'Safe model response',
messageId: 'persisted-assistant',
parentMessageId: 'persisted-user',
},
];
TestClient = initializeFakeClient(
apiKey,
{
...options,
req: {
config: {
filters: {
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: [
{
id: 'historical-private',
label: 'historical private value',
regex: 'PRIVATE-HISTORICAL-[A-Z]+',
},
],
},
},
},
},
},
},
history,
);
let error;
try {
await TestClient.sendMessage('Safe new message', {
conversationId: 'persisted-conversation',
parentMessageId: 'persisted-assistant',
});
} catch (caughtError) {
error = caughtError;
}
expect(error).toMatchObject({
code: 'content_filter_block',
body: {
error: 'content_filter_block',
source: 'message',
field: 'text',
},
});
expect(JSON.stringify({ message: error.message, body: error.body })).not.toContain(secret);
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
});
test('allows persisted user text that the built model payload prunes out', async () => {
const secret = 'PRIVATE-PRUNED-HISTORICAL-VALUE';
TestClient = initializeFakeClient(
apiKey,
{
...options,
req: {
config: {
filters: {
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: [
{
id: 'pruned-private',
label: 'pruned private value',
regex: 'PRIVATE-PRUNED-HISTORICAL-[A-Z]+',
},
],
},
},
},
},
},
},
[
{
role: 'user',
isCreatedByUser: true,
text: `Old ${secret}`,
messageId: 'pruned-user',
parentMessageId: Constants.NO_PARENT,
},
{
role: 'assistant',
isCreatedByUser: false,
text: 'Safe response',
messageId: 'safe-assistant',
parentMessageId: 'pruned-user',
},
],
);
TestClient.buildMessages.mockResolvedValue({
prompt: [{ role: 'user', content: 'Safe new message' }],
tokenCountMap: null,
});
await expect(
TestClient.sendMessage('Safe new message', {
conversationId: 'pruned-conversation',
parentMessageId: 'safe-assistant',
}),
).resolves.toEqual(expect.objectContaining({ isCreatedByUser: false }));
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
expect(TestClient.sendCompletion).toHaveBeenCalledTimes(1);
});
test('blocks historical tool arguments without classifying assistant prose as user input', async () => {
const filters = {
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: [
{
id: 'assistant-prose',
label: 'assistant prose value',
regex: 'PRIVATE-PROSE',
},
],
},
},
toolArguments: {
pii: {
fields: ['arguments'],
starterPatterns: [],
customPatterns: [
{
id: 'historical-tool',
label: 'historical tool value',
regex: 'PRIVATE-TOOL',
},
],
},
},
};
const safeUserMessage = {
role: 'user',
isCreatedByUser: true,
text: 'Safe historical question',
messageId: 'safe-user',
parentMessageId: Constants.NO_PARENT,
};
const assistantMessage = {
role: 'assistant',
isCreatedByUser: false,
text: 'Model generated PRIVATE-PROSE',
content: [
{
type: 'tool_call',
tool_call: {
name: 'lookup',
args: { query: 'PRIVATE-TOOL' },
},
},
],
messageId: 'assistant-with-tool',
parentMessageId: 'safe-user',
};
const clientOptions = {
...options,
req: { config: { filters } },
};
TestClient = initializeFakeClient(apiKey, clientOptions, [safeUserMessage, assistantMessage]);
await expect(
TestClient.sendMessage('Safe new message', {
conversationId: 'tool-conversation',
parentMessageId: 'assistant-with-tool',
}),
).rejects.toMatchObject({
code: 'content_filter_block',
body: {
source: 'tool_argument',
field: 'arguments',
},
});
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
const proseOnlyClient = initializeFakeClient(apiKey, clientOptions, [
safeUserMessage,
{
...assistantMessage,
content: undefined,
},
]);
await expect(
proseOnlyClient.sendMessage('Safe new message', {
conversationId: 'prose-conversation',
parentMessageId: 'assistant-with-tool',
}),
).resolves.toEqual(expect.objectContaining({ isCreatedByUser: false }));
expect(proseOnlyClient.buildMessages).toHaveBeenCalledTimes(1);
expect(proseOnlyClient.sendCompletion).toHaveBeenCalledTimes(1);
});
test('resolves and inspects owner-scoped historical files before building messages', async () => {
const historicalMessage = {
role: 'user',
isCreatedByUser: true,
text: 'Use my file',
files: [{ file_id: 'owned-file' }],
messageId: 'historical-file-message',
parentMessageId: Constants.NO_PARENT,
};
getFiles.mockReset();
getFiles.mockResolvedValueOnce([
{
file_id: 'owned-file',
filename: 'owned.txt',
filepath: '/uploads/owned.txt',
text: 'safe canonical file content',
user: 'user-1',
},
]);
TestClient = initializeFakeClient(
apiKey,
{
...options,
req: {
user: { id: 'user-1', tenantId: 'tenant-a' },
config: {
filters: {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
},
},
},
},
[historicalMessage],
);
await expect(
TestClient.sendMessage('Safe new message', {
conversationId: 'historical-file-conversation',
parentMessageId: 'historical-file-message',
}),
).resolves.toBeDefined();
expect(getFiles).toHaveBeenCalledWith(
{
file_id: { $in: ['owned-file'] },
user: 'user-1',
tenantId: 'tenant-a',
},
{},
{},
);
expect(TestClient.buildMessages).toHaveBeenCalled();
});
test('does not block a missing historical file omitted from the final payload', async () => {
getFiles.mockReset();
getFiles.mockResolvedValueOnce([]);
TestClient = initializeFakeClient(
apiKey,
{
...options,
req: {
user: { id: 'user-1', tenantId: 'tenant-a' },
config: {
filters: {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
},
},
},
},
[
{
role: 'user',
isCreatedByUser: true,
text: 'Use a foreign file',
files: [{ file_id: 'foreign-file' }],
messageId: 'foreign-file-message',
parentMessageId: Constants.NO_PARENT,
},
],
);
await expect(
TestClient.sendMessage('Safe new message', {
conversationId: 'foreign-file-conversation',
parentMessageId: 'foreign-file-message',
}),
).resolves.toEqual(expect.objectContaining({ isCreatedByUser: false }));
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
expect(TestClient.sendCompletion).toHaveBeenCalledTimes(1);
});
test('surfaces historical file lookup failures instead of silently dropping context', async () => {
getFiles.mockReset();
getFiles.mockRejectedValueOnce(new Error('historical file lookup unavailable'));
TestClient = initializeFakeClient(
apiKey,
{
...options,
req: { user: { id: 'user-1', tenantId: 'tenant-a' }, config: {} },
},
[
{
role: 'user',
isCreatedByUser: true,
text: 'Use my historical file',
files: [{ file_id: 'historical-file' }],
messageId: 'historical-file-message',
parentMessageId: Constants.NO_PARENT,
},
],
);
await expect(
TestClient.sendMessage('Safe new message', {
conversationId: 'historical-file-error-conversation',
parentMessageId: 'historical-file-message',
}),
).rejects.toThrow('historical file lookup unavailable');
expect(TestClient.buildMessages).not.toHaveBeenCalled();
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
});
test('ignores historical file refs when the endpoint does not resend files', async () => {
getFiles.mockReset();
TestClient = initializeFakeClient(
apiKey,
{
...options,
resendFiles: false,
req: {
user: { id: 'user-1', tenantId: 'tenant-a' },
config: {
filters: {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
},
},
},
},
[
{
role: 'user',
isCreatedByUser: true,
text: 'A prior turn referenced a file.',
files: [{ file_id: 'deleted-historical-file' }],
content: [
{
type: 'input_file',
files: [{ file_id: 'part-file' }],
image_file: { file_id: 'image-file' },
file_id: 'direct-file',
file: { file_id: 'nested-file' },
},
],
messageId: 'historical-file-message',
parentMessageId: Constants.NO_PARENT,
},
],
);
await expect(
TestClient.sendMessage('Safe text-only continuation', {
conversationId: 'no-file-replay-conversation',
parentMessageId: 'historical-file-message',
}),
).resolves.toBeDefined();
expect(getFiles).not.toHaveBeenCalled();
expect(TestClient.buildMessages).toHaveBeenCalled();
const [modelMessages] = TestClient.buildMessages.mock.calls[0];
expect(modelMessages[0]).not.toHaveProperty('files');
expect(modelMessages[0].content[0]).toEqual({ type: 'input_file' });
expect(TestClient.sendCompletion).toHaveBeenCalled();
});
test('keeps a materialized current attachment inspectable when historical replay is disabled', () => {
const currentFile = {
file_id: 'current-file',
filename: 'safe.txt',
text: 'Safe current attachment content',
};
TestClient = initializeFakeClient(apiKey, {
...options,
resendFiles: false,
attachments: [currentFile],
req: {
config: {
filters: {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
},
},
},
});
TestClient.message_file_map = { 'current-source': [currentFile] };
TestClient.setModelBoundStoredMessages([
{
messageId: 'current-source',
role: 'user',
isCreatedByUser: true,
text: 'Use the current file',
},
]);
expect(() =>
TestClient.assertBuiltModelBoundContent([
{
role: 'user',
content: 'Use the current file',
additional_kwargs: { sourceMessageId: 'current-source' },
},
]),
).not.toThrow();
});
test('should return chat history', async () => {
TestClient = initializeFakeClient(apiKey, options, messageHistory);
const chatMessages = await TestClient.loadHistory(conversationId, '2');
@ -1754,6 +2408,129 @@ describe('BaseClient', () => {
expect(JSON.stringify(message)).not.toContain('forged owner text');
});
test('hydrates files referenced by non-steer provider content parts', async () => {
getFiles.mockResolvedValueOnce([ownerFile]);
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-content-file',
isCreatedByUser: true,
content: [
{
type: 'input_file',
files: [{ file_id: 'owner-file' }],
},
],
},
]);
expect(getFiles).toHaveBeenCalledWith(
{
file_id: { $in: ['owner-file'] },
user: 'user-1',
tenantId: 'tenant-a',
},
{},
{},
);
expect(TestClient.authorizedHistoricalFiles.get('owner-file')).toEqual(ownerFile);
expect(message.content[0].files).toEqual([{ file_id: 'owner-file' }]);
});
test('hydrates nested provider file references', async () => {
getFiles.mockResolvedValueOnce([ownerFile]);
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-nested-content-file',
isCreatedByUser: true,
content: [
{
type: 'input_file',
file: { file_id: 'owner-file' },
},
],
},
]);
expect(getFiles).toHaveBeenCalledWith(
{
file_id: { $in: ['owner-file'] },
user: 'user-1',
tenantId: 'tenant-a',
},
{},
{},
);
expect(TestClient.authorizedHistoricalFiles.get('owner-file')).toEqual(ownerFile);
expect(message.content[0].file).toEqual({ file_id: 'owner-file' });
});
test('preserves owner-scoped historical attachments when file patterns are inactive', async () => {
TestClient.options.req.config = {
filters: {
files: {
pii: {
starterPatterns: [],
customPatterns: [],
},
},
},
};
getFiles.mockResolvedValueOnce([ownerFile]);
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-inactive-file-policy',
files: [{ file_id: 'owner-file', filename: 'forged-input.txt' }],
attachments: [{ file_id: 'owner-file', filename: 'forged-output.txt' }],
},
]);
expect(getFiles).toHaveBeenCalledWith(
{
file_id: { $in: ['owner-file'] },
user: 'user-1',
tenantId: 'tenant-a',
},
{},
{},
);
expect(message.files).toEqual([
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
]);
expect(message.attachments).toEqual([
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
]);
});
test('strips an unresolved historical file reference without pre-pruning enforcement', async () => {
TestClient.options.req.config = {
filters: {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
},
};
getFiles.mockResolvedValueOnce([]);
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-unresolved',
isCreatedByUser: true,
files: [{ file_id: 'foreign-file' }],
},
]);
expect(message).toEqual(expect.objectContaining({ messageId: 'msg-unresolved' }));
expect(message).not.toHaveProperty('files');
expect(TestClient.addFileContextToMessage).not.toHaveBeenCalled();
expect(TestClient.processAttachments).not.toHaveBeenCalled();
});
test('strips historical file context when no authenticated owner scope is available', async () => {
TestClient.options.req = {};

View file

@ -108,7 +108,7 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
const formattedMessages = orderedMessages.map((message) => {
let { role: _role, sender, text } = message;
const role = _role ?? sender;
const content = text ?? '';
const content = Array.isArray(message.content) ? message.content : (text ?? '');
return {
role: role?.toLowerCase() === 'user' ? 'user' : 'assistant',
content,