diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 78bf314dc4f..70b97b1d1d5 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -1053,6 +1053,106 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { expect(JSON.stringify(response.body)).not.toContain("PAPERCLIP_PUBLIC_URL"); }); + it("keeps an action the connection left off from agents that hold only the default unscoped tools:use grant", async () => { + installMcpOAuthFixture({ auth: "header", requiredHeader: { name: "Authorization", value: "Bearer fixture-key-123" }, tools: [ + { name: "list_insights", description: "List insights", annotations: { readOnlyHint: true, destructiveHint: false } }, + { name: "create_insight", description: "Create an insight", annotations: { readOnlyHint: false, destructiveHint: true } }, + ] }); + const company = await createCompany(db); + const service = toolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board-user", userId: "board-user" } as never; + const connected = await service.connectGalleryApp(company.id, { link: MCP_URL, name: "Fixture bearer", authMode: "bearer", + credentialValues: { "credentials.authorization": "fixture-key-123" }, grantKind: "organization" } as never, actor); + const readOnly = connected.actions.readOnly.map((row: { catalogEntryId: string }) => row.catalogEntryId); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Morgan", role: "engineer", status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + await service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: readOnly, askFirstCatalogEntryIds: [], access: { agentIds: [] } }, actor); + await service.putConnectionInstalls(connected.connectionId, { installs: [{ targetType: "agent", targetId: agent!.id }] }, actor); + // Every new standard agent receives this unscoped grant. + await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "agent", principalId: agent!.id, permissionKey: "tools:use", scope: null }); + const gateway = createToolGatewayService(db, { toolActionSigningSecret: "fixture-secret" }); + const summary = async () => (await gateway.summarizeConnectionAccessForAgent({ companyId: company.id, connectionId: connected.connectionId, agentId: agent!.id })) + .tools.map(tool => [tool.toolName, tool.decision]).sort(); + expect(await summary()).toEqual([["create_insight", "off"], ["list_insights", "allowed"]]); + // A grant scoped to the action is an explicit choice and still applies. + await db.update(principalPermissionGrants).set({ scope: { allow: ["tool:create_insight"] } }) + .where(and(eq(principalPermissionGrants.principalId, agent!.id), eq(principalPermissionGrants.permissionKey, "tools:use"))); + expect(await summary()).toEqual([["create_insight", "allowed"], ["list_insights", "allowed"]]); + }); + + async function setUpGovernedTwoActionConnection(options: { enabled: "read" | "none"; access?: "all_agents" }) { + installMcpOAuthFixture({ auth: "header", requiredHeader: { name: "Authorization", value: "Bearer fixture-key-123" }, tools: [ + { name: "list_insights", description: "List insights", annotations: { readOnlyHint: true, destructiveHint: false } }, + { name: "create_insight", description: "Create an insight", annotations: { readOnlyHint: false, destructiveHint: true } }, + ] }); + const company = await createCompany(db); + const service = toolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board-user", userId: "board-user" } as never; + const connected = await service.connectGalleryApp(company.id, { link: MCP_URL, name: "Fixture bearer", authMode: "bearer", + credentialValues: { "credentials.authorization": "fixture-key-123" }, grantKind: "organization" } as never, actor); + const readOnly = connected.actions.readOnly.map((row: { catalogEntryId: string }) => row.catalogEntryId); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Morgan", role: "engineer", status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + await service.finishGalleryAppConnection(company.id, connected.connectionId, { + enabledCatalogEntryIds: options.enabled === "read" ? readOnly : [], askFirstCatalogEntryIds: [], + access: options.access ?? { agentIds: [] }, + }, actor); + if (!options.access) await service.putConnectionInstalls(connected.connectionId, { installs: [{ targetType: "agent", targetId: agent!.id }] }, actor); + // Every new standard agent receives this unscoped grant. + await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "agent", principalId: agent!.id, permissionKey: "tools:use", scope: null }); + const gateway = createToolGatewayService(db, { toolActionSigningSecret: "fixture-secret" }); + const summary = async (agentId = agent!.id) => (await gateway.summarizeConnectionAccessForAgent({ companyId: company.id, connectionId: connected.connectionId, agentId })) + .tools.map(tool => [tool.toolName, tool.decision]).sort(); + const setGrantScope = (scope: Record | null) => db.update(principalPermissionGrants).set({ scope }) + .where(and(eq(principalPermissionGrants.principalId, agent!.id), eq(principalPermissionGrants.permissionKey, "tools:use"))); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + return { company, agent: agent!, connection: connection!, summary, setGrantScope }; + } + + it("keeps every action off when the connection turns all of them off, even for the default unscoped tools:use grant", async () => { + const { connection, summary, setGrantScope } = await setUpGovernedTwoActionConnection({ enabled: "none" }); + expect(await summary()).toEqual([["create_insight", "off"], ["list_insights", "off"]]); + // A grant that only narrows when it applies (agent, project) still covers every tool, so it is not a choice about this connection. + await setGrantScope({ actorType: "agent" }); + expect(await summary()).toEqual([["create_insight", "off"], ["list_insights", "off"]]); + // Grants that name the connection or the application are explicit choices and still apply. + await setGrantScope({ connectionId: connection.id }); + expect(await summary()).toEqual([["create_insight", "allowed"], ["list_insights", "allowed"]]); + await setGrantScope({ allow: [`application:${connection.applicationId}`] }); + expect(await summary()).toEqual([["create_insight", "allowed"], ["list_insights", "allowed"]]); + }); + + it("keeps the unscoped tools:use grant for agents the connection's profile does not cover", async () => { + const { company, summary } = await setUpGovernedTwoActionConnection({ enabled: "read" }); + const [other] = await db.insert(agents).values({ companyId: company.id, name: "Riley", role: "engineer", status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "agent", principalId: other!.id, permissionKey: "tools:use", scope: null }); + expect(await summary(other!.id)).toEqual([["create_insight", "allowed"], ["list_insights", "allowed"]]); + }); + + it("does not let the unscoped tools:use grant override a profile entry that excludes the application", async () => { + const { company, summary } = await setUpGovernedTwoActionConnection({ enabled: "read" }); + const [other] = await db.insert(agents).values({ companyId: company.id, name: "Riley", role: "engineer", status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "agent", principalId: other!.id, permissionKey: "tools:use", scope: null }); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id)); + const [profile] = await db.insert(toolProfiles).values({ companyId: company.id, profileKey: `no-insights-${randomUUID()}`, name: "No insights", defaultAction: "allow" }).returning(); + // An application selector carries no connection id. + await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile!.id, selectorType: "application", effect: "exclude", applicationId: connection!.applicationId }); + await db.insert(toolProfileBindings).values({ companyId: company.id, profileId: profile!.id, targetType: "agent", targetId: other!.id }); + expect(await summary(other!.id)).toEqual([["create_insight", "off"], ["list_insights", "off"]]); + }); + + it("applies the connection profile on the direct policy check and keeps a board user's unscoped grant", async () => { + const { company, agent, connection, summary } = await setUpGovernedTwoActionConnection({ enabled: "read", access: "all_agents" }); + expect(await summary()).toEqual([["create_insight", "off"], ["list_insights", "allowed"]]); + const [create] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, connection.id), eq(toolCatalogEntries.toolName, "create_insight"))); + const request = { connectionId: connection.id, catalogEntryId: create!.id, toolName: create!.toolName, arguments: {} }; + const policy = toolAccessPolicyService(db); + const agentDecision = await policy.decide({ companyId: company.id, actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, request }); + expect([agentDecision.decision, agentDecision.reasonCode]).toEqual(["deny", "deny_default"]); + // The connection's profile is bound to the company, which also matches board users; their operator grant still applies. + await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "user", principalId: "board-user", permissionKey: "tools:use", scope: null }); + const userDecision = await policy.decide({ companyId: company.id, actor: { actorType: "user", actorId: "board-user" }, request }); + expect([userDecision.decision, userDecision.reasonCode]).toEqual(["allow", "allow_explicit_grant"]); + }); + it("stores a bearer key as a secret and never reads it back", async () => { installMcpOAuthFixture({ auth: "header", diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index cfd2f4478a6..839c2a43c54 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -49,6 +49,7 @@ import { toolPolicyConditionsSchema } from "@paperclipai/shared"; import { badRequest, conflict, notFound, unprocessable } from "../errors.js"; import { effectiveToolProfileBindings, + isConnectionAccessProfile, profileIdsInBindingOrder, } from "./tool-profile-binding-precedence.js"; import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js"; @@ -760,6 +761,23 @@ function rateBucket(rule: ToolRateLimitRule, ctx: ToolAccessContext): string { }).join("|"); } +// Grant scope keys that name which tools the grant covers: an action, a +// connection or an application. Context keys such as agentId or projectId only +// narrow when the grant applies, so a grant with only those still covers every +// tool and does not count as a choice about one connection's actions. +const TOOL_GRANT_TARGET_SCOPE_KEYS = new Set([ + "allow", + "toolName", "toolNames", + "catalogEntryId", "catalogEntryIds", + "connectionId", "connectionIds", + "applicationId", "applicationIds", + "applicationKey", "applicationKeys", +]); + +function isToolTargetedGrant(scope: unknown) { + return isRecord(scope) && Object.keys(scope).some((key) => TOOL_GRANT_TARGET_SCOPE_KEYS.has(key)); +} + function scopeAllowsTool(scope: Record | null, ctx: ToolAccessContext) { if (scope === null || scope === undefined) return true; if (!isRecord(scope)) return false; @@ -1246,7 +1264,7 @@ export function toolAccessPolicyService(db: Pick { + async function explicitGrant(ctx: ToolAccessContext, cache?: ToolAccessDecisionCache, options: { toolTargetedOnly?: boolean } = {}): Promise { const principalType = ctx.actorType === "agent" ? "agent" : ctx.actorType === "user" ? "user" : null; const principalId = ctx.actorType === "agent" ? ctx.agentId : ctx.actorId; if (!principalType || !principalId) return false; @@ -1260,7 +1278,7 @@ export function toolAccessPolicyService(db: Pick scopeAllowsTool(grant.scope, ctx)); + return grants.some((grant) => (!options.toolTargetedOnly || isToolTargetedGrant(grant.scope)) && scopeAllowsTool(grant.scope, ctx)); } async function enforceRateLimit(policy: typeof toolPolicies.$inferSelect, ctx: ToolAccessContext, consume: boolean) { @@ -1650,7 +1668,19 @@ export function toolAccessPolicyService(db: Pick isConnectionAccessProfile(profile, ctx.connectionId)) + || profileState.entries.some((entry) => entry.connectionId === ctx.connectionId || profileEntryMatches(entry, ctx)) + ); + if (await explicitGrant(ctx, cache, { toolTargetedOnly: governedByConnectionProfile })) { return decision("allow", "allow_explicit_grant", "Tool access allowed by explicit grant.", effectiveProfileIds, [], { redactionPlan: redaction.redactionPlan }); } diff --git a/server/src/services/tool-profile-binding-precedence.ts b/server/src/services/tool-profile-binding-precedence.ts index 73b94c71314..c43711c80a0 100644 --- a/server/src/services/tool-profile-binding-precedence.ts +++ b/server/src/services/tool-profile-binding-precedence.ts @@ -56,7 +56,12 @@ export function profileIdsInBindingOrder` profile or an agent's `connection-intent` profile. + * With a connection id, only that connection's profiles match. + */ +export function isConnectionAccessProfile(profile: ProfileLike, connectionId?: string | null): boolean { if (!profile.metadata || typeof profile.metadata !== "object" || Array.isArray(profile.metadata)) return false; const metadata = profile.metadata as Record; if (metadata.source === "connection_intent" && typeof metadata.connectionId === "string" && typeof metadata.agentId === "string") { @@ -85,7 +90,7 @@ export function effectiveToolProfileBindings( return narrowestScopeBindings(bindings); } const appProfileIds = new Set( - profiles.filter((profile) => isWizardAppProfile(profile, connectionId)).map((profile) => profile.id), + profiles.filter((profile) => isConnectionAccessProfile(profile, connectionId)).map((profile) => profile.id), ); const accessProfileIds = new Set(profiles.filter(profile => { const metadata = profile.metadata as Record | null;