diff --git a/doc/MCP-ACCESS-GOVERNANCE.md b/doc/MCP-ACCESS-GOVERNANCE.md index bca41af7f4..561d50f754 100644 --- a/doc/MCP-ACCESS-GOVERNANCE.md +++ b/doc/MCP-ACCESS-GOVERNANCE.md @@ -13,6 +13,7 @@ Operator guide for Paperclip's MCP tool access surface. Audience: board users an - [Managed connections](#managed-connections) - [Catalog and risk classification](#catalog-and-risk-classification) - [Profiles and bindings](#profiles-and-bindings) +- [Scoped tool grants](#scoped-tool-grants) - [Policies](#policies) - [Approval flow and trust rules](#approval-flow-and-trust-rules) - [Runtime slots](#runtime-slots) @@ -243,6 +244,28 @@ curl -fsS -H "Authorization: Bearer $BOARD_API_KEY" \ | jq '{profileIds, allowedToolNames}' ``` +## Scoped tool grants + +A stored tool grant can restrict access with `scope.allow` and tool selectors. +An explicit `allow` must contain a matching `tool:`, `connection:`, or +`application:`. Tool names can use the gateway name or the upstream name. +If the scope also includes selectors, those selectors must match as well. + +For example, `{"allow":["tool:read_item"],"connectionId":""}` +allows that tool only on the selected connection. A matching allow entry does +not override a mismatched connection selector. + +Unknown selector names, malformed values, empty restrictions, and non-object +scopes deny access. Singular selectors require a nonempty string. Plural +selectors require a nonempty array of nonempty strings. The supported selector +names are `actorType`, `agentId`, `projectId`, `routineId`, `issueId`, `gatewayId`, +`applicationId`, `connectionId`, `catalogEntryId`, `applicationKey`, +`providerType`, `toolName`, and `riskLevel`, plus their plural forms. + +A null scope or an empty object preserves the existing unrestricted grant +behavior. Discovery and execution evaluate the current grant. A revoked grant +cannot authorize a later call merely because discovery previously listed it. + ## Policies Policies run after profile selection. A profile decides *can this agent see the tool*; a policy decides *is this exact call allowed right now*. Policy types: diff --git a/server/src/__tests__/tool-grant-scope.test.ts b/server/src/__tests__/tool-grant-scope.test.ts new file mode 100644 index 0000000000..9e244c24a8 --- /dev/null +++ b/server/src/__tests__/tool-grant-scope.test.ts @@ -0,0 +1,89 @@ +import { eq } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { beforeAll, afterAll, describe, expect, it } from "vitest"; +import { connectionGrants, createDb, principalPermissionGrants, toolPolicies, toolProfiles, startEmbeddedPostgresTestDatabase, getEmbeddedPostgresTestSupport } from "@paperclipai/db"; +import { toolAccessPolicyService } from "../services/tool-access-policy.js"; +import { createToolGatewayService } from "../services/tool-gateway.js"; +import { mcpGatewayProtocolRoutes } from "../routes/tool-gateway.js"; +import { createListingFixture } from "./helpers/tool-gateway-listing-fixture.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const suite = support.supported ? describe : describe.skip; +suite("tool grant restrictions", () => { + let db!: ReturnType; + let temp!: Awaited>; + beforeAll(async () => { + temp = await startEmbeddedPostgresTestDatabase("paperclip-grant-scope-"); + db = createDb(temp.connectionString); + }); + afterAll(async () => { await temp?.cleanup(); }); + + it.each([ + { name: "exact tool", scope: { allow: ["tool:tool_0004"] }, allowed: true }, + { name: "other tool", scope: { allow: ["tool:tool_0005"] }, allowed: false }, + { name: "empty allow list", scope: { allow: [] }, allowed: false }, + { name: "malformed allow list", scope: { allow: [42] }, allowed: false }, + { name: "unknown selector", scope: { unsupported: true }, allowed: false }, + { name: "empty selector list", scope: { toolNames: [] }, allowed: false }, + { name: "exact selector", scope: { toolName: "tool_0004" }, allowed: true }, + { name: "other selector", scope: { toolName: "tool_0005" }, allowed: false }, + { name: "combined mismatch", scope: { allow: ["tool:tool_0004"], toolName: "tool_0005" }, allowed: false }, + { name: "unknown combined selector", scope: { allow: ["tool:tool_0004"], unsupported: true }, allowed: false }, + { name: "unrestricted empty scope", scope: {}, allowed: true }, + { name: "unrestricted null scope", scope: null, allowed: true }, + { name: "invalid scope array", scope: [] as unknown as Record, allowed: false }, + { name: "invalid scope number", scope: 42 as unknown as Record, allowed: false }, + { name: "invalid scope boolean", scope: false as unknown as Record, allowed: false }, + ])("enforces $name", async ({ scope, allowed }) => { + const fixture = await createListingFixture(db, 6); + await db.update(principalPermissionGrants).set({ scope }).where(eq(principalPermissionGrants.principalId, fixture.agent.id)); + const result = await toolAccessPolicyService(db).decide({ + companyId: fixture.company.id, + actor: { actorType: "agent", actorId: fixture.agent.id, agentId: fixture.agent.id }, + runContext: { heartbeatRunId: fixture.run.id }, + request: { connectionId: fixture.connection.id, catalogEntryId: fixture.entries[4]!.id, toolName: "tool_0004" }, + }); + expect(result.allowed).toBe(allowed); + }); + + it("lists and calls only granted tools through the HTTP gateway, then observes revocation", async () => { + const fixture = await createListingFixture(db, 6); + await db.update(toolProfiles).set({ defaultAction: "deny" }).where(eq(toolProfiles.id, fixture.namedGateway.profileId)); + await db.delete(toolPolicies).where(eq(toolPolicies.companyId, fixture.company.id)); + await db.update(principalPermissionGrants).set({ scope: {} }) + .where(eq(principalPermissionGrants.principalId, fixture.agent.id)); + await db.insert(connectionGrants).values({ companyId: fixture.company.id, connectionId: fixture.connection.id, + kind: "organization", status: "active", isDefault: true }); + const calls: string[] = []; + const service = createToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { + const body = JSON.parse(String(init.body)); + if (body.method === "tools/call") calls.push(body.params.name); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: body.method === "initialize" + ? { protocolVersion: "2025-03-26", capabilities: {}, serverInfo: { name: "fixture", version: "1" } } + : { content: [{ type: "text", text: "fixture result" }] } }), { headers: { "content-type": "application/json" } }); + } }); + const app = express().use(express.json()).use(mcpGatewayProtocolRoutes(service)); + const post = (method: string, params?: unknown) => request(app).post(`/mcp/gateways/${fixture.namedGateway.gatewayPublicId}`) + .set("Authorization", `Bearer ${fixture.token.token}`).send({ jsonrpc: "2.0", id: 1, method, params }); + const allTools = await service.listToolsForNamedGateway({ gatewayId: fixture.namedGateway.id, bearerToken: fixture.token.token }); + const ungrantedTool = allTools.find((entry) => entry.catalogEntryId === fixture.entries[5]!.id)!; + expect(ungrantedTool).toBeDefined(); + await db.update(principalPermissionGrants).set({ scope: { allow: ["tool:tool_0004"] } }) + .where(eq(principalPermissionGrants.principalId, fixture.agent.id)); + const names = await service.listToolsForNamedGateway({ gatewayId: fixture.namedGateway.id, bearerToken: fixture.token.token }); + const granted = names.filter((entry) => entry.connectionId === fixture.connection.id); + expect(granted.map((entry) => entry.catalogEntryId)).toEqual([fixture.entries[4]!.id]); + const listing = await post("tools/list").expect(200); + expect(listing.body.result.tools.some((entry: { name: string }) => entry.name === granted[0]!.name)).toBe(true); + expect(listing.body.result.tools.some((entry: { name: string }) => entry.name === ungrantedTool.name)).toBe(false); + await post("tools/call", { name: ungrantedTool.name, arguments: {} }).expect(403); + expect(calls).toEqual([]); + await post("tools/call", { name: granted[0]!.name, arguments: {} }).expect(200); + expect(calls).toEqual(["tool_0004"]); + await db.update(principalPermissionGrants).set({ scope: { allow: ["tool:tool_0005"] } }) + .where(eq(principalPermissionGrants.principalId, fixture.agent.id)); + await post("tools/call", { name: granted[0]!.name, arguments: {} }).expect(403); + expect(calls).toHaveLength(1); + }); +}); diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index 827203f82d..aea8c2cba9 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -729,12 +729,35 @@ function rateBucket(rule: ToolRateLimitRule, ctx: ToolAccessContext): string { } function scopeAllowsTool(scope: Record | null, ctx: ToolAccessContext) { - if (!scope || Object.keys(scope).length === 0) return true; - const allowed = listValues(scope.allow); - if (allowed.includes(`tool:${ctx.toolName}`)) return true; - if (ctx.connectionId && allowed.includes(`connection:${ctx.connectionId}`)) return true; - if (ctx.applicationId && allowed.includes(`application:${ctx.applicationId}`)) return true; - return selectorMatches(scope, ctx); + if (scope === null || scope === undefined) return true; + if (!isRecord(scope)) return false; + if (Object.keys(scope).length === 0) return true; + const { allow, ...selectors } = scope; + if (Object.hasOwn(scope, "allow")) { + const values = typeof allow === "string" ? [allow] : allow; + if (!Array.isArray(values) || values.length === 0 || + !values.every((value) => typeof value === "string" && value.trim().length > 0)) return false; + if (!values.includes(`tool:${ctx.toolName}`) && + !(ctx.upstreamToolName && values.includes(`tool:${ctx.upstreamToolName}`)) && + !(ctx.connectionId && values.includes(`connection:${ctx.connectionId}`)) && + !(ctx.applicationId && values.includes(`application:${ctx.applicationId}`))) return false; + } + // Grant restrictions must all be understood and match. The permissive policy + // selector matcher cannot validate untrusted or historical grant JSON itself. + const keys = new Set([ + "actorType", "agentId", "projectId", "routineId", "issueId", "gatewayId", + "applicationId", "connectionId", "catalogEntryId", "applicationKey", + "providerType", "toolName", "riskLevel", + ]); + for (const [key, value] of Object.entries(selectors)) { + if (keys.has(key)) { + if (typeof value !== "string" || value.trim().length === 0) return false; + } else if (key.endsWith("s") && keys.has(key.slice(0, -1))) { + if (!Array.isArray(value) || value.length === 0 || + !value.every((entry) => typeof entry === "string" && entry.trim().length > 0)) return false; + } else return false; + } + return selectorMatches(selectors, ctx); } export function toolAccessPolicyService(db: Db) {