🪪 fix: Enforce VIEW ACL on Agent Edge References at Write and Runtime (#12246)

* 🛡️ fix: Enforce ACL checks on handoff edge and added-convo agent loading

Edge-linked agents and added-convo agents were fetched by ID via
getAgent without verifying the requesting user's access permissions.
This allowed an authenticated user to reference another user's private
agent in edges or addedConvo and have it initialized at runtime.

Add checkPermission(VIEW) gate in processAgent before initializing
any handoff agent, and in processAddedConvo for non-ephemeral added
agents. Unauthorized agents are logged and added to skippedAgentIds
so orphaned-edge filtering removes them cleanly.

* 🛡️ fix: Validate edge agent access at agent create/update time

Reject agent create/update requests that reference agents in edges
the requesting user cannot VIEW. This provides early feedback and
prevents storing unauthorized agent references as defense-in-depth
alongside the runtime ACL gate in processAgent.

Add collectEdgeAgentIds utility to extract all unique agent IDs from
an edge array, and validateEdgeAgentAccess helper in the v1 handler.

* 🧪 test: Improve ACL gate test coverage and correctness

- Add processAgent ACL gate tests for initializeClient (skip/allow handoff agents)
- Fix addedConvo.spec.js to mock loadAddedAgent directly instead of getAgent
- Seed permMap with ownedAgent VIEW bits in v1.spec.js update-403 test

* 🧹 chore: Remove redundant addedConvo ACL gate (now in middleware)

PR #12243 moved the addedConvo agent ACL check upstream into
canAccessAgentFromBody middleware, making the runtime check in
processAddedConvo and its spec redundant.

* 🧪 test: Rewrite processAgent ACL test with real DB and minimal mocking

Replace heavy mock-based test (12 mocks, Providers.XAI crash) with
MongoMemoryServer-backed integration test that exercises real getAgent,
checkPermission, and AclEntry — only external I/O (initializeAgent,
ToolService, AgentClient) remains mocked. Load edge utilities directly
from packages/api/src/agents/edges to sidestep the config.ts barrel.

* 🧪 fix: Use requireActual spread for @librechat/agents and @librechat/api mocks

The Providers.XAI crash was caused by mocking @librechat/agents with
a minimal replacement object, breaking the @librechat/api initialization
chain. Match the established pattern from client.test.js and
recordCollectedUsage.spec.js: spread jest.requireActual for both
packages, overriding only the functions under test.
This commit is contained in:
Danny Avila 2026-03-15 18:08:57 -04:00 committed by GitHub
parent 1312cd757c
commit bcf45519bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 457 additions and 4 deletions

View file

@ -1,5 +1,11 @@
import type { GraphEdge } from 'librechat-data-provider';
import { getEdgeKey, getEdgeParticipants, filterOrphanedEdges, createEdgeCollector } from './edges';
import {
getEdgeKey,
getEdgeParticipants,
collectEdgeAgentIds,
filterOrphanedEdges,
createEdgeCollector,
} from './edges';
describe('edges utilities', () => {
describe('getEdgeKey', () => {
@ -70,6 +76,49 @@ describe('edges utilities', () => {
});
});
describe('collectEdgeAgentIds', () => {
it('should return empty set for undefined input', () => {
expect(collectEdgeAgentIds(undefined)).toEqual(new Set());
});
it('should return empty set for empty array', () => {
expect(collectEdgeAgentIds([])).toEqual(new Set());
});
it('should collect IDs from simple string from/to', () => {
const edges: GraphEdge[] = [{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' }];
expect(collectEdgeAgentIds(edges)).toEqual(new Set(['agent_a', 'agent_b']));
});
it('should collect IDs from array from/to values', () => {
const edges: GraphEdge[] = [
{ from: ['agent_a', 'agent_b'], to: ['agent_c', 'agent_d'], edgeType: 'handoff' },
];
expect(collectEdgeAgentIds(edges)).toEqual(
new Set(['agent_a', 'agent_b', 'agent_c', 'agent_d']),
);
});
it('should deduplicate IDs across edges', () => {
const edges: GraphEdge[] = [
{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' },
{ from: 'agent_b', to: 'agent_c', edgeType: 'handoff' },
{ from: 'agent_a', to: 'agent_c', edgeType: 'direct' },
];
expect(collectEdgeAgentIds(edges)).toEqual(new Set(['agent_a', 'agent_b', 'agent_c']));
});
it('should handle mixed scalar and array edges', () => {
const edges: GraphEdge[] = [
{ from: 'agent_a', to: ['agent_b', 'agent_c'], edgeType: 'handoff' },
{ from: ['agent_c', 'agent_d'], to: 'agent_e', edgeType: 'direct' },
];
expect(collectEdgeAgentIds(edges)).toEqual(
new Set(['agent_a', 'agent_b', 'agent_c', 'agent_d', 'agent_e']),
);
});
});
describe('filterOrphanedEdges', () => {
const edges: GraphEdge[] = [
{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' },

View file

@ -43,6 +43,20 @@ export function filterOrphanedEdges(edges: GraphEdge[], skippedAgentIds: Set<str
});
}
/** Collects all unique agent IDs referenced across an array of edges. */
export function collectEdgeAgentIds(edges: GraphEdge[] | undefined): Set<string> {
const ids = new Set<string>();
if (!edges || edges.length === 0) {
return ids;
}
for (const edge of edges) {
for (const id of getEdgeParticipants(edge)) {
ids.add(id);
}
}
return ids;
}
/**
* Result of discovering and aggregating edges from connected agents.
*/