mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🛂 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:
parent
10f95c0ce9
commit
67b7b441b2
303 changed files with 75546 additions and 2327 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
110
api/server/services/Endpoints/assistants/title.test.js
Normal file
110
api/server/services/Endpoints/assistants/title.test.js
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
19
api/server/services/Endpoints/titlePolicy.js
Normal file
19
api/server/services/Endpoints/titlePolicy.js
Normal 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,
|
||||
};
|
||||
28
api/server/services/Endpoints/titlePolicy.test.js
Normal file
28
api/server/services/Endpoints/titlePolicy.test.js
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue