diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index cc6813a3d..52c7a316c 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -134,6 +134,7 @@ import { import { isFirstPartyOAuthClientSlug, type OAuthService } from "./oauth-client"; import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { + combineEffectivePolicies, comparePolicyRow, isValidPattern, matchPattern, @@ -4801,6 +4802,7 @@ export const createExecutor = EffectivePolicy; + readonly globalRows: readonly ToolPolicyRow[]; }; const compareProviderPolicyRule = ( @@ -4842,50 +4845,60 @@ export const createExecutor = => - activeToolPolicyProvider - ? // Batched per-operation resolver: fetch all policy + connection state - // once, then resolve every tool in this operation against that - // snapshot. Avoids the per-tool resolve N+1 on the list surface. - activeToolPolicyProvider.prepare - ? activeToolPolicyProvider - .prepare() - .pipe(Effect.map((resolve) => ({ kind: "prepared" as const, resolve }))) - : activeToolPolicyProvider.resolve - ? Effect.succeed({ - kind: "provider" as const, - provider: activeToolPolicyProvider, - rules: null, - }) - : activeToolPolicyProvider.list().pipe( - Effect.map((rules) => ({ - kind: "provider" as const, - provider: activeToolPolicyProvider!, - rules, - })), - ) - : core - .findMany("tool_policy", {}) - .pipe(Effect.map((rows) => ({ kind: "global" as const, rows }))); + Effect.gen(function* () { + const globalRows = yield* core.findMany("tool_policy", {}); + if (!activeToolPolicyProvider) { + return { kind: "global" as const, rows: globalRows }; + } + if (activeToolPolicyProvider.prepare) { + const resolve = yield* activeToolPolicyProvider.prepare(); + return { kind: "prepared" as const, resolve, globalRows }; + } + if (activeToolPolicyProvider.resolve) { + return { + kind: "provider" as const, + provider: activeToolPolicyProvider, + rules: null, + globalRows, + }; + } + const rules = yield* activeToolPolicyProvider.list(); + return { + kind: "provider" as const, + provider: activeToolPolicyProvider, + rules, + globalRows, + }; + }); const resolvePolicyFromRuleSet = ( toolId: string, ruleSet: ActivePolicyRuleSet, defaultRequiresApproval?: boolean, ): Effect.Effect => - ruleSet.kind === "prepared" - ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval })) - : ruleSet.kind === "provider" - ? ruleSet.provider.resolve - ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) - : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? [])) - : Effect.succeed( - resolveEffectivePolicy( - toolId, - ruleSet.rows, - ownerRankForRow, - defaultRequiresApproval, - ), - ); + Effect.gen(function* () { + if (ruleSet.kind === "global") { + return resolveEffectivePolicy( + toolId, + ruleSet.rows, + ownerRankForRow, + defaultRequiresApproval, + ); + } + const globalPolicy = resolveEffectivePolicy( + toolId, + ruleSet.globalRows, + ownerRankForRow, + defaultRequiresApproval, + ); + const providerPolicy = + ruleSet.kind === "prepared" + ? ruleSet.resolve({ toolId, defaultRequiresApproval }) + : ruleSet.provider.resolve + ? yield* ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) + : resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []); + return combineEffectivePolicies(providerPolicy, globalPolicy); + }); // ------------------------------------------------------------------ // Tools (read surface) @@ -5437,7 +5450,6 @@ export const createExecutor = => Effect.gen(function* () { const parsed = parseToolAddress(String(address)); - const policyRows = yield* core.findMany("tool_policy", {}); const toolId = parsed ? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}` : String(address); @@ -5458,7 +5470,8 @@ export const createExecutor = { expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); }), ); + + it.effect("enforces workspace require_approval policy over a provider approve rule", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [staticPlugin, policyProviderPlugin] as const, + }); + + yield* executor.policies.create({ + owner: "org", + pattern: "toolkit-fixture.ctl.allowed", + action: "require_approval", + }); + + const calls = { count: 0 }; + const allowed = yield* executor.execute( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(allowed).toBe("allowed"); + expect(calls.count).toBe(1); + }), + ); + + it.effect("enforces workspace block policy over a provider approve rule", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [staticPlugin, policyProviderPlugin] as const, + }); + + yield* executor.policies.create({ + owner: "org", + pattern: "toolkit-fixture.ctl.allowed", + action: "block", + }); + + const tools = yield* executor.tools.list(); + expect(tools).toHaveLength(0); + + const blocked = yield* Effect.result( + executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}), + ); + expect(Result.isFailure(blocked)).toBe(true); + if (!Result.isFailure(blocked)) return; + expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); + }), + ); + + it.effect("enforces provider require_approval rule when workspace has no policy", () => + Effect.gen(function* () { + const approvalProviderPlugin = definePlugin(() => ({ + id: "approval-provider" as const, + storage: () => ({}), + toolPolicyProvider: () => ({ + list: () => + Effect.succeed([ + { + id: "require-approval-static", + pattern: "toolkit-fixture.ctl.allowed", + action: "require_approval" as const, + position: "a0", + }, + ]), + }), + }))(); + + const executor = yield* makeTestExecutor({ + plugins: [staticPlugin, approvalProviderPlugin] as const, + }); + + const calls = { count: 0 }; + const allowed = yield* executor.execute( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(allowed).toBe("allowed"); + expect(calls.count).toBe(1); + }), + ); + + it.effect("combines prepared provider resolver with workspace policies", () => + Effect.gen(function* () { + const preparedProviderPlugin = definePlugin(() => ({ + id: "prepared-provider" as const, + storage: () => ({}), + toolPolicyProvider: () => ({ + list: () => Effect.succeed([]), + prepare: () => + Effect.succeed((input) => + input.toolId === "toolkit-fixture.ctl.allowed" + ? { action: "approve", source: "user", pattern: "toolkit-fixture.ctl.allowed" } + : { action: "block", source: "user", pattern: "*" }, + ), + }), + }))(); + + const executor = yield* makeTestExecutor({ + plugins: [staticPlugin, preparedProviderPlugin] as const, + }); + + yield* executor.policies.create({ + owner: "org", + pattern: "toolkit-fixture.ctl.allowed", + action: "require_approval", + }); + + const calls = { count: 0 }; + const allowed = yield* executor.execute( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(allowed).toBe("allowed"); + expect(calls.count).toBe(1); + + const blocked = yield* Effect.result( + executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}), + ); + expect(Result.isFailure(blocked)).toBe(true); + if (!Result.isFailure(blocked)) return; + expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); + }), + ); }); describe("approve / require_approval interaction with annotations", () => { diff --git a/packages/core/sdk/src/policies.ts b/packages/core/sdk/src/policies.ts index 8620d9c6d..513f69c39 100644 --- a/packages/core/sdk/src/policies.ts +++ b/packages/core/sdk/src/policies.ts @@ -188,7 +188,7 @@ const actionRestrictionRank = (action: ToolPolicyAction): number => Match.exhaustive, ); -const moreRestrictive = ( +export const moreRestrictive = ( current: T | undefined, candidate: T, ): T => { @@ -198,6 +198,37 @@ const moreRestrictive = ( return candidateRank > currentRank ? candidate : current; }; +export const moreRestrictivePolicy = moreRestrictive; + +/** + * Combine policy resolution from a scoped tool policy provider (e.g. a toolkit) + * with the ambient workspace/global policy set under the principle of least privilege: + * - If either policy explicitly blocks (or the tool is outside the toolkit connection boundary), it is blocked. + * - If both have explicit user/org rules, the most restrictive user rule wins. + * - If only one has an explicit user/org rule, that user rule takes precedence over plugin default. + * - If neither has an explicit user/org rule, fall back to the most restrictive plugin default. + */ +export const combineEffectivePolicies = ( + providerPolicy: EffectivePolicy, + globalPolicy: EffectivePolicy, +): EffectivePolicy => { + if (providerPolicy.action === "block") return providerPolicy; + if (globalPolicy.action === "block") return globalPolicy; + + if (providerPolicy.source === "user" && globalPolicy.source === "user") { + return moreRestrictive(providerPolicy, globalPolicy); + } + + if (globalPolicy.source === "user") { + return globalPolicy; + } + if (providerPolicy.source === "user") { + return providerPolicy; + } + + return moreRestrictive(providerPolicy, globalPolicy); +}; + export const resolveToolPolicy = ( toolId: string, policies: readonly ToolPolicyRow[], diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index bab67eb0e..83f60d2fc 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Predicate, Result } from "effect"; -import { makeTestExecutor } from "@executor-js/sdk/testing"; +import { Effect, Predicate, Result, Schema } from "effect"; +import { createExecutor, definePlugin, tool, ToolAddress } from "@executor-js/sdk"; +import { makeTestExecutor, makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; import { toolkitsPlugin } from "./server"; @@ -199,4 +200,142 @@ describe("toolkitsPlugin", () => { ).toContain("google_docs.org.* approve"); }), ); + + it.effect("enforces workspace policies when running under an active toolkit", () => + Effect.gen(function* () { + const samplePlugin = definePlugin(() => ({ + id: "sample" as const, + storage: () => ({}), + staticIntegrations: () => [ + { + kind: "control" as const, + id: "sample.ctl", + name: "Sample Control", + tools: [ + tool({ + name: "readTool", + description: "read tool", + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => Effect.succeed("read-data"), + }), + tool({ + name: "deleteTool", + description: "delete tool", + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => Effect.succeed("deleted"), + }), + ], + }, + ], + }))(); + + const harness = yield* makeTestWorkspaceHarness({ + plugins: [toolkitsPlugin(), samplePlugin] as const, + }); + const setup = harness.executor; + + const toolkit = yield* setup.toolkits.create({ + owner: "org", + name: "Test Kit", + }); + yield* setup.toolkits.createConnection(toolkit.id, { + pattern: "sample.ctl.*", + }); + + // Workspace policy: require_approval on readTool + yield* setup.policies.create({ + owner: "org", + pattern: "sample.ctl.readTool", + action: "require_approval", + }); + + // Workspace policy: block on deleteTool + yield* setup.policies.create({ + owner: "org", + pattern: "sample.ctl.deleteTool", + action: "block", + }); + + // Now create a toolkit-scoped executor (as built for /mcp/toolkits/) sharing the same db + const toolkitExecutor = yield* createExecutor({ + ...harness.config, + plugins: [toolkitsPlugin({ activeToolkitSlug: toolkit.slug }), samplePlugin] as const, + }); + + // 1. readTool is visible in tools.list + const tools = yield* toolkitExecutor.tools.list(); + expect(tools.map((t) => String(t.address))).toContain("sample.ctl.readTool"); + // deleteTool was blocked by workspace policy, so it must not be in tools.list + expect(tools.map((t) => String(t.address))).not.toContain("sample.ctl.deleteTool"); + + // 2. readTool policy resolves to require_approval + const readPolicy = yield* toolkitExecutor.policies.resolve( + ToolAddress.make("sample.ctl.readTool"), + ); + expect(readPolicy.action).toBe("require_approval"); + + // 3. deleteTool policy resolves to block + const deletePolicy = yield* toolkitExecutor.policies.resolve( + ToolAddress.make("sample.ctl.deleteTool"), + ); + expect(deletePolicy.action).toBe("block"); + + // 4. Executing readTool prompts elicitation + let elicited = false; + const result = yield* toolkitExecutor.execute( + ToolAddress.make("sample.ctl.readTool"), + {}, + { + onElicitation: () => { + elicited = true; + return Effect.succeed({ action: "accept" as const }); + }, + }, + ); + expect(result).toBe("read-data"); + expect(elicited).toBe(true); + + // 5. Executing deleteTool is blocked + const blocked = yield* Effect.result( + toolkitExecutor.execute(ToolAddress.make("sample.ctl.deleteTool"), {}), + ); + expect(Result.isFailure(blocked)).toBe(true); + if (!Result.isFailure(blocked)) return; + expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); + + // 6. Toolkit approve policy cannot weaken workspace require_approval policy + yield* setup.toolkits.createPolicy(toolkit.id, { + pattern: "sample.ctl.readTool", + action: "approve", + }); + + const toolkitExecutorWithApprove = yield* createExecutor({ + ...harness.config, + plugins: [toolkitsPlugin({ activeToolkitSlug: toolkit.slug }), samplePlugin] as const, + }); + + const stillRequireApproval = yield* toolkitExecutorWithApprove.policies.resolve( + ToolAddress.make("sample.ctl.readTool"), + ); + expect(stillRequireApproval.action).toBe("require_approval"); + + let elicitedAgain = false; + const resultAgain = yield* toolkitExecutorWithApprove.execute( + ToolAddress.make("sample.ctl.readTool"), + {}, + { + onElicitation: () => { + elicitedAgain = true; + return Effect.succeed({ action: "accept" as const }); + }, + }, + ); + expect(resultAgain).toBe("read-data"); + expect(elicitedAgain).toBe(true); + }), + ); }); diff --git a/packages/react/src/pages/api-keys.tsx b/packages/react/src/pages/api-keys.tsx index cebd2194b..c02d74d4d 100644 --- a/packages/react/src/pages/api-keys.tsx +++ b/packages/react/src/pages/api-keys.tsx @@ -347,7 +347,8 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { if (!open) setConfirmRevoke(null); }} > - + {/* A confirmation with nothing to lose: clicking away cancels it. */} + Revoke API key