🧱 fix: Enforce Agent Access Control on Context and OCR File Loading (#12253)

* 🔏 fix: Apply agent access control filtering to context/OCR resource loading

The context/OCR file path in primeResources fetched files by file_id
without applying filterFilesByAgentAccess, unlike the file_search and
execute_code paths. Add filterFiles dependency injection to primeResources
and invoke it after getFiles to enforce consistent access control.

* fix: Wire filterFilesByAgentAccess into all agent initialization callers

Pass the filterFilesByAgentAccess function from the JS layer into the TS
initializeAgent → primeResources chain via dependency injection, covering
primary, handoff, added-convo, and memory agent init paths.

* test: Add access control filtering tests for primeResources

Cover filterFiles invocation with context/OCR files, verify filtering
rejects inaccessible files, and confirm graceful fallback when filterFiles,
userId, or agentId are absent.

* fix: Guard filterFilesByAgentAccess against ephemeral agent IDs

Ephemeral agents have no DB document, so getAgent returns null and the
access map defaults to all-false, silently blocking all non-owned files.
Short-circuit with isEphemeralAgentId to preserve the pass-through
behavior for inline-built agents (memory, tool agents).

* fix: Clean up resources.ts and JS caller import order

Remove redundant optional chain on req.user.role inside user-guarded
block, update primeResources JSDoc with filterFiles and agentId params,
and reorder JS imports to longest-to-shortest per project conventions.

* test: Strengthen OCR assertion and add filterFiles error-path test

Use toHaveBeenCalledWith for the OCR filtering test to verify exact
arguments after the OCR→context merge step. Add test for filterFiles
rejection to verify graceful degradation (logs error, returns original
tool_resources).

* fix: Correct import order in addedConvo.js and initialize.js

Sort by total line length descending: loadAddedAgent (91) before
filterFilesByAgentAccess (84), loadAgentTools (91) before
filterFilesByAgentAccess (84).

* test: Add unit tests for filterFilesByAgentAccess and hasAccessToFilesViaAgent

Cover every branch in permissions.js: ephemeral agent guard, missing
userId/agentId/files early returns, all-owned short-circuit, mixed
owned + non-owned with VIEW/no-VIEW, agent-not-found fail-closed,
author path scoped to attached files, EDIT gate on delete, DB error
fail-closed, and agent with no tool_resources.

* test: Cover file.user undefined/null in permissions spec

Files with no user field fall into the non-owned path and get run
through hasAccessToFilesViaAgent. Add two cases: attached file with
no user field is returned, unattached file with no user field is
excluded.
This commit is contained in:
Danny Avila 2026-03-15 23:02:36 -04:00 committed by GitHub
parent 6f87b49df8
commit 8e8fb01d18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 708 additions and 25 deletions

View file

@ -31,6 +31,7 @@ import { filterFilesByEndpointConfig } from '~/files';
import { generateArtifactsPrompt } from '~/prompts';
import { getProviderConfig } from '~/endpoints';
import { primeResources } from './resources';
import type { TFilterFilesByAgentAccess } from './resources';
/**
* Extended agent type with additional fields needed after initialization
@ -111,7 +112,9 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods {
/** Update usage tracking for multiple files */
updateFilesUsage: (files: Array<{ file_id: string }>, fileIds?: string[]) => Promise<unknown[]>;
/** Get files from database */
getFiles: (filter: unknown, sort: unknown, select: unknown, opts?: unknown) => Promise<unknown[]>;
getFiles: (filter: unknown, sort: unknown, select: unknown) => Promise<unknown[]>;
/** Filter files by agent access permissions (ownership or agent attachment) */
filterFilesByAgentAccess?: TFilterFilesByAgentAccess;
/** Get tool files by IDs (user-uploaded files only, code files handled separately) */
getToolFilesByIds: (fileIds: string[], toolSet: Set<EToolResources>) => Promise<unknown[]>;
/** Get conversation file IDs */
@ -271,6 +274,7 @@ export async function initializeAgent(
const { attachments: primedAttachments, tool_resources } = await primeResources({
req: req as never,
getFiles: db.getFiles as never,
filterFiles: db.filterFilesByAgentAccess,
appConfig: req.config,
agentId: agent.id,
attachments: currentFiles

View file

@ -4,7 +4,7 @@ import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-dat
import type { TAgentsEndpoint, TFile } from 'librechat-data-provider';
import type { IUser, AppConfig } from '@librechat/data-schemas';
import type { Request as ServerRequest } from 'express';
import type { TGetFiles } from './resources';
import type { TGetFiles, TFilterFilesByAgentAccess } from './resources';
// Mock logger
jest.mock('@librechat/data-schemas', () => ({
@ -17,16 +17,16 @@ describe('primeResources', () => {
let mockReq: ServerRequest & { user?: IUser };
let mockAppConfig: AppConfig;
let mockGetFiles: jest.MockedFunction<TGetFiles>;
let mockFilterFiles: jest.MockedFunction<TFilterFilesByAgentAccess>;
let requestFileSet: Set<string>;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Setup mock request
mockReq = {} as unknown as ServerRequest & { user?: IUser };
mockReq = {
user: { id: 'user1', role: 'USER' },
} as unknown as ServerRequest & { user?: IUser };
// Setup mock appConfig
mockAppConfig = {
endpoints: {
[EModelEndpoint.agents]: {
@ -35,10 +35,9 @@ describe('primeResources', () => {
},
} as AppConfig;
// Setup mock getFiles function
mockGetFiles = jest.fn();
mockFilterFiles = jest.fn().mockImplementation(({ files }) => Promise.resolve(files));
// Setup request file set
requestFileSet = new Set(['file1', 'file2', 'file3']);
});
@ -70,20 +69,21 @@ describe('primeResources', () => {
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
filterFiles: mockFilterFiles,
requestFileSet,
attachments: undefined,
tool_resources,
agentId: 'agent_test',
});
expect(mockGetFiles).toHaveBeenCalledWith(
{ file_id: { $in: ['ocr-file-1'] } },
{},
{},
{ userId: undefined, agentId: undefined },
);
expect(mockGetFiles).toHaveBeenCalledWith({ file_id: { $in: ['ocr-file-1'] } }, {}, {});
expect(mockFilterFiles).toHaveBeenCalledWith({
files: mockOcrFiles,
userId: 'user1',
role: 'USER',
agentId: 'agent_test',
});
expect(result.attachments).toEqual(mockOcrFiles);
// Context field is deleted after files are fetched and re-categorized
// Since the file is not embedded and has no special properties, it won't be categorized
expect(result.tool_resources).toEqual({});
});
});
@ -1108,12 +1108,10 @@ describe('primeResources', () => {
'ocr-file-1',
);
// Verify getFiles was called with merged file_ids
expect(mockGetFiles).toHaveBeenCalledWith(
{ file_id: { $in: ['context-file-1', 'ocr-file-1'] } },
{},
{},
{ userId: undefined, agentId: undefined },
);
});
@ -1241,6 +1239,249 @@ describe('primeResources', () => {
});
});
describe('access control filtering', () => {
it('should filter context files through filterFiles when provided', async () => {
const ownedFile: TFile = {
user: 'user1',
file_id: 'owned-file',
filename: 'owned.pdf',
filepath: '/uploads/owned.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: false,
usage: 0,
};
const inaccessibleFile: TFile = {
user: 'other-user',
file_id: 'inaccessible-file',
filename: 'secret.pdf',
filepath: '/uploads/secret.pdf',
object: 'file',
type: 'application/pdf',
bytes: 2048,
embedded: false,
usage: 0,
};
mockGetFiles.mockResolvedValue([ownedFile, inaccessibleFile]);
mockFilterFiles.mockResolvedValue([ownedFile]);
const tool_resources = {
[EToolResources.context]: {
file_ids: ['owned-file', 'inaccessible-file'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
filterFiles: mockFilterFiles,
requestFileSet,
attachments: undefined,
tool_resources,
agentId: 'agent_shared',
});
expect(mockFilterFiles).toHaveBeenCalledWith({
files: [ownedFile, inaccessibleFile],
userId: 'user1',
role: 'USER',
agentId: 'agent_shared',
});
expect(result.attachments).toEqual([ownedFile]);
expect(result.attachments).not.toContainEqual(inaccessibleFile);
});
it('should filter OCR files merged into context through filterFiles', async () => {
const ocrFile: TFile = {
user: 'other-user',
file_id: 'ocr-restricted',
filename: 'scan.pdf',
filepath: '/uploads/scan.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: false,
usage: 0,
};
mockGetFiles.mockResolvedValue([ocrFile]);
mockFilterFiles.mockResolvedValue([]);
const tool_resources = {
[EToolResources.ocr]: {
file_ids: ['ocr-restricted'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
filterFiles: mockFilterFiles,
requestFileSet,
attachments: undefined,
tool_resources,
agentId: 'agent_shared',
});
expect(mockFilterFiles).toHaveBeenCalledWith({
files: [ocrFile],
userId: 'user1',
role: 'USER',
agentId: 'agent_shared',
});
expect(result.attachments).toBeUndefined();
});
it('should skip filtering when filterFiles is not provided', async () => {
const mockFile: TFile = {
user: 'user1',
file_id: 'file-1',
filename: 'doc.pdf',
filepath: '/uploads/doc.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: false,
usage: 0,
};
mockGetFiles.mockResolvedValue([mockFile]);
const tool_resources = {
[EToolResources.context]: {
file_ids: ['file-1'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
tool_resources,
agentId: 'agent_test',
});
expect(mockFilterFiles).not.toHaveBeenCalled();
expect(result.attachments).toEqual([mockFile]);
});
it('should skip filtering when user ID is missing', async () => {
const reqNoUser = {} as unknown as ServerRequest & { user?: IUser };
const mockFile: TFile = {
user: 'user1',
file_id: 'file-1',
filename: 'doc.pdf',
filepath: '/uploads/doc.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: false,
usage: 0,
};
mockGetFiles.mockResolvedValue([mockFile]);
const tool_resources = {
[EToolResources.context]: {
file_ids: ['file-1'],
},
};
const result = await primeResources({
req: reqNoUser,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
filterFiles: mockFilterFiles,
requestFileSet,
attachments: undefined,
tool_resources,
agentId: 'agent_test',
});
expect(mockFilterFiles).not.toHaveBeenCalled();
expect(result.attachments).toEqual([mockFile]);
});
it('should gracefully handle filterFiles rejection', async () => {
const mockFile: TFile = {
user: 'user1',
file_id: 'file-1',
filename: 'doc.pdf',
filepath: '/uploads/doc.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: false,
usage: 0,
};
mockGetFiles.mockResolvedValue([mockFile]);
mockFilterFiles.mockRejectedValue(new Error('DB failure'));
const tool_resources = {
[EToolResources.context]: {
file_ids: ['file-1'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
filterFiles: mockFilterFiles,
requestFileSet,
attachments: undefined,
tool_resources,
agentId: 'agent_test',
});
expect(logger.error).toHaveBeenCalledWith('Error priming resources', expect.any(Error));
expect(result.tool_resources).toEqual(tool_resources);
});
it('should skip filtering when agentId is missing', async () => {
const mockFile: TFile = {
user: 'user1',
file_id: 'file-1',
filename: 'doc.pdf',
filepath: '/uploads/doc.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: false,
usage: 0,
};
mockGetFiles.mockResolvedValue([mockFile]);
const tool_resources = {
[EToolResources.context]: {
file_ids: ['file-1'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
filterFiles: mockFilterFiles,
requestFileSet,
attachments: undefined,
tool_resources,
});
expect(mockFilterFiles).not.toHaveBeenCalled();
expect(result.attachments).toEqual([mockFile]);
});
});
describe('edge cases', () => {
it('should handle missing appConfig agents endpoint gracefully', async () => {
const reqWithoutLocals = {} as ServerRequest & { user?: IUser };

View file

@ -10,16 +10,26 @@ import type { Request as ServerRequest } from 'express';
* @param filter - MongoDB filter query for files
* @param _sortOptions - Sorting options (currently unused)
* @param selectFields - Field selection options
* @param options - Additional options including userId and agentId for access control
* @returns Promise resolving to array of files
*/
export type TGetFiles = (
filter: FilterQuery<IMongoFile>,
_sortOptions: ProjectionType<IMongoFile> | null | undefined,
selectFields: QueryOptions<IMongoFile> | null | undefined,
options?: { userId?: string; agentId?: string },
) => Promise<Array<TFile>>;
/**
* Function type for filtering files by agent access permissions.
* Used to enforce that only files the user has access to (via ownership or agent attachment)
* are returned after a raw DB query.
*/
export type TFilterFilesByAgentAccess = (params: {
files: Array<TFile>;
userId: string;
role?: string;
agentId: string;
}) => Promise<Array<TFile>>;
/**
* Helper function to add a file to a specific tool resource category
* Prevents duplicate files within the same resource category
@ -128,7 +138,7 @@ const categorizeFileForToolResources = ({
/**
* Primes resources for agent execution by processing attachments and tool resources
* This function:
* 1. Fetches OCR files if OCR is enabled
* 1. Fetches context/OCR files (filtered by agent access control when available)
* 2. Processes attachment files
* 3. Categorizes files into appropriate tool resources
* 4. Prevents duplicate files across all sources
@ -137,15 +147,18 @@ const categorizeFileForToolResources = ({
* @param params.req - Express request object
* @param params.appConfig - Application configuration object
* @param params.getFiles - Function to retrieve files from database
* @param params.filterFiles - Optional function to enforce agent-based file access control
* @param params.requestFileSet - Set of file IDs from the current request
* @param params.attachments - Promise resolving to array of attachment files
* @param params.tool_resources - Existing tool resources for the agent
* @param params.agentId - Agent ID used for access control filtering
* @returns Promise resolving to processed attachments and updated tool resources
*/
export const primeResources = async ({
req,
appConfig,
getFiles,
filterFiles,
requestFileSet,
attachments: _attachments,
tool_resources: _tool_resources,
@ -157,6 +170,7 @@ export const primeResources = async ({
attachments: Promise<Array<TFile | null>> | undefined;
tool_resources: AgentToolResources | undefined;
getFiles: TGetFiles;
filterFiles?: TFilterFilesByAgentAccess;
agentId?: string;
}): Promise<{
attachments: Array<TFile | undefined> | undefined;
@ -228,15 +242,23 @@ export const primeResources = async ({
if (fileIds.length > 0 && isContextEnabled) {
delete tool_resources[EToolResources.context];
const context = await getFiles(
let context = await getFiles(
{
file_id: { $in: fileIds },
},
{},
{},
{ userId: req.user?.id, agentId },
);
if (filterFiles && req.user?.id && agentId) {
context = await filterFiles({
files: context,
userId: req.user.id,
role: req.user.role,
agentId,
});
}
for (const file of context) {
if (!file?.file_id) {
continue;