Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
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 @@ -5949,7 +5962,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 @@ -5970,7 +5982,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
143 changes: 143 additions & 0 deletions packages/core/sdk/src/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ElicitationResponse, type ElicitationHandler } from "./elicitation";
import { createExecutor } from "./executor";
import type { FumaDb } from "./fuma-runtime";
import {
combineEffectivePolicies,
effectivePolicyFromSorted,
isValidPattern,
matchPattern,
Expand Down Expand Up @@ -248,6 +249,54 @@ 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
Expand Down Expand Up @@ -695,6 +744,100 @@ describe("active tool-policy provider", () => {
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", () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/core/sdk/src/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,22 @@ const moreRestrictive = <T extends { readonly action: ToolPolicyAction }>(
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[],
Expand Down
Loading
Loading