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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-08 21:05:24 -06:00
1 parent 8384a85876
commit 9926a13870
2 files changed
+40 -3

No files matched your search

@@ -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",
+14 -3
View File
@@ -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<string, unknown> | null, ctx: ToolAccessContext) {
if (scope === null || scope === undefined) return true;
if (!isRecord(scope)) return false;
@@ -1246,7 +1250,7 @@ export function toolAccessPolicyService(db: Pick<Db, "select" | "insert" | "upda
return { profiles: activeProfiles, entries };
}
async function explicitGrant(ctx: ToolAccessContext, cache?: ToolAccessDecisionCache): Promise<boolean> {
async function explicitGrant(ctx: ToolAccessContext, cache?: ToolAccessDecisionCache, options: { scopedOnly?: boolean } = {}): Promise<boolean> {
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<Db, "select" | "insert" | "upda
eq(principalPermissionGrants.principalId, principalId),
eq(principalPermissionGrants.permissionKey, "tools:use"),
)));
return grants.some((grant) => 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<Db, "select" | "insert" | "upda
return decision("allow", "allow_policy", "Tool access allowed by policy.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation });
}
}
if (await explicitGrant(ctx, cache)) {
// A connection's profile decides which of its actions agents may call. An
// unscoped tools:use grant, which every new standard agent receives, is
// permission to use tools at all; it must not turn on an action that a
// profile governing this connection leaves off. A grant scoped to the
// action, connection or application still applies.
const governedByConnectionProfile = !!ctx.connectionId
&& profileState.entries.some((entry) => 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 });
}