mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🗝️ fix: Protect Model Spec Instructions (#13125)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* fix: prevent instruction exposure * fix: tighten model spec preset restoration * refactor: type model spec preset handling
This commit is contained in:
parent
b993d9fb28
commit
ca8c212c0d
11 changed files with 668 additions and 21 deletions
|
|
@ -233,6 +233,26 @@ describe('loadAgent', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('should use parsed promptPrefix for ephemeral agent instructions', async () => {
|
||||
const { EPHEMERAL_AGENT_ID } = Constants;
|
||||
|
||||
const result = await loadAgent(
|
||||
{
|
||||
req: { user: { id: 'user123' }, body: {} },
|
||||
agent_id: EPHEMERAL_AGENT_ID as string,
|
||||
endpoint: 'openai',
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
promptPrefix: 'Server-side model spec instructions',
|
||||
} as unknown as AgentModelParameters,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result?.instructions).toBe('Server-side model spec instructions');
|
||||
expect(result?.model_parameters).not.toHaveProperty('promptPrefix');
|
||||
});
|
||||
|
||||
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
|
||||
const { EPHEMERAL_AGENT_ID } = Constants;
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
import { getCustomEndpointConfig } from '~/app/config';
|
||||
|
||||
const { mcp_all, mcp_delimiter } = Constants;
|
||||
type ModelParametersWithPromptPrefix = AgentModelParameters & { promptPrefix?: string | null };
|
||||
|
||||
export interface LoadAgentDeps {
|
||||
getAgent: (searchParameter: { id: string }) => Promise<Agent | null>;
|
||||
|
|
@ -89,7 +90,11 @@ export async function loadEphemeralAgent(
|
|||
}
|
||||
}
|
||||
|
||||
const instructions = req.body?.promptPrefix;
|
||||
const requestPromptPrefix = req.body?.promptPrefix;
|
||||
const { promptPrefix: modelPromptPrefix, ...safeModelParameters } =
|
||||
model_parameters as ModelParametersWithPromptPrefix;
|
||||
const instructions =
|
||||
typeof modelPromptPrefix === 'string' ? modelPromptPrefix : requestPromptPrefix;
|
||||
|
||||
// Get endpoint config for modelDisplayLabel fallback
|
||||
const appConfig = req.config;
|
||||
|
|
@ -122,7 +127,7 @@ export async function loadEphemeralAgent(
|
|||
id: ephemeralId,
|
||||
instructions,
|
||||
provider: endpoint,
|
||||
model_parameters,
|
||||
model_parameters: safeModelParameters as AgentModelParameters,
|
||||
model,
|
||||
tools,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export * from './flow/manager';
|
|||
export * from './middleware';
|
||||
/* Memory */
|
||||
export * from './memory';
|
||||
/* Model Specs */
|
||||
export * from './modelSpecs';
|
||||
/* Agents */
|
||||
export * from './agents';
|
||||
/* Prompts */
|
||||
|
|
|
|||
|
|
@ -159,11 +159,27 @@ export class MCPServersInitializer {
|
|||
logger.info(`${prefix} OAuth Required: ${config.requiresOAuth}`);
|
||||
logger.info(`${prefix} Capabilities: ${config.capabilities}`);
|
||||
logger.info(`${prefix} Tools: ${config.tools}`);
|
||||
logger.info(`${prefix} Server Instructions: ${config.serverInstructions}`);
|
||||
logger.info(
|
||||
`${prefix} Server Instructions: ${MCPServersInitializer.formatInstructionsForLogging(
|
||||
config.serverInstructions,
|
||||
)}`,
|
||||
);
|
||||
logger.info(`${prefix} Initialized in: ${config.initDuration ?? 'N/A'}ms`);
|
||||
logger.info(`${prefix} -------------------------------------------------┘`);
|
||||
}
|
||||
|
||||
private static formatInstructionsForLogging(instructions?: string | boolean): string {
|
||||
if (!instructions) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (typeof instructions !== 'string') {
|
||||
return 'configured';
|
||||
}
|
||||
|
||||
return `configured (${instructions.length} chars)`;
|
||||
}
|
||||
|
||||
// Returns formatted log prefix for server messages
|
||||
private static prefix(serverName: string): string {
|
||||
return `[MCP][${serverName}]`;
|
||||
|
|
|
|||
|
|
@ -458,6 +458,17 @@ describe('MCPServersInitializer', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should not log raw server instructions', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
expect(mockLogger.info).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Instructions for file_tools_server'),
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[MCP][file_tools_server] Server Instructions: configured'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use Promise.allSettled for parallel server initialization', async () => {
|
||||
const allSettledSpy = jest.spyOn(Promise, 'allSettled');
|
||||
|
||||
|
|
|
|||
189
packages/api/src/modelSpecs/index.ts
Normal file
189
packages/api/src/modelSpecs/index.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import {
|
||||
parseCompactConvo,
|
||||
replaceSpecialVars,
|
||||
type EModelEndpoint,
|
||||
type TConversation,
|
||||
type TModelSpec,
|
||||
type TModelSpecPreset,
|
||||
type TPreset,
|
||||
type TSpecsConfig,
|
||||
type TUser,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
export const PRIVATE_MODEL_SPEC_PRESET_FIELDS = [
|
||||
'promptPrefix',
|
||||
'instructions',
|
||||
'additional_instructions',
|
||||
'system',
|
||||
'context',
|
||||
'examples',
|
||||
] as const satisfies readonly (keyof TModelSpecPreset)[];
|
||||
|
||||
export type PrivateModelSpecPresetField = (typeof PRIVATE_MODEL_SPEC_PRESET_FIELDS)[number];
|
||||
export type ModelSpecParsedBody = Partial<TConversation | TPreset | TModelSpecPreset> &
|
||||
Record<string, unknown>;
|
||||
|
||||
export type ApplyModelSpecPresetParams = {
|
||||
modelSpec: TModelSpec;
|
||||
parsedBody: ModelSpecParsedBody;
|
||||
endpoint?: string | null;
|
||||
endpointType?: string | null;
|
||||
defaultParamsEndpoint?: string | null;
|
||||
includePresetDefaults?: boolean;
|
||||
};
|
||||
|
||||
export type ApplyModelSpecPresetResult = {
|
||||
parsedBody: ModelSpecParsedBody;
|
||||
appliedPrivateFields: Set<PrivateModelSpecPresetField>;
|
||||
};
|
||||
|
||||
function hasModelSpecValue(field: PrivateModelSpecPresetField, value: unknown): boolean {
|
||||
if (value == null || value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (field === 'examples') {
|
||||
return value.some((example) => {
|
||||
const input = example?.input?.content;
|
||||
const output = example?.output?.content;
|
||||
return Boolean(input || output);
|
||||
});
|
||||
}
|
||||
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
function mergeModelSpecPreset(
|
||||
modelSpec: TModelSpec,
|
||||
parsedBody: ModelSpecParsedBody,
|
||||
{ includePresetDefaults = false }: Pick<ApplyModelSpecPresetParams, 'includePresetDefaults'> = {},
|
||||
): ApplyModelSpecPresetResult {
|
||||
const preset = modelSpec.preset;
|
||||
const merged = {
|
||||
...(includePresetDefaults ? preset : {}),
|
||||
...parsedBody,
|
||||
spec: modelSpec.name,
|
||||
} as ModelSpecParsedBody;
|
||||
const appliedPrivateFields = new Set<PrivateModelSpecPresetField>();
|
||||
|
||||
for (const field of PRIVATE_MODEL_SPEC_PRESET_FIELDS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(preset, field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (includePresetDefaults) {
|
||||
appliedPrivateFields.add(field);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasModelSpecValue(field, parsedBody[field])) {
|
||||
(merged as Record<string, unknown>)[field] = preset[field];
|
||||
appliedPrivateFields.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
return { parsedBody: merged, appliedPrivateFields };
|
||||
}
|
||||
|
||||
export function findModelSpecByName(
|
||||
modelSpecs: Pick<TSpecsConfig, 'list'> | undefined,
|
||||
spec: string | null | undefined,
|
||||
): TModelSpec | undefined {
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return modelSpecs?.list?.find((modelSpec) => modelSpec.name === spec);
|
||||
}
|
||||
|
||||
export function isModelSpecEndpointMatch(
|
||||
modelSpec: Pick<TModelSpec, 'preset'> | undefined,
|
||||
endpoint: string | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(modelSpec && endpoint === modelSpec.preset?.endpoint);
|
||||
}
|
||||
|
||||
export function applyModelSpecPreset({
|
||||
modelSpec,
|
||||
parsedBody,
|
||||
endpoint,
|
||||
endpointType,
|
||||
defaultParamsEndpoint,
|
||||
includePresetDefaults,
|
||||
}: ApplyModelSpecPresetParams): ApplyModelSpecPresetResult {
|
||||
const { parsedBody: conversation, appliedPrivateFields } = mergeModelSpecPreset(
|
||||
modelSpec,
|
||||
parsedBody,
|
||||
{
|
||||
includePresetDefaults,
|
||||
},
|
||||
);
|
||||
const reparsedBody = parseCompactConvo({
|
||||
endpoint: endpoint as EModelEndpoint | undefined,
|
||||
endpointType: endpointType as EModelEndpoint | null | undefined,
|
||||
conversation,
|
||||
defaultParamsEndpoint,
|
||||
});
|
||||
|
||||
if (!reparsedBody) {
|
||||
throw new Error('Model spec preset produced an empty parsed body');
|
||||
}
|
||||
|
||||
const modelSpecParsedBody = reparsedBody as ModelSpecParsedBody;
|
||||
if (modelSpec.iconURL != null && modelSpec.iconURL !== '') {
|
||||
modelSpecParsedBody.iconURL = modelSpec.iconURL;
|
||||
}
|
||||
|
||||
return { parsedBody: modelSpecParsedBody, appliedPrivateFields };
|
||||
}
|
||||
|
||||
export function resolveModelSpecPromptPrefixVariables<T extends { promptPrefix?: string | null }>(
|
||||
parsedBody: T,
|
||||
user?: TUser | null,
|
||||
now?: string | number | Date,
|
||||
): T {
|
||||
if (typeof parsedBody.promptPrefix !== 'string') {
|
||||
return parsedBody;
|
||||
}
|
||||
|
||||
return {
|
||||
...parsedBody,
|
||||
promptPrefix: replaceSpecialVars({
|
||||
text: parsedBody.promptPrefix,
|
||||
user,
|
||||
now,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeModelSpecs<T extends Partial<TSpecsConfig> | null | undefined>(
|
||||
modelSpecs: T,
|
||||
): T {
|
||||
if (!modelSpecs?.list || !Array.isArray(modelSpecs.list)) {
|
||||
return modelSpecs;
|
||||
}
|
||||
|
||||
return {
|
||||
...modelSpecs,
|
||||
list: modelSpecs.list.map((modelSpec) => {
|
||||
const preset = modelSpec?.preset;
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
return modelSpec;
|
||||
}
|
||||
|
||||
const sanitizedPreset = { ...preset };
|
||||
for (const field of PRIVATE_MODEL_SPEC_PRESET_FIELDS) {
|
||||
delete sanitizedPreset[field];
|
||||
}
|
||||
|
||||
return {
|
||||
...modelSpec,
|
||||
preset: sanitizedPreset,
|
||||
};
|
||||
}),
|
||||
} as T;
|
||||
}
|
||||
149
packages/api/src/modelSpecs/modelSpecs.test.ts
Normal file
149
packages/api/src/modelSpecs/modelSpecs.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TModelSpec } from 'librechat-data-provider';
|
||||
import {
|
||||
applyModelSpecPreset,
|
||||
findModelSpecByName,
|
||||
isModelSpecEndpointMatch,
|
||||
resolveModelSpecPromptPrefixVariables,
|
||||
sanitizeModelSpecs,
|
||||
} from './index';
|
||||
|
||||
describe('modelSpecs helpers', () => {
|
||||
it('should strip private prompt fields from model spec presets', () => {
|
||||
const modelSpecs = {
|
||||
enforce: false,
|
||||
prioritize: true,
|
||||
list: [
|
||||
{
|
||||
name: 'guarded-spec',
|
||||
label: 'Guarded Spec',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4o',
|
||||
promptPrefix: 'private prompt prefix',
|
||||
instructions: 'private assistant instructions',
|
||||
additional_instructions: 'private additional instructions',
|
||||
system: 'private bedrock system',
|
||||
context: 'private context',
|
||||
examples: [{ input: { content: 'a' }, output: { content: 'b' } }],
|
||||
greeting: 'Hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(sanitizeModelSpecs(modelSpecs).list[0].preset).toEqual({
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4o',
|
||||
greeting: 'Hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('should restore only private fields for non-enforced model specs', () => {
|
||||
const modelSpec: TModelSpec = {
|
||||
name: 'guarded-openai',
|
||||
label: 'Guarded OpenAI',
|
||||
iconURL: EModelEndpoint.openAI,
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4o',
|
||||
promptPrefix: 'private prompt prefix',
|
||||
instructions: 'private instructions',
|
||||
additional_instructions: 'private additional instructions',
|
||||
temperature: 0.2,
|
||||
maxContextTokens: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
const { parsedBody, appliedPrivateFields } = applyModelSpecPreset({
|
||||
modelSpec,
|
||||
parsedBody: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
spec: 'guarded-openai',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.8,
|
||||
},
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
expect(parsedBody.promptPrefix).toBe('private prompt prefix');
|
||||
expect(parsedBody.instructions).toBeUndefined();
|
||||
expect(parsedBody.additional_instructions).toBeUndefined();
|
||||
expect(parsedBody.temperature).toBe(0.8);
|
||||
expect(parsedBody.maxContextTokens).toBeUndefined();
|
||||
expect(parsedBody.iconURL).toBe(EModelEndpoint.openAI);
|
||||
expect(appliedPrivateFields.has('promptPrefix')).toBe(true);
|
||||
});
|
||||
|
||||
it('should restore preset defaults when model specs are enforced', () => {
|
||||
const modelSpec: TModelSpec = {
|
||||
name: 'enforced-openai',
|
||||
label: 'Enforced OpenAI',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4o',
|
||||
promptPrefix: 'private prompt prefix',
|
||||
temperature: 0.2,
|
||||
},
|
||||
};
|
||||
|
||||
const { parsedBody } = applyModelSpecPreset({
|
||||
modelSpec,
|
||||
parsedBody: modelSpec.preset,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
includePresetDefaults: true,
|
||||
});
|
||||
|
||||
expect(parsedBody.spec).toBe('enforced-openai');
|
||||
expect(parsedBody.promptPrefix).toBe('private prompt prefix');
|
||||
expect(parsedBody.temperature).toBe(0.2);
|
||||
});
|
||||
|
||||
it('should restore private examples when parser supplies an empty default', () => {
|
||||
const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }];
|
||||
const modelSpec: TModelSpec = {
|
||||
name: 'guarded-google',
|
||||
label: 'Guarded Google',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.google,
|
||||
model: 'gemini-pro',
|
||||
examples,
|
||||
},
|
||||
};
|
||||
|
||||
const { parsedBody } = applyModelSpecPreset({
|
||||
modelSpec,
|
||||
parsedBody: {
|
||||
endpoint: EModelEndpoint.google,
|
||||
spec: 'guarded-google',
|
||||
model: 'gemini-pro',
|
||||
},
|
||||
endpoint: EModelEndpoint.google,
|
||||
});
|
||||
|
||||
expect(parsedBody.examples).toEqual(examples);
|
||||
});
|
||||
|
||||
it('should find specs and validate endpoint matches', () => {
|
||||
const modelSpec: TModelSpec = {
|
||||
name: 'guarded-openai',
|
||||
label: 'Guarded OpenAI',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
};
|
||||
|
||||
expect(findModelSpecByName({ list: [modelSpec] }, 'guarded-openai')).toBe(modelSpec);
|
||||
expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.openAI)).toBe(true);
|
||||
expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.google)).toBe(false);
|
||||
});
|
||||
|
||||
it('should resolve special variables in model spec prompt prefixes', () => {
|
||||
expect(
|
||||
resolveModelSpecPromptPrefixVariables({ promptPrefix: 'Help {{current_user}}.' }, {
|
||||
name: 'Ada',
|
||||
} as never).promptPrefix,
|
||||
).toBe('Help Ada.');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue