From 90858265dfbf5da9ded5c1eeebf0a3ada2473e53 Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:10:15 +1200 Subject: [PATCH 1/9] test(sdk): define least-privilege policy composition --- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/policies.test.ts | 52 ++++++++++++++++++++++++++ packages/core/sdk/src/policies.ts | 16 ++++++++ 3 files changed, 69 insertions(+) diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 95fe72e8f3..06dcabddb0 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -192,6 +192,7 @@ export { matchPattern, isValidPattern, effectivePolicyFromSorted, + combineEffectivePolicies, ToolPolicyActionSchema, type ToolPolicy, type CreateToolPolicyInput, diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index beb9703c49..5dc01acd70 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -14,6 +14,7 @@ import { } from "./ids"; import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; import { + combineEffectivePolicies, effectivePolicyFromSorted, isValidPattern, matchPattern, @@ -246,6 +247,57 @@ describe("effectivePolicyFromSorted", () => { }); }); +describe("combineEffectivePolicies", () => { + const user = (action: "approve" | "require_approval" | "block", pattern: string) => ({ + action, + source: "user" as const, + pattern, + }); + const pluginDefault = (action: "approve" | "require_approval") => ({ + action, + source: "plugin-default" as const, + }); + + it("keeps a provider capability-boundary block", () => { + expect( + combineEffectivePolicies(user("block", "*"), user("approve", "sample.*")), + ).toEqual(user("block", "*")); + }); + + it("keeps a workspace block", () => { + expect( + combineEffectivePolicies( + user("approve", "sample.ctl.read"), + user("block", "sample.*"), + ), + ).toEqual(user("block", "sample.*")); + }); + + it("keeps workspace approval when the toolkit approves", () => { + expect( + combineEffectivePolicies( + user("approve", "sample.ctl.read"), + user("require_approval", "sample.*"), + ), + ).toEqual(user("require_approval", "sample.*")); + }); + + it("uses an explicit rule over a plugin default", () => { + expect( + combineEffectivePolicies( + user("approve", "sample.ctl.read"), + pluginDefault("require_approval"), + ), + ).toEqual(user("approve", "sample.ctl.read")); + }); + + it("uses the more restrictive result when both are plugin defaults", () => { + expect( + combineEffectivePolicies(pluginDefault("approve"), pluginDefault("require_approval")), + ).toEqual(pluginDefault("require_approval")); + }); +}); + // --------------------------------------------------------------------------- // Executor integration — v2 surface. A test plugin produces per-connection // tools via `resolveTools`; policies are owner-scoped; tools are addressed by diff --git a/packages/core/sdk/src/policies.ts b/packages/core/sdk/src/policies.ts index 8620d9c6d3..b2bd5c0e0d 100644 --- a/packages/core/sdk/src/policies.ts +++ b/packages/core/sdk/src/policies.ts @@ -198,6 +198,22 @@ const moreRestrictive = ( return candidateRank > currentRank ? candidate : current; }; +export const combineEffectivePolicies = ( + providerPolicy: EffectivePolicy, + workspacePolicy: EffectivePolicy, +): EffectivePolicy => { + if (providerPolicy.action === "block") return providerPolicy; + if (workspacePolicy.action === "block") return workspacePolicy; + + if (providerPolicy.source === "user" && workspacePolicy.source === "user") { + return moreRestrictive(providerPolicy, workspacePolicy); + } + + if (workspacePolicy.source === "user") return workspacePolicy; + if (providerPolicy.source === "user") return providerPolicy; + return moreRestrictive(providerPolicy, workspacePolicy); +}; + export const resolveToolPolicy = ( toolId: string, policies: readonly ToolPolicyRow[], From 9539afb03a6c4314d0dc60b75f4e55a57ddb5dcf Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:16:05 +1200 Subject: [PATCH 2/9] fix(sdk): enforce workspace policies in toolkits --- packages/core/sdk/src/executor.ts | 94 ++++++++------- packages/core/sdk/src/policies.test.ts | 96 ++++++++++++++++ packages/plugins/toolkits/src/server.test.ts | 113 ++++++++++++++++++- 3 files changed, 261 insertions(+), 42 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index b31e60149c..bd12d65061 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -139,6 +139,7 @@ import { } from "./oauth-client"; import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { + combineEffectivePolicies, comparePolicyRow, isValidPattern, matchPattern, @@ -4839,6 +4840,7 @@ export const createExecutor = EffectivePolicy; + readonly workspaceRows: readonly ToolPolicyRow[]; }; const compareProviderPolicyRule = ( @@ -4880,50 +4883,61 @@ 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 workspaceRows = yield* core.findMany("tool_policy", {}); + if (!activeToolPolicyProvider) { + return { kind: "global" as const, rows: workspaceRows }; + } + if (activeToolPolicyProvider.prepare) { + const resolve = yield* activeToolPolicyProvider.prepare(); + return { kind: "prepared" as const, resolve, workspaceRows }; + } + if (activeToolPolicyProvider.resolve) { + return { + kind: "provider" as const, + provider: activeToolPolicyProvider, + rules: null, + workspaceRows, + }; + } + const rules = yield* activeToolPolicyProvider.list(); + return { + kind: "provider" as const, + provider: activeToolPolicyProvider, + rules, + workspaceRows, + }; + }); 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 workspacePolicy = resolveEffectivePolicy( + toolId, + ruleSet.workspaceRows, + 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, workspacePolicy); + }); // ------------------------------------------------------------------ // Tools (read surface) @@ -5475,7 +5489,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); @@ -5496,7 +5509,8 @@ export const createExecutor = { expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); }), ); + + it.effect("enforces workspace require_approval over provider approve", () => + 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 result = yield* executor.execute( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(result).toBe("allowed"); + expect(calls.count).toBe(1); + }), + ); + + it.effect("enforces workspace block over provider approve", () => + 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", + }); + + expect(yield* executor.tools.list()).toHaveLength(0); + const policy = yield* executor.policies.resolve( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + ); + expect(policy.action).toBe("block"); + + const result = yield* Effect.result( + executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ToolBlockedError")(result.failure)).toBe(true); + }), + ); + + it.effect("combines a prepared provider with workspace policies", () => + Effect.gen(function* () { + const preparedProviderPlugin = definePlugin(() => ({ + id: "prepared-policy-provider" as const, + storage: () => ({}), + toolPolicyProvider: () => ({ + list: () => Effect.succeed([]), + prepare: () => + Effect.succeed((input: { readonly toolId: string }) => + input.toolId === "toolkit-fixture.ctl.allowed" + ? { + action: "approve" as const, + source: "user" as const, + pattern: "toolkit-fixture.ctl.allowed", + } + : { action: "block" as const, source: "user" as const, 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 }; + yield* executor.execute( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(calls.count).toBe(1); + + const hidden = yield* Effect.result( + executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}), + ); + expect(Result.isFailure(hidden)).toBe(true); + expect( + (yield* executor.policies.resolve( + ToolAddress.make("toolkit-fixture.ctl.hidden"), + )).action, + ).toBe("block"); + }), + ); }); describe("approve / require_approval interaction with annotations", () => { diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index bab67eb0e9..d9274f35b6 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,112 @@ describe("toolkitsPlugin", () => { ).toContain("google_docs.org.* approve"); }), ); + + it.effect("enforces workspace policies in a toolkit-scoped executor", () => + 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"), + }), + tool({ + name: "outsideTool", + description: "outside toolkit", + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => Effect.succeed("outside"), + }), + ], + }, + ], + }))(); + 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.readTool", + }); + yield* setup.toolkits.createConnection(toolkit.id, { + pattern: "sample.ctl.deleteTool", + }); + yield* setup.toolkits.createPolicy(toolkit.id, { + pattern: "sample.ctl.readTool", + action: "approve", + }); + yield* setup.policies.create({ + owner: "org", + pattern: "sample.ctl.readTool", + action: "require_approval", + }); + yield* setup.policies.create({ + owner: "org", + pattern: "sample.ctl.deleteTool", + action: "block", + }); + + const scoped = yield* Effect.acquireRelease( + createExecutor({ + ...harness.config, + plugins: [toolkitsPlugin({ activeToolkitSlug: toolkit.slug }), samplePlugin] as const, + }), + (executor) => executor.close().pipe(Effect.ignore), + ); + const tools = yield* scoped.tools.list(); + expect(tools.map((entry) => String(entry.address))).toEqual(["sample.ctl.readTool"]); + expect( + (yield* scoped.policies.resolve(ToolAddress.make("sample.ctl.readTool"))).action, + ).toBe("require_approval"); + expect( + (yield* scoped.policies.resolve(ToolAddress.make("sample.ctl.deleteTool"))).action, + ).toBe("block"); + expect( + (yield* scoped.policies.resolve(ToolAddress.make("sample.ctl.outsideTool"))).action, + ).toBe("block"); + + let elicited = false; + expect( + yield* scoped.execute( + ToolAddress.make("sample.ctl.readTool"), + {}, + { + onElicitation: () => { + elicited = true; + return Effect.succeed({ action: "accept" as const }); + }, + }, + ), + ).toBe("read-data"); + expect(elicited).toBe(true); + + const blocked = yield* Effect.result( + scoped.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); + }), + ); }); From 364e82fb6786a8f9df7dc7f124c7b43697a46c44 Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:16:05 +1200 Subject: [PATCH 3/9] chore: add toolkit policy security changeset --- .changeset/secure-toolkit-workspace-policies.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/secure-toolkit-workspace-policies.md diff --git a/.changeset/secure-toolkit-workspace-policies.md b/.changeset/secure-toolkit-workspace-policies.md new file mode 100644 index 0000000000..9382c718e2 --- /dev/null +++ b/.changeset/secure-toolkit-workspace-policies.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Enforce workspace approval and block policies when tools run through a toolkit-scoped executor. From e099e7b75fec3b775ffba4bd7ee972b86407f467 Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:18:28 +1200 Subject: [PATCH 4/9] style: format toolkit policy tests --- packages/core/sdk/src/policies.test.ts | 15 +++++---------- packages/plugins/toolkits/src/server.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index fddb562624..4928543baf 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -259,17 +259,14 @@ describe("combineEffectivePolicies", () => { }); it("keeps a provider capability-boundary block", () => { - expect( - combineEffectivePolicies(user("block", "*"), user("approve", "sample.*")), - ).toEqual(user("block", "*")); + expect(combineEffectivePolicies(user("block", "*"), user("approve", "sample.*"))).toEqual( + user("block", "*"), + ); }); it("keeps a workspace block", () => { expect( - combineEffectivePolicies( - user("approve", "sample.ctl.read"), - user("block", "sample.*"), - ), + combineEffectivePolicies(user("approve", "sample.ctl.read"), user("block", "sample.*")), ).toEqual(user("block", "sample.*")); }); @@ -772,9 +769,7 @@ describe("active tool-policy provider", () => { ); expect(Result.isFailure(hidden)).toBe(true); expect( - (yield* executor.policies.resolve( - ToolAddress.make("toolkit-fixture.ctl.hidden"), - )).action, + (yield* executor.policies.resolve(ToolAddress.make("toolkit-fixture.ctl.hidden"))).action, ).toBe("block"); }), ); diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index d9274f35b6..03a8e704c6 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -275,9 +275,9 @@ describe("toolkitsPlugin", () => { ); const tools = yield* scoped.tools.list(); expect(tools.map((entry) => String(entry.address))).toEqual(["sample.ctl.readTool"]); - expect( - (yield* scoped.policies.resolve(ToolAddress.make("sample.ctl.readTool"))).action, - ).toBe("require_approval"); + expect((yield* scoped.policies.resolve(ToolAddress.make("sample.ctl.readTool"))).action).toBe( + "require_approval", + ); expect( (yield* scoped.policies.resolve(ToolAddress.make("sample.ctl.deleteTool"))).action, ).toBe("block"); From 261de3a186d4f4daf93a08500d6ceb543215fa51 Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:32:06 +1200 Subject: [PATCH 5/9] ci: retry upstream checks From 2f6671a9e76360c189bbd2c50ec509d4132ea603 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:32:33 +0530 Subject: [PATCH 6/9] Allow personal connections in toolkits on desktop/single-player hosts (#1925) --- .../desktop-toolkits-personal-connections.md | 5 ++ packages/plugins/toolkits/src/page.test.ts | 53 +++++++++++++++++++ packages/plugins/toolkits/src/page.tsx | 25 +++++---- packages/plugins/toolkits/src/server.test.ts | 37 +++++++++++++ 4 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 .changeset/desktop-toolkits-personal-connections.md create mode 100644 packages/plugins/toolkits/src/page.test.ts diff --git a/.changeset/desktop-toolkits-personal-connections.md b/.changeset/desktop-toolkits-personal-connections.md new file mode 100644 index 0000000000..7e1246b6f0 --- /dev/null +++ b/.changeset/desktop-toolkits-personal-connections.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-toolkits": patch +--- + +Allow toolkits on single-player/desktop hosts to display and manage personal connections without multi-tenant workspace isolation restrictions. diff --git a/packages/plugins/toolkits/src/page.test.ts b/packages/plugins/toolkits/src/page.test.ts new file mode 100644 index 0000000000..6df50e21a4 --- /dev/null +++ b/packages/plugins/toolkits/src/page.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ToolAddress } from "@executor-js/sdk/shared"; + +import { toolCanAppearInToolkit } from "./page"; +import type { ToolkitResponse } from "./shared"; + +describe("toolCanAppearInToolkit", () => { + const sampleOrgToolkit: ToolkitResponse = { + id: "tk_1", + owner: "org", + slug: "org-kit", + name: "Org Kit", + createdAt: 1, + updatedAt: 1, + }; + + const sampleUserToolkit: ToolkitResponse = { + id: "tk_2", + owner: "user", + slug: "user-kit", + name: "User Kit", + createdAt: 1, + updatedAt: 1, + }; + + const userTool = { + address: "tools.github.user.main.repos.list" as ToolAddress, + integration: "github", + owner: "user" as const, + name: "repos.list", + }; + + const orgTool = { + address: "tools.github.org.main.repos.list" as ToolAddress, + integration: "github", + owner: "org" as const, + name: "repos.list", + }; + + it("allows all tools when showOwnerLabels is false (single-player / desktop host)", () => { + expect(toolCanAppearInToolkit(sampleOrgToolkit, userTool, false)).toBe(true); + expect(toolCanAppearInToolkit(sampleOrgToolkit, orgTool, false)).toBe(true); + expect(toolCanAppearInToolkit(sampleUserToolkit, userTool, false)).toBe(true); + expect(toolCanAppearInToolkit(sampleUserToolkit, orgTool, false)).toBe(true); + }); + + it("hides personal tools in org toolkits when showOwnerLabels is true (multiplayer / cloud host)", () => { + expect(toolCanAppearInToolkit(sampleOrgToolkit, userTool, true)).toBe(false); + expect(toolCanAppearInToolkit(sampleOrgToolkit, orgTool, true)).toBe(true); + expect(toolCanAppearInToolkit(sampleUserToolkit, userTool, true)).toBe(true); + expect(toolCanAppearInToolkit(sampleUserToolkit, orgTool, true)).toBe(true); + }); +}); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 9e78eaddbb..fd67a104e3 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -156,8 +156,12 @@ const resolveToolkitPolicy = ( const toolMatchId = (tool: ToolRow): string => tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, ""); -const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean => - toolkit.owner === "user" || tool.static === true || tool.owner !== "user"; +export const toolCanAppearInToolkit = ( + toolkit: ToolkitResponse, + tool: ToolRow, + showOwnerLabels = true, +): boolean => + !showOwnerLabels || toolkit.owner === "user" || tool.static === true || tool.owner !== "user"; const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => { const path = orgSlug ? `/${orgSlug}/mcp/toolkits/${slug}` : `/mcp/toolkits/${slug}`; @@ -461,10 +465,10 @@ function ToolkitTile(props: { showOwnerLabels: boolean; toolkit: ToolkitResponse () => AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]).filter((tool) => - toolCanAppearInToolkit(toolkit, tool), + toolCanAppearInToolkit(toolkit, tool, props.showOwnerLabels), ) : [], - [toolkit, tools], + [props.showOwnerLabels, toolkit, tools], ); const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; @@ -674,7 +678,7 @@ function ToolkitGrid(props: {
(null); const visibleTools = useMemo( - () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)), - [props.toolkit, props.tools], + () => + props.tools.filter((tool) => + toolCanAppearInToolkit(props.toolkit, tool, props.showOwnerLabels), + ), + [props.showOwnerLabels, props.toolkit, props.tools], ); const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); const hiddenPersonalConnectionCount = useMemo(() => { - if (props.toolkit.owner !== "org") return 0; + if (!props.showOwnerLabels || props.toolkit.owner !== "org") return 0; return buildConnectionGroups(props.tools.filter((tool) => toolOwner(tool) === "user")).length; - }, [props.toolkit.owner, props.tools]); + }, [props.showOwnerLabels, props.toolkit.owner, props.tools]); const configuredConnections = useMemo( () => configuredConnectionViews( diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index bab67eb0e9..4af0305080 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -199,4 +199,41 @@ describe("toolkitsPlugin", () => { ).toContain("google_docs.org.* approve"); }), ); + + it.effect("allows personal toolkits to connect and resolve personal connections", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + }); + + const toolkit = yield* executor.toolkits.create({ + owner: "user", + name: "Desktop Kit", + }); + yield* executor.toolkits.createConnection(toolkit.id, { + pattern: "github.user.main.*", + }); + + const personalResult = yield* executor.toolkits.resolvePolicyForSlug( + toolkit.slug, + "github.user.main.repos.list", + false, + ); + expect(personalResult.action).toBe("approve"); + + const personalApprovalResult = yield* executor.toolkits.resolvePolicyForSlug( + toolkit.slug, + "github.user.main.repos.list", + true, + ); + expect(personalApprovalResult.action).toBe("require_approval"); + + const preparedResolver = yield* executor.toolkits.preparePolicyResolverForSlug(toolkit.slug); + const preparedResult = preparedResolver({ + toolId: "github.user.main.repos.list", + defaultRequiresApproval: false, + }); + expect(preparedResult.action).toBe("approve"); + }), + ); }); From d21a679df5a2ae0f1bd29d7ee5d139e6649f3e81 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:36:18 -0700 Subject: [PATCH 7/9] Verify workspace policies through toolkit MCP --- e2e/scenarios/toolkits-mcp.test.ts | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/e2e/scenarios/toolkits-mcp.test.ts b/e2e/scenarios/toolkits-mcp.test.ts index b84eed1e15..4dee74e7b2 100644 --- a/e2e/scenarios/toolkits-mcp.test.ts +++ b/e2e/scenarios/toolkits-mcp.test.ts @@ -769,3 +769,73 @@ scenario( }), ), ); + +scenario( + "Toolkits · workspace approval and block survive a toolkit approve rule", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const apiSurface = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiSurface.client(api, identity); + const name = unique("workspace-policy-kit"); + const createdPattern = `${unique("workspace-gated-result")}.*`; + const toolPattern = "executor.coreTools.policies.create"; + yield* Effect.gen(function* () { + const toolkit = yield* client.toolkits.create({ payload: { owner: "org", name } }); + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: toolPattern }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { pattern: toolPattern, action: "approve" }, + }); + yield* client.policies.create({ + payload: { owner: "org", pattern: toolPattern, action: "require_approval" }, + }); + const session = mcp.session(identity, { url: toolkitUrl(target.baseUrl, toolkit.slug) }); + const paused = yield* session.call("execute", { + code: createPolicyCode({ pattern: createdPattern, action: "block" }), + }); + expect(paused.text).toContain("Execution paused"); + expect((yield* client.policies.list()).some((p) => p.pattern === createdPattern)).toBe(false); + const resumed = yield* session.approvePaused(paused.text); + expect(resumed.ok).toBe(true); + expect((yield* client.policies.list()).some((p) => p.pattern === createdPattern)).toBe(true); + yield* client.policies.create({ + payload: { owner: "org", pattern: toolPattern, action: "block" }, + }); + const blockedPattern = `${createdPattern}blocked`; + const blockedSession = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, toolkit.slug), + }); + const blocked = yield* blockedSession.call("execute", { + code: createPolicyCode({ pattern: blockedPattern, action: "block" }), + }); + expect(blocked.text).not.toContain("Execution paused"); + expect((yield* client.policies.list()).some((p) => p.pattern === blockedPattern)).toBe(false); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((t) => t.name === name), + (t) => client.toolkits.remove({ params: { toolkitId: t.id } }), + { discard: true }, + ); + const policies = yield* client.policies.list(); + yield* Effect.forEach( + policies.filter( + (p) => p.pattern === toolPattern || p.pattern.startsWith(createdPattern), + ), + (p) => + client.policies.remove({ params: { policyId: p.id }, payload: { owner: p.owner } }), + { discard: true }, + ); + }).pipe(Effect.ignore), + ), + ); + }), +); From 2ece8fd08c66c7c4b8b50b19c41dc6090d6ddd06 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:12:06 -0700 Subject: [PATCH 8/9] Test queue timeout with a controlled clock --- apps/cloud/src/mcp/session-build-semaphore.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad76343..584b65ee0e 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); From 74616e2e7fff86ec47a68e85dbe6827d82743778 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:39:24 -0700 Subject: [PATCH 9/9] Keep toolkit connection visibility consistent with ownership --- .../desktop-toolkits-personal-connections.md | 2 +- e2e/local/toolkits-mcp.test.ts | 93 ++++++++++++++++++- packages/plugins/toolkits/src/page.test.ts | 17 +--- packages/plugins/toolkits/src/page.tsx | 23 ++--- 4 files changed, 106 insertions(+), 29 deletions(-) diff --git a/.changeset/desktop-toolkits-personal-connections.md b/.changeset/desktop-toolkits-personal-connections.md index 7e1246b6f0..b1facd2b8a 100644 --- a/.changeset/desktop-toolkits-personal-connections.md +++ b/.changeset/desktop-toolkits-personal-connections.md @@ -2,4 +2,4 @@ "@executor-js/plugin-toolkits": patch --- -Allow toolkits on single-player/desktop hosts to display and manage personal connections without multi-tenant workspace isolation restrictions. +Create personal toolkits by default on desktop and local hosts so new toolkits can use personal connections. Existing workspace toolkits retain their ownership restrictions. diff --git a/e2e/local/toolkits-mcp.test.ts b/e2e/local/toolkits-mcp.test.ts index 5cf12eef7e..94eb868296 100644 --- a/e2e/local/toolkits-mcp.test.ts +++ b/e2e/local/toolkits-mcp.test.ts @@ -14,7 +14,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; -import { Cli, RunDir } from "../src/services"; +import { Browser, Cli, RunDir, Target } from "../src/services"; import { withLocalServer } from "./local-server"; const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const); @@ -318,3 +318,94 @@ scenario( }), ), ); + +scenario( + "Local toolkits · UI creates a personal toolkit that executes a personal connection", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const cli = yield* Cli; + const runDir = yield* RunDir; + const browser = yield* Browser; + const target = yield* Target; + const identity = yield* target.newIdentity(); + const upstream = yield* servePingApi; + yield* withLocalServer(cli, runDir, (server) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(api, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + const integration = unique("personal_ping"); + const name = unique("personal-kit"); + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec(upstream.url) }, + slug: IntegrationSlug.make(integration), + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { + "x-e2e-token": [{ type: "variable", name: "token" }], + }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "user", + name: ConnectionName.make("personal"), + integration: IntegrationSlug.make(integration), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); + yield* browser.session(identity, async ({ page, step }) => { + await step("Create a toolkit on the local console", async () => { + await page.goto(server.url, { waitUntil: "domcontentloaded" }); + await page.goto(new URL("/toolkits/", server.origin).toString()); + await page.getByRole("button", { name: "Add toolkit", exact: true }).click(); + await page.getByLabel("Toolkit name", { exact: true }).fill(name); + await page.getByRole("button", { name: "Create toolkit", exact: true }).click(); + await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); + }); + }); + const toolkit = (yield* client.toolkits.list()).toolkits.find((t) => t.name === name); + expect(toolkit?.owner).toBe("user"); + if (!toolkit) return yield* Effect.die("Created toolkit missing"); + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: `${integration}.user.personal.*` }, + }); + const scoped = yield* Effect.acquireRelease( + Effect.promise(() => + makeMcp( + new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin).toString(), + server.token, + "personal-kit-test", + ), + ), + ({ client }) => Effect.promise(() => client.close()).pipe(Effect.ignore), + ); + const result = yield* Effect.promise(() => + executeJson( + scoped.client, + callPingCode({ + integration, + connection: "personal", + id: "personal-result", + }).replaceAll(`${integration}.org.`, `${integration}.user.`), + ), + ); + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ id: "personal-result" }); + }), + ); + }), + ), +); diff --git a/packages/plugins/toolkits/src/page.test.ts b/packages/plugins/toolkits/src/page.test.ts index 6df50e21a4..657da17040 100644 --- a/packages/plugins/toolkits/src/page.test.ts +++ b/packages/plugins/toolkits/src/page.test.ts @@ -37,17 +37,10 @@ describe("toolCanAppearInToolkit", () => { name: "repos.list", }; - it("allows all tools when showOwnerLabels is false (single-player / desktop host)", () => { - expect(toolCanAppearInToolkit(sampleOrgToolkit, userTool, false)).toBe(true); - expect(toolCanAppearInToolkit(sampleOrgToolkit, orgTool, false)).toBe(true); - expect(toolCanAppearInToolkit(sampleUserToolkit, userTool, false)).toBe(true); - expect(toolCanAppearInToolkit(sampleUserToolkit, orgTool, false)).toBe(true); - }); - - it("hides personal tools in org toolkits when showOwnerLabels is true (multiplayer / cloud host)", () => { - expect(toolCanAppearInToolkit(sampleOrgToolkit, userTool, true)).toBe(false); - expect(toolCanAppearInToolkit(sampleOrgToolkit, orgTool, true)).toBe(true); - expect(toolCanAppearInToolkit(sampleUserToolkit, userTool, true)).toBe(true); - expect(toolCanAppearInToolkit(sampleUserToolkit, orgTool, true)).toBe(true); + it("hides personal tools in org toolkits regardless of display settings", () => { + expect(toolCanAppearInToolkit(sampleOrgToolkit, userTool)).toBe(false); + expect(toolCanAppearInToolkit(sampleOrgToolkit, orgTool)).toBe(true); + expect(toolCanAppearInToolkit(sampleUserToolkit, userTool)).toBe(true); + expect(toolCanAppearInToolkit(sampleUserToolkit, orgTool)).toBe(true); }); }); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 104361fb0b..22b478e5b6 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -156,12 +156,8 @@ const resolveToolkitPolicy = ( const toolMatchId = (tool: ToolRow): string => tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, ""); -export const toolCanAppearInToolkit = ( - toolkit: ToolkitResponse, - tool: ToolRow, - showOwnerLabels = true, -): boolean => - !showOwnerLabels || toolkit.owner === "user" || tool.static === true || tool.owner !== "user"; +export const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean => + toolkit.owner === "user" || tool.static === true || tool.owner !== "user"; const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => { const path = orgSlug ? `/${orgSlug}/mcp/toolkits/${slug}` : `/mcp/toolkits/${slug}`; @@ -464,10 +460,10 @@ function ToolkitTile(props: { showOwnerLabels: boolean; toolkit: ToolkitResponse () => AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]).filter((tool) => - toolCanAppearInToolkit(toolkit, tool, props.showOwnerLabels), + toolCanAppearInToolkit(toolkit, tool), ) : [], - [props.showOwnerLabels, toolkit, tools], + [toolkit, tools], ); const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; @@ -1050,17 +1046,14 @@ function ToolkitWorkspace(props: { const [addOpen, setAddOpen] = useState(false); const [selectedToolId, setSelectedToolId] = useState(null); const visibleTools = useMemo( - () => - props.tools.filter((tool) => - toolCanAppearInToolkit(props.toolkit, tool, props.showOwnerLabels), - ), - [props.showOwnerLabels, props.toolkit, props.tools], + () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)), + [props.toolkit, props.tools], ); const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); const hiddenPersonalConnectionCount = useMemo(() => { - if (!props.showOwnerLabels || props.toolkit.owner !== "org") return 0; + if (props.toolkit.owner !== "org") return 0; return buildConnectionGroups(props.tools.filter((tool) => toolOwner(tool) === "user")).length; - }, [props.showOwnerLabels, props.toolkit.owner, props.tools]); + }, [props.toolkit.owner, props.tools]); const configuredConnections = useMemo( () => configuredConnectionViews(