🛂 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

@ -16,6 +16,7 @@ const {
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
getAgentStartupTelemetry,
isContentFilterError,
buildAgentContextAttachmentsByAgentId,
collectCodeExecutionProfileRoutes,
getLazySubagentConfigId,
@ -122,7 +123,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC
accessibleMcpServerNames,
});
} catch (error) {
if (isFatalAgentInitializationError(error)) {
if (isFatalAgentInitializationError(error) || isContentFilterError(error)) {
throw error;
}
logger.error('Error loading tools for agent ' + agentId, error);

View file

@ -3,6 +3,7 @@ const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const { saveConvo } = require('~/models');
const { resolveConversationTitle } = require('../titlePolicy');
/**
* Add title to conversation in a way that avoids memory retention.
@ -105,7 +106,7 @@ const addTitle = async (
return;
}
const title = await titlePromise;
const generatedTitle = await titlePromise;
if (!abortController.signal.aborted) {
abortController.abort();
}
@ -113,11 +114,16 @@ const addTitle = async (
clearTimeout(timeoutId);
}
if (!title) {
if (!generatedTitle) {
logger.debug(`[${key}] No title generated`);
return;
}
const title = resolveConversationTitle(req, generatedTitle);
if (title == null) {
return;
}
await titleCache.set(key, title, 120000);
if (!signal?.aborted && typeof onTitleGenerated === 'function') {

View file

@ -9,6 +9,7 @@ const mockCache = {
const mockSaveConvo = jest.fn();
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: (val) => val === true || val === 'true',
sanitizeTitle: (title) => title,
}));
@ -17,10 +18,6 @@ jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));
jest.mock('librechat-data-provider', () => ({
CacheKeys: { GEN_TITLE: 'GEN_TITLE' },
}));
jest.mock('~/cache/getLogStores', () => jest.fn(() => mockCache));
jest.mock('~/models', () => ({
@ -181,6 +178,39 @@ describe('agents addTitle', () => {
expect(order).toEqual(['cache', 'title-event', 'save']);
});
it('replaces a blocked generated title before caching, emitting, or saving it', async () => {
const client = makeClient('BLOCKED-TITLE');
const req = makeReq();
req.config.filters = {
conversationTitles: {
pii: {
starterPatterns: [],
customPatterns: [{ id: 'blocked', label: 'blocked', regex: 'BLOCKED' }],
},
},
};
const onTitleGenerated = jest.fn();
await addTitle(req, {
text: 'hello',
client,
conversationId: 'cid-filtered',
immediate: true,
convoReady: Promise.resolve(),
onTitleGenerated,
});
expect(mockCache.set).toHaveBeenCalledWith('user-1-cid-filtered', 'New Chat', 120000);
expect(onTitleGenerated).toHaveBeenCalledWith({
conversationId: 'cid-filtered',
title: 'New Chat',
});
expect(mockSaveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ conversationId: 'cid-filtered', title: 'New Chat' }),
expect.objectContaining({ noUpsert: true }),
);
});
it('skips generation when the endpoint disables titleConvo', async () => {
const client = makeClient();
client.options.titleConvo = false;

View file

@ -4,6 +4,7 @@ const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const initializeClient = require('./initalize');
const { saveConvo } = require('~/models');
const { resolveConversationTitle } = require('../titlePolicy');
/**
* Generates a conversation title using OpenAI SDK
@ -60,7 +61,11 @@ const addTitle = async (req, { text, responseText, conversationId }) => {
try {
const { openai } = await initializeClient({ req });
const title = await generateTitle({ openai, text, responseText });
const generatedTitle = await generateTitle({ openai, text, responseText });
const title = resolveConversationTitle(req, generatedTitle);
if (title == null) {
return;
}
await titleCache.set(key, title, 120000);
const reqCtx = {
@ -88,8 +93,12 @@ const addTitle = async (req, { text, responseText, conversationId }) => {
if (!fallbackSource) {
return;
}
const fallbackTitle =
const submittedFallback =
fallbackSource.length > 40 ? fallbackSource.substring(0, 37) + '...' : fallbackSource;
const fallbackTitle = resolveConversationTitle(req, submittedFallback);
if (fallbackTitle == null) {
return;
}
await titleCache.set(key, fallbackTitle, 120000);
await saveConvo(
{

View file

@ -0,0 +1,110 @@
const mockCache = {
set: jest.fn(),
};
const mockSaveConvo = jest.fn();
const mockInitializeClient = jest.fn();
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: (value) => value === true || value === 'true',
sanitizeTitle: (title) => title,
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));
jest.mock('~/cache/getLogStores', () => jest.fn(() => mockCache));
jest.mock(
'./initalize',
() =>
(...args) =>
mockInitializeClient(...args),
);
jest.mock('~/models', () => ({
saveConvo: (...args) => mockSaveConvo(...args),
}));
const addTitle = require('./title');
describe('assistants addTitle content policy', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('replaces a blocked generated title before caching or saving it', async () => {
const create = jest.fn().mockResolvedValue({
choices: [{ message: { content: 'BLOCKED-GENERATED-TITLE' } }],
});
mockInitializeClient.mockResolvedValue({
openai: { chat: { completions: { create } } },
});
const req = {
user: { id: 'user-1' },
body: {},
config: {
filters: {
conversationTitles: {
pii: {
starterPatterns: [],
customPatterns: [{ id: 'blocked', label: 'blocked', regex: 'BLOCKED' }],
},
},
},
},
};
await addTitle(req, {
text: 'submitted text',
responseText: 'response',
conversationId: 'conversation-generated',
});
expect(mockCache.set).toHaveBeenCalledWith('user-1-conversation-generated', 'New Chat', 120000);
expect(mockSaveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
conversationId: 'conversation-generated',
title: 'New Chat',
}),
expect.objectContaining({ noUpsert: true }),
);
});
it('replaces a blocked submitted-text fallback before caching or saving it', async () => {
const create = jest.fn().mockRejectedValue(new Error('title model unavailable'));
mockInitializeClient.mockResolvedValue({
openai: { chat: { completions: { create } } },
});
const req = {
user: { id: 'user-1' },
body: {},
config: {
filters: {
conversationTitles: {
pii: {
starterPatterns: [],
customPatterns: [{ id: 'blocked', label: 'blocked', regex: 'BLOCKED' }],
},
},
},
},
};
await addTitle(req, {
text: 'BLOCKED-SUBMISSION',
responseText: 'response',
conversationId: 'conversation-1',
});
expect(mockCache.set).toHaveBeenCalledWith('user-1-conversation-1', 'New Chat', 120000);
expect(mockSaveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
conversationId: 'conversation-1',
title: 'New Chat',
}),
expect.objectContaining({ noUpsert: true }),
);
});
});

View file

@ -0,0 +1,19 @@
const {
SAFE_CONVERSATION_TITLE,
resolveConversationTitle: resolveTitlePolicy,
} = require('@librechat/api');
/**
* @param {ServerRequest} req
* @param {unknown} candidate
* @param {string} [fallback]
* @returns {string|null}
*/
function resolveConversationTitle(req, candidate, fallback = SAFE_CONVERSATION_TITLE) {
return resolveTitlePolicy({ filters: req?.config?.filters, candidate, fallback });
}
module.exports = {
SAFE_CONVERSATION_TITLE,
resolveConversationTitle,
};

View file

@ -0,0 +1,28 @@
const mockResolveTitlePolicy = jest.fn();
jest.mock('@librechat/api', () => ({
SAFE_CONVERSATION_TITLE: 'New Chat',
resolveConversationTitle: (...args) => mockResolveTitlePolicy(...args),
}));
const { resolveConversationTitle } = require('./titlePolicy');
describe('titlePolicy adapter', () => {
beforeEach(() => {
jest.clearAllMocks();
mockResolveTitlePolicy.mockReturnValue('Resolved title');
});
it('delegates the policy decision to the TypeScript implementation', () => {
const filters = { conversationTitles: { pii: {} } };
expect(resolveConversationTitle({ config: { filters } }, 'Candidate', 'Fallback')).toBe(
'Resolved title',
);
expect(mockResolveTitlePolicy).toHaveBeenCalledWith({
filters,
candidate: 'Candidate',
fallback: 'Fallback',
});
});
});