Skip to content
5 changes: 5 additions & 0 deletions .changeset/secure-toolkit-workspace-policies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

Enforce workspace approval and block policies when tools run through a toolkit-scoped executor.
11 changes: 10 additions & 1 deletion apps/cloud/src/mcp/session-build-semaphore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach } from "@effect/vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";

import {
acquireBuildSlot,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
Expand Down
70 changes: 70 additions & 0 deletions e2e/scenarios/toolkits-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
);
}),
);
99 changes: 56 additions & 43 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import {
} from "./oauth-client";
import type { FirstPartyOAuthClientConfig } from "./oauth-client";
import {
combineEffectivePolicies,
comparePolicyRow,
isValidPattern,
matchPattern,
Expand Down Expand Up @@ -5290,13 +5291,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
readonly kind: "provider";
readonly provider: ToolPolicyProvider;
readonly rules: readonly ToolPolicyProviderRule[] | null;
readonly workspaceRows: readonly ToolPolicyRow[];
}
| {
readonly kind: "prepared";
readonly resolve: (input: {
readonly toolId: string;
readonly defaultRequiresApproval?: boolean;
}) => EffectivePolicy;
readonly workspaceRows: readonly ToolPolicyRow[];
};

const compareProviderPolicyRule = (
Expand Down Expand Up @@ -5331,53 +5334,63 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
};

const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
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* () {
// Fetch workspace policy once per operation, then reuse the provider's
// prepared snapshot for every tool on the list surface.
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<EffectivePolicy, StorageFailure> =>
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)
Expand Down Expand Up @@ -5954,7 +5967,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
): Effect.Effect<EffectivePolicy, StorageFailure> =>
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);
Expand All @@ -5975,7 +5987,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
requiresApproval = annotations?.requiresApproval;
}
}
return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval);
const ruleSet = yield* listActivePolicyRuleSet();
return yield* resolvePolicyFromRuleSet(toolId, ruleSet, requiresApproval);
});

// ------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export {
matchPattern,
isValidPattern,
effectivePolicyFromSorted,
combineEffectivePolicies,
ToolPolicyActionSchema,
type ToolPolicy,
type CreateToolPolicyInput,
Expand Down
Loading
Loading