🗝️ 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

* fix: prevent instruction exposure

* fix: tighten model spec preset restoration

* refactor: type model spec preset handling
This commit is contained in:
Danny Avila 2026-05-14 10:07:23 -04:00 committed by GitHub
parent b993d9fb28
commit ca8c212c0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 668 additions and 21 deletions

View file

@ -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;

View file

@ -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,
};

View file

@ -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 */

View file

@ -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}]`;

View file

@ -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');

View 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;
}

View 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.');
});
});