diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 16b46f9cb4b..cda3ccc50c1 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -316,6 +316,13 @@ Attention counts use the same catalog projection and read only invocation IDs and connection IDs to associate pending requests. Summary queries also omit review titles and descriptions; the separate review query still returns them. +Internal effective-tool assignment lookups use the same catalog projection. +Run preparation, native runtime setup, and connection policy checks need tool +IDs and names, not complete tool definitions. They still resolve current profile +bindings, selectors, exclusions, and installed connections on every call. The +public effective-profile response continues to include complete tool schemas +and annotations. + ## Execution identity row locks Identity initialization, credential acquisition, and steering reconciliation lock diff --git a/server/src/__tests__/tool-effective-assignments.test.ts b/server/src/__tests__/tool-effective-assignments.test.ts new file mode 100644 index 00000000000..2f69d9e62d0 --- /dev/null +++ b/server/src/__tests__/tool-effective-assignments.test.ts @@ -0,0 +1,133 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { + agents, companies, createDb, toolApplications, toolCatalogEntries, + toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles, type Db, +} from "@paperclipai/db"; +import { toolAccessService } from "../services/tool-access.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describeDatabase = support.supported ? describe : describe.skip; + +describeDatabase("effective tool assignments", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("effective-tool-assignments-"); + db = createDb(database.connectionString, { maxConnections: 1 }); + }); + afterEach(async () => { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); }); + afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); }); + + async function fixture() { + const [company] = await db.insert(companies).values({ name: "Assignments", issuePrefix: `AT${randomUUID().slice(0, 6)}` }).returning(); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Assigned agent" }).returning(); + const [application] = await db.insert(toolApplications).values({ companyId: company.id, name: "Fixture app", type: "mcp_http" }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, + name: "Fixture connection", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true }).returning(); + const payload = "schema payload ".repeat(4096); + const entries = await db.insert(toolCatalogEntries).values([ + { name: "read_item", riskLevel: "read" as const }, + { name: "write_item", riskLevel: "write" as const }, + { name: "removed_item", riskLevel: "read" as const, status: "removed" as const }, + ].map(entry => ({ companyId: company.id, applicationId: application.id, connectionId: connection.id, + ...entry, toolName: entry.name, versionHash: entry.name, inputSchema: { type: "object", description: payload }, + outputSchema: { description: payload }, annotations: { description: payload }, + firstSeenAt: new Date(Date.now() - 86_400_000) }))).returning(); + const [profile] = await db.insert(toolProfiles).values({ companyId: company.id, profileKey: "fixture", name: "Fixture", defaultAction: "deny" }).returning(); + await db.insert(toolProfileBindings).values({ companyId: company.id, profileId: profile.id, targetType: "agent", targetId: agent.id }); + const [read, write, removed] = entries; + return { company, agent, application, connection, profile, read, write, removed }; + } + + async function compare(f: Awaited>) { + const service = toolAccessService(db); + const full = await service.getEffectiveProfilesForAgent(f.company.id, f.agent.id); + const assignments = await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id); + expect(assignments).toEqual({ ...full, allowedTools: full.allowedTools.map( + ({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }), + ) }); + return { service, full, assignments }; + } + + it.each(["application", "connection", "catalog_entry", "tool_name", "risk_level"] as const)("preserves %s selectors and exclusions", async selectorType => { + const f = await fixture(); + await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id, selectorType, effect: "include", + ...(selectorType === "application" ? { applicationId: f.application.id } : {}), + ...(selectorType === "connection" ? { connectionId: f.connection.id } : {}), + ...(selectorType === "catalog_entry" ? { catalogEntryId: f.read.id } : {}), + ...(selectorType === "tool_name" ? { toolName: f.read.toolName } : {}), + ...(selectorType === "risk_level" ? { riskLevel: "read" as const } : {}), + }); + await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id, + selectorType: "catalog_entry", effect: "exclude", catalogEntryId: f.write.id }); + const { full, assignments } = await compare(f); + expect(assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id]); + expect(full.allowedTools[0].inputSchema).toEqual(f.read.inputSchema); + expect(full.allowedTools[0].outputSchema).toEqual(f.read.outputSchema); + expect(full.allowedTools[0].annotations).toEqual(f.read.annotations); + }); + + it("keeps unresolved exact names and observes changed catalog, profile and binding state", async () => { + const f = await fixture(); + await db.insert(toolProfileEntries).values([ + { companyId: f.company.id, profileId: f.profile.id, selectorType: "risk_level", effect: "include", riskLevel: "read" }, + { companyId: f.company.id, profileId: f.profile.id, selectorType: "tool_name", effect: "include", toolName: "not_discovered_yet" }, + ]); + expect((await compare(f)).assignments.allowedToolNames).toEqual(["not_discovered_yet", "read_item"]); + await db.update(toolCatalogEntries).set({ riskLevel: "write" }).where(eq(toolCatalogEntries.id, f.read.id)); + expect((await compare(f)).assignments.allowedTools).toEqual([]); + await db.update(toolProfiles).set({ status: "archived" }).where(eq(toolProfiles.id, f.profile.id)); + expect((await compare(f)).assignments.profiles).toEqual([]); + await db.update(toolProfiles).set({ status: "active", defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id)); + expect((await compare(f)).assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id, f.write.id]); + await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, f.profile.id)); + expect((await compare(f)).assignments.allowedTools).toEqual([]); + }); + + it("preserves company bindings and rejects an agent from another company", async () => { + const f = await fixture(); + await db.update(toolProfileBindings).set({ targetType: "company", targetId: f.company.id }).where(eq(toolProfileBindings.profileId, f.profile.id)); + await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id)); + expect((await compare(f)).assignments.allowedToolNames).toEqual(["read_item", "write_item"]); + const [other] = await db.insert(companies).values({ name: "Other", issuePrefix: `OT${randomUUID().slice(0, 6)}` }).returning(); + const [foreign] = await db.insert(agents).values({ companyId: other.id, name: "Foreign" }).returning(); + await expect(toolAccessService(db).getEffectiveToolAssignmentsForAgent(f.company.id, foreign.id)).rejects.toThrow("same company"); + expect((await toolAccessService(db).getEffectiveToolAssignmentsForAgent(other.id, foreign.id)).allowedTools).toEqual([]); + }); + + it("reads small catalog rows from real PostgreSQL while preserving complete public definitions", async () => { + const f = await fixture(); + await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id)); + const observed: Array<{ rows: number; bytes: number }> = []; + const measuredDb = new Proxy(db, { get(target, key) { + if (key !== "select") return Reflect.get(target, key); + return (...fields: Parameters) => { + const builder = target.select(...fields), from = builder.from.bind(builder); + builder.from = ((...args: Parameters) => { + const selection = from(...args); + if (args[0] === toolCatalogEntries) { + const execute = selection.execute.bind(selection); + selection.execute = async () => { + const rows = await execute(); + observed.push({ rows: rows.length, bytes: Buffer.byteLength(JSON.stringify(rows)) }); + return rows; + }; + } + return selection; + }) as typeof builder.from; + return builder; + }; + } }); + const service = toolAccessService(measuredDb); + const full = await service.getEffectiveProfilesForAgent(f.company.id, f.agent.id); + const assignments = await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id); + expect(assignments.allowedTools.map(tool => tool.id)).toEqual(full.allowedTools.map(tool => tool.id)); + expect(observed).toHaveLength(2); + expect(observed[1].rows).toBe(observed[0].rows); + expect(observed[1].bytes).toBeLessThan(observed[0].bytes / 100); + expect(full.allowedTools[0].inputSchema).toEqual(f.read.inputSchema); + }); +}); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index bca005ab898..90381467751 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -288,7 +288,7 @@ export function connectionIntentService(db: Db) { && connection.status !== "archived" && connection.connectionPurpose !== "ai" ); if (matching.length === 0) return null; - const effective = await access.getEffectiveProfilesForAgent(input.companyId, input.agentId); + const effective = await access.getEffectiveToolAssignmentsForAgent(input.companyId, input.agentId); const installedIds = new Set(effective.installedConnections.map((connection) => connection.id)); const permittedIds = new Set(effective.allowedTools.map((tool) => tool.connectionId)); const usable = (connection: ToolConnection | undefined) => connection @@ -335,7 +335,7 @@ export function connectionIntentService(db: Db) { } async function administrativeDenial(companyId: string, agentId: string, serviceSlug: string, inventory: Awaited>) { - const effective = await access.getEffectiveProfilesForAgent(companyId, agentId); + const effective = await access.getEffectiveToolAssignmentsForAgent(companyId, agentId); const installed = effective.installedConnections.filter((connection) => sourceSlugForConnection(connection, inventory.applicationsById) === serviceSlug); if (!installed.length || effective.allowedTools.some((tool) => installed.some((connection) => connection.id === tool.connectionId))) return false; for (const connection of installed) { @@ -748,7 +748,7 @@ export function connectionIntentService(db: Db) { 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 effective = await access.getEffectiveToolAssignmentsForAgent(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)); if (missing && tools.length && tools.every(tool => Boolean(tool))) { @@ -1141,7 +1141,7 @@ export function connectionIntentService(db: Db) { }); } - const effective = await txAccess.getEffectiveProfilesForAgent(loaded.issue.companyId, payload.requestingAgentId); + const effective = await txAccess.getEffectiveToolAssignmentsForAgent(loaded.issue.companyId, payload.requestingAgentId); if (!effective.allowedTools.some((tool) => tool.connectionId === selectedConnection.id)) throw conflict("This connection has no permitted tools. Review its action permissions before continuing."); const runtimeConnection = await connectionIntentService(txDb).usableConnectionForAgent({ diff --git a/server/src/services/heartbeat/run-preparation.ts b/server/src/services/heartbeat/run-preparation.ts index 9e8c40eec39..38d40e02aa9 100644 --- a/server/src/services/heartbeat/run-preparation.ts +++ b/server/src/services/heartbeat/run-preparation.ts @@ -933,7 +933,7 @@ export async function buildPaperclipRuntimeMcpServers(input: { expectedAssignmentDigest?: string | null; }): Promise { const access = toolAccessService(input.db); - const effective = await access.getEffectiveProfilesForAgent( + const effective = await access.getEffectiveToolAssignmentsForAgent( input.agent.companyId, input.agent.id, ); diff --git a/server/src/services/native-runtime/runtime-context.test.ts b/server/src/services/native-runtime/runtime-context.test.ts index 8fd5e101e8b..5af85205e21 100644 --- a/server/src/services/native-runtime/runtime-context.test.ts +++ b/server/src/services/native-runtime/runtime-context.test.ts @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const serviceMocks = vi.hoisted(() => ({ exportFiles: vi.fn(), readCommittedForRuntime: vi.fn(), - getEffectiveProfilesForAgent: vi.fn(), + getEffectiveToolAssignmentsForAgent: vi.fn(), githubBotConnectionIdsForRun: vi.fn(), spekoToolsForSession: vi.fn(), })); @@ -32,7 +32,7 @@ vi.mock("../agent-instruction-revisions.js", () => ({ vi.mock("../tool-access.js", () => ({ toolAccessService: () => ({ - getEffectiveProfilesForAgent: serviceMocks.getEffectiveProfilesForAgent, + getEffectiveToolAssignmentsForAgent: serviceMocks.getEffectiveToolAssignmentsForAgent, }), })); @@ -71,7 +71,7 @@ beforeEach(async () => { temporaryRoots.push(root); process.env.PAPERCLIP_HOME = root; process.env.PAPERCLIP_INSTANCE_ID = "runtime_context_test"; - serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ + serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({ agentId: "agent-1", profiles: [], entries: [{ effect: "include", connectionId: "connection-1" }], @@ -165,7 +165,7 @@ describe("buildNativeRuntimeContext", () => { entryFile: "AGENTS.md", files: { "AGENTS.md": "Continue work without unavailable apps.\n" }, }); - serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ + serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({ agentId: "agent-1", profiles: [], entries: [{ effect: "include", connectionId: "connection-1" }], @@ -205,7 +205,7 @@ describe("buildNativeRuntimeContext", () => { entryFile: "AGENTS.md", files: { "AGENTS.md": "Continue work with the apps that are available.\n" }, }); - serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ + serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({ agentId: "agent-1", profiles: [], entries: [ @@ -293,7 +293,7 @@ describe("buildNativeRuntimeContext", () => { const repeated = await buildNativeRuntimeContext(input); expect(serviceMocks.exportFiles).toHaveBeenCalledWith(input.agent, { rejectSymlinks: true }); - expect(serviceMocks.getEffectiveProfilesForAgent).toHaveBeenCalledWith("company-1", "agent-1"); + expect(serviceMocks.getEffectiveToolAssignmentsForAgent).toHaveBeenCalledWith("company-1", "agent-1"); expect(context.instructions.entryPath).toBe("AGENTS.md"); expect(await readFile(path.join(context.instructions.bundle.rootPath, "AGENTS.md"), "utf8")) .toBe("Follow the agent instructions.\n"); @@ -333,7 +333,7 @@ describe("buildNativeRuntimeContext", () => { }); expect(context.skills).toEqual([]); expect(context.mcp.bindingId).toBeNull(); - expect(serviceMocks.getEffectiveProfilesForAgent).not.toHaveBeenCalled(); + expect(serviceMocks.getEffectiveToolAssignmentsForAgent).not.toHaveBeenCalled(); }); it("fails closed for a missing assigned skill and omits a stale legacy operational skill", async () => { @@ -393,7 +393,7 @@ it("pins both permitted GitHub tool catalogs even when another user’s health p id, status: "active", enabled: true, healthStatus: "missing_secret", transport: "mcp_remote", config: { sourceTemplateKey: "github" }, transportConfig: {}, })); - serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ + serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({ entries: connections.map((connection) => ({ effect: "include", connectionId: connection.id })), installedConnections: connections, allowedTools: connections.map((connection) => ({ id: `${connection.id}-get_me`, connectionId: connection.id })), @@ -411,7 +411,7 @@ it("pins both permitted GitHub tool catalogs even when another user’s health p }); it("pins channel tools only when the current task run is bound to that bot", async () => { - serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ + serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "bot-connection" }], installedConnections: [{ id: "bot-connection", status: "active", enabled: true, healthStatus: "healthy", @@ -427,7 +427,7 @@ it("pins channel tools only when the current task run is bound to that bot", asy }); it("pins a voice tool only when the current requester can call through that assigned endpoint", async () => { - serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "voice-connection" }], installedConnections: [{ id: "voice-connection", status: "active", enabled: true, healthStatus: "healthy", transport: "voice", config: { provider: "speko" }, transportConfig: {} }], allowedTools: [{ id: "phone-tool", connectionId: "voice-connection" }] }); + serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "voice-connection" }], installedConnections: [{ id: "voice-connection", status: "active", enabled: true, healthStatus: "healthy", transport: "voice", config: { provider: "speko" }, transportConfig: {} }], allowedTools: [{ id: "phone-tool", connectionId: "voice-connection" }] }); const limit = vi.fn().mockResolvedValue([{ issueId: "task-1", activeIdentityContextId: "identity-1" }]); const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db; const input = { db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" }; diff --git a/server/src/services/native-runtime/runtime-context.ts b/server/src/services/native-runtime/runtime-context.ts index 8c561aebbf2..174a6fd4ff8 100644 --- a/server/src/services/native-runtime/runtime-context.ts +++ b/server/src/services/native-runtime/runtime-context.ts @@ -193,7 +193,7 @@ async function materializeSelectedSkills(runtimeConfig: Record, } export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pick; runId: string }) { - const effective = await toolAccessService(input.db).getEffectiveProfilesForAgent(input.agent.companyId, input.agent.id); + const effective = await toolAccessService(input.db).getEffectiveToolAssignmentsForAgent(input.agent.companyId, input.agent.id); const permitted = new Set([...effective.entries.filter((entry) => entry.effect === "include" && entry.connectionId).map((entry) => entry.connectionId!), ...effective.allowedTools.map((tool) => tool.connectionId)]); const hasGitHubConnection = effective.installedConnections.some((connection) => { const config = connection.config && typeof connection.config === "object" diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index e0344731a91..7fb34b6b7f0 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -17473,6 +17473,178 @@ export function toolAccessService( }; } + async function resolveEffectiveProfiles( + companyId: string, + agentId: string, + loadCatalog: () => PromiseLike, + toAllowedTool: (entry: TEntry) => TAllowedTool, + ): Promise & { allowedTools: TAllowedTool[] }> { + await assertOptionalAgent( + companyId, + agentId, + "Tool profile effective agent", + ); + const allBindings = await db + .select() + .from(toolProfileBindings) + .where(eq(toolProfileBindings.companyId, companyId)) + .orderBy( + asc(toolProfileBindings.priority), + asc(toolProfileBindings.createdAt), + ); + const matchingBindings = allBindings.filter( + (binding) => + (binding.targetType === "company" && + binding.targetId === companyId) || + (binding.targetType === "agent" && binding.targetId === agentId), + ); + if (matchingBindings.length === 0) { + return { + agentId, + profiles: [], + entries: [], + bindings: [], + allowedTools: [], + allowedToolNames: [], + installedConnections: await resolveInstalledConnectionsForAgent( + companyId, + agentId, + ), + }; + } + const candidateProfileIds = profileIdsInBindingOrder(matchingBindings); + const candidateProfiles = await db + .select() + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, companyId), + inArray(toolProfiles.id, candidateProfileIds), + ), + ); + const bindings = effectiveToolProfileBindings( + matchingBindings, + candidateProfiles, + ); + const profileIds = profileIdsInBindingOrder(bindings); + const profilesById = new Map( + candidateProfiles.map((profile) => [profile.id, profile]), + ); + const activeProfiles = profileIds + .map((profileId) => profilesById.get(profileId) ?? null) + .filter((profile): profile is typeof toolProfiles.$inferSelect => + Boolean(profile && profile.status === "active"), + ); + if (activeProfiles.length === 0) { + return { + agentId, + profiles: [], + entries: [], + bindings: bindings.map(toProfileBinding), + allowedTools: [], + allowedToolNames: [], + installedConnections: await resolveInstalledConnectionsForAgent( + companyId, + agentId, + ), + }; + } + const activeProfileIds = activeProfiles.map((profile) => profile.id); + const [entries, catalog, companyAgents] = await Promise.all([ + db + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, companyId), + inArray(toolProfileEntries.profileId, activeProfileIds), + ), + ) + .orderBy(asc(toolProfileEntries.createdAt)), + loadCatalog(), + db + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.companyId, companyId)), + ]); + const entriesByProfile = new Map< + string, + Array + >(); + for (const entry of entries) { + const list = entriesByProfile.get(entry.profileId) ?? []; + list.push(entry); + entriesByProfile.set(entry.profileId, list); + } + const allowedCatalogIds = new Set(); + const allowedToolNames = new Set(); + for (const profile of activeProfiles) { + const profileEntries = entriesByProfile.get(profile.id) ?? []; + const includes = profileEntries.filter( + (entry) => entry.effect === "include", + ); + const excludes = profileEntries.filter( + (entry) => entry.effect === "exclude", + ); + for (const catalogEntry of catalog) { + if ( + excludes.some((entry) => + profileEntryMatchesCatalog(entry, catalogEntry), + ) + ) + continue; + if ( + profile.defaultAction === "allow" || + includes.some((entry) => + profileEntryMatchesCatalog(entry, catalogEntry), + ) + ) { + allowedCatalogIds.add(catalogEntry.id); + allowedToolNames.add(catalogEntry.toolName); + } + } + for (const entry of includes.filter( + (item) => item.selectorType === "tool_name" && item.toolName, + )) { + const matchingExclude = excludes.some( + (item) => + item.selectorType === "tool_name" && + item.toolName === entry.toolName, + ); + if (!matchingExclude) allowedToolNames.add(entry.toolName!); + } + } + const agentIds = companyAgents.map((agent) => agent.id); + const details: ToolProfileWithDetails[] = activeProfiles.map((profile) => + buildProfileDetails({ + profile, + entries: entriesByProfile.get(profile.id) ?? [], + bindings: bindings.filter( + (binding) => binding.profileId === profile.id, + ), + catalog, + agentIds, + }), + ); + const allowedTools = catalog + .filter((entry) => allowedCatalogIds.has(entry.id)) + .map(toAllowedTool); + return { + agentId, + profiles: details, + entries: entries.map(toProfileEntry), + bindings: bindings.map(toProfileBinding), + allowedTools, + allowedToolNames: [...allowedToolNames].sort((a, b) => + a.localeCompare(b), + ), + installedConnections: await resolveInstalledConnectionsForAgent( + companyId, + agentId, + ), + }; + } + return { /** Identity-only verification: no bot installation/repository access required. */ verifyPersonalGitHubIdentity: async (companyId: string, connectionId: string, userId: string) => { @@ -20291,184 +20463,35 @@ export function toolAccessService( return { unbound: rows.length }; }), - getEffectiveProfilesForAgent: async ( - companyId: string, - agentId: string, - ): Promise => { - await assertOptionalAgent( + getEffectiveProfilesForAgent: (companyId: string, agentId: string) => + resolveEffectiveProfiles( companyId, agentId, - "Tool profile effective agent", - ); - const allBindings = await db - .select() - .from(toolProfileBindings) - .where(eq(toolProfileBindings.companyId, companyId)) - .orderBy( - asc(toolProfileBindings.priority), - asc(toolProfileBindings.createdAt), - ); - const matchingBindings = allBindings.filter( - (binding) => - (binding.targetType === "company" && - binding.targetId === companyId) || - (binding.targetType === "agent" && binding.targetId === agentId), - ); - if (matchingBindings.length === 0) { - return { - agentId, - profiles: [], - entries: [], - bindings: [], - allowedTools: [], - allowedToolNames: [], - installedConnections: await resolveInstalledConnectionsForAgent( - companyId, - agentId, - ), - }; - } - const candidateProfileIds = profileIdsInBindingOrder(matchingBindings); - const candidateProfiles = await db - .select() - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, companyId), - inArray(toolProfiles.id, candidateProfileIds), - ), - ); - const bindings = effectiveToolProfileBindings( - matchingBindings, - candidateProfiles, - ); - const profileIds = profileIdsInBindingOrder(bindings); - const profilesById = new Map( - candidateProfiles.map((profile) => [profile.id, profile]), - ); - const activeProfiles = profileIds - .map((profileId) => profilesById.get(profileId) ?? null) - .filter((profile): profile is typeof toolProfiles.$inferSelect => - Boolean(profile && profile.status === "active"), - ); - if (activeProfiles.length === 0) { - return { - agentId, - profiles: [], - entries: [], - bindings: bindings.map(toProfileBinding), - allowedTools: [], - allowedToolNames: [], - installedConnections: await resolveInstalledConnectionsForAgent( - companyId, - agentId, - ), - }; - } - const activeProfileIds = activeProfiles.map((profile) => profile.id); - const [entries, catalog, companyAgents] = await Promise.all([ - db - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, companyId), - inArray(toolProfileEntries.profileId, activeProfileIds), - ), - ) - .orderBy(asc(toolProfileEntries.createdAt)), - db + () => db .select() .from(toolCatalogEntries) - .where( - and( - eq(toolCatalogEntries.companyId, companyId), - eq(toolCatalogEntries.status, "active"), - ), - ) + .where(and( + eq(toolCatalogEntries.companyId, companyId), + eq(toolCatalogEntries.status, "active"), + )) .orderBy(asc(toolCatalogEntries.toolName)), - db - .select({ id: agents.id }) - .from(agents) - .where(eq(agents.companyId, companyId)), - ]); - const entriesByProfile = new Map< - string, - Array - >(); - for (const entry of entries) { - const list = entriesByProfile.get(entry.profileId) ?? []; - list.push(entry); - entriesByProfile.set(entry.profileId, list); - } - const allowedCatalogIds = new Set(); - const allowedToolNames = new Set(); - for (const profile of activeProfiles) { - const profileEntries = entriesByProfile.get(profile.id) ?? []; - const includes = profileEntries.filter( - (entry) => entry.effect === "include", - ); - const excludes = profileEntries.filter( - (entry) => entry.effect === "exclude", - ); - for (const catalogEntry of catalog) { - if ( - excludes.some((entry) => - profileEntryMatchesCatalog(entry, catalogEntry), - ) - ) - continue; - if ( - profile.defaultAction === "allow" || - includes.some((entry) => - profileEntryMatchesCatalog(entry, catalogEntry), - ) - ) { - allowedCatalogIds.add(catalogEntry.id); - allowedToolNames.add(catalogEntry.toolName); - } - } - for (const entry of includes.filter( - (item) => item.selectorType === "tool_name" && item.toolName, - )) { - const matchingExclude = excludes.some( - (item) => - item.selectorType === "tool_name" && - item.toolName === entry.toolName, - ); - if (!matchingExclude) allowedToolNames.add(entry.toolName!); - } - } - const agentIds = companyAgents.map((agent) => agent.id); - const details: ToolProfileWithDetails[] = activeProfiles.map((profile) => - buildProfileDetails({ - profile, - entries: entriesByProfile.get(profile.id) ?? [], - bindings: bindings.filter( - (binding) => binding.profileId === profile.id, - ), - catalog, - agentIds, - }), - ); - const allowedTools = catalog - .filter((entry) => allowedCatalogIds.has(entry.id)) - .map(toCatalogEntry); - return { + toCatalogEntry, + ), + + getEffectiveToolAssignmentsForAgent: (companyId: string, agentId: string) => + resolveEffectiveProfiles( + companyId, agentId, - profiles: details, - entries: entries.map(toProfileEntry), - bindings: bindings.map(toProfileBinding), - allowedTools, - allowedToolNames: [...allowedToolNames].sort((a, b) => - a.localeCompare(b), - ), - installedConnections: await resolveInstalledConnectionsForAgent( - companyId, - agentId, - ), - }; - }, + () => db + .select(profileCatalogColumns) + .from(toolCatalogEntries) + .where(and( + eq(toolCatalogEntries.companyId, companyId), + eq(toolCatalogEntries.status, "active"), + )) + .orderBy(asc(toolCatalogEntries.toolName)), + ({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }), + ), mintConnectionTokenForAgent: async (input: { connectionId: string;