🔒 fix: enforce REMOTE_AGENT ACL on handoff sub-agents for API routes

Addresses the second P1 codex finding on #12740: the OpenAI-compat
`/v1/chat/completions` and Open Responses `/v1/responses` routes gate
the primary agent on `REMOTE_AGENT` (via `createCheckRemoteAgentAccess`),
but `discoverConnectedAgents` was checking handoff sub-agents against
the looser in-app `AGENT` resource type. That allowed a remote caller
who could reach the orchestrator but had only in-app visibility on a
sub-agent to invoke it via the API — bypassing the remote-sharing
boundary.

Adds an optional `resourceType` param to `discoverConnectedAgents`
(defaulting to `AGENT` for the chat UI path) and passes
`ResourceType.REMOTE_AGENT` from both API controllers so every
discovered sub-agent clears the same sharing boundary enforced at
route entry.
This commit is contained in:
Danny Avila 2026-04-18 11:21:19 -04:00
parent 7418dd83b5
commit e491a23a77
4 changed files with 46 additions and 1 deletions

View file

@ -278,6 +278,10 @@ const OpenAIChatCompletionController = async (req, res) => {
requestFiles: [],
conversationId,
parentMessageId,
// The route enforces REMOTE_AGENT on the primary; every discovered
// sub-agent must clear the same sharing boundary, not the looser
// in-app AGENT one.
resourceType: ResourceType.REMOTE_AGENT,
},
{
getAgent: db.getAgent,

View file

@ -416,6 +416,10 @@ const createResponse = async (req, res) => {
requestFiles: [],
conversationId,
parentMessageId,
// The route enforces REMOTE_AGENT on the primary; every discovered
// sub-agent must clear the same sharing boundary, not the looser
// in-app AGENT one.
resourceType: ResourceType.REMOTE_AGENT,
},
{
getAgent: db.getAgent,

View file

@ -202,6 +202,34 @@ describe('discoverConnectedAgents', () => {
expect(result.edges.map((e) => e.to)).toEqual(['B']);
});
it('passes the configured resourceType (e.g. REMOTE_AGENT) to checkPermission', async () => {
const primaryConfig = makeConfig('A', [{ from: 'A', to: 'B', edgeType: 'handoff' }]);
const getAgent = jest.fn(async () => makeAgent('B', []));
const checkPermission = jest.fn().mockResolvedValue(true);
await discoverConnectedAgents(
{
req: makeReq(),
res: makeRes(),
primaryConfig,
allowedProviders: new Set(),
modelsConfig: { openai: ['gpt-4o'] },
loadTools: jest.fn(),
resourceType: 'REMOTE_AGENT',
},
{
getAgent,
checkPermission,
logViolation: jest.fn(),
db: {} as never,
},
);
expect(checkPermission).toHaveBeenCalledWith(
expect.objectContaining({ resourceType: 'REMOTE_AGENT' }),
);
});
it('drops edges whose `from` references a skipped agent (bidirectional graph)', async () => {
// Orchestrator A with bidirectional handoffs A<->B. B is inaccessible,
// so `A -> B` and `B -> A` must both be filtered — otherwise `B -> A`

View file

@ -54,6 +54,14 @@ export interface DiscoverConnectedAgentsParams {
requestFiles?: InitializeAgentParams['requestFiles'];
conversationId?: string | null;
parentMessageId?: string | null;
/**
* ResourceType to check each sub-agent's access against. Defaults to
* `AGENT` for the in-app chat flow. Callers whose entry-point gates on
* a different resource type (e.g. the OpenAI-compat controllers gate on
* `REMOTE_AGENT`) must pass the matching resource type so sub-agents
* don't bypass the same sharing boundary enforced at the route.
*/
resourceType?: string;
}
export interface DiscoverConnectedAgentsDeps {
@ -117,6 +125,7 @@ export async function discoverConnectedAgents(
requestFiles,
conversationId,
parentMessageId,
resourceType = ResourceType.AGENT,
} = params;
const {
@ -162,7 +171,7 @@ export async function discoverConnectedAgents(
const hasAccess = await checkPermission({
userId,
role: req.user?.role,
resourceType: ResourceType.AGENT,
resourceType,
resourceId: agent._id,
requiredPermission: PermissionBits.VIEW,
});