mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Core resolves tool profiles before it prepares runtime MCP assignments. > - A selected profile can contain a rule for each individual tool. > - The resolver and profile summary scan these rules for each catalog row. > - This repeats millions of exact comparisons for a large selected profile. > - This pull request indexes each selector kind once per computation. > - The benefit is less server CPU with the same current access decisions and result contract. ## Linked Issues or Issue Description **What existing behavior does this improve?** Effective tool assignment and profile summary computation for large selected profiles. **Current behavior** Each catalog row scans the profile's includes and excludes. The work grows with catalog size times rule count. The runtime assignment path also computes a profile summary with the same scans. **Proposed behavior** Build five selector sets for each include or exclude group. Match catalog rows with exact set membership. Retain all current database reads and the full result contract. **Reason and benefit** Reduce repeated JavaScript comparisons. On the synthetic PostgreSQL fixture below, median process CPU per resolution falls from 50.068 ms to 10.129 ms. This is independent of [#18371](https://github.com/paperclipai/paperclip/pull/18371), which removes unused administrative results and narrows instruction descriptors. This PR retains those results and does not include that change. ## What Changed - Index application, connection, catalog-entry, tool-name, and risk selectors in the profile owner. - Use indexed matching in effective access resolution and profile summaries. - Add a differential test against the original single-rule matcher. Cover null applications, exact special-character names, unknown selectors, and empty rule groups. - Add a PostgreSQL regression for per-profile exclusion precedence, the union of multiple effective profiles, and current rule removal. - Add a real PostgreSQL resolver benchmark and document reproduction and limits. ## Verification Baseline: `410b0e27015495ab8951768b5b0b94727de33785`. Treatment: `895ee2eec15cf4cdedee4c257fcdddb2a7000a1b`. Node 24.22.2 on macOS arm64. Source modules run with `tsx`; these are local service measurements, not production-build or hosted acceptance. The real owning resolver ran against disposable PostgreSQL with 1,890 active catalog tools, 1,575 individual include/exclude rules, one deny-by-default effective profile, and 12 agents. The installation dependency returns an empty list because the fixture has no installed connections. There are no provider calls. Full returned assignments, profile summaries, rules, bindings, and names are identical to baseline. After two warmups per version, seven alternating samples ran ten resolutions each. Other workers paused CPU measurements and builds. Unrelated host work was not controlled. | Per resolution | Baseline median (range) | Change median (range) | | --- | --- | --- | | Process CPU | 50.068 ms (46.380–58.880) | 10.129 ms (8.089–11.072) | | Elapsed time | 50.362 ms (44.799–106.408) | 8.853 ms (7.719–18.590) | | Queries | 6 | 6 | | Returned database rows | 3,480 | 3,480 | Median process CPU falls 79.8%; median elapsed time falls 82.4%. The long baseline elapsed sample had no matching process CPU increase. Query and row counts do not decrease. Small or broadly allowed profiles can save less. This does not establish whole-run latency or hosted performance. Reproduce with `server/scripts/benchmark-tool-profile-matching.ts`; baseline module preparation is in `doc/mcp-discovery-performance.md`. Passed on the treatment source: ```sh node node_modules/vitest/vitest.mjs run server/src/__tests__/tool-profile-matching.test.ts server/src/__tests__/tool-effective-assignments.test.ts server/src/services/native-runtime/runtime-context.test.ts server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts ``` All 45 tests in four suites passed. `git diff --check` passed. The first PostgreSQL test attempt failed at setup because the sandbox blocked its loopback listener (`EPERM`); the authorized unsandboxed retry passed. No behavior failure was discarded. At measured head `895ee2eec15cf4cdedee4c257fcdddb2a7000a1b`, CI typecheck, build, canary, Runner checks, and all eight browser shards passed. Server general shard 8/12 failed an unchanged background-quietness assertion in `heartbeat-run-dispatch-work.test.ts`: expected 235 select calls, observed 236 after advancing fake timers. Its cause remains unverified; no baseline reproduction was run. That head also failed the module-boundary policy because the benchmark inserted synthetic agents outside the test fixture tree. Follow-up head `e20165b6eb` moves the fixture body byte-for-byte into `server/src/__tests__/helpers/tool-profile-matching-fixture.ts`, following the existing listing benchmark pattern. The production matcher and benchmark measurement loop are unchanged. `node scripts/check-module-boundaries.mjs`, a Node 24/tsx fixture import check, and `git diff --check` pass. The benchmark was not rerun for this extraction. New-head CI and review are pending. Greptile gave the measured head 5/5 with no actionable issues. No deployment or live runtime mutation was performed. ## Risks Exact equality must remain identical to the original matcher, especially for null application selectors and exclusions. The differential and PostgreSQL tests cover these cases. Sets add linear temporary memory in rule count. They live only for the current computation. Current company-scoped state reads, profile precedence, revocation, and runtime assignment digest checks remain in place. Revert this commit to roll back. ## Model Used OpenAI GPT-6.1 Sol (`gpt-6.1-sol`) with source inspection, code execution, tests, and local PostgreSQL benchmarking. The context window size is not available in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
311 lines
23 KiB
TypeScript
311 lines
23 KiB
TypeScript
import { randomBytes, 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 { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "./helpers/full-database-response-capture.js";
|
|
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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
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<ReturnType<typeof fixture>>) {
|
|
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 }),
|
|
) });
|
|
const connections = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]);
|
|
expect(connections).toEqual({
|
|
installedConnections: full.installedConnections,
|
|
allowedConnectionIds: [...new Set(full.allowedTools.filter(tool => tool.connectionId === f.connection.id).map(tool => tool.connectionId))],
|
|
});
|
|
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 exclusions local to each profile when effective profiles grant the same tool", async () => {
|
|
const f = await fixture();
|
|
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
|
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
|
|
selectorType: "catalog_entry", effect: "exclude", catalogEntryId: f.read.id });
|
|
const [second] = await db.insert(toolProfiles).values({ companyId: f.company.id, profileKey: "second", name: "Second", defaultAction: "deny" }).returning();
|
|
await db.insert(toolProfileBindings).values({ companyId: f.company.id, profileId: second.id, targetType: "agent", targetId: f.agent.id });
|
|
const [include] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: second.id,
|
|
selectorType: "catalog_entry", effect: "include", catalogEntryId: f.read.id }).returning();
|
|
expect((await compare(f)).assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id, f.write.id]);
|
|
await db.delete(toolProfileEntries).where(eq(toolProfileEntries.id, include.id));
|
|
expect((await compare(f)).assignments.allowedTools.map(tool => tool.id)).toEqual([f.write.id]);
|
|
});
|
|
|
|
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<Db["select"]>) => {
|
|
const builder = target.select(...fields), from = builder.from.bind(builder);
|
|
builder.from = ((...args: Parameters<typeof from>) => {
|
|
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);
|
|
});
|
|
it("scopes catalog reads, omits rule payloads, and keeps current full definitions", async () => {
|
|
const f = await fixture();
|
|
const bulk = randomBytes(128 * 1024).toString("base64");
|
|
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
|
|
selectorType: "application", effect: "include", applicationId: f.application.id, conditions: { savedRuleMetadata: bulk } }).returning();
|
|
const [unrelated] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: f.application.id,
|
|
name: "Other connection", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true }).returning();
|
|
await db.insert(toolCatalogEntries).values(Array.from({ length: 100 }, (_, index) => ({ companyId: f.company.id,
|
|
applicationId: f.application.id, connectionId: unrelated.id, name: `other_${index}`, toolName: `other_${index}`, versionHash: `v${index}` })));
|
|
const service = toolAccessService(db);
|
|
const before = await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id);
|
|
expect(before.allowedTools).toHaveLength(102);
|
|
const events: Array<Record<string, unknown>> = [], now = Date.now();
|
|
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
|
|
installDatabaseResponseCapture(db, { toolCatalogEntries, toolProfileEntries }, capture);
|
|
let scoped: Awaited<ReturnType<typeof service.getConnectionAssignmentsForAgent>>;
|
|
try { scoped = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]); }
|
|
finally { capture.flush(); }
|
|
expect(Number(events.at(-1)?.completedQueries)).toBeGreaterThan(0);
|
|
expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(16 * 1024);
|
|
const catalogQueries = events.filter(event => event.event === "paperclip.test_db_response_capture.entry.v1"
|
|
&& event.table === "tool_catalog_entries" && event.operation === "select");
|
|
expect(catalogQueries).toHaveLength(1);
|
|
expect(catalogQueries[0]).toMatchObject({ calls: 1, rows: 2 });
|
|
expect(scoped!).toEqual({ allowedConnectionIds: [f.connection.id], installedConnections: before.installedConnections });
|
|
expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
|
|
expect((await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, rule.id)))[0]).toEqual(rule);
|
|
expect(await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id)).toEqual(before);
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [])).allowedConnectionIds).toEqual([]);
|
|
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, f.profile.id));
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
|
});
|
|
|
|
it("omits unrelated rules and bindings while retaining current connection decisions", async () => {
|
|
const f = await fixture();
|
|
const [otherApplication] = await db.insert(toolApplications).values({ companyId: f.company.id, name: "Other app", type: "mcp_http" }).returning();
|
|
const [otherConnection] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: otherApplication.id,
|
|
name: "Other connection", uid: randomUUID(), transport: "mcp_remote" }).returning();
|
|
const [otherTool] = await db.insert(toolCatalogEntries).values({ companyId: f.company.id, applicationId: otherApplication.id,
|
|
connectionId: otherConnection.id, name: "other", toolName: "other", versionHash: "other", riskLevel: "destructive" }).returning();
|
|
await db.insert(toolProfileEntries).values(Array.from({ length: 200 }, (_, index) => [
|
|
{ selectorType: "application" as const, applicationId: otherApplication.id },
|
|
{ selectorType: "connection" as const, connectionId: otherConnection.id },
|
|
{ selectorType: "catalog_entry" as const, catalogEntryId: otherTool.id },
|
|
{ selectorType: "tool_name" as const, toolName: `unrelated_${index}` },
|
|
{ selectorType: "risk_level" as const, riskLevel: "destructive" as const },
|
|
].map(entry => ({ companyId: f.company.id, profileId: f.profile.id, effect: index % 2 ? "include" as const : "exclude" as const, ...entry }))).flat());
|
|
await db.insert(toolProfileBindings).values(Array.from({ length: 200 }, () => ({ companyId: f.company.id,
|
|
profileId: f.profile.id, targetType: "agent" as const, targetId: randomUUID() })));
|
|
const [include, exclude] = await db.insert(toolProfileEntries).values([
|
|
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "connection", effect: "include", connectionId: f.connection.id },
|
|
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "catalog_entry", effect: "exclude", catalogEntryId: f.write.id },
|
|
]).returning();
|
|
const service = toolAccessService(db), before = await compare(f);
|
|
const savedRules = await db.select().from(toolProfileEntries);
|
|
const events: Array<Record<string, unknown>> = [], now = Date.now();
|
|
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
|
|
installDatabaseResponseCapture(db, { toolProfileEntries, toolProfileBindings }, capture);
|
|
try {
|
|
const scoped = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]);
|
|
capture.flush();
|
|
const rules = events.filter(event => event.event === "paperclip.test_db_response_capture.entry.v1" && event.table === "tool_profile_entries");
|
|
const bindings = events.filter(event => event.event === "paperclip.test_db_response_capture.entry.v1" && event.table === "tool_profile_bindings");
|
|
expect(rules).toHaveLength(1);
|
|
expect(Number(rules[0].responseValueBytes)).toBeLessThan(4096);
|
|
expect(rules[0]).toMatchObject({ calls: 1, rows: 2 });
|
|
expect(bindings).toHaveLength(1);
|
|
expect(bindings[0]).toMatchObject({ calls: 1, rows: 1 });
|
|
expect(scoped).toEqual({ allowedConnectionIds: [f.connection.id], installedConnections: before.full.installedConnections });
|
|
expect(await db.select().from(toolProfileEntries)).toEqual(savedRules);
|
|
await db.update(toolProfileEntries).set({ effect: "exclude" }).where(eq(toolProfileEntries.id, include.id));
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
|
await db.delete(toolProfileEntries).where(eq(toolProfileEntries.id, include.id));
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
|
expect((await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, exclude.id)))[0]).toEqual(exclude);
|
|
} finally { capture.flush(); }
|
|
});
|
|
|
|
it("preserves null application matches and exact special-character tool names", async () => {
|
|
const f = await fixture(), toolName = "quoted' path\\tool_🙂";
|
|
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
|
|
await db.update(toolCatalogEntries).set({ applicationId: null, toolName }).where(eq(toolCatalogEntries.id, f.read.id));
|
|
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
|
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
|
|
selectorType: "application", applicationId: null, effect: "exclude" }).returning();
|
|
const service = toolAccessService(db);
|
|
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
|
await db.update(toolProfileEntries).set({ selectorType: "tool_name", toolName }).where(eq(toolProfileEntries.id, rule.id));
|
|
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
|
await db.update(toolProfileEntries).set({ toolName: `${toolName}different` }).where(eq(toolProfileEntries.id, rule.id));
|
|
expect((await compare(f)).assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id]);
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
|
|
});
|
|
|
|
it.each(["application", "connection", "catalog_entry", "tool_name", "risk_level"] as const)("denies the connection when a %s exclusion blocks its only active tool", async selectorType => {
|
|
const f = await fixture();
|
|
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
|
|
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
|
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id, selectorType, effect: "exclude",
|
|
...(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 } : {}),
|
|
}).returning();
|
|
const service = toolAccessService(db);
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
|
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
|
await db.delete(toolProfileEntries).where(eq(toolProfileEntries.id, rule.id));
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
|
|
});
|
|
|
|
it("filters rules against the catalog rows being evaluated when the catalog changes during the read", async () => {
|
|
const f = await fixture();
|
|
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
|
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
|
|
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
|
|
selectorType: "tool_name", toolName: f.read.toolName, effect: "exclude" });
|
|
let changed = false;
|
|
const changingDb = new Proxy(db, { get(target, key) {
|
|
if (key !== "select") return Reflect.get(target, key);
|
|
return (...fields: Parameters<Db["select"]>) => {
|
|
const builder = target.select(...fields), from = builder.from.bind(builder);
|
|
builder.from = ((...args: Parameters<typeof from>) => {
|
|
const selection = from(...args);
|
|
if (args[0] === toolCatalogEntries) {
|
|
const execute = selection.execute.bind(selection);
|
|
selection.execute = async () => {
|
|
const rows = await execute();
|
|
if (!changed) {
|
|
changed = true;
|
|
await db.update(toolCatalogEntries).set({ toolName: "changed-after-read" }).where(eq(toolCatalogEntries.id, f.read.id));
|
|
}
|
|
return rows;
|
|
};
|
|
}
|
|
return selection;
|
|
}) as typeof builder.from;
|
|
return builder;
|
|
};
|
|
} });
|
|
const service = toolAccessService(changingDb);
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
|
expect(changed).toBe(true);
|
|
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
|
|
});
|
|
|
|
it("keeps other companies' catalog rows out of scoped assignments", async () => {
|
|
const f = await fixture();
|
|
const other = await fixture();
|
|
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
|
expect((await toolAccessService(db).getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [other.connection.id])).allowedConnectionIds).toEqual([]);
|
|
await expect(toolAccessService(db).getConnectionAssignmentsForAgent(f.company.id, other.agent.id, [f.connection.id])).rejects.toThrow("same company");
|
|
});
|
|
|
|
});
|