diff --git a/doc/connections/CONNECTION-SEARCH.md b/doc/connections/CONNECTION-SEARCH.md index e3301d780d2..594b10a66ec 100644 --- a/doc/connections/CONNECTION-SEARCH.md +++ b/doc/connections/CONNECTION-SEARCH.md @@ -75,3 +75,10 @@ Circleback/Attio/ClickUp, indexed Executor tools, and provider consent. The nati tool authority test searches a paragraph longer than the old 200-character limit and then creates a real connection interaction. Existing tests retain checks for private metadata, administrative denials, stale identities, and saved declines. + +When `connection_request` rejects an unknown `toolNames` entry, its error includes +up to twenty exact names from that eligible connection's active tool catalog. +This is discovery metadata: the request still fails with 422, grants no access, +and creates no approval card. The agent must select the needed exact names and +submit a new request; normal user approval, catalog-version and policy checks +still apply. Ineligible connections and inactive catalog entries are excluded. diff --git a/server/src/__tests__/connection-aggregator-fallback.test.ts b/server/src/__tests__/connection-aggregator-fallback.test.ts index e0678f5c57e..c129a6e092f 100644 --- a/server/src/__tests__/connection-aggregator-fallback.test.ts +++ b/server/src/__tests__/connection-aggregator-fallback.test.ts @@ -608,6 +608,51 @@ const support = await getEmbeddedPostgresTestSupport(); ); expect(result.instruction).toContain("Arcade"); }); + it("returns exact eligible catalog names after a guessed tool without granting access", async () => { + await resetQuestions(); + const connection = await seedProvider("arcade", "Hubspot_ListContacts", "responsible-user", false); + await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id)); + await seedProvider("composio", "Unrelated_PrivateTool", "other-user", false); + await db.insert(toolCatalogEntries).values({ companyId: claims.company_id, connectionId: connection.id, + toolName: "Hubspot_RemovedTool", name: "Removed", versionHash: "old", entryKind: "tool", status: "removed" }); + const answer = await selectProvider("via:arcade:hubspot"); + const service = connectionIntentService(db); + const error = await service.request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, toolNames: ["hubspot_list_contacts"], + }).catch(error => error); + expect(error).toMatchObject({ status: 422 }); + expect(error.message).toContain('"Hubspot_ListContacts"'); + expect(error.message).toContain("Request only the needed exact names"); + expect(error.message).not.toMatch(/Unrelated_PrivateTool|Hubspot_RemovedTool/); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]); + const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, claims.company_id)); + expect(interactions.map(row => [row.id, row.status])).toEqual([[answer.id, "answered"]]); + const requested = await service.request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, toolNames: ["Hubspot_ListContacts"], + }); + expect(requested.state).toBe("needs_user_action"); + const loaded = await service.loadIntent(requested.interactionId!); + expect(loaded.interaction.payload.accessRequest?.tools.map(tool => tool.toolName)).toEqual(["Hubspot_ListContacts"]); + expect(loaded.interaction.status).toBe("pending"); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]); + }); + it("bounds catalog-name recovery and does not disclose an ineligible connection", async () => { + await resetQuestions(); + const connection = await seedProvider("arcade", "Read_00", "responsible-user", false); + await db.insert(toolCatalogEntries).values(Array.from({ length: 24 }, (_, index) => ({ + companyId: claims.company_id, connectionId: connection.id, toolName: `Read_${String(index + 1).padStart(2, "0")}`, + name: `Read_${String(index + 1).padStart(2, "0")}`, versionHash: "v1", entryKind: "tool" as const, status: "active" as const, + }))); + const service = connectionIntentService(db); + const error = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error); + expect(error).toMatchObject({ status: 422 }); + expect(error.message).toContain("first 20 of 25"); + expect(error.message.match(/Read_\d{2}/g)).toHaveLength(20); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, connection.id)); + const denied = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error); + expect(denied.message).not.toContain("Read_"); + expect(denied.message).toContain("not eligible"); + }); it("does not switch providers when the chosen route loses permission", async () => { await resetQuestions(); const answer = await selectProvider("via:arcade:hubspot"); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index e51d4db74e8..68005f19f4c 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -739,7 +739,12 @@ export function connectionIntentService(db: Db) { const catalog = (await indexedCatalog(connection.id, context.run.companyId)).filter(tool => tool.entryKind === "tool"); const names = options.toolNames ?? (app.slug === "composio" ? ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] : catalog.map(tool => tool.toolName)); const tools = names.map(name => catalog.find(tool => tool.toolName === name)); - if (options.toolNames && tools.some(tool => !tool)) throw unprocessable("A requested tool is not in this connection's active catalog"); + if (options.toolNames && tools.some(tool => !tool)) { + // Return discovery metadata only after validating this connection's + // eligibility. Never substitute guessed names or grant access here. + const availableNames = catalog.map(tool => tool.toolName).sort(); + throw unprocessable(`A requested tool is not in this connection's active catalog. Available indexed tool names (first ${Math.min(20, availableNames.length)} of ${availableNames.length}): ${JSON.stringify(availableNames.slice(0, 20))}. Request only the needed exact names with connection_request. No access was granted.`); + } const effective = await access.getEffectiveProfilesForAgent(context.run.companyId, context.agent.id); const installed = effective.installedConnections.some(item => item.id === connection.id); const missing = !installed || tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id));