History

feat(toolkits): per-toolkit policies + access-mode enforcement (slice 4)

Add a toolkit_policies collection (pattern -> approve|require_approval|block) to the plugin + CRUD/view. resolveScope returns the policies; the Executor wrapper enforces `block` as exclusion (the tool is removed from the slice and blocked at execute). Read-only access filters to tools annotated readOnly (fail-closed: unclassified counts as write). approve/require_approval are persisted + surfaced; org-level approval policies still apply via the base executor (toolkit-level approval enforcement is a follow-up). e2e (selfhost): full runs the tool; a block policy stops it; read-only access blocks an unclassified tool.

Rhys Sullivan committed 4 months ago

Commit d059ee81 parent 2f9241d5 files · +240 −21Browse files

Unified · Split

5 files
  1. e2e/selfhost/toolkits-policies.test.ts +121 −0
  2. packages/core/sdk/src/index.ts +2 −0
  3. packages/core/sdk/src/toolkit-scope.ts +39 −1
  4. packages/plugins/toolkits/src/server.ts +63 −20
  5. packages/plugins/toolkits/src/shared.ts +15 −0
e2e/selfhost/toolkits-policies.test.ts+121−0
@@ -0,0 +1,121 @@
1+// Selfhost · Slice 4: per-toolkit policies + access modes at execute time.
2+// - full access -> the connection's tool runs;
3+// - a `block` policy on that tool -> blocked at execute (exclusion);
4+// - read-only access -> an unclassified (non-read-only) tool is blocked
5+// (fail-closed: unclassified counts as write).
6+// One greeting connection, three toolkits over it, compared via MCP execute.
7+import { randomBytes } from "node:crypto";
8+
9+import { expect } from "@effect/vitest";
10+import { Effect } from "effect";
11+import { composePluginApi } from "@executor-js/api/server";
12+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
13+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
14+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
15+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
16+
17+import { scenario } from "../src/scenario";
18+import { Api, Mcp, Target } from "../src/services";
19+
20+const api = composePluginApi([mcpHttpPlugin(), toolkitsPlugin()] as const);
21+const ident = (prefix: string): string => `${prefix}${randomBytes(4).toString("hex")}`;
22+
23+scenario(
24+ "Toolkits · access modes + block policies are enforced at execute",
25+ { timeout: 180_000 },
26+ Effect.scoped(
27+ Effect.gen(function* () {
28+ const target = yield* Target;
29+ const { client: makeApiClient } = yield* Api;
30+ const mcp = yield* Mcp;
31+ const identity = yield* target.newIdentity();
32+ const client = yield* makeApiClient(api, identity);
33+
34+ const slug = ident("pol");
35+ const conn = ident("conn");
36+ const token = `tok-${randomBytes(6).toString("hex")}`;
37+ const server = yield* serveMcpServer(() => makeGreetingMcpServer(), {
38+ auth: {
39+ validateAuthorization: (authorization) =>
40+ Effect.succeed(authorization === `Bearer ${token}`),
41+ },
42+ });
43+ yield* client.mcp.addServer({
44+ payload: {
45+ transport: "remote",
46+ name: `Greeting ${slug}`,
47+ endpoint: server.endpoint,
48+ slug,
49+ authenticationTemplate: [
50+ {
51+ type: "apiKey",
52+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
53+ },
54+ ],
55+ },
56+ });
57+ yield* client.connections.create({
58+ payload: {
59+ owner: "org",
60+ name: ConnectionName.make(conn),
61+ integration: IntegrationSlug.make(slug),
62+ template: AuthTemplateSlug.make("header"),
63+ value: token,
64+ },
65+ });
66+
67+ const full = yield* client.toolkits.create({
68+ payload: {
69+ slug: ident("kitfull"),
70+ name: "Full",
71+ scope: "workspace",
72+ connections: [
73+ { integration: IntegrationSlug.make(slug), connection: conn, access: "full" },
74+ ],
75+ },
76+ });
77+ const blocked = yield* client.toolkits.create({
78+ payload: {
79+ slug: ident("kitblock"),
80+ name: "Blocked",
81+ scope: "workspace",
82+ connections: [
83+ { integration: IntegrationSlug.make(slug), connection: conn, access: "full" },
84+ ],
85+ policies: [{ pattern: `${slug}.${conn}.simple_echo`, action: "block" }],
86+ },
87+ });
88+ const readonly = yield* client.toolkits.create({
89+ payload: {
90+ slug: ident("kitread"),
91+ name: "Read",
92+ scope: "workspace",
93+ connections: [
94+ { integration: IntegrationSlug.make(slug), connection: conn, access: "read" },
95+ ],
96+ },
97+ });
98+
99+ const code = `return await tools.${slug}.org.${conn}.simple_echo({});`;
100+ const fullCall = yield* mcp
101+ .session(identity, { toolkit: full.slug })
102+ .call("execute", { code });
103+ const blockCall = yield* mcp
104+ .session(identity, { toolkit: blocked.slug })
105+ .call("execute", { code });
106+ const readCall = yield* mcp
107+ .session(identity, { toolkit: readonly.slug })
108+ .call("execute", { code });
109+
110+ expect(fullCall.ok, `full access runs the tool; text=${fullCall.text}`).toBe(true);
111+ expect(
112+ blockCall.text,
113+ `block policy must stop the tool; full=${fullCall.text} block=${blockCall.text}`,
114+ ).not.toBe(fullCall.text);
115+ expect(
116+ readCall.text,
117+ `read-only must hide an unclassified (write) tool; full=${fullCall.text} read=${readCall.text}`,
118+ ).not.toBe(fullCall.text);
119+ }),
120+ ),
121+);
packages/core/sdk/src/index.ts+2−0
@@ -358,6 +358,8 @@ export { · 357 unmodified lines
358358 EMPTY_TOOLKIT_SCOPE,
359359 type ResolvedToolkitScope,
360360 type ToolkitAccess,
361+ type ToolkitPolicyAction,
362+ type ToolkitPolicyRule,
361363 type ToolkitResolver,
362364 type ToolkitScopeEntry,
363365 } from "./toolkit-scope";
packages/core/sdk/src/toolkit-scope.ts+39−1
@@ -19,6 +19,15 @@ import type { StorageFailure } from "./fuma-runtime"; · 18 unmodified lines
1919
2020 export type ToolkitAccess = "off" | "read" | "full";
2121
22+export type ToolkitPolicyAction = "approve" | "require_approval" | "block";
23+
24+export interface ToolkitPolicyRule {
25+ /** Glob over `<integration>.<connection>.<tool>` — `*` matches one segment,
26+ * a trailing `*` matches the rest, and `*` inside a segment globs. */
27+ readonly pattern: string;
28+ readonly action: ToolkitPolicyAction;
29+}
30+
2231 export interface ToolkitScopeEntry {
2332 readonly integration: string;
2433 /** A pinned connection name, or "*" to track every connection of the integration. */
@@ -28,13 +37,37 @@ export interface ToolkitScopeEntry { · 3 unmodified lines
2837
2938 export interface ResolvedToolkitScope {
3039 readonly entries: readonly ToolkitScopeEntry[];
40+ /** Per-toolkit policy rules. v1 enforces `block` (exclusion); `approve` /
41+ * `require_approval` are carried for the briefing — org-level approval
42+ * policies are still enforced by the base executor when inherited. */
43+ readonly policies: readonly ToolkitPolicyRule[];
3144 readonly inheritOrgPolicies: boolean;
3245 }
3346
3447 /** Empty slice — exposes no connection tools (static/core tools stay visible).
3548 * Applied fail-closed when a selector resolves to no toolkit (unknown or
3649 * not visible to the caller), so a bad selector never leaks the full account. */
37−export const EMPTY_TOOLKIT_SCOPE: ResolvedToolkitScope = { entries: [], inheritOrgPolicies: true };
50+export const EMPTY_TOOLKIT_SCOPE: ResolvedToolkitScope = {
51+ entries: [],
52+ policies: [],
53+ inheritOrgPolicies: true,
54+};
55+
56+/** Glob match over dot segments: `*` matches one segment, a trailing bare `*`
57+ * matches the rest, and `*` inside a segment globs within it. */
58+const matchPattern = (pattern: string, target: string): boolean => {
59+ const p = pattern.toLowerCase().trim().split(".");
60+ const a = target.toLowerCase().split(".");
61+ for (let i = 0; i < p.length; i++) {
62+ if (p[i] === "*" && i === p.length - 1) return true;
63+ if (a[i] === undefined) return false;
64+ const re = new RegExp(
65+ "^" + p[i].replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
66+ );
67+ if (!re.test(a[i])) return false;
68+ }
69+ return p.length === a.length;
70+};
3871
3972 /** A plugin extension capable of resolving a selector (slug or id) to a scope,
4073 * scoped to the caller. `resolveScope` returns null when nothing matches. */
@@ -66,6 +99,10 @@ export const applyToolkitScope = <TPlugins extends readonly AnyPlugin[]>( · 25 unmodified lines
6699 scope: ResolvedToolkitScope,
67100 ): Effect.Effect<Executor<TPlugins>, StorageFailure> =>
68101 Effect.gen(function* () {
102+ const blockRules = scope.policies.filter((p) => p.action === "block");
103+ const isBlocked = (integration: string, connection: string, name: string) =>
104+ blockRules.some((p) => matchPattern(p.pattern, `${integration}.${connection}.${name}`));
105+
69106 const all = yield* base.tools.list();
70107 const allowed = new Set<string>();
71108 for (const t of all) {
@@ -75,6 +112,7 @@ export const applyToolkitScope = <TPlugins extends readonly AnyPlugin[]>( · 3 unmodified lines
75112 allowed.add(String(t.address));
76113 continue;
77114 }
115+ if (isBlocked(String(t.integration), String(t.connection), String(t.name))) continue;
78116 const a = accessFor(scope, String(t.integration), String(t.connection));
79117 if (a === "full" || (a === "read" && t.annotations?.readOnly === true)) {
80118 allowed.add(String(t.address));
packages/plugins/toolkits/src/server.ts+63−20
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
2929 ToolkitsApi,
3030 type CreateToolkitPayload,
3131 type ToolkitAccess,
32+ type ToolkitPolicyAction,
3233 type ToolkitScope,
3334 type ToolkitView,
3435 type UpdateToolkitPayload,
@@ -57,10 +58,17 @@ const ConnectionRow = Schema.Struct({ · 22 unmodified lines
5758 note: Schema.NullOr(Schema.String),
5859 });
5960
61+const PolicyRow = Schema.Struct({
62+ toolkitId: Schema.String,
63+ pattern: Schema.String,
64+ action: Schema.String,
65+});
66+
6067 const TOOLKITS = definePluginStorageCollection("toolkits", ToolkitRow, { indexes: ["slug"] });
6168 const CONNECTIONS = definePluginStorageCollection("connections", ConnectionRow, {
6269 indexes: ["toolkitId"],
6370 });
71+const POLICIES = definePluginStorageCollection("policies", PolicyRow, { indexes: ["toolkitId"] });
6472
6573 const newId = Effect.sync(() => crypto.randomUUID());
6674 const scopeOfOwner = (owner: Owner): ToolkitScope => (owner === ORG ? "workspace" : "personal");
@@ -79,26 +87,31 @@ type ToolkitRowEntry = { · 12 unmodified lines
7987 const makeToolkitsExtension = (ctx: PluginCtx) => {
8088 const toolkits = ctx.pluginStorage.collection(TOOLKITS);
8189 const connections = ctx.pluginStorage.collection(CONNECTIONS);
90+ const policies = ctx.pluginStorage.collection(POLICIES);
8291
8392 const viewFromRow = (row: ToolkitRowEntry) =>
84− connections.query({ where: { toolkitId: row.key } }).pipe(
85− Effect.map(
86− (rows): ToolkitView => ({
87− id: row.key,
88− slug: row.data.slug,
89− name: row.data.name,
90− scope: scopeOfOwner(row.owner),
91− inheritOrgPolicies: row.data.inheritOrgPolicies,
92− briefing: row.data.briefing,
93− connections: rows.map((c) => ({
94− integration: c.data.integration as ToolkitConnectionIntegration,
95− connection: c.data.connection,
96− access: c.data.access as ToolkitAccess,
97− ...(c.data.note == null ? {} : { note: c.data.note }),
98− })),
99− }),
100− ),
101− );
93+ Effect.gen(function* () {
94+ const conns = yield* connections.query({ where: { toolkitId: row.key } });
95+ const pols = yield* policies.query({ where: { toolkitId: row.key } });
96+ return {
97+ id: row.key,
98+ slug: row.data.slug,
99+ name: row.data.name,
100+ scope: scopeOfOwner(row.owner),
101+ inheritOrgPolicies: row.data.inheritOrgPolicies,
102+ briefing: row.data.briefing,
103+ connections: conns.map((c) => ({
104+ integration: c.data.integration as ToolkitConnectionIntegration,
105+ connection: c.data.connection,
106+ access: c.data.access as ToolkitAccess,
107+ ...(c.data.note == null ? {} : { note: c.data.note }),
108+ })),
109+ policies: pols.map((p) => ({
110+ pattern: p.data.pattern,
111+ action: p.data.action as ToolkitPolicyAction,
112+ })),
113+ } satisfies ToolkitView;
114+ });
102115
103116 // candidate connections honoring scope: a workspace toolkit may use only
104117 // org connections; a personal toolkit may use org + the caller's own.
@@ -146,6 +159,23 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 41 unmodified lines
146159 ),
147160 );
148161
162+ const putPolicies = (
163+ toolkitId: string,
164+ owner: Owner,
165+ rules: ReadonlyArray<{ readonly pattern: string; readonly action: string }>,
166+ ) =>
167+ Effect.forEach(rules, (r) =>
168+ newId.pipe(
169+ Effect.flatMap((pid) =>
170+ policies.put({
171+ key: pid,
172+ owner,
173+ data: { toolkitId, pattern: r.pattern, action: r.action },
174+ }),
175+ ),
176+ ),
177+ );
178+
149179 const create = (input: CreateToolkitPayload) =>
150180 Effect.gen(function* () {
151181 if (input.scope === "personal" && ctx.owner.subject == null) {
@@ -165,6 +195,7 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 13 unmodified lines
165195 };
166196 yield* toolkits.put({ key: id, owner, data });
167197 yield* putConnections(id, owner, entries);
198+ yield* putPolicies(id, owner, input.policies ?? []);
168199 return yield* viewFromRow({ key: id, owner, data });
169200 });
170201
@@ -197,6 +228,11 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 26 unmodified lines
197228 );
198229 yield* putConnections(id, row.owner, patch.connections);
199230 }
231+ if (patch.policies !== undefined) {
232+ const existing = yield* policies.query({ where: { toolkitId: id } });
233+ yield* Effect.forEach(existing, (p) => policies.remove({ key: p.key, owner: row.owner }));
234+ yield* putPolicies(id, row.owner, patch.policies);
235+ }
200236 return yield* viewFromRow({ key: id, owner: row.owner, data });
201237 });
202238
@@ -204,8 +240,10 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 1 unmodified line
204240 Effect.gen(function* () {
205241 const row = yield* toolkits.get({ key: id });
206242 if (row == null) return yield* new ToolkitNotFound({ id });
207− const existing = yield* connections.query({ where: { toolkitId: id } });
208− yield* Effect.forEach(existing, (c) => connections.remove({ key: c.key, owner: row.owner }));
243+ const conns = yield* connections.query({ where: { toolkitId: id } });
244+ yield* Effect.forEach(conns, (c) => connections.remove({ key: c.key, owner: row.owner }));
245+ const pols = yield* policies.query({ where: { toolkitId: id } });
246+ yield* Effect.forEach(pols, (p) => policies.remove({ key: p.key, owner: row.owner }));
209247 yield* toolkits.remove({ key: id, owner: row.owner });
210248 return { removed: true };
211249 });
@@ -222,12 +260,17 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 10 unmodified lines
222260 const row = bySlug[0] ?? (yield* toolkits.get({ key: selector }));
223261 if (row == null) return null;
224262 const conns = yield* connections.query({ where: { toolkitId: row.key } });
263+ const pols = yield* policies.query({ where: { toolkitId: row.key } });
225264 return {
226265 entries: conns.map((c) => ({
227266 integration: c.data.integration,
228267 connection: c.data.connection,
229268 access: c.data.access as ToolkitAccess,
230269 })),
270+ policies: pols.map((p) => ({
271+ pattern: p.data.pattern,
272+ action: p.data.action as ToolkitPolicyAction,
273+ })),
231274 inheritOrgPolicies: row.data.inheritOrgPolicies,
232275 };
233276 });
packages/plugins/toolkits/src/shared.ts+15−0
@@ -31,6 +31,18 @@ export const ToolkitConnectionEntry = Schema.Struct({ · 30 unmodified lines
3131 });
3232 export type ToolkitConnectionEntry = typeof ToolkitConnectionEntry.Type;
3333
34+// off/read/full is the per-connection access mode; policies are pattern rules
35+// layered on top. v1 enforces `block` (the tool is excluded); `approve` /
36+// `require_approval` are persisted + briefed (org approval policies still apply).
37+export const ToolkitPolicyAction = Schema.Literals(["approve", "require_approval", "block"]);
38+export type ToolkitPolicyAction = typeof ToolkitPolicyAction.Type;
39+
40+export const ToolkitPolicy = Schema.Struct({
41+ pattern: Schema.String,
42+ action: ToolkitPolicyAction,
43+});
44+export type ToolkitPolicy = typeof ToolkitPolicy.Type;
45+
3446 export const ToolkitView = Schema.Struct({
3547 id: Schema.String,
3648 slug: Schema.String,
@@ -39,6 +51,7 @@ export const ToolkitView = Schema.Struct({ · 2 unmodified lines
3951 inheritOrgPolicies: Schema.Boolean,
4052 briefing: Schema.NullOr(Schema.String),
4153 connections: Schema.Array(ToolkitConnectionEntry),
54+ policies: Schema.Array(ToolkitPolicy),
4255 });
4356 export type ToolkitView = typeof ToolkitView.Type;
4457
@@ -49,6 +62,7 @@ export const CreateToolkitPayload = Schema.Struct({ · 4 unmodified lines
4962 inheritOrgPolicies: Schema.optional(Schema.Boolean),
5063 briefing: Schema.optional(Schema.String),
5164 connections: Schema.optional(Schema.Array(ToolkitConnectionEntry)),
65+ policies: Schema.optional(Schema.Array(ToolkitPolicy)),
5266 });
5367 export type CreateToolkitPayload = typeof CreateToolkitPayload.Type;
5468
@@ -57,6 +71,7 @@ export const UpdateToolkitPayload = Schema.Struct({ · 2 unmodified lines
5771 briefing: Schema.optional(Schema.NullOr(Schema.String)),
5872 inheritOrgPolicies: Schema.optional(Schema.Boolean),
5973 connections: Schema.optional(Schema.Array(ToolkitConnectionEntry)),
74+ policies: Schema.optional(Schema.Array(ToolkitPolicy)),
6075 });
6176 export type UpdateToolkitPayload = typeof UpdateToolkitPayload.Type;
6277