From 9926a138705b8251a0563d718417dee38f82fc6e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Nov 2026 20:46:51 -0600 Subject: [PATCH] fix(tool-access): an unscoped tools:use grant does not turn on actions a connection left off Every new standard agent gets an unscoped tools:use grant. The policy checked explicit grants before profiles, so that default grant allowed every action of every connection installed for the agent, including actions the person left off when they finished the connection. The agent's effective profile showed the action off while the test-as-agent summary and the gateway allowed it. When a profile in effect for the agent governs the tool's connection, only a grant scoped to the action, connection or application now allows it. An unscoped grant still applies to tools that no connection profile governs. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/generic-mcp-connection.test.ts | 26 +++++++++++++++++++ server/src/services/tool-access-policy.ts | 17 +++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 78bf314dc4f..eb9f3100ce1 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -1053,6 +1053,32 @@ 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"]]); + }); + 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..7f0192453ae 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -760,6 +760,10 @@ function rateBucket(rule: ToolRateLimitRule, ctx: ToolAccessContext): string { }).join("|"); } +function isScopedToolGrant(scope: unknown) { + return isRecord(scope) && Object.keys(scope).length > 0; +} + function scopeAllowsTool(scope: Record | null, ctx: ToolAccessContext) { if (scope === null || scope === undefined) return true; if (!isRecord(scope)) return false; @@ -1246,7 +1250,7 @@ export function toolAccessPolicyService(db: Pick { + async function explicitGrant(ctx: ToolAccessContext, cache?: ToolAccessDecisionCache, options: { scopedOnly?: 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 +1264,7 @@ export function toolAccessPolicyService(db: Pick scopeAllowsTool(grant.scope, ctx)); + return grants.some((grant) => (!options.scopedOnly || isScopedToolGrant(grant.scope)) && scopeAllowsTool(grant.scope, ctx)); } async function enforceRateLimit(policy: typeof toolPolicies.$inferSelect, ctx: ToolAccessContext, consume: boolean) { @@ -1650,7 +1654,14 @@ export function toolAccessPolicyService(db: Pick entry.connectionId === ctx.connectionId); + if (await explicitGrant(ctx, cache, { scopedOnly: governedByConnectionProfile })) { return decision("allow", "allow_explicit_grant", "Tool access allowed by explicit grant.", effectiveProfileIds, [], { redactionPlan: redaction.redactionPlan }); }