diff --git a/server/src/__tests__/tool-gateway-listing-memory.test.ts b/server/src/__tests__/tool-gateway-listing-memory.test.ts index 4708aaf0c47..24d20cdc9fc 100644 --- a/server/src/__tests__/tool-gateway-listing-memory.test.ts +++ b/server/src/__tests__/tool-gateway-listing-memory.test.ts @@ -18,7 +18,7 @@ import { toolProfiles, toolMcpGatewayTokens, } from "@paperclipai/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { ToolAccessDecisionInput } from "@paperclipai/shared"; import { createToolAccessDecisionCache, @@ -178,6 +178,48 @@ describeEmbeddedPostgres("tool gateway listing memory", () => { expect(largeListing.statements.length).toBeLessThan(100); }); + it("does not construct a condition clock for unmatched entries in a decision batch", async () => { + const fixture = await createListingFixture(db, 6); + const [binding] = await db.select().from(toolProfileBindings) + .where(and(eq(toolProfileBindings.companyId, fixture.company.id), eq(toolProfileBindings.targetId, fixture.namedGateway.id))); + expect(binding).toBeDefined(); + for (let start = 0; start < 47_930; start += 1000) { + await db.insert(toolProfileEntries).values(Array.from({ length: Math.min(1000, 47_930 - start) }, (_, offset) => ({ + companyId: fixture.company.id, profileId: binding!.profileId, + selectorType: "tool_name" as const, toolName: `not_selected_${start + offset}`, effect: "exclude" as const, + }))); + } + const policy = toolAccessPolicyService(db); + const cache = createToolAccessDecisionCache(); + const originalDate = Date; + let clocks = 0; + vi.stubGlobal("Date", new Proxy(originalDate, { + construct(target, args, constructor) { + if (args.length === 0) clocks++; + return Reflect.construct(target, args, constructor); + }, + })); + const decisions = []; + try { + for (let index = 0; index < 10; index++) { + const entry = fixture.entries[4 + index % 2]!; + decisions.push(await policy.decide({ + companyId: fixture.company.id, + actor: { actorType: "agent", actorId: fixture.agent.id, agentId: fixture.agent.id }, + runContext: { heartbeatRunId: fixture.run.id, issueId: fixture.issue.id, projectId: fixture.project.id, gatewayId: fixture.namedGateway.id }, + request: { toolName: entry.toolName!, applicationId: fixture.application.id, connectionId: fixture.connection.id, + catalogEntryId: entry.id, upstreamToolName: entry.toolName!, riskLevel: entry.riskLevel, arguments: {} }, + consumeRateLimit: false, + }, { cache })); + } + } finally { + vi.unstubAllGlobals(); + } + expect(decisions).toHaveLength(10); + expect(decisions.every(decision => decision.allowed && decision.reasonCode === "allow_profile")).toBe(true); + expect(clocks).toBeLessThan(5000); + }, 30_000); + it("reads the catalog without repeating the connection row for each tool", async () => { const fixture = await createListingFixture(db, 20); // An eligible connection without catalog tools adds nothing to a listing. diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index 60db597245e..6357c9b5e87 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -591,14 +591,14 @@ function evaluatePolicyConditions( } function profileEntryMatches(entry: typeof toolProfileEntries.$inferSelect, ctx: ToolAccessContext): boolean { + const selected = entry.selectorType === "application" ? entry.applicationId === ctx.applicationId + : entry.selectorType === "connection" ? entry.connectionId === ctx.connectionId + : entry.selectorType === "catalog_entry" ? entry.catalogEntryId === ctx.catalogEntryId + : entry.selectorType === "tool_name" ? entry.toolName === ctx.toolName + : entry.selectorType === "risk_level" ? entry.riskLevel === ctx.riskLevel : false; + if (!selected) return false; const conditions = isRecord(entry.conditions) ? entry.conditions as ToolPolicyConditions : null; - if (!evaluatePolicyConditions(conditions, ctx).matched) return false; - if (entry.selectorType === "application") return entry.applicationId === ctx.applicationId; - if (entry.selectorType === "connection") return entry.connectionId === ctx.connectionId; - if (entry.selectorType === "catalog_entry") return entry.catalogEntryId === ctx.catalogEntryId; - if (entry.selectorType === "tool_name") return entry.toolName === ctx.toolName; - if (entry.selectorType === "risk_level") return entry.riskLevel === ctx.riskLevel; - return false; + return evaluatePolicyConditions(conditions, ctx).matched; } function targetMatches(binding: typeof toolProfileBindings.$inferSelect, ctx: ToolAccessContext): boolean {